{"spec_id":"waterfall-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// waterfall-basic: Basic Waterfall Chart\n// Library: highcharts 12.6.0 | JavaScript 22.23.1\n// Quality: 88/100 | Created: 2026-08-04\n\n//# anyplot-orientation: landscape\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Quarterly financial breakdown: revenue streams down to net profit.\n// Only the core Highcharts bundle is loaded (no `modules/waterfall.js`), so the\n// waterfall is built from stacked `column` series instead of the native\n// `type: \"waterfall\"` series — an invisible \"base\" series floats each visible\n// segment at the right height, and a thin connector line bridges consecutive\n// running totals.\nconst steps = [\n  { label: \"Starting Balance\", type: \"total\", value: 500 },\n  { label: \"Product Revenue\", type: \"increase\", value: 350 },\n  { label: \"Service Revenue\", type: \"increase\", value: 150 },\n  { label: \"COGS\", type: \"decrease\", value: -220 },\n  { label: \"Operating Expenses\", type: \"decrease\", value: -180 },\n  { label: \"Interest Expense\", type: \"decrease\", value: -30 },\n  { label: \"Tax Adjustment\", type: \"decrease\", value: -70 },\n  { label: \"Net Profit\", type: \"total\", value: null },\n];\n\nconst categories = steps.map((s) => s.label);\n\n// Running cumulative total after each step; the final \"total\" step reads its\n// own displayed value off this running sum instead of a hard-coded number.\nconst cumulative = [];\nlet running = 0;\nsteps.forEach((s, i) => {\n  if (s.type === \"total\") {\n    if (i === 0) running = s.value;\n    else s.value = running;\n  } else {\n    running += s.value;\n  }\n  cumulative.push(running);\n});\n\nconst fmtTotal = (v) => `$${v.toLocaleString()}`;\nconst fmtChange = (v) =>\n  v >= 0 ? `+$${v.toLocaleString()}` : `−$${Math.abs(v).toLocaleString()}`;\n// Change label plus the running cumulative total it leaves the waterfall at,\n// so intermediate bars communicate both the step delta and the running total.\nconst fmtChangeWithRunningTotal = (v, cum) => `${fmtChange(v)} → ${fmtTotal(cum)}`;\n\nconst baseData = [];\nconst totalData = [];\nconst increaseData = [];\nconst decreaseData = [];\n\nsteps.forEach((s, i) => {\n  if (s.type === \"total\") {\n    baseData.push(0);\n    totalData.push({ y: s.value, custom: { label: fmtTotal(s.value) } });\n    increaseData.push(null);\n    decreaseData.push(null);\n  } else if (s.type === \"increase\") {\n    const prevCum = cumulative[i] - s.value;\n    baseData.push(prevCum);\n    totalData.push(null);\n    increaseData.push({\n      y: s.value,\n      custom: { label: fmtChangeWithRunningTotal(s.value, cumulative[i]) },\n    });\n    decreaseData.push(null);\n  } else {\n    baseData.push(cumulative[i]);\n    totalData.push(null);\n    increaseData.push(null);\n    decreaseData.push({\n      y: -s.value,\n      custom: { label: fmtChangeWithRunningTotal(s.value, cumulative[i]) },\n    });\n  }\n});\n\n// Height (in axis units) at which the connector between bar i and bar i+1 sits\n// — the running total right after bar i, which is also the boundary point the\n// two neighbouring columns share.\nconst connectorLevels = cumulative.slice(0, -1);\n\nfunction firstShapeArgsAt(chart, index) {\n  for (const series of chart.series) {\n    const point = series.points[index];\n    if (point && point.shapeArgs) return point.shapeArgs;\n  }\n  return null;\n}\n\n// --- Chart -------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"column\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    events: {\n      render: function () {\n        const chart = this;\n        if (chart.waterfallConnectors) {\n          chart.waterfallConnectors.forEach((el) => el.destroy());\n        }\n        chart.waterfallConnectors = [];\n        const shapeArgs = firstShapeArgsAt(chart, 0);\n        if (!shapeArgs) return;\n        const halfWidth = shapeArgs.width / 2;\n        const xAxis = chart.xAxis[0];\n        const yAxis = chart.yAxis[0];\n        connectorLevels.forEach((level, i) => {\n          const x1 = xAxis.toPixels(i) + halfWidth;\n          const x2 = xAxis.toPixels(i + 1) - halfWidth;\n          const y = yAxis.toPixels(level);\n          const path = chart.renderer\n            .path([\"M\", x1, y, \"L\", x2, y])\n            .attr({\n              \"stroke-width\": 1.5,\n              stroke: t.inkSoft,\n              dashstyle: \"Dash\",\n              zIndex: 5,\n            })\n            .add();\n          chart.waterfallConnectors.push(path);\n        });\n      },\n    },\n  },\n  credits: { enabled: false },\n  title: {\n    text: \"waterfall-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Quarterly financial breakdown — revenue to net profit\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: {\n    categories,\n    lineColor: t.inkSoft,\n    tickColor: t.inkSoft,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  yAxis: {\n    title: {\n      text: \"Amount ($ thousands)\",\n      style: { color: t.inkSoft, fontSize: \"16px\" },\n    },\n    gridLineColor: t.grid,\n    labels: { style: { color: t.inkSoft, fontSize: \"14px\" } },\n  },\n  legend: {\n    itemStyle: { color: t.inkSoft, fontSize: \"14px\" },\n    itemHoverStyle: { color: t.ink },\n  },\n  tooltip: {\n    formatter: function () {\n      return `<b>${this.series.name}</b>: ${this.point.custom.label}`;\n    },\n  },\n  plotOptions: {\n    series: { animation: false, stacking: \"normal\" },\n    column: {\n      borderWidth: 0,\n      borderRadius: 2,\n      pointPadding: 0.08,\n      groupPadding: 0.12,\n      dataLabels: {\n        enabled: true,\n        y: -16,\n        style: {\n          color: t.ink,\n          fontSize: \"14px\",\n          fontWeight: \"600\",\n          textOutline: \"none\",\n        },\n        formatter: function () {\n          return this.point.custom ? this.point.custom.label : null;\n        },\n      },\n    },\n  },\n  series: [\n    {\n      name: \"Base\",\n      data: baseData,\n      color: \"transparent\",\n      enableMouseTracking: false,\n      showInLegend: false,\n      dataLabels: { enabled: false },\n    },\n    {\n      name: \"Increase\",\n      data: increaseData,\n      color: t.palette[0],\n    },\n    {\n      name: \"Decrease\",\n      data: decreaseData,\n      color: t.palette[4],\n    },\n    {\n      name: \"Total\",\n      data: totalData,\n      color: t.ink,\n      dataLabels: { style: { color: t.pageBg } },\n    },\n  ],\n});\n"}