2025 Week 33 | Create a Circular Heatmap with Deneb

Introduction

It’s good to be back again, and a bit sooner than last time! This time, we will be creating a visual using Deneb. Deneb has featured a lot in WoW, which I’m very grateful for, but I’ve yet to submit and exercise for it. As such, I wanted to try something that would help those who are undecided or think it’s a lot of work to see what might be possible and provide some guided exposure to many of its features in a single visual. Of course, you may decide it’s still not for you, and that’s OK! 😊

For this workout, we will apply a different lens to the data we worked with in the box plot exercise we completed a couple of months ago.

In that exercise, we utilized the error bars feature to modify a core column (or bar) chart into something that isn’t conventionally available. We’re seeing all kinds of innovative ideas emerge that utilize these new visual features, particularly when combined with other innovations, such as visual calculations.

While box plots provide concise summary information, they do not reveal much about the internals of the data distribution. There are many ways to unpack this (some being closely tied to the box plot concept, such as a raincloud plot), but given the nature of the dataset we’ll work with, I’d like to take this opportunity to explore and build a circular (or radial) heatmap.

As before, I’d like to take the opportunity to provide theory as well as practice, so you’re welcome to skip ahead to the Requirements section if you want to get on with creating.

A Personal Anecdote

What helped me develop these skills was starting to understand the concept of the “grammar of graphics,” which – to try and put it as succinctly as possible – is to think beyond the constraints of a specific type of chart you might choose from a palette like you do in Power BI or Excel and to approach a visualization challenge for a different angle: understanding how charts are composed from the perspective of primitive shapes and how their attributes can be bound to our data.

If you are limited to the core visuals, this mindset can help you to think differently about what you can do with their capabilities if they feel too restrictive.

But this does require you to practice your skills. Many tools and frameworks exist in our field to support this concept and develop with, such as ggplot2 for R, Vega (and Vega-Lite), Observable Plot, and Tableau.

For these reasons, I developed Deneb so that it would at least be easier to explore my data and prototype a design using either Vega or Vega-Lite directly inside Power BI without having to put a lot of effort into researching and developing a custom visual.

By using a higher-level graphical language, I could learn how my data is bound to shapes, layers, and other concepts, and how I can do more with what is available. It is also a great way to determine how you might need to break something down into SVG if you want to leverage it in other core visuals.

So, even if you never intend to use a custom visual like Deneb in a production report, it can still be an excellent tool for practicing and exploring ideas.

Dataset

As we did last time, we will analyze a dataset sourced from Stats New Zealand, which in turn is derived from the National Institute of Water and Atmospheric Research (NIWA)’s seven-station temperature series from 1909 to 2022.

You can learn more about this dataset and its findings here.

This time, our visual design works with individual data points, which (if we’re using the entire dataset) can make us have to think about a lot of design aspects that go beyond the scope of our introduction, so keep us on the straight and narrow, this dataset has been filtered down to the final 5 years (2018–2022).

We are going to use one column and one measure for our visual dataset:

  • Month End from our Date table, which groups multiple dates into a single month.
  • Mean Temperature from our Daily Readings table

Again, I’ve already prepared this data into a starter workbook that you can use for your work. The complete workbook, which includes our finished recipe, also utilizes this data.

For those who want the entire dataset to play with, you can get this from the Box Plot workout.

Our Circular Heatmap

The idea behind this visual concept is to visualize the time series of our data like the rings of a tree (typically analyzed using dendrochronology).

  • We will have a “ring” for each year of our data, and each month will be represented around the ring, allowing us to see not only the seasonality of our data but also how it varies for the same period each year.
  • We will shade the data in our chart from the lowest monthly mean temperature to the highest monthly mean temperature using a gradient ranging from blue to red.
  • Note that to keep the number of moving parts in this exercise to a minimum, we are adding labels for the months and years. We aren’t labelling each data point, but we will include the steps for you to add a tooltip for when the user hovers over them. From an accessibility standpoint, one of the many beneficial aspects of Vega-Lite is that it automatically generates alternative text for each mark to support screen readers.

You can, of course, experiment with adding labels and some of the challenges with labelling a gradient chart (such as the contrast of your text against the background).

And because I couldn’t resist going further with an idea, I have also added a few riffs on where I think you can take the design. Still, the best thing about using the grammar of graphics approach is that folks who are better designers than I (basically everyone) can teach me how to make better visuals. If you go further or have a great idea, I would love to see how you’re getting on, and so would our awesome community, if you’re willing to share too!

Thinking Like a Circular Heatmap

The Vega languages typically favor Cartesian (X/Y) coordinates and have support for geospatial data. They are somewhat limited when it comes to polar coordinates, which is how many radial plots are constructed.

Vega-Lite has native language support for two types of marks – arc and text – which allows you to encode them using polar channels, and we’ll explore how we can leverage them to design our chart.

Side note: with enough knowledge of trigonometric functions, the languages are powerful enough for you to convert marks that don’t have native polar support to a suitable polar representation of x/y coordinates. This is beyond the scope of today’s lesson, but it’s a lot of fun if you like that kind of stuff  😉

For the marks that do support polar coordinates, they have two key encoding channels:

  • radius – which specifies how far from the center of the visual the mark is plotted.
  • theta – the angle around the plot area (starting from zero) that the mark is plotted.

For the arc mark, each channel has a second value that you can use to control further the placement of each arc’s start and end. As we will be using ordinal (discrete, ordered) scales for our data, Vega-Lite will handle this for us. For a more detailed view, the documentation includes a great interactive example.

Radius

For our visual, we can take the year component of our Month End field and map this to the radius encoding channel. As our dataset has five distinct year values, and by mapping this to our channel, we would get something like the following layout for our data:

Theta

Similarly, we can take the month component of our Month End field and map this to the theta encoding channel. As our dataset has twelve distinct month values, and by mapping this to our channel, we would get something like the following layout for our data:

Combining the Channels

When we encode both radius and theta, we now get an arc for each data point, representing an intersection of month and year:

 

Layout Composition

We will use the concept of Vega-Lite’s layers to build our chart, with the following composition:

  1. An arc mark, representing each data point.
  2. A text mark, representing a label for each unique year in our dataset.
  3. A text mark, representing a label for each unique month in our dataset.

To achieve this, we will also employ several techniques that demonstrate the expressiveness of the Vega-Lite language and hopefully provide you with some inspiration for your own visual ideas.

What We're Building

Requirements

Note that I have added a significant amount of exposition to each step (in italics). This is not necessary knowledge to complete the exercise, which you can achieve in a few minutes if you follow the regular text. However, I like to provide insight into some of my design decisions, as well as offer additional learning resources for those who want to understand the languages or the process in more detail. You can skip over these notes if you’re keen to take the lead and explore on your own. Happy vizzing!

Step 1: Add Deneb to Your Report

First, we will acquire the Deneb visual. If you don’t have it pinned to your reports like I do 😉, you can get this from AppSource (the custom visual store) in Power BI.

If you haven’t used custom visuals before, you can find out how to acquire them here.

You’ll want to search for, or find Deneb:

Click on the option to add to your report, and after a short time, you will see Deneb in the visual palette.

Click on the Deneb icon to add it to your report and size it so that it is large enough to hold our example (about 450 wide x 450 high).

Step 2: Adding Data and Setting Up Your Specification

If you haven’t used Deneb before, it works slightly differently from other Power BI visuals. Most visuals have defined roles that determine how data is mapped, such as X Axis, Y Axis, Legend, or Tooltips. Because Deneb is flexible in terms of how you build your visual, these roles are entirely up to you, so you need to think of your visual dataset like a table visual.

You can read more about what this means in the documentation for Deneb, or the sample workbook you can download from its store listing.

Deneb requires a dataset to function, so drag the following into Deneb’s Values bucket:

  • Month End column (from Date)
  • Mean Temperature measure (from Daily Readings)

Click on the ellipsis (…) in the visual header and choose ‘Edit’:

This will open the Create or import new specification screen, and is how you will set up your design.

Under Create using… select Vega-Lite and then [empty (with Power BI theming)].

Click the Create button.

You will now be taken to the advanced editor, where you can see an area to edit your Vega-Lite specification, an empty preview area, and a debug area, where you can see your dataset, e.g.:

 

You can learn more about the editor UI in Deneb’s documentation.

Step 3: Create the arc Mark

Our goal is to create incremental changes that allow you to appreciate the process and understand how you can influence your design. Any time you make a change, you can click the Apply button (or press [Ctrl + Enter] to see your changes take effect.

In the layer array (between the square brackets), add an arc mark with the following setup (changes are in bold, but the whole JSON specification is included if you want to copy/paste):

{
"data": {
  "name": "dataset"
},
"layer": [
  {
    "mark": {
      "type": "arc",
      "stroke": "white"
    }
  }
]
}

Click the Apply button and you’ll see a complete circle.

This is our arc mark, without any encoding of our data, so it currently appears as a circle. There are actually 60 of these circles superimposed over the top of each other – one for each unique row in our dataset, but we’ll change that next.

Step 4: Add the radius Encoding Channel

At the top level of the specification (outside the layer definition), add an encoding object, with the radius channel configuration:

{
"data": {
  "name": "dataset"
},
"layer": [
  ...
],
"encoding": {
  "radius": {
    "field": "Month End",
    "timeUnit": "year",
    "type": "ordinal"
  }
}
}

Click the Apply button and observe the changes:

This now encodes the year portion of our date to an ordered position for our radius.

Further reading:

It’s also worth noting that you don’t need to use this method if you don’t want to. You could achieve this just as easily by adding a Year column from your Date table to the visual dataset and using it directly. However, one advantage of keeping the number of fields to a minimum is that it makes it easier to share your work, as this creates fewer dependencies for users who want to utilize it. In many cases, it can also maintain optimal query performance against your semantic model.

Step 5: Add the theta Encoding Channel

Underneath our radius encoding, add one for theta:

{
"data": {
  "name": "dataset"
},
"layer": [
   ...   
],
"encoding": {
  "radius": {
     ...
  },
  "theta": {
    "field": "Month End",
    "timeUnit": "month",
    "type": "ordinal"
  }
}
}

Click the Apply button and observe the changes:

Our data points are now encoded to both channels for the month and year components of the Month End field.

Further reading:

Step 6: Radius Inner Padding

In the radius encoding channel, add a scale object and set the minimum range to 50:

{
"data": {
  "name": "dataset"
},
"layer": [
  ...
],
"encoding": {
  "radius": {
    ...
    "type": "ordinal",
    "scale": {
      "rangeMin": 50
    }
  },
  ...
}
}

Click the Apply button and observe the changes:

This sets the inner radius to start 50 pixels from the center, giving us the appearance of a “donut” chart, and ensures that the inner arcs use a slightly larger portion of the circumference.

The range is analogous to the physical translation (pixels) of the data (domain) values in this case, but isn’t always necessarily pixels. It could be the mapping of a data value to a color or some other attribute. You can read more on the range concept here.

Step 7: arc Encoding for color and tooltip

Within our layer definition, in the same section as the arc mark, we will add a local encoding object, with channels for color and tooltip:

{
"data": {
  "name": "dataset"
},
"layer": [
  {
    "mark": {
      ...
    },
    "encoding": {
      "color": {
        "field": "Mean Temperature",
        "type": "quantitative",
        "scale": {
          "scheme": "redblue",
          "reverse": true
        },
        "legend": null
      },
      "tooltip": [
        {
          "field": "Month End",
          "title": "Month/Year",
          "format": "MMM yyyy",
          "formatType": "pbiFormat"
        },
        {
          "field": "Mean Temperature"
        }
      ]
    }
  }
],
"encoding": {
  ...
}
}

Click the Apply button and observe the changes. You can also hover over each arc to see the tooltip apply, e.g.:

I won’t dwell too much on what each specific thing does, as the documentation does this much better than I can, but I’ll point out some of the conscious design decisions made by me in this step (and some useful links where necessary):

  • Not adding the encoding channel at the top-level:
    • Encodings are “normalized” by Vega-Lite so you can think of them as contextual (and inherited).
    • Therefore, adding something at the top level will propagate downwards through any descendant layers unless it is overridden in a specific layer.
    • We will want all layers to use the scales generated by the theta and radius
    • However, our color channel should only affect the arc mark, so we place it in the same layer as the mark definition.
    • The same logic is employed with the tooltip The information we’re displaying is not relevant to the other marks we want to add. As such, we may not have a tooltip channel for those marks, but what’s cool is that we can configure how a tooltip behaves for specific marks or layers if we want to, which gives us a lot more control over the contextual information we present to our end users for certain parts of our visual.
  • Choices for the color encoding:
  • Choices for tooltip values:
    • For the Month End value, I’m customizing the title to be different from the name of the field I’m using, which I could do in the Values bucket, but it may make sense to keep this field name internally to my specification.
    • I’m applying Power BI formatting to the value in the tooltip to have it display differently from that of my data model. Power BI formatting syntax is a feature that Deneb provides, as opposed to Vega-Lite’s syntax, which utilizes D3. You can use whichever version you prefer in Deneb.
    • If Deneb can resolve your field directly to the source dataset, it will do its best to apply any formatting definition that may be there, which is why the Mean Temperature channel setup is simpler. If this cannot be resolved, Deneb also provides a corresponding __formatted field in the dataset for each measure, which gives you direct access (it also works for dynamic format strings, including calculation groups).

Step 8: Make Room for Year Labels

Modify the top-level theta encoding’s scale to have a revised range:

{
"data": {
  "name": "dataset"
},
"layer": [
  ...
],
"encoding": {
  ...
  "theta": {
    "field": "Month End",
    "timeUnit": "month",
    "type": "ordinal",
    "scale": {
      "rangeMin": 0.3,
      "rangeMax": {
        "expr": "2 * PI - 0.3"
      }
    }
  }
}
}

Click the Apply button and observe the changes:

 

This changes the start and end position of the theta channel by 0.3 radians in each direction.

I’ve opted to do this for both directions so that the labels are centered at the top of the chart. You may prefer only to change one direction and customize the text marks accordingly – their properties are very flexible. I also considered overlaying text marks and “haloing” them (example) to create sufficient contrast, but some of my reviewers found it too distracting in this case, hence the decision to make space for them by doing this offset.

Because 360 degrees is equal to 2π radians, we’re using a neat feature called expressions, which enable us to enhance our designs with dynamic, logic-based behavior. This is a simple example, but it demonstrates that you can take designs even further beyond static JSON.

Step 9: Add Year Labels

Add a new entry to the layer definition:

{
"data": {
  "name": "dataset"
},
"layer": [
  {
    ...
  },
  {
    "transform": [
      {
        "calculate": "year(datum['Month End'])",
        "as": "Year"
      },
      {
        "aggregate": [
          {
            "op": "min",
            "field": "Month End",
            "as": "Month End"
          }
        ],
        "groupby": [
          "Year"
        ]
      }
    ],
    "mark": {
      "type": "text"
    },
    "encoding": {
      "text": {
        "field": "Year"
      },
      "theta": {}
    }
  }
],
"encoding": {
  ...
}
}

Click the Apply button and observe the changes:

We now have a label for each unique year derived from our Month End column.

As we did a lot in this step, here’s a brief overview of the strategy for the curious:

  • Add a new layer for the text mark we want to create
  • Add inline transforms specifically for this layer, so it doesn’t affect the others.
  • Extract the year portion of the date in the Month End column as a calculation, named Year.
    • As mentioned earlier, you can add a dedicated column to the dataset from your date table if you find this approach easier; however, it keeps our required dataset minimal by performing the transformation.
  • aggregate the dataset, obtaining the minimum Month End value by our added Year column, which we just calculated.
    • This ensures that we only end up with a dataset for the values we need, rather than all 60 rows of the original dataset.
    • We use the as clause to rename the generated column Month End, the same as our source column.
    • We could call it something else, but keeping the name the same allows us to reuse the top-level encoding channels that reference it. Of course, we don’t need to do this, but it makes the JSON slightly less verbose if we plan to position it the same.
  • Add a text mark, which will represent our value.
  • Add a layer-specific encoding with two channels that we want to apply to this mark:
    • text: bind the displayed text to the calculated Year field in this layer’s stream.
    • theta: make this an empty object ( {} ), which is a neat technique to remove an encoding from a higher level for the same channel. Because the resulting mark has no theta value, it is automatically encoded at the zero-degree position.

Step 10: Add Month Labels

Add a new entry to the layer definition:

{
"data": {
  "name": "dataset"
},
"layer": [
  {
    ...
  },
  {
    ...
  },
  {
    "transform": [
      {
        "calculate": "monthAbbrevFormat(month(datum['Month End']))",
        "as": "Month"
      },
      {
        "aggregate": [
          {
            "op": "max",
            "field": "Month End",
            "as": "Month End"
          }
        ],
        "groupby": [
          "Month"
        ]
      }
    ],
    "mark": {
      "type": "text",
      "radiusOffset": 25
    },
    "encoding": {
      "text": {
        "field": "Month"
      }
    }
  }
],
"encoding": {
  ...
}
}

Click the Apply button and observe the changes:

We now have a label for each unique month in our dataset, correctly encoded to the theta channel.

The strategy for this layer was very similar to the previous one, with the following exceptions:

  • When calculating the Month column, because the month portion of a date is numeric, we use the monthAbbrevFormat function from Vega’s expression language to convert it to a short 3-character representation.
    • Again, a suitable column from a date table could be a substitute for this method.
  • Because we want each month from the last year in our data (for the final radius position), when we aggregate, we group the Month End column by month when we derive the max value.
  • Our encoding object inherits the theta and radius channels from the top level, which is what we want, as each derived date will be in the ‘outer’ ring as they fall in the latest year we have in our dataset. All we do is assign the text channel to our calculated Month
  • However, because our radius encoding would typically put the value in the same place as the arc, we add a radiusOffset in the mark properties, which offsets the text mark an additional 25 pixels from its original encoded position and is a way we can “fine-tune” the placement of our marks if needed without having to do tricky things with the original scale.

We’re Done!

At this point, we have our intended design, which displays our data points in a circular heatmap. The months are encoded in an angular fashion, and the years radiate outwards from the center, with labels created to help highlight the positional meaning.

We’ve also assigned a gradual color value to each mark, allowing us to quickly perceive the coldest months through to the warmest. This gives us an understanding of the seasonality of our data and how the trend over time for each month is revealed by reviewing a specific angular segment of the chart.

To further aid discoverability of each point’s meaning we have added a bespoke tooltip giving access to the month and its mean temperature value.

 

Some Other Ideas

But this is one of many possible ways you can approach this challenge! As I mentioned earlier, the grammar of graphics concept and functionality offered by the Vega languages open up numerous possibilities. I’ve added a couple of other ideas to the complete workbook that might give you some inspiration:

  • Dynamic, central label on hover, showing the current month/year and temperature
  • An alternative to a legend, labelling the high and low data points in-place

In each case, I’ve added comments to the workbook page and specifications to help you see how I arrived at the solution. You can open the visual editor, take a look at these and experiment further.

The most exciting aspect is how you can explore and iterate to help tell more compelling data stories. I hope that this exercise has piqued your interest in how you can think more visually when it comes to your report canvas. Thank you for staying with us!

Share

After you finish your workout, share on social media using the hashtags #WOW2025 and #PowerBI. Tag me (Daniel) on LinkedIn, along with organizers MeaganKerry, and Shannon.

On Bluesky, tag @dm-p.nz@mmarie.bsky.social@shan-gsd.bsky.social, and @merrykerry.bsky.social.

Solution

Leave a Comment

Scroll to Top