Skip to content

Instantly share code, notes, and snippets.

@dankremniov
Last active February 21, 2024 06:41
Show Gist options
  • Save dankremniov/a9a6b969e63dfc4f0f83e6f82b82eb4f to your computer and use it in GitHub Desktop.
Save dankremniov/a9a6b969e63dfc4f0f83e6f82b82eb4f to your computer and use it in GitHub Desktop.
Render React component for Highcharts tooltip

This is an example of how to render React component for Highcharts tooltip using React portals. See Chart.tsx and Tooltip.tsx files below. Latest Highcharts version is required for the example to work correctly (9.0.0 at the time of writting).

Codesandbox: https://codesandbox.io/s/highcharts-react-tooltip-ut1uy

import React, { useState, useCallback } from "react";
import Highcharts, { Chart as HighchartsChart } from "highcharts";
import HighchartsReact from "highcharts-react-official";
import { Tooltip } from "./Tooltip";
const options = {
title: {
text: "Custom tooltip as React component"
},
series: [
{
type: "line",
data: [1, 2, 3]
}
],
tooltip: {
style: {
pointerEvents: "auto"
}
}
};
export const Chart = () => {
const [chart, setChart] = useState<HighchartsChart | null>(null);
const callback = useCallback((chart: HighchartsChart) => {
setChart(chart);
}, []);
return (
<>
<HighchartsReact
highcharts={Highcharts}
options={options}
callback={callback}
/>
<Tooltip chart={chart}>
{(formatterContext) => {
const { x, y } = formatterContext;
return (
<>
<div>x: {x}</div>
<div>y: {y}</div>
<br />
<button onClick={() => alert(`x: ${x}, y: ${y}`)}>Action</button>
</>
);
}}
</Tooltip>
</>
);
};
import {
Chart,
TooltipFormatterCallbackFunction,
TooltipFormatterContextObject
} from "highcharts";
import { useEffect, useState, useRef } from "react";
import ReactDOM from "react-dom";
const generateTooltipId = (chartId: number) =>
`highcharts-custom-tooltip-${chartId}`;
interface Props {
chart: Chart | null;
children(formatterContext: TooltipFormatterContextObject): JSX.Element;
}
export const Tooltip = ({ chart, children }: Props) => {
const isInit = useRef(false);
const [context, setContext] = useState<TooltipFormatterContextObject | null>(
null
);
useEffect(() => {
if (chart) {
const formatter: TooltipFormatterCallbackFunction = function () {
// Ensures that tooltip DOM container is rendered before React portal is created.
if (!isInit.current) {
isInit.current = true;
// TODO: Is there a better way to create tooltip DOM container?
chart.tooltip.refresh.apply(chart.tooltip, [this.point]);
chart.tooltip.hide(0);
}
setContext(this);
return `<div id="${generateTooltipId(chart.index)}"></div>`;
};
chart.update({
tooltip: {
formatter,
useHTML: true
}
});
}
}, [chart]);
const node = chart && document.getElementById(generateTooltipId(chart.index));
return node && context
? ReactDOM.createPortal(children(context), node)
: null;
};
@maciej-gurban
Copy link

Have you ever arrived at a better solution for creating the tooltip container? Speaking of: chart.tooltip.refresh.apply(chart.tooltip, [this.point]);.

In my case, I was attempting use a tooltip configured as { shared: true } which means I'm not receiving this.point, but rather this.points (every element in that array being a point of the series). Passing that, a single point from that array, or anything else seem to throw an error trying to render the tooltip.

Alternatively, I've tried running the .update() method, that in that scenario first time hovering over the tooltip results in empty tooltip, and only moving to another point will update it to correct value.

@dankremniov
Copy link
Author

@maciej-gurban, I think it was throwing an error for you because it needs actual Point rather than TooltipFormatterContextObject. The following should work for both shared and not shared tooltips:

chart.tooltip.refresh.apply(chart.tooltip, [
  this.points ? this.points.map(({ point }) => point) : this.point
]);

Obviously, you need to tweak rendering logic in Chart.tsx as well. E.g. https://codesandbox.io/s/highcharts-react-tooltip-forked-mvw0i

@maciej-gurban
Copy link

Oh man, thanks a lot, that actually saves me quite a lot of headache. I don't know if I'm missing something super obvious after too many hours of staring at this, but I tried something that I was thought was 1:1 equivalent, namely:

chart.tooltip.refresh.apply(chart.tooltip, [
  this.points[0].point,
  this.points[1].point,
]);

But this will produce (b || []).forEach is not a function, but those should be equivalent, at least according to the diff between those objects.

@dankremniov
Copy link
Author

Yeah, you need nested array there. chart.tooltip.refresh accepts Point|Array<Point>, and apply accepts an array of argument. Thus, you need the following:

chart.tooltip.refresh.apply(chart.tooltip, [
  [this.points[0].point, this.points[1].point]
]);

or the version with map as in the comment above.

@maciej-gurban
Copy link

Ah, of course. Makes perfect sense. Thanks again man!

@solofeed
Copy link

solofeed commented Sep 1, 2021

Great idea 💪
I have used your example, thanks ! 😅

@solofeed
Copy link

solofeed commented Feb 1, 2022

@dankremniov Hi Dan
I have faced an interesting bug with the tooltip
image

If there is more than one chart, at some point in time, it will break the tooltip for one of them
The problem is that the Tooltip component start getting the wrong chart instances for some reason
I cannot understand why it's happening and how to avoid it

I have two different components, which use this component on the same page

// This is LineChart.tsx
<HighchartsReact
                callback={(chart: HighchartsChart) => setChart(chart)}
                containerProps={CHART_CONTAINER_PROPS}
                highcharts={Highcharts}
                options={chartOptions}
            />

            <ChartTooltip chart={chart}>
                {formatterContext => formatterContext.points &&
                     // content
            </ChartTooltip>

After some debugging, I found that chart.index is wrong for some reason

Do you have any ideas about what it could be?

@dankremniov
Copy link
Author

Hi @solofeed, I am happy to have a look into that if you can share a repository where the issue is reproducible.

@Prograd
Copy link

Prograd commented Feb 14, 2022

Thanks man, you save my day! 😄

@bogdan-fortum
Copy link

First of all, thanks for the lovely gist, it works very well! Until I updated to react 18. The issue that I found was using the StrictMode and I assume that the problem might be coming from refs and strict mode incompatibility

I was also able to reproduce this issue in my own sandbox that I forked from the one referenced in README.md

Not sure if anyone else stumbled upon this issue though

@dankremniov
Copy link
Author

@bogdan-fortum, it seems that highcharts-react-official behaviour has changed with React 18. Initial ref it returns seems to be pointing to a not initialised chart, thus, the error. This can be fixed by changing condition within useEffect in Tooltip.tsx from chart to chart?.options.

However, it also seems that React 18 has introduced some asynchrony in the way how tooltip is render, thus, I had to wrap chart.tooptip.apply and chart.tooltip.hide into setTimeout.

Here is a working version of your sandbox.

@bogdan-fortum
Copy link

Thanks, don't know how I didn't think of that 🤦

As far as the setTimeout thing, it works well without it, but if I notice some rendering issues, I'll introduce it. Thanks a bunch 🙏

@dankremniov
Copy link
Author

@bogdan-fortum, without setTimeout, tooltip background and position are wrong in the very first render. Is that not the case for you?

Screenshot 2022-12-15 at 13 26 26

@bogdan-fortum
Copy link

Actually, you're right. It's on the first render, I didn't notice it at first. I guess I do need the setTimeout then. Thanks 🙏

@galangel
Copy link

galangel commented Mar 17, 2023

Hi, thank you for this amazing gist.
When using the outside: true tooltip prop, hovering over the tooltip to keep it isn't working. Any ideas?

@danimaxi54
Copy link

Hello. Thanks for the example. I ran into a problem: my tooltip (children) has a dynamic height and width. It depends on the selected mode by the user. Backend sends less or more data. So the tooltip width can be either 150px or 300px. And the height will also increase.

The problem is the following: when choosing different user modes and rebuilding the tooltip, it retains its old top and left position when hovering over a chart point.
For example, the tooltip width was 300px on first render. The tooltip is displayed next to the plot point. Then the tooltip width was reduced to 150px. When hovering over the graph point again, the tooltip will be positioned quite far from the hover point.

The question has already been asked here: https://www.highcharts.com/forum/viewtopic.php?f=9&t=48218, but the solution is not fully developed, since the tooltip can fall outside the body.

@dankremniov
Copy link
Author

@danimaxi54, can you provide an example of what you need to achieve with such tooltip (e.g. should it contain interactive elements or just static text)? There is a way to achieve dynamic width/height by rendering to string within formatter; however, all the event listeners won't be attached declaratively as in the example above.

@dankremniov
Copy link
Author

@galangel, in case of outside: true the tooltip is hidden due to:

tooltip: {
  style: {
    pointerEvents: "auto"
  }
}

However, this is needed for the tooltip to be clickable. Unfortunately, I can't suggest any solution if you need both outside: true and clickable tooltip.

@borglin
Copy link

borglin commented May 9, 2023

Is there a way to have the tooltip adapt to the content for each point? I'd like to render different content based on the based on the data for each point. Ideally I would also be able to render a different tooltip if the point is clicked. Currently the tooltip gets the dimensions from the first rendered tooltip and then the same dimensions are used for every other point. I've tried using tooltip.refresh(...) but I haven't seen any difference.

Example, the second point has different tooltip content than the other: https://codesandbox.io/s/highcharts-react-tooltip-forked-jjxo43?file=/src/Chart.tsx

@knutmarius
Copy link

@borglin I've also used this gist as a way of being able to render complex React components inside a highcharts tooltip, and I think i was able to make a fairly decent solution for the height problem. I realized that the SVG container rendered by highcharts that was having a static height, and the solution I found was to just set opacity: 0 on the options.tooltip.style, and instead make a custom TooltipContainer component that wraps the content of the Tooltips. Hope this could help anyone with similar issues.

BTW: Also noticed that the error that is thrown from tooltip.refresh function went away after upgrading to Highcharts 11. Here is an updated sandbox:

https://codesandbox.io/s/highcharts-react-tooltip-forked-s6xyqc?file=/src/TooltipContainer.tsx

@dankremniov
Copy link
Author

Thanks @knutmarius - great solution!

I think this addresses @danimaxi54 question too.

@knutmarius
Copy link

Small update: As I was trying to roll out this solution to all our charts, I hit a roadblock when I came to some gannt charts that were having <ReactHighcharts immutable={true} ... /> . When the immutable flag is set to true, it crashes inside highcharts when the tooltip tries to do chart.update(..)to set the formatter function. However, I cannot replicate this in the sandbox, but it's very reproducable with all the charts in my application. Not sure why it acts differently from the sandbox. If the chart is rendered as immutable it crashes. Is this something that you have experienced before?

I started thinking about a solution that would not involve using chart.update, but rather set the formatter function on the chart options object at initialization, but then the rapid updating of the context in state causes the entire chart to rerender as well, so that would need to be solved. Is this a solution you have considered as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment