Decorative background for title

Report Components: Chart

When text or tables can't quite convey the analysis, there is usually a chart that will do the job. The wide array of possible visualisations makes the Chart Report Component a very versatile option when you want to portray a particular pattern or relationship.

When Should I Use It?

Often, a chart can be a very space-efficient tactic when working with large quantities of data. If you find yourself stuck trying to force your data into a table, take a look at the Highcharts demos to see if any of those spark an idea for how better to visualize your data.

What Is Available?

Under the hood, Chart uses the popular Highcharts library, which has lots of different charting options. Check out the Highcharts demos for examples of possible visualisations. While the Report Components code editor does give intellisense for a lot (but not all) of the Highcharts options, the Highcharts API documentation is a great place to explore the full set of possible configuration. Note that none of the function-typed properties (such as event listeners or formatters) will be enabled.

Example: Deaths by Ability With Player Breakdown

To get started with the Chart component, we're going to build a pie chart. It's recommended to follow along using your own Report Components dashboard.

We're going to build a pie chart that shows the abilities causing deaths in the fight selection. We'll also allow the user to select a slice to drill down and see which players were dying to that ability.

The final result will look like this:

The report used to test this component for this article is this Mythic Jailer Report.

1. Get the relevant data

Unlike some of the previous examples, we don't need to implement any guards for this component. It should work for any encounter and for single or multiple fights, with or without specific players selected. In the event that there are no death events, an empty pie chart will be shown.

This means that we can get straight to gathering the data we need for our chart. All we need are the death events, as these contain information on both the killing ability and the player that died. We can get these with fight.friendlyPlayerDeathEvents (which is cached so will be more efficient than filtering to them ourselves). We can use reportGroup.fights.flatMap to get the friendlyPlayerDeathEvents array for each fight and then concatenate them together:

getComponent = () => {
  const deathEvents = reportGroup.fights
    .flatMap(fight => fight.friendlyPlayerDeathEvents);

  return deathEvents;
}

When run, this will render with the default JsonTree component and let us inspect the data:

2. Build the chart data

Now we need to build the series data for our pie chart. We are aiming to build an array of objects with name and y properties. The name will be the label for the pie chart slice, and y will determine its size. Something like:

const seriesData = [{
  name: 'Ability 1',
  y: 3
}, {
  name: 'Ability 2',
  y: 1
}]

You might have noticed that some of our deathEvents do not specify a killingAbility. This sometimes happens in World of Warcraft when the encounter instantly kills a player, typically if they fall down a hole or off a ledge and leave the encounter space. For these events, the most likely cause of death is falling, so we should fallback to a "Falling" name when the killingAbility isn't present.

Note that you may also find some events that actually do have a "Falling" killingAbility. This is likely from when a player lands on solid ground but takes falling damage that kills them (slightly different to falling off and leaving the encounter space). By giving both of these use cases the same name, we'll end up grouping them together, which makes sense for our purposes.

Let's add a helper function to the bottom of our script for falling back to the "Falling" name if no killingAbility is found:

function getKillingAbilityName(deathEvent) {
  return deathEvent.killingAbility?.name ?? 'Falling';
}

Then we can reduce our deathEvents to group them by name to build the data for our series:

getComponent = () => {
  const deathEvents = reportGroup.fights
    .flatMap(fight => fight.friendlyPlayerDeathEvents);

  const seriesData = deathEvents.reduce((data, event) => {
    const abilityName = getKillingAbilityName(event);
    const datum = data
      .find(x => x.name === abilityName);

    if (datum) {
      datum.y++;
    } else {
      data.push({
        name: abilityName,
        y: 1
      });
    }

    return data;
  }, []);

  return seriesData;
}

Inspecting that with the default JsonTree component shows us:

Which looks good!

3. Render the chart

Now that we have the data in the format our pie chart is expecting, we can return a Chart component. The props for the Chart component match the Highcharts API (as that is the charting library used under the hood). All of the non-function props should be supported, though note that not all of them will appear in intellisense inside the code editor.

We can render a simple pie chart with:

return {
  component: 'Chart',
  props: {
    chart: {
      type: 'pie'
    },
    title: {
      text: 'Deaths by Ability'
    },
    series: [
      {
        name: 'Deaths',
        data: seriesData
      }
    ]
  }
}

Which will look like:

Our full script at the moment looks like:

getComponent = () => {
  const deathEvents = reportGroup.fights
    .flatMap(fight => fight.friendlyPlayerDeathEvents);

  const seriesData = deathEvents.reduce((data, event) => {
    const abilityName = getKillingAbilityName(event);
    const datum = data
      .find(x => x.name === abilityName);

    if (datum) {
      datum.y++;
    } else {
      data.push({
        name: abilityName,
        y: 1
      });
    }

    return data;
  }, []);

  return {
    component: 'Chart',
    props: {
      chart: {
        type: 'pie'
      },
      title: {
        text: 'Deaths by Ability'
      },
      series: [
        {
          name: 'Deaths',
          data: seriesData
        }
      ]
    }
  }
}

function getKillingAbilityName(deathEvent) {
  return deathEvent.killingAbility?.name ?? 'Falling';
}

4. Improve the UI

Our pie chart is functional, but there are definitely improvements we could make.

Add a legend

First off, if we add a legend to our chart then it allows the user to hide/show segments as needed (for example, if there is an ability that they are not interested in).

We can do this by setting plotOptions.pie.showInLegend: true. As our legend may have a lot of options in it, we can also increase its height with legend.maxHeight: 150. Now our returned component is:

return {
  component: 'Chart',
  props: {
    chart: {
      type: 'pie'
    },
    title: {
      text: 'Deaths by Ability'
    },
    plotOptions: {
      pie: {
        showInLegend: true,
      },
    },
    series: [
      {
        name: 'Deaths',
        data: seriesData
      }
    ],
    legend: {
      maxHeight: 150
    }
  }
}

Which adds a legend to our chart:

Color the slices

One thing that is consistent across SWTOR Logs is the coloring of ability names by school. It feels very strange to look at a chart and not have the colors match, so let's fix it!

The data for our series is an array of name & y objects. We can also add color to each object. The sandbox exposes a helper function for getting the ability color from its type: styles.getColorForAbilityType. We can use this to update how we build seriesData:

const seriesData = deathEvents.reduce((data, event) => {
  const abilityName = getKillingAbilityName(event);
  const datum = data
    .find(x => x.name === abilityName);

  if (datum) {
    datum.y++;
  } else {
    data.push({
      name: abilityName,
      color: styles.getColorForAbilityType(
        event.killingAbility?.type ?? 1
      ),
      y: 1
    });
  }

  return data;
}, []);

In the case that killingAbility isn't present, we fallback to a type of 1. This corresponds to the physical damage type which is consistent with our decision to fallback to a "Falling" ability.

Running our new code gives us colors!

Add slice borders

Now that we are re-using colors, we have a new problem. Abilities like Meteor Cleave, Falling, and Misery all have the same school, so are all colored the same and almost blend into the same slice. We can fix this by adding borders to our slices.

This is quite simple, and just involves setting plotOptions.pie.borderColor: 'rgba(0, 0, 0, 0.3)' and plotOptions.pie.borderWidth: 2. Which makes our returned component now:

return {
  component: 'Chart',
  props: {
    chart: {
      type: 'pie'
    },
    title: {
      text: 'Deaths by Ability'
    },
    plotOptions: {
      pie: {
        borderColor: 'rgba(0, 0, 0, 0.3)',
        borderWidth: 2,
        showInLegend: true,
      },
    },
    series: [
      {
        name: 'Deaths',
        data: seriesData
      }
    ],
    legend: {
      maxHeight: 150
    }
  }
}

The borders make it clear where the slice for one ability ends and another starts:

Color the labels

As we are also used to seeing ability names colored, we can make another small change to color the data labels the same color as the slices. This involves setting plotOptions.pie.dataLabels.format, which accepts a Highcharts format string that allows us access to data about the point being labelled, including its name and its color. We will use the following format string:

<span style="color:{point.color};">{point.name}</span>

Which makes our returned component now:

return {
  component: 'Chart',
  props: {
    chart: {
      type: 'pie'
    },
    title: {
      text: 'Deaths by Ability'
    },
    plotOptions: {
      pie: {
        borderColor: 'rgba(0, 0, 0, 0.3)',
        borderWidth: 2,
        showInLegend: true,
        dataLabels: {
          format: '<span style="color:{point.color};">{point.name}</span>'
        }
      },
    },
    series: [
      {
        name: 'Deaths',
        data: seriesData
      }
    ],
    legend: {
      maxHeight: 150
    }
  }
}

Which gives us our colored ability names:

5. Add the drilldown

The Chart Report Component also has the Highcharts Drill Down Module enabled. This lets users click on parts of charts to drill down into another set of data. We can use this to allow our user to click on an ability slice to view a breakdown of which players died to that ability.

First, let's build an array of all of our players. We can do this by looking at fight.friendlyParticipants:

const players = reportGroup.fights
  // flatMap lets us concatenate the friendly participants of all fights together
  .flatMap(fight => fight.friendlyParticipants)
  // Then we filter out any actor that isn't a player (such as pets and NPCs)
  .filter(actor => actor.type === 'Player')
  // Finally, as players may be present for multiple fights,
  // we filter out any duplicates by taking the first player for each ID
  .filter((x, index, array) =>
    array.findIndex(y => x.id === y.id) === index
  );

Now we build the series for our drilldown. We need an array that contains a set of data for each slice that can be drilled into (which in our use case is all of them). We also need to give this set of data a name (shown in tooltips), and an id (used to link a pie slice to its drilldown):

const drilldownSeries = seriesData
  .map(data => ({
    name: data.name,
    id: data.name,
    data: players.map(player => {
      const count = deathEvents
        .filter(event => getKillingAbilityName(event) === data.name &&
                          event.target.id === player.id)
        .length;
      
      return {
        name: player.name,
        color: styles.getColorForActorType(player.subType),
        y: count === 0 ? null : count
      };
    })
  }));

Here's an annotated version as it can get a little murky when trying to reason about the nested data.

const drilldownSeries = seriesData
  // Our `seriesData` array contains an element for each pie slice,
  // so we can `map` from this to make the drilldown slices for each top-level slice.
  .map(data => ({
    // In each set of drilldown slices,
    // we set the `name` and `id` to be the same as the top-level slice.
    name: data.name,
    id: data.name,
    // For the `data` of each set of drilldown slices,
    // we want to show one slice for each player that died to the ability.
    // To do this, we `map` from our `players` array.
    data: players.map(player => {
      // For each player, we count the number of death events
      // for that ability/player combination.
      const count = deathEvents
        .filter(event => getKillingAbilityName(event) === data.name &&
                          event.target.id === player.id)
        .length;
      
      // We set the `name`/`color`/`y` of the drilldown slice.
      return {
        name: player.name,
        // Similar to how we colored the top-level slices by ability,
        // we color the drilldown slices by the actor's `subType`.
        color: styles.getColorForActorType(player.subType),
        // If the player didn't die to this ability,
        // we set `y` to `null` instead of `0`.
        // This ensures the slice doesn't show at all -
        // otherwise it would have 0 width, but still have a border and a label.
        y: count === 0 ? null : count
      };
    })
  }));

We have set the ids for our drilldown data, but we still need to use it to link our top-level slices. We can adjust our earlier seriesData initialization to also set drilldown appropriately:

const seriesData = deathEvents.reduce((data, event) => {
  const abilityName = getKillingAbilityName(event);
  const datum = data
    .find(x => x.name === abilityName);

  if (datum) {
    datum.y++;
  } else {
    data.push({
      name: abilityName,
      color: styles.getColorForAbilityType(
        event.killingAbility?.type ?? 1
      ),
      y: 1,
      // This corresponds to the `id` of our drilldown data for this top-level slice
      drilldown: abilityName
    });
  }

  return data;
}, []);

Finally, we need to add our drilldown series to the chart by setting drilldown.series. We can also add a subtitle to let the user know to click on the slices to drill down by using subtitle.text:

return {
  component: 'Chart',
  props: {
    chart: {
      type: 'pie'
    },
    title: {
      text: 'Deaths by Ability'
    },
    subtitle: {
      text: 'Click the slices to break down by player'
    },
    plotOptions: {
      pie: {
        borderColor: 'rgba(0, 0, 0, 0.3)',
        borderWidth: 2,
        showInLegend: true,
        dataLabels: {
          format: '<span style="color:{point.color};">{point.name}</span>'
        }
      },
    },
    series: [
      {
        name: 'Deaths',
        data: seriesData
      }
    ],
    drilldown: {
      series: drilldownSeries
    },
    legend: {
      maxHeight: 150
    }
  }
}

This gives us a full (and final) script of:

getComponent = () => {
  const deathEvents = reportGroup.fights
    .flatMap(fight => fight.friendlyPlayerDeathEvents);

  const seriesData = deathEvents.reduce((data, event) => {
    const abilityName = getKillingAbilityName(event);
    const datum = data
      .find(x => x.name === abilityName);

    if (datum) {
      datum.y++;
    } else {
      data.push({
        name: abilityName,
        color: styles.getColorForAbilityType(
          event.killingAbility?.type ?? 1
        ),
        y: 1,
        drilldown: abilityName
      });
    }

    return data;
  }, []);

  const players = reportGroup.fights
    .flatMap(fight => fight.friendlyParticipants)
    .filter(actor => actor.type === 'Player')
    .filter((x, index, array) =>
      array.findIndex(y => x.id === y.id) === index
    );

  const drilldownSeries = seriesData
    .map(data => ({
      name: data.name,
      id: data.name,
      data: players.map(player => {
        const count = deathEvents
          .filter(event => getKillingAbilityName(event) === data.name &&
                            event.target.id === player.id)
          .length;
        
        return {
          name: player.name,
          color: styles.getColorForActorType(player.subType),
          y: count === 0 ? null : count
        };
      })
    }));

return {
  component: 'Chart',
  props: {
    chart: {
      type: 'pie'
    },
    title: {
      text: 'Deaths by Ability'
    },
    subtitle: {
      text: 'Click the slices to break down by player'
    },
    plotOptions: {
      pie: {
        borderColor: 'rgba(0, 0, 0, 0.3)',
        borderWidth: 2,
        showInLegend: true,
        dataLabels: {
          format: '<span style="color:{point.color};">{point.name}</span>'
        }
      },
    },
    series: [
      {
        name: 'Deaths',
        data: seriesData
      }
    ],
    drilldown: {
      series: drilldownSeries
    },
    legend: {
      maxHeight: 150
    }
  }
}
}

function getKillingAbilityName(deathEvent) {
  return deathEvent.killingAbility?.name ?? 'Falling';
}

Which, when run, gives us the following component:

Closing Thoughts

We've seen how we can leverage the power of Highcharts to build more complicated UI from our data, while even adding user interaction and the usual color-coding. Mastering the EnhancedMarkdown, Table, and Chart Report Components should give you a very flexible suite of options to help visualize your analysis.

Advertisements
Remove Ads