Shaigro

Custom external tooltip in ChartJS (React, TypeScript, ChakraUI)

Last updated on

Contents

Problem

My application is in React and TypeScript. I am using ChartJS to draw line charts and I want to completely customize the tooltips displayed on my chart.

Requirements

  • ChartJS
  • React
  • react-chartjs-2

A line chart with tooltip enabled

Here is an example of a line chart with tooltips enabled. We will build on it to implement an external tooltip.

import {
  CategoryScale,
  Chart,
  LineElement,
  LinearScale,
  PointElement,
  Tooltip,
} from "chart.js";
import { Line } from "react-chartjs-2";

Chart.register([
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Tooltip,
]);

const App = () => <LineChart />;

const LineChart = () => {
  return (
    <Line
      data={FAKE_DATA}
      options={{
        interaction: {
          intersect: false,
          mode: "index",
        },
        plugins: {
          tooltip: {
            enabled: true,
          },
        },
      }}
    />
  );
};

Adding the tooltip

We want to use the external 1 property on the tooltip plugin. We will be checking the tooltip opacity to show and hide our custom tooltip.

const LineChart = () => {
  const [isTooltipOpen, setIsTooltipOpen] = useState(false)

  const closeTooltip = () => { setIsTooltipOpen(false) }
  const openTooltip = () => { setIsTooltipOpen(true) }

  return (
    <Line
      data={FAKE_DATA}
      options={{
        interaction: {
          intersect: false,
          mode: "index",
        },
        plugins: {
          tooltip: {
            enabled: false,
            external: ({ tooltip }) => {
              if (tooltip.opacity === 0 && isTooltipOpen) {
                closeTooltip()
                return
              }

              openTooltip()
            }
          },
        },
      }}
    />
    {isTooltipOpen && <CustomTooltip />}
  )
}

Now that the tooltip is displayed, we only need to pass it data.

First, we explicit the type of the data:

import { TooltipItem } from "chart.js";

type TooltipData = {
  dataPoints: TooltipItem<"line">[];
};

Then, we can update the data and pass it to our custom tooltip:

const LineChart = () => {
  const [isTooltipOpen, setIsTooltipOpen] = useState(false);
  const [data, setData] = useState<TooltipData>({ dataPoints: [] });

  const resetData = () => {
    setData({ dataPoints: [] });
  };
  const closeTooltip = () => {
    setIsTooltipOpen(false);
  };
  const openTooltip = () => {
    setIsTooltipOpen(true);
  };

  return (
    <Box>
      <Line
        data={FAKE_DATA}
        options={{
          interaction: {
            intersect: false,
            mode: "index",
          },
          plugins: {
            tooltip: {
              enabled: false,
              external: ({ tooltip }) => {
                if (tooltip.opacity === 0 && isTooltipOpen) {
                  resetData();
                  closeTooltip();
                  return;
                }

                const newData = {
                  dataPoints: tooltip.dataPoints,
                };

                setData(newData);
                openTooltip();
              },
            },
          },
        }}
      />
      {isTooltipOpen && <CustomTooltip {...data} />}
    </Box>
  );
};

The custom tooltip displays a box of color per data point next to their values.

const CustomTooltip = (data: TooltipData) => (
  <Tooltip
    isOpen={true}
    label={
      <Box>
        {data.dataPoints.map((point) => (
          <Flex gridGap="1rem" key={`${point.datasetIndex}-${point.dataIndex}`}>
            <Box
              backgroundColor={point.dataset.borderColor as string}
              boxSize="1rem"
            />
            {point.formattedValue}
          </Flex>
        ))}
      </Box>
    }
    hasArrow
    placement="right"
  >
    <Box boxSize="1rem" />
  </Tooltip>
);

And here it is! The tooltip displays when hovering the graph.

Hovering the line chart displays external tooltip

Placing the tooltip on the chart

Right now, the tooltip is under the chart. Ideally, we want it to be on the chart where the tooltip is supposed to be.

For that, our custom tooltip will be in position: absolute and its parent will be position: relative.

const LineChart = () => {
  ...
  return (
    <Box position="relative">
      <Line
        ...
      />
      {isTooltipOpen && <CustomTooltip {...data} />}
    </Box>
  )
}
const CustomTooltip = (data: TooltipData) => (
  <Tooltip
    ...
  >
    <Box boxSize="1rem" position="absolute" top="35%" left={data.left} />
  </Tooltip>
);

Previously, we set top: 35% on CustomTooltip and left properties. left depends on the tooltip position.

const LineChart = () => {
  ...
  return (
    <Box position="relative">
      <Line
        data={FAKE_DATA}
        options={{
          interaction: {
            intersect: false,
            mode: "index",
          },
          plugins: {
            tooltip: {
              enabled: false,
              external: ({ tooltip }) => {
                if (tooltip.opacity === 0 && isTooltipOpen) {
                  resetData()
                  closeTooltip()
                  return
                }

                const newData = {
                  dataPoints: tooltip.dataPoints,
                  left: tooltip.caretX // use caretX value instead of x
                }

                setData(newData)
                openTooltip()
              }
            },
          },
        }}
      />
      {isTooltipOpen && <CustomTooltip {...data} />}
    </Box>
  )
}

TooltipData type is modified accordingly.

type TooltipData = {
  dataPoints: TooltipItem<"line">[];
  left: number;
};
Tooltip displays at the proper location

Optimize rendering

It works well. All that is left is improving the render.

Right now, it’s rerendering way too much due to how the external callback works. A quick way to check that is to add a simple state to count how many times the external callback is called.

  ...
  external: ({ tooltip }) => {
    if (tooltip.opacity === 0 && isTooltipOpen) {
      resetData()
      closeTooltip()
      return
    }

    const newData = {
      dataPoints: tooltip.dataPoints,
      left: tooltip.caretX
    }

    setData(newData)
    openTooltip()
    increaseCounter()
  }
  ...
Before comparison: Counter displays rerender

An easy way to fix that is to update the data only when the new data is different from the previous one.

In this case, we only check if data.left is different from newData.left but sometimes you need to compare the rest of the data for a better comparison.

const arePositionsDifferent = (d1: TooltipData, d2: TooltipData) =>
  d1.left !== d2.left;
  ...
  external: ({ tooltip }) => {
    if (tooltip.opacity === 0 && isTooltipOpen) {
      resetData()
      closeTooltip()
      return
    }

    const newData = {
      dataPoints: tooltip.dataPoints,
      left: tooltip.caretX
    }

    if (arePositionsDifferent(data, newData)) {
      setData(newData)
      openTooltip()
      increaseCounter()
    }
  }
  ...

Here is the result:

After comparison: Counter displays rerender

Final code

Here is the final code resulting from this post.

FAKE_DATA can be found in this repository.

import { Box, Flex, Tooltip } from "@chakra-ui/react";
import {
  CategoryScale,
  Chart,
  LineElement,
  LinearScale,
  PointElement,
  Tooltip as ChartTooltip,
  TooltipItem,
} from "chart.js";
import { useState } from "react";
import { Line } from "react-chartjs-2";
import { FAKE_DATA } from "./utils";

Chart.register([
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  ChartTooltip,
]);

export const App = () => <LineChart />;

type TooltipData = {
  dataPoints: TooltipItem<"line">[];
  left: number;
};

const LineChart = () => {
  const [isTooltipOpen, setIsTooltipOpen] = useState(false);
  const [data, setData] = useState<TooltipData>({ dataPoints: [], left: -1 });

  const resetData = () => {
    setData({ dataPoints: [], left: -1 });
  };
  const closeTooltip = () => {
    setIsTooltipOpen(false);
  };
  const openTooltip = () => {
    setIsTooltipOpen(true);
  };

  return (
    <Box position="relative">
      <Line
        data={FAKE_DATA}
        options={{
          interaction: {
            intersect: false,
            mode: "index",
          },
          plugins: {
            tooltip: {
              enabled: false,
              external: ({ tooltip }) => {
                if (tooltip.opacity === 0 && isTooltipOpen) {
                  resetData();
                  closeTooltip();
                  return;
                }

                const newData = {
                  dataPoints: tooltip.dataPoints,
                  left: tooltip.caretX,
                };

                if (arePositionsDifferent(data, newData)) {
                  setData(newData);
                  openTooltip();
                }
              },
            },
          },
        }}
      />
      {isTooltipOpen && <CustomTooltip {...data} />}
    </Box>
  );
};

const arePositionsDifferent = (d1: TooltipData, d2: TooltipData) =>
  d1.left !== d2.left;

const CustomTooltip = (data: TooltipData) => (
  <Tooltip
    isOpen={true}
    label={
      <Box>
        {data.dataPoints.map((point) => (
          <Flex gridGap="1rem" key={`${point.datasetIndex}-${point.dataIndex}`}>
            <Box
              backgroundColor={point.dataset.borderColor as string}
              boxSize="1rem"
            />
            {point.formattedValue}
          </Flex>
        ))}
      </Box>
    }
    hasArrow
    placement="right"
  >
    <Box boxSize="1rem" position="absolute" top="35%" left={data.left} />
  </Tooltip>
);

You can also find an example in this repository.

Documentation & tutorials

If you want to learn more about ChartJS, I recommend checking out:

  1. The documentation of ChartJS. It is a great documentation, well written and mostly complete.
  2. @ChartJS-tutorials on YouTube. The videos are well done and cover a lot of subjects.

References


  1. ChartJS - External tooltip