Build a Power BI Progress Thermometer With Deneb and Vega

Power BI includes several ways to display progress toward a target, but a custom thermometer provides a compact alternative to standard charts and gauges. Deneb makes it possible to build this type of visual directly in Power BI using the Vega visualization grammar.

This example creates a vertical thermometer that displays a current value, a target, and the percentage of the target reached. The same visual can represent sales quotas, project completion, production goals, fundraising totals, or other measures of numeric progress.

The thermometer can display currency, quantities, or any other numeric measure. Dates, durations, and text-based statuses must first be converted into suitable numeric measures.

Screenshot of the custom progress thermometer visual using Deneb and Vega in Power BI Desktop.
Power BI Desktop: Custom Progress Thermometer Visual

Data Setup

The visual uses two numeric values:

The specification uses CurrentValue and Target as its default field names. The currentValueField and targetField signals can be changed to match the measure names used in a report.

The specification reads the first row of the Deneb dataset. The current value and target must therefore resolve to a single row for the intended result. A dataset containing multiple rows is not automatically aggregated by this specification.

The target should be greater than zero. A zero or negative target does not provide a meaningful scale for measuring progress.

The embedded dataset definition contains a single sample row, which allows the specification to be previewed in the Vega Editor before it is connected to Power BI. The currentValueDefault and targetDefault signals, both set to 0, apply only when the dataset contains no rows. Once the visual uses the Power BI dataset, the values come from that dataset instead.

How the Vega Specification Works

The visual combines signals, a data source, a linear scale, and graphical marks to construct the thermometer. Signals control the input fields, number formatting, dimensions, colors, and other display settings.

The complete Vega specification appears in the Source Code section at the end of this article. It can be pasted into the Deneb editor with the included sample data.

Configure the Signals

The valueFormat signal controls how numeric values are displayed. Its default format, $,.0f, displays values as whole-dollar amounts with thousands separators. Other numeric formats can be used for quantities, percentages, or values that do not represent currency. For example, ,.0f displays whole numbers with thousands separators but no currency symbol.

Color signals control the background, thermometer fill, track, outline, tick marks, and labels. Font family, text colors, label sizes, and line widths are also configurable. Changing these signals provides a straightforward way to adapt the visual to a report’s design.

Calculate Progress

The specification calculates the percentage of the target reached.

A current value of 7,300 against a target of 10,000 produces a progress value of 0.73, or 73%.

The calculation is constrained to the range from zero to one. This keeps the thermometer fill within its track when the current value is negative or exceeds the target. When the current value is greater than the target, the fill remains at 100%, while the numeric value label can still show the actual current value.

Build the Scale and Tick Marks

A linear scale maps the numeric target range to positions along the thermometer. Tick marks and labels provide reference points so the fill can be interpreted without relying solely on the percentage label.

The tickCount signal controls the number of intervals on the scale. With the default value of five, the scale displays six tick marks, including the endpoints at zero and the target.

Draw the Thermometer

The thermometer is constructed from several Vega marks:

The scale starts at the bottom of the bulb, so the bulb fills first. At the default dimensions, the fill reaches the top of the bulb at roughly 20 percent of the target, then continues up the tube.

The fill uses red in the supplied specification, while the track and wall use gray tones.

Adjust the Layout

The containerWidth and containerHeight signals control the overall size, and the example uses 300 by 500 pixels. The tube height and horizontal position are calculated from these signals, but values such as tubeWidth, bulbRadius, and the gutters remain fixed. If the visual is much narrower than the 300 pixel default, adjust those values.

Connect the Visual to Power BI

After testing the specification with its sample data, connect it to the report’s measures. This example was built with Deneb 2.0.0 using Vega 6.

  1. Add the Deneb visual to the report.
  2. Select Vega as the visualization language.
  3. Add CurrentValue and Target to Values.

Deneb exposes each data field to Vega by the name shown in the Values field well, so the names must match the specification. If the model columns use different names, rename the fields in the Values field well or set the currentValueField and targetField signals to those names.

  1. Add the Vega specification.
  2. Replace the embedded dataset definition, including the sample values, with the definition shown below.
{ "name": "dataset" }
  1. Optionally, connect the size of the thermometer to the size of the Power BI visual by replacing the containerWidth and containerHeight signals with the following definitions:
{ "name": "containerWidth", "update": "denebContainer.width - 10" },
{ "name": "containerHeight", "update": "denebContainer.height - 10" }

The denebContainer.width and denebContainer.height values report the size of the Power BI visual. The subtraction leaves a small margin around the thermometer. If scrollbars appear, increase the amount subtracted. These values are provided by Deneb, so the fixed dimensions remain useful when testing the specification elsewhere, such as the Vega Editor.

  1. Apply the specification and confirm that the thermometer displays the expected value, target, and percentage.
Screenshot of the Deneb configuration in Power BI Desktop.
Power BI Desktop: Deneb Configuration

Results

The completed visual provides a compact view of progress toward a numeric target. It displays the current value, target scale, and percentage reached in a thermometer layout.

The result should have the following characteristics:

The same visual can be reused for different measures by changing the field names, formatting, and appearance.

Troubleshooting

The following are common issues when connecting the specification to a report.

Source Code

{
  "$schema": "https://vega.github.io/schema/vega/v6.json",
  "description": "Progress thermometer.",
  "padding": { "signal": "chartPadding" },
  "background": { "signal": "backgroundColor" },
  "signals": [
    { "name": "currentValueField", "value": "CurrentValue" },
    { "name": "targetField", "value": "Target" },
    { "name": "currentValueDefault", "value": 0 },
    { "name": "targetDefault", "value": 0 },
    { "name": "currentValue", "update": "data('dataset').length ? toNumber(data('dataset')[0][currentValueField]) || 0 : currentValueDefault" },
    { "name": "target", "update": "data('dataset').length ? toNumber(data('dataset')[0][targetField]) || 0 : targetDefault" },
    { "name": "containerWidth", "value": 300 },
    { "name": "containerHeight", "value": 500 },
    { "name": "tickCount", "value": 5 },
    { "name": "valueFormat", "value": "$,.0f" },
    { "name": "percentFormat", "value": ".0%" },
    { "name": "tubeWidth", "value": 40 },
    { "name": "minTubeHeight", "value": 60 },
    { "name": "bulbRadius", "value": 48 },
    { "name": "wallWidth", "value": 6 },
    { "name": "fillGap", "value": 5 },
    { "name": "chartPadding", "value": 10 },
    { "name": "topPad", "value": 20 },
    { "name": "bottomPad", "value": 10 },
    { "name": "leftGutter", "value": 80 },
    { "name": "rightGutter", "value": 100 },
    { "name": "tickLength", "value": 8 },
    { "name": "tickGap", "value": 6 },
    { "name": "tickLabelGap", "value": 6 },
    { "name": "tickWidth", "value": 1 },
    { "name": "markerWidth", "value": 2 },
    { "name": "markerGap", "value": 10 },
    { "name": "backgroundColor", "value": "#ffffff" },
    { "name": "fillColor", "value": "#d62728" },
    { "name": "trackColor", "value": "#f3f3f3" },
    { "name": "wallColor", "value": "#8c8c8c" },
    { "name": "tickColor", "value": "#8c8c8c" },
    { "name": "markerColor", "value": "#d62728" },
    { "name": "textColor", "value": "#333333" },
    { "name": "bulbTextColor", "value": "#ffffff" },
    { "name": "bulbTextHaloColor", "value": "#7a1415" },
    { "name": "fontFamily", "value": "sans-serif" },
    { "name": "fontSize", "value": 13 },
    { "name": "levelFontSize", "value": 15 },
    { "name": "bulbFontSize", "value": 18 },
    { "name": "labelFontWeight", "value": "bold" },
    { "name": "bulbTextHaloWidth", "value": 4 },
    { "name": "pct", "update": "target > 0 ? clamp(currentValue / target, 0, 1) : 0" },
    { "name": "width", "update": "containerWidth - 2 * chartPadding" },
    { "name": "height", "update": "containerHeight - 2 * chartPadding" },
    { "name": "contentWidth", "update": "leftGutter + 2 * (bulbRadius + wallWidth) + rightGutter" },
    { "name": "cx", "update": "max(0, (width - contentWidth) / 2) + leftGutter + wallWidth + bulbRadius" },
    { "name": "tubeHeight", "update": "max(minTubeHeight, height - topPad - bulbRadius - wallWidth - bottomPad)" },
    { "name": "bulbCy", "update": "topPad + tubeHeight" },
    { "name": "tubeLeft", "update": "cx - tubeWidth / 2" },
    { "name": "tubeRight", "update": "cx + tubeWidth / 2" },
    { "name": "fillWidth", "update": "tubeWidth - 2 * fillGap" },
    { "name": "scaleTop", "update": "topPad + fillGap" },
    { "name": "scaleBottom", "update": "bulbCy + bulbRadius - fillGap" },
    { "name": "level", "update": "scaleBottom - pct * (scaleBottom - scaleTop)" },
    { "name": "fillTopRadius", "update": "pct >= 1 ? fillWidth / 2 : 0" },
    { "name": "levelEdge", "update": "abs(level - bulbCy) < bulbRadius + wallWidth ? cx + sqrt(pow(bulbRadius + wallWidth, 2) - pow(level - bulbCy, 2)) : 0" },
    { "name": "markerX", "update": "max(tubeRight + wallWidth, levelEdge) + tickGap" }
  ],
  "data": [
    { "name": "dataset", "values": [{ "CurrentValue": 7300, "Target": 10000 }] },
    {
      "name": "ticks",
      "transform": [
        { "type": "sequence", "start": 0, "stop": { "signal": "tickCount + 1" }, "step": 1 },
        { "type": "formula", "as": "value", "expr": "datum.data * target / tickCount" }
      ]
    }
  ],
  "scales": [
    {
      "name": "y",
      "type": "linear",
      "domain": [0, { "signal": "target" }],
      "range": [{ "signal": "scaleBottom" }, { "signal": "scaleTop" }]
    }
  ],
  "marks": [
    {
      "type": "rect",
      "name": "wallTube",
      "encode": {
        "update": {
          "x": { "signal": "tubeLeft - wallWidth" },
          "x2": { "signal": "tubeRight + wallWidth" },
          "y": { "signal": "topPad - wallWidth" },
          "y2": { "signal": "bulbCy" },
          "cornerRadiusTopLeft": { "signal": "tubeWidth / 2 + wallWidth" },
          "cornerRadiusTopRight": { "signal": "tubeWidth / 2 + wallWidth" },
          "fill": { "signal": "wallColor" }
        }
      }
    },
    {
      "type": "symbol",
      "name": "wallBulb",
      "encode": {
        "update": {
          "shape": { "value": "circle" },
          "x": { "signal": "cx" },
          "y": { "signal": "bulbCy" },
          "size": { "signal": "pow(2 * (bulbRadius + wallWidth), 2)" },
          "fill": { "signal": "wallColor" }
        }
      }
    },
    {
      "type": "rect",
      "name": "trackTube",
      "encode": {
        "update": {
          "x": { "signal": "tubeLeft" },
          "x2": { "signal": "tubeRight" },
          "y": { "signal": "topPad" },
          "y2": { "signal": "bulbCy" },
          "cornerRadiusTopLeft": { "signal": "tubeWidth / 2" },
          "cornerRadiusTopRight": { "signal": "tubeWidth / 2" },
          "fill": { "signal": "trackColor" }
        }
      }
    },
    {
      "type": "symbol",
      "name": "trackBulb",
      "encode": {
        "update": {
          "shape": { "value": "circle" },
          "x": { "signal": "cx" },
          "y": { "signal": "bulbCy" },
          "size": { "signal": "pow(2 * bulbRadius, 2)" },
          "fill": { "signal": "trackColor" }
        }
      }
    },
    {
      "type": "group",
      "name": "fillGroup",
      "encode": {
        "update": {
          "width": { "signal": "width" },
          "y": { "signal": "level" },
          "height": { "signal": "height - level" },
          "clip": { "value": true }
        }
      },
      "marks": [
        {
          "type": "rect",
          "name": "fillTube",
          "encode": {
            "update": {
              "x": { "signal": "tubeLeft + fillGap" },
              "x2": { "signal": "tubeRight - fillGap" },
              "y": { "value": 0 },
              "y2": { "signal": "bulbCy - level" },
              "cornerRadiusTopLeft": { "signal": "fillTopRadius" },
              "cornerRadiusTopRight": { "signal": "fillTopRadius" },
              "fill": { "signal": "fillColor" }
            }
          }
        },
        {
          "type": "symbol",
          "name": "fillBulb",
          "encode": {
            "update": {
              "shape": { "value": "circle" },
              "x": { "signal": "cx" },
              "y": { "signal": "bulbCy - level" },
              "size": { "signal": "pow(2 * (bulbRadius - fillGap), 2)" },
              "fill": { "signal": "fillColor" }
            }
          }
        }
      ]
    },
    {
      "type": "text",
      "name": "percentLabelHalo",
      "encode": {
        "update": {
          "x": { "signal": "cx" },
          "y": { "signal": "bulbCy" },
          "text": { "signal": "format(pct, percentFormat)" },
          "align": { "value": "center" },
          "baseline": { "value": "middle" },
          "fontWeight": { "signal": "labelFontWeight" },
          "font": { "signal": "fontFamily" },
          "fontSize": { "signal": "bulbFontSize" },
          "fill": { "signal": "bulbTextHaloColor" },
          "stroke": { "signal": "bulbTextHaloColor" },
          "strokeWidth": { "signal": "bulbTextHaloWidth" },
          "strokeJoin": { "value": "round" }
        }
      }
    },
    {
      "type": "text",
      "name": "percentLabel",
      "encode": {
        "update": {
          "x": { "signal": "cx" },
          "y": { "signal": "bulbCy" },
          "text": { "signal": "format(pct, percentFormat)" },
          "align": { "value": "center" },
          "baseline": { "value": "middle" },
          "fontWeight": { "signal": "labelFontWeight" },
          "font": { "signal": "fontFamily" },
          "fontSize": { "signal": "bulbFontSize" },
          "fill": { "signal": "bulbTextColor" }
        }
      }
    },
    {
      "type": "rule",
      "name": "tickMarks",
      "from": { "data": "ticks" },
      "encode": {
        "update": {
          "x": { "signal": "abs(scale('y', datum.value) - bulbCy) < bulbRadius + wallWidth ? cx - sqrt(pow(bulbRadius + wallWidth, 2) - pow(scale('y', datum.value) - bulbCy, 2)) - tickGap - tickLength : tubeLeft - wallWidth - tickGap - tickLength" },
          "x2": { "signal": "abs(scale('y', datum.value) - bulbCy) < bulbRadius + wallWidth ? cx - sqrt(pow(bulbRadius + wallWidth, 2) - pow(scale('y', datum.value) - bulbCy, 2)) - tickGap : tubeLeft - wallWidth - tickGap" },
          "y": { "scale": "y", "field": "value" },
          "stroke": { "signal": "tickColor" },
          "strokeWidth": { "signal": "tickWidth" }
        }
      }
    },
    {
      "type": "text",
      "name": "tickLabels",
      "from": { "data": "ticks" },
      "encode": {
        "update": {
          "x": { "signal": "abs(scale('y', datum.value) - bulbCy) < bulbRadius + wallWidth ? cx - sqrt(pow(bulbRadius + wallWidth, 2) - pow(scale('y', datum.value) - bulbCy, 2)) - tickGap - tickLength - tickLabelGap : tubeLeft - wallWidth - tickGap - tickLength - tickLabelGap" },
          "y": { "scale": "y", "field": "value" },
          "text": { "signal": "format(datum.value, valueFormat)" },
          "align": { "value": "right" },
          "baseline": { "value": "middle" },
          "font": { "signal": "fontFamily" },
          "fontSize": { "signal": "fontSize" },
          "fill": { "signal": "textColor" }
        }
      }
    },
    {
      "type": "rule",
      "name": "levelMarker",
      "encode": {
        "update": {
          "x": { "signal": "markerX" },
          "x2": { "signal": "markerX + tickLength" },
          "y": { "signal": "level" },
          "stroke": { "signal": "markerColor" },
          "strokeWidth": { "signal": "markerWidth" }
        }
      }
    },
    {
      "type": "text",
      "name": "levelLabel",
      "encode": {
        "update": {
          "x": { "signal": "markerX + tickLength + markerGap" },
          "y": { "signal": "level" },
          "text": { "signal": "format(currentValue, valueFormat)" },
          "baseline": { "value": "middle" },
          "fontWeight": { "signal": "labelFontWeight" },
          "font": { "signal": "fontFamily" },
          "fontSize": { "signal": "levelFontSize" },
          "fill": { "signal": "markerColor" }
        }
      }
    }
  ]
}

Summary

Deneb and Vega make it possible to build a custom progress thermometer for Power BI without a dedicated custom visual. The specification combines a numeric scale, calculated progress, and graphical marks to display a current value against a target.

The visual can be adapted to different numeric measures by changing the field names, number formatting, colors, and dimensions. Its single-row data requirement and numeric-value limitation should be considered when connecting it to a report.