Coral Charts: Part 2

In this recipe, we're taking a look at complex multi-visualisation charts using the core library primitives exported in coral-charts. Here's what the completed chart will look like.

JanFebMarAprMay−300−200−1000100200300

Step 1

Firstly, we need to set up the basic layout to render the chart. We'll import CoralContainerPrimitive and use a callback to get the width and height of the wrapping div to pass down into the chart.

import { SetStateAction, useCallback, useState } from 'react';

import { CoralContainerPrimitive } from '@krakentech/coral-charts';

const [boundingRect, setBoundingRect] = useState({ width: 0, height: 0 });

const graphRef = useCallback(
  (node) => {
      if (node !== null) {
          setBoundingRect(node.getBoundingClientRect());
      }
  }, []
);

<div style={{ width: '100%', height: '500px' }} ref={graphRef}>
  <CoralContainerPrimitive
      height={boundingRect.height}
      width={boundingRect.width}
  />
</div>

Step 2

Next, we'll build the axes.

x-axis

This is the code we'll use to render out the x-axis.

<CoralAxisPrimitive
  height={boundingRect.height}
  width={boundingRect.width}
  orientation="top"
  standalone={false}
  tickValues={genericTickValueX}
  theme={ChartsTheme}
  domainPadding={{ x: boundingRect.width * 0.1 }}
  style={{
      axis: { stroke: 'transparent' },
      grid: { stroke: 'transparent' },
      ticks: { stroke: 'transparent' },
  }}
/> 

Broken down by line, this is what we're achieving:

  • height and width: Set the dimensions of the axis to be the same as the parent container
  • orientation: Pin the axis content to the top of the screen. You could change this to bottom (or omit it entirely) to have the axis appear at the bottom.
  • standalone: An important property when tooling a custom chart – this tells the rendering engine to render the item in a g so it stacks properly within the svg element created by CoralContainerPrimitive, otherwise it will render the axis within a div element, which won't appear within a nested svg.
  • tickValues: Create generic tick values to show on the axis. Our dataset in this example has grouped dates for the stack chart (01-01-2024, 01-02-2024, etc.), however, the line charts have individual dates to create the zig-zag (01-01-2024, 31-01-2024, 01-02-2024, 29-02-2024, etc.). If we don't create generic tick values, the rendering of the axis will be wrong.
  • theme: As this isn't a standalone chart, the theme isn't inherited from the parent, so for each primitive you need to pass the theme directly.
  • domainPadding: This offsets the axis from the start and the end of the x-axis line, to prevent the axes (and the chart itself, later on) from colliding.
  • style: Unsets some of the default chart styling provided by ChartsTheme

tickValues

If we don't pass down a tickValues property, we get something like the below, where the chart engine cannot logically figure out the appropriate ticks so renders out to defaults.

0.20.40.60.81.0−300−200−1000100200300

Our genericTickValueX function looks like this for our dataset:

const getMonth = (d) =>
  d.x.toLocaleDateString('en-GB', { month: 'short' });

const genericTickValueX = [
  consumptionData.flat(1),
  actualBalanceData,
  recommendedBalanceData,
]
  .flat()
  .map(getMonth)
  .filter((a, b, self) => {
      return self.indexOf(a) == b;
  });

We flatten our consumptionData from being ChartData[][] into ChartData[]. We then flatten all three datasets, and map the x value to be the short month. Finally, we filter our duplicate values, so the x axis only shows unique short month ticks.

y-axis

<CoralAxisPrimitive
  height={boundingRect.height}
  width={boundingRect.width}
  orientation="left"
  standalone={false}
  crossAxis={false}
  dependentAxis
  domain={{
      y: [
          minBalance(domainY) - 50,
          maxBalance(domainY) + 100,
      ],
  }}
  theme={ChartsTheme}
/> 

This introduces a few new properties that aren't in the x-axis:

  • crossAxis: By setting crossAxis to false, we can force the zero tick to appear on the y-axis. By default, this value is true and your y-axis would skip from -100 to 100.
  • dependentAxis: Tell the chart engine that this is the dependent axis (usually the y-axis)
  • domain: Describes the range of data that we're going to be displaying on the chart, so we want to pass the minimum and maximum range of expected values you could see on the y-axis.

domain

We use a couple of functions to determine the y-axis domains. As this visualisation contains a stack chart, we need to know what the upper and lower-most cumulative values are for the stack. We can do this using a function to total up these values:

function calculateCumulativeTotals(data) {
  const resultMap = new Map();

  // Iterate through each stack item
  data.forEach((group) => {
      // Iterate through each data point in this data group
      group.forEach((dataPoint) => {
          // Set the key to be the x value (i.e. 2024-01-01)
          const key = dataPoint.x.toISOString();

          // If the key is not present in the resultMap, add it with initial values
          if (!resultMap.has(key)) {
              resultMap.set(key, {
                  x: dataPoint.x,
                  cumulativePositive: 0,
                  cumulativeNegative: 0,
              });
          }

          // Update cumulative totals based on the sign of the y value
          const entry = resultMap.get(key)!;
          if (dataPoint.y > 0) {
              entry.cumulativePositive += dataPoint.y;
          } else if (dataPoint.y < 0) {
              entry.cumulativeNegative += dataPoint.y;
          }
      });
  });

  // Convert resultMap to an array of objects
  const resultArray = Array.from(resultMap.values());

  // Sort the result array by x value
  resultArray.sort((a, b) => a.x.getTime() - b.x.getTime());

  return resultArray;
}

Now we have this function, we can pull all of the possible y values for each of our datasets – the standard y values for the line data, and the newly created cumulative values for our stack data.

const actualBalances = actualBalanceData.map((str) => str.y);
const recommendedBalances = recommendedBalanceData.map((str) => str.y);
const consumptionDataLow = calculateCumulativeTotals(consumptionData).map(
  (str) => str.cumulativeNegative
);
const consumptionDataHigh = calculateCumulativeTotals(consumptionData).map(
  (str) => str.cumulativePositive
); 

Then flatten this data into a simple array of values.

const domainY = [
  consumptionDataLow,
  consumptionDataHigh,
  recommendedBalances,
  actualBalances,
].flat();

Finally, we can set up some helper functions to find the min and max values in domainY.

const minBalance = (balances: number[]) => Math.min(...balances);
const maxBalance = (balances: number[]) => Math.max(...balances);

Here's how the visualisation looks if we don't add any buffer – note that the stack chart runs to the top of the visualisation, and the line chart runs to the bottom.

JanFebMarAprMay−200−1000100200

Step 3

Thirdly, we can introduce our stack visualisation.

<CoralStackPrimitive
  height={boundingRect.height}
  width={boundingRect.width}
  standalone={false}
  domainPadding={{ x: boundingRect.width * 0.1 }}
  theme={ChartsTheme}
  colorScale={[
      octopusTheme.color.action.success,
      octopusTheme.color.action.warning,
      octopusTheme.color.action.error,
      octopusTheme.color.action.info,
      octopusTheme.color.secondary.light,
  ]}
>
  {consumptionData.map((data, index) => {
      return (
          <CoralBarPrimitive
              theme={ChartsTheme}
              key={index}
              barRatio={0.9}
              data={data}
              domain={{
                  y: [
                      minBalance(domainY) - 50,
                      maxBalance(domainY) + 100,
                  ],
              }}
          />
      );
  })}
</CoralStackPrimitive>

Thankfully, the visualisation works much like how our built-ins do.

As with the axes, we need to provide height, width, theme and standalone, and as with the x-axis we provide a domainPadding value. We provide an array of colours into colorScale, which you should be familiar with if you've touched the stack or group charts.

Then we loop the data and output the bars using CoralBarPrimitive. We need to set the domain to ensure the bars line up correctly with the y-axis, and we opt to provide a barRatio value to set the width of the bars.

Step 4

Finally, we can add our lines.

<CoralLinePrimitive
  height={boundingRect.height}
  width={boundingRect.width}
  standalone={false}
  data={actualBalanceData}
  domain={{
      y: [
          minBalance(domainY) - 50,
          maxBalance(domainY) + 100,
      ],
  }}
  theme={ChartsTheme}
/>

<CoralLinePrimitive
  height={boundingRect.height}
  width={boundingRect.width}
  standalone={false}
  data={recommendedBalanceData}
  domain={{
      y: [
          minBalance(domainY) - 50,
          maxBalance(domainY) + 100,
      ],
  }}
  style={{
      data: {
          strokeDasharray: 4,
      },
  }}
  theme={ChartsTheme}
/>

Are you sensing a pattern?

The only optional parameter we provide to the second line is style, which lets us add some dash array to the rendering to make it dotted.

Putting it together

Here's the full code block, fully typed.

import { SetStateAction, useCallback, useState } from 'react';

import {
  ChartData,
  ChartsTheme,
  CoralAxisPrimitive,
  CoralBarPrimitive,
  CoralContainerPrimitive,
  CoralLinePrimitive,
  CoralStackPrimitive,
} from '@krakentech/coral-charts';

import { octopusTheme } from '@krakentech/coral';

function calculateCumulativeTotals(data: ChartData[][]) {
  const resultMap = new Map();

  data.forEach((group) => {
      group.forEach((dataPoint) => {
          const key = dataPoint.x.toISOString();

          if (!resultMap.has(key)) {
              resultMap.set(key, {
                  x: dataPoint.x,
                  cumulativePositive: 0,
                  cumulativeNegative: 0,
              });
          }

          const entry = resultMap.get(key)!;
          if (dataPoint.y > 0) {
              entry.cumulativePositive += dataPoint.y;
          } else if (dataPoint.y < 0) {
              entry.cumulativeNegative += dataPoint.y;
          }
      });
  });

  const resultArray = Array.from(resultMap.values());
  resultArray.sort((a, b) => a.x.getTime() - b.x.getTime());

  return resultArray;
}

const actualBalances = actualBalanceData.map((str) => str.y);
const recommendedBalances = recommendedBalanceData.map((str) => str.y);
const consumptionDataLow = calculateCumulativeTotals(consumptionData).map(
  (str) => str.cumulativeNegative
);
const consumptionDataHigh = calculateCumulativeTotals(consumptionData).map(
  (str) => str.cumulativePositive
);

const domainY = [
  consumptionDataLow,
  consumptionDataHigh,
  recommendedBalances,
  actualBalances,
].flat();

const getMonth = (d: { x: Date }) =>
  d.x.toLocaleDateString('en-GB', { month: 'short' });

const genericTickValueX = [
  consumptionData.flat(1),
  actualBalanceData,
  recommendedBalanceData,
]
  .flat()
  .map(getMonth)
  .filter((a, b, self) => {
      return self.indexOf(a) == b;
  });

const minBalance = (balances: number[]) => Math.min(...balances);
const maxBalance = (balances: number[]) => Math.max(...balances);

const ComplexChart = () => {
  const [boundingRect, setBoundingRect] = useState({ width: 0, height: 0 });
  const graphRef = useCallback(
      (
          node: {
              getBoundingClientRect: () => SetStateAction<{
                  width: number;
                  height: number;
              }>;
          } | null
      ) => {
          if (node !== null) {
              setBoundingRect(node.getBoundingClientRect());
          }
      },
      []
  );

  return (
      <div style={{ width: '100%', height: '500px' }} ref={graphRef}>
          <CoralContainerPrimitive
              height={boundingRect.height}
              width={boundingRect.width}
          >
              <CoralAxisPrimitive
                  height={boundingRect.height}
                  width={boundingRect.width}
                  standalone={false}
                  orientation="top"
                  tickValues={genericTickValueX}
                  theme={ChartsTheme}
                  domainPadding={{ x: boundingRect.width * 0.1 }}
                  style={{
                      axis: { stroke: 'transparent' },
                      grid: { stroke: 'transparent' },
                      ticks: { stroke: 'transparent' },
                  }}
              />

              <CoralAxisPrimitive
                  height={boundingRect.height}
                  width={boundingRect.width}
                  standalone={false}
                  orientation="left"
                  crossAxis={false}
                  dependentAxis
                  domain={{
                      y: [
                          minBalance(domainY) - 50,
                          maxBalance(domainY) + 100,
                      ],
                  }}
                  theme={ChartsTheme}
              />

              <CoralStackPrimitive
                  height={boundingRect.height}
                  width={boundingRect.width}
                  standalone={false}
                  domainPadding={{ x: boundingRect.width * 0.1 }}
                  theme={ChartsTheme}
                  colorScale={[
                      octopusTheme.color.action.success,
                      octopusTheme.color.action.warning,
                      octopusTheme.color.action.error,
                      octopusTheme.color.action.info,
                      octopusTheme.color.secondary.light,
                  ]}
              >
                  {consumptionData.map((data, index) => {
                      return (
                          <CoralBarPrimitive
                              theme={ChartsTheme}
                              key={index}
                              barRatio={0.9}
                              data={data}
                              domain={{
                                  y: [
                                      minBalance(domainY) - 50,
                                      maxBalance(domainY) + 100,
                                  ],
                              }}
                          />
                      );
                  })}
              </CoralStackPrimitive>

              <CoralLinePrimitive
                  height={boundingRect.height}
                  width={boundingRect.width}
                  standalone={false}
                  data={actualBalanceData}
                  domain={{
                      y: [
                          minBalance(domainY) - 50,
                          maxBalance(domainY) + 100,
                      ],
                  }}
                  theme={ChartsTheme}
              />

              <CoralLinePrimitive
                  height={boundingRect.height}
                  width={boundingRect.width}
                  standalone={false}
                  data={recommendedBalanceData}
                  domain={{
                      y: [
                          minBalance(domainY) - 50,
                          maxBalance(domainY) + 100,
                      ],
                  }}
                  style={{
                      data: {
                          strokeDasharray: 4,
                      },
                  }}
                  theme={ChartsTheme}
              />
          </CoralContainerPrimitive>
      </div>
  );
};

And here's the different data arrays that we're using to power the recipe.

import { ChartData } from '@krakentech/coral-charts';

const consumptionData: ChartData[][] = [
  [
      { x: new Date('2024-01-01'), y: 80 },
      { x: new Date('2024-02-01'), y: 80 },
      { x: new Date('2024-03-01'), y: 80 },
      { x: new Date('2024-04-01'), y: 80 },
      { x: new Date('2024-05-01'), y: 0 },
  ],
  [
      { x: new Date('2024-01-01'), y: 130 },
      { x: new Date('2024-02-01'), y: 130 },
      { x: new Date('2024-03-01'), y: 130 },
      { x: new Date('2024-04-01'), y: 130 },
      { x: new Date('2024-05-01'), y: 210 },
  ],
  [
      { x: new Date('2024-01-01'), y: -80 },
      { x: new Date('2024-02-01'), y: -75 },
      { x: new Date('2024-03-01'), y: -89 },
      { x: new Date('2024-04-01'), y: -34 },
      { x: new Date('2024-05-01'), y: -68 },
  ],
  [
      { x: new Date('2024-01-01'), y: -25 },
      { x: new Date('2024-02-01'), y: -40 },
      { x: new Date('2024-03-01'), y: -35 },
      { x: new Date('2024-04-01'), y: -40 },
      { x: new Date('2024-05-01'), y: -15 },
  ],
  [
      { x: new Date('2024-01-01'), y: -50 },
      { x: new Date('2024-02-01'), y: -50 },
      { x: new Date('2024-03-01'), y: -50 },
      { x: new Date('2024-04-01'), y: -50 },
      { x: new Date('2024-05-01'), y: -50 },
  ],
];

const actualBalanceData: ChartData[] = [
  { x: new Date('2024-01-01'), y: -200 },
  { x: new Date('2024-01-31'), y: -250 },
  { x: new Date('2024-02-01'), y: -100 },
  { x: new Date('2024-02-29'), y: -150 },
  { x: new Date('2024-03-01'), y: 0 },
  { x: new Date('2024-03-31'), y: -50 },
  { x: new Date('2024-04-01'), y: 100 },
  { x: new Date('2024-04-30'), y: 50 },
  { x: new Date('2024-05-01'), y: 200 },
  { x: new Date('2024-05-31'), y: 150 },
];

const recommendedBalanceData: ChartData[] = [
  { x: new Date('2024-01-01'), y: -200 },
  { x: new Date('2024-01-31'), y: -250 },
  { x: new Date('2024-02-01'), y: -140 },
  { x: new Date('2024-02-29'), y: -190 },
  { x: new Date('2024-03-01'), y: -90 },
  { x: new Date('2024-03-31'), y: -140 },
  { x: new Date('2024-04-01'), y: -40 },
  { x: new Date('2024-04-30'), y: -90 },
  { x: new Date('2024-05-01'), y: 10 },
  { x: new Date('2024-05-31'), y: -40 },
];

Now we've reviewed a fairly complex integration using multiple primitives and creating our own axes, you can get into the weeds and really start to make your visualisations come alive. Happy hunting!