{"spec_id":"waterfall-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nwaterfall-basic: Basic Waterfall Chart\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 92/100 | Updated: 2026-08-04\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens (see prompts/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_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint palette — semantic exception applied: green=gain, matte red=loss, blue=total/baseline\nPOSITIVE_COLOR = \"#009E73\"  # Imprint position 1 — brand green, always first\nNEGATIVE_COLOR = \"#AE3030\"  # Imprint position 5 — semantic anchor for loss\nTOTAL_COLOR = \"#4467A3\"  # Imprint position 3 — blue for totals/subtotals\n\n# Data: Quarterly financial breakdown from revenue to net income\ncategories = [\"Revenue\", \"Cost of Goods\", \"Gross Profit\", \"Operating Expenses\", \"Other Income\", \"Taxes\", \"Net Income\"]\nvalues = [500, -200, None, -150, 25, -45, None]\n\n# Calculate running totals and bar positions\nn = len(categories)\nrunning_total = [0] * n\nbar_bottom = [0] * n\nbar_top = [0] * n\nbar_types = []\n\nrunning_total[0] = values[0]\nbar_bottom[0] = 0\nbar_top[0] = values[0]\nbar_types.append(\"total\")\n\ncurrent = values[0]\nfor i in range(1, n):\n    if values[i] is None:\n        running_total[i] = current\n        bar_bottom[i] = 0\n        bar_top[i] = current\n        bar_types.append(\"total\")\n    else:\n        running_total[i] = current + values[i]\n        if values[i] >= 0:\n            bar_bottom[i] = current\n            bar_top[i] = current + values[i]\n            bar_types.append(\"positive\")\n        else:\n            bar_bottom[i] = current + values[i]\n            bar_top[i] = current\n            bar_types.append(\"negative\")\n        current = running_total[i]\n\n# Create display values for labels\ndisplay_values = []\nfor i, val in enumerate(values):\n    if bar_types[i] == \"total\":\n        display_values.append(f\"${int(running_total[i])}\")\n    elif val >= 0:\n        display_values.append(f\"+${int(val)}\")\n    else:\n        display_values.append(f\"-${int(abs(val))}\")\n\n# Create DataFrame for bars\ndf = pd.DataFrame(\n    {\n        \"category\": categories,\n        \"bar_bottom\": bar_bottom,\n        \"bar_top\": bar_top,\n        \"bar_type\": bar_types,\n        \"running_total\": running_total,\n        \"display_value\": display_values,\n        \"order\": list(range(n)),\n        \"label_y\": [(b + t) / 2 for b, t in zip(bar_bottom, bar_top, strict=True)],\n    }\n)\n\n# Color scale using the Imprint palette (semantic exception: total/positive/negative)\ncolor_scale = alt.Scale(domain=[\"total\", \"positive\", \"negative\"], range=[TOTAL_COLOR, POSITIVE_COLOR, NEGATIVE_COLOR])\n\n# Sort by order field\nsort_order = alt.EncodingSortField(field=\"order\", order=\"ascending\")\n\n# Bars using bar marks with y and y2\nbars = (\n    alt.Chart(df)\n    .mark_bar(size=28, stroke=INK_SOFT, strokeWidth=1)\n    .encode(\n        x=alt.X(\n            \"category:N\",\n            sort=sort_order,\n            title=\"Category\",\n            axis=alt.Axis(labelFontSize=10, titleFontSize=12, labelAngle=-20, grid=False),\n        ),\n        y=alt.Y(\n            \"bar_bottom:Q\", title=\"Amount ($)\", axis=alt.Axis(labelFontSize=10, titleFontSize=12, gridOpacity=0.12)\n        ),\n        y2=alt.Y2(\"bar_top:Q\"),\n        color=alt.Color(\"bar_type:N\", scale=color_scale, legend=None),\n    )\n)\n\n# Value labels, split by bar_type so each sits on its own fill with solid contrast:\n# bold + larger on totals (visual hierarchy anchor), regular + smaller on deltas (secondary).\n# White reads best on the dark blue/red fills; ink reads best on the brighter green fill.\ntotal_labels = (\n    alt.Chart(df)\n    .transform_filter(alt.datum.bar_type == \"total\")\n    .mark_text(fontSize=13, fontWeight=\"bold\", color=\"#FFFFFF\", dy=-4)\n    .encode(x=alt.X(\"category:N\", sort=sort_order), y=alt.Y(\"label_y:Q\"), text=\"display_value:N\")\n)\n\npositive_labels = (\n    alt.Chart(df)\n    .transform_filter(alt.datum.bar_type == \"positive\")\n    .mark_text(fontSize=11, fontWeight=\"normal\", color=INK, dy=-4)\n    .encode(x=alt.X(\"category:N\", sort=sort_order), y=alt.Y(\"label_y:Q\"), text=\"display_value:N\")\n)\n\nnegative_labels = (\n    alt.Chart(df)\n    .transform_filter(alt.datum.bar_type == \"negative\")\n    .mark_text(fontSize=11, fontWeight=\"normal\", color=\"#FFFFFF\", dy=-4)\n    .encode(x=alt.X(\"category:N\", sort=sort_order), y=alt.Y(\"label_y:Q\"), text=\"display_value:N\")\n)\n\n# Connector lines between cumulative levels\nconnector_data = []\nfor i in range(n - 1):\n    connector_data.append(\n        {\"x\": categories[i], \"x2\": categories[i + 1], \"y\": running_total[i], \"order_x\": i, \"order_x2\": i + 1}\n    )\n\ndf_connectors = pd.DataFrame(connector_data)\n\nconnectors = (\n    alt.Chart(df_connectors)\n    .mark_rule(color=INK_SOFT, strokeDash=[5, 3], strokeWidth=1.2)\n    .encode(x=alt.X(\"x:N\", sort=sort_order), x2=alt.X2(\"x2:N\"), y=alt.Y(\"y:Q\"))\n)\n\n# Combine all layers\ntitle = \"waterfall-basic · python · altair · anyplot.ai\"\nchart = (\n    alt.layer(connectors, bars, total_labels, positive_labels, negative_labels)\n    .properties(\n        width=620,\n        height=320,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        background=PAGE_BG,\n        title=alt.Title(title, fontSize=16, color=INK),\n    )\n    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, continuousWidth=620, continuousHeight=320)\n    .configure_axis(domainColor=INK_SOFT, tickColor=INK_SOFT, gridColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .configure_title(color=INK)\n)\n\n# Save as PNG (padded to the canonical target) and HTML with theme suffix\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Canvas contract: pad (never crop) the saved PNG up to the exact 3200x1800 target\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}