Deneb Example - Financial Waterfall

Deneb/Vega-Lite can be used to generate a Financial Waterfall, which graphically depicts the major elements of a profit and loss statement.

(Perhaps its just the engineer in me, but I’ve always found P&L statements difficult to read and wanted to see if Deneb could help make the comparisons more visible.)

The example presented herein uses a ranged column chart and data label arrays to increase the utility of the visual. A simple year slicer is included, and a percent toggle (gross revenue; previous statistic) is included to provide interactivity.

As this was primarily a Deneb investigation, and as I have minimal financial experience, minimal time was spent sourcing a representative dataset, and in the end a simple dataset representing some common P&L categories was synthesized.

This example illustrates a number of Deneb/Vega-Lite features, including:
0 - General:

  • a “title” block with subtitle
  • a shared “params” block with:
    • radio buttons (gross revenue; previous statistic) to select the percent target
  • a shared “transform” block with:
    • a “calculate” transform to compose an expanded item identifier (id, type, delimited name) to ease sorting and to make the id, type, and delimited name available for axis processing
    • a “calculate” transform to determine the signed increment amount
    • a “window/sum” transform to determine the cumulative amount for the range top amount
    • a “calculate” transform to determine the range bottom amount (statistic = 0)
    • 5x “calculate” transforms and 5x “joinaggregate/sum” transforms to determine the statistic amounts
    • 3x “calculate” transforms to determine the percentages
    • 3x “calculate” transforms to format and compose the data label array
    • a “vconcat” block for the custom legend and column chart

1 - Legend:

  • a 3-record in-line dataset (income, expense, statistic)
  • an “arc” mark of zero size to leverage the legend functionality built-in to Vega-Lite
  • a “color” encoding with:
    • a hard-coded scale (domain and range) for the colours
    • conditional label font size, weight, and position and conditional symbol size (statistic larger and bold)

2 - Waterfall:

  • a shared “encoding” block to ensure all marks use the same X and Y axes and colours
    • the X axis configured with:
      • multi-line labels for the items (split into an array using the delimiter)
      • conditional font size (statistic larger)
      • conditional font weight (statistic bold)
    • the Y axis configured with:
      • both a Y (top) and Y2 (bottom) to create an amount range
  • a “layer” block with:
    • a “bar” mark for the columns with conditional colour
    • a “text” mark for the amount/percent labels with:
      • conditional font size, weight, and colour (statistic larger, bold, white)
      • conditional label position (income above, expense and statistic below)
Deneb/Vega-Lite JSON Code:
{
  "title": {
    "anchor": "start",
    "align": "left",
    "offset": 20,
    "text": "Power BI Financial Waterfall using Deneb",
    "font": "Verdana",
    "fontSize": 24,
    "fontWeight": "bold",
    "fontStyle": "normal",
    "subtitle": "Data Source: synthetic data",
    "subtitleFont": "Verdana",
    "subtitleFontSize": 16,
    "subtitleFontWeight": "normal",
    "subtitleFontStyle": "italic"
  },
  "data": {
    "name": "dataset"
  },
  "params": [
    {
      "name": "_percent_target",
      "value": "of Gross Revenue",
      "bind": {
        "input": "radio",
        "options": [
          "of Gross Revenue",
          "of Previous Statistic"
        ],
        "name": "Percent: "
      }
    }
  ],
  "transform": [
    {
      "calculate": "pad( datum['ID'], 2, '0', 'left' ) + '-' + slice( datum['Type'], 0, 1 ) + '-' + datum['Item with Delimiter']",
      "as": "_item_id_type_name"
    },
    {
      "calculate": "datum['Type'] == 'Income' ? datum['Amount'] : datum['Type'] == 'Expense' ? -1 * datum['Amount'] : 0",
      "as": "_increment_amount"
    },
    {
      "window": [
        {
          "op": "sum",
          "field": "_increment_amount",
          "as": "_top_amount"
        }
      ],
      "sort": [
        {
          "field": "ID",
          "order": "ascending"
        }
      ],
      "frame": [
        null,
        0
      ]
    },
    {
      "calculate": "datum['Type'] == 'Statistic' ? 0 : datum['_top_amount'] - datum['_increment_amount']",
      "as": "_bottom_amount"
    },
    {
      "calculate": "datum['Item'] == 'Gross Revenue' ? datum['Amount'] : 0",
      "as": "_gross_revenue"
    },
    {
      "calculate": "datum['Item'] == 'Net Revenue' ? datum['Amount'] : 0",
      "as": "_net_revenue"
    },
    {
      "calculate": "datum['Item'] == 'Gross Profit' ? datum['Amount'] : 0",
      "as": "_gross_profit"
    },
    {
      "calculate": "datum['Item'] == 'Operating Profit' ? datum['Amount'] : 0",
      "as": "_operating_profit"
    },
    {
      "calculate": "datum['Item'] == 'Earnings Before Income Tax' ? datum['Amount'] : 0",
      "as": "_earnings_before_income_tax"
    },
    {
      "joinaggregate": [
        {
          "op": "sum",
          "field": "_gross_revenue",
          "as": "_gross_revenue_amount"
        }
      ]
    },
    {
      "joinaggregate": [
        {
          "op": "sum",
          "field": "_net_revenue",
          "as": "_net_revenue_amount"
        }
      ]
    },
    {
      "joinaggregate": [
        {
          "op": "sum",
          "field": "_gross_profit",
          "as": "_gross_profit_amount"
        }
      ]
    },
    {
      "joinaggregate": [
        {
          "op": "sum",
          "field": "_operating_profit",
          "as": "_operating_profit_amount"
        }
      ]
    },
    {
      "joinaggregate": [
        {
          "op": "sum",
          "field": "_earnings_before_income_tax",
          "as": "_earnings_before_income_tax_amount"
        }
      ]
    },
    {
      "calculate": "datum['Amount'] / datum['_gross_revenue_amount']",
      "as": "_percent_of_gross_revenue"
    },
    {
      "calculate": "datum['Item'] == 'Returns' ? datum['Amount'] / datum['_gross_revenue_amount'] : datum['Item'] == 'Cost of Goods Sold' ? datum['Amount'] / datum['_net_revenue_amount'] : datum['Item'] == 'Sales and Marketing' ? datum['Amount'] / datum['_gross_profit_amount'] : datum['Item'] == 'Research and Development' ? datum['Amount'] / datum['_gross_profit_amount'] : datum['Item'] == 'General and Administrative' ? datum['Amount'] / datum['_gross_profit_amount'] : datum['Item'] == 'Cost of Interest on Debt' ? datum['Amount'] / datum['_operating_profit_amount'] : datum['Item'] == 'Income Tax' ? datum['Amount'] / datum['_earnings_before_income_tax_amount'] : null",
      "as": "_percent_of_previous_statistic"
    },
    {
      "calculate": "_percent_target == 'of Previous Statistic' ? datum['_percent_of_previous_statistic'] : datum['_percent_of_gross_revenue']",
      "as": "_percent"
    },
    // label arrays
    {
      "calculate": "datum['Amount'] < 10000 ? format( datum['Amount'], '.2s' ) : format( datum['Amount'], '.3s' )",
      "as": "_label_amount"
    },
    {
      "calculate": "isValid( datum['_percent'] ) ? datum['_percent'] < 0.2 ? format( datum['_percent'], '.1%' ) : format( datum['_percent'], '.0%' ) : null",
      "as": "_label_percent"
    },
    {
      "calculate": "[ datum['_label_amount'], datum['_label_percent'] ]",
      "as": "_label_array"
    }
  ],
  "spacing": 30,
  "vconcat": [
    {
      "name": "LEGEND",
      "width": 1180,
      "height": 1,
      "data": {
        "values": [
          {
            "legend_id": 1,
            "legend_size": 2,
            "legend_label": "Statistic"
          },
          {
            "legend_id": 2,
            "legend_size": 1,
            "legend_label": "Income"
          },
          {
            "legend_id": 3,
            "legend_size": 1,
            "legend_label": "Expense"
          }
        ]
      },
      "mark": {
        "type": "arc",
        "radius": 0
      },
      "encoding": {
        "theta": {
          "field": "legend_size",
          "type": "quantitative"
        },
        "color": {
          "field": "legend_label",
          "type": "nominal",
          "scale": {
            "domain": [
              "Statistic",
              "Income",
              "Expense"
            ],
            "range": [
              "#0F4C81",
              "#217356",
              "#7F2E42"
            ]
          },
          "legend": {
            "direction": "horizontal",
            "title": null,
            "orient": "none",
            "legendX": -60,
            "labelColor": "black",
            "labelFont": "Segoe UI",
            "labelFontStyle": "italic",
            "labelFontSize": {
              "expr": "datum.value == 'Statistic' ? 14 : 12"
            },
            "labelFontWeight": {
              "expr": "datum.value == 'Statistic' ? 'bold' : 'normal'"
            },
            "labelOffset": {
              "expr": "datum.value == 'Statistic' ? 8 : 4"
            },
            "labelBaseline": "top",
            "symbolType": "circle",
            "symbolSize": {
              "expr": "datum.value == 'Statistic' ? 600 : 300"
            },
            "symbolOpacity": 0.7
          }
        }
      }
    },
    {
      "name": "WATERFALL",
      "width": 1180,
      "height": 510,
      "encoding": {
        "x": {
          "field": "_item_id_type_name",
          "type": "nominal",
          "axis": {
            "title": null,
            "labelAngle": 0,
            "labelExpr": "split( slice( datum.value, 5, 100 ), '|' )",
            "labelFontWeight": {
              "expr": "slice( datum.value, 3, 4 ) == 'S' ? 'bold' : 'normal'"
            },
            "labelFontSize": {
              "expr": "slice( datum.value, 3, 4 ) == 'S' ? 14 : 10"
            }
          }
        },
        "y": {
          "field": "_top_amount",
          "type": "quantitative",
          "axis": {
            "title": "Amount",
            "labelExpr": "datum.value == 0 ? 0 : format( datum.value, '.2s')"
          },
          "scale": {
            "domain": [
              0,
              160000
            ]
          }
        },
        "y2": {
          "field": "_bottom_amount"
        }
      },
      "layer": [
        {
          "name": "COLUMN_CHART",
          "mark": {
            "type": "bar",
            "color": {
              "expr": "datum['Type'] == 'Income' ? '#217356' : datum['Type'] == 'Expense' ? '#7F2E42' : '#0F4C81'"
            },
            "opacity": 0.7
          }
        },
        {
          "name": "DATA_LABELS",
          "mark": {
            "type": "text",
            "fontStyle": "italic",
            "fontWeight": {
              "expr": "datum['Type'] == 'Statistic' ? 'bold' : 'normal'"
            },
            "fontSize": {
              "expr": "datum['Type'] == 'Statistic' ? 16 : 12"
            },
            "yOffset": {
              "expr": "datum['Type'] == 'Income' && isValid( datum['_percent'] ) ? -22 : datum['Type'] == 'Income' && ! isValid( datum['_percent'] ) ? -10 : 10"
            },
            "color": {
              "expr": "datum['Type'] == 'Statistic' ? 'white' : 'black'"
            }
          },
          "encoding": {
            "text": {
              "field": "_label_array"
            }
          }
        }
      ]
    }
  ]
}

Also included is the development sample PBIX using the synthetic dataset described above.

The intent of this example was not to provide a finished visual, but rather to explore the use of the Deneb custom visual and the Vega-Lite language within Power BI and to serve as a starting point for further development.

This example is provided as-is for information purposes only, and its use is solely at the discretion of the end user; no responsibility is assumed by the author.

Greg
Deneb Example - Financial Waterfall - V9.pbix (2.3 MB)

3 Likes

marking as solved

Amazing! Do you have a stacked version of a waterfall chart in your portfolio?

Hi @bartus2409200121

It is discouraged to make a new post on a solved thread. Please start a new thread and link to an existing forum post if it help explain your issue.
_eDNA Forum - Link to existing post

To help us further analyze your current state and visualize your issue, could you please provide as many as you can of:

  • Your work-in-progress PBIX file, using sanitized data if necessary
  • Your dataset as an Excel file (again, sanitized if necessary)
  • A detailed mock-up (marked-up screenshot of the visual in question or Excel or PowerPoint file) showing your desired outcome.

Also, if you provide DAX, Power Query, or Deneb JSON code in your post, please format it using the built-in formatter.

Greg
_eDNA Forum - Format DAX or PQ

Amazing stuff as per usual.

1 Like