Skip to content

LG-4580: tooltip pinning #2837

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
5 changes: 5 additions & 0 deletions .changeset/lovely-days-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lg-charts/core': minor
---

Adds tooltip pinning logic and refactors tooltip visibility
6 changes: 3 additions & 3 deletions charts/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,22 @@
"@dnd-kit/utilities": "^3.2.2",
"@leafygreen-ui/emotion": "workspace:^",
"@leafygreen-ui/hooks": "workspace:^",
"@leafygreen-ui/icon": "workspace:^",
"@leafygreen-ui/icon-button": "workspace:^",
"@leafygreen-ui/lib": "workspace:^",
"@leafygreen-ui/palette": "workspace:^",
"@leafygreen-ui/tokens": "workspace:^",
"@leafygreen-ui/typography": "workspace:^",
"@lg-charts/chart-card": "workspace:^",
"@lg-charts/colors": "workspace:^",
"@lg-charts/series-provider": "workspace:^",
"echarts": "^5.5.1",
"lodash.debounce": "^4.0.8"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed debounce in previous PR but forgot to drop this dep

"echarts": "^5.5.1"
},
"peerDependencies": {
"@leafygreen-ui/leafygreen-provider": "workspace:^"
},
"devDependencies": {
"@faker-js/faker": "8.0.2",
"@leafygreen-ui/icon": "workspace:^",
"@types/lodash.debounce": "^4.0.9"
},
"repository": {
Expand Down
35 changes: 18 additions & 17 deletions charts/core/src/Chart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,24 @@ export const WithTooltip: StoryObj<{}> = {
},
};

export const WithZoomAndTooltip: StoryObj<{}> = {
render: () => {
return (
<Chart
zoomSelect={{
xAxis: true,
yAxis: true,
}}
>
<ChartTooltip />
{lineData.map(({ name, data }) => (
<Line name={name} data={data} key={name} />
))}
</Chart>
);
},
};

export const WithXAxis: StoryObj<{}> = {
render: () => {
return (
Expand Down Expand Up @@ -1064,23 +1082,6 @@ export const WithWarningEventMarkerLine: StoryObj<{}> = {
},
};

export const WithZoomAndTooltip: StoryObj<{}> = {
render: () => {
return (
<Chart
zoomSelect={{
xAxis: true,
yAxis: true,
}}
>
{lineData.map(({ name, data }) => (
<Line name={name} data={data} key={name} />
))}
</Chart>
);
},
};

export const WithZoom: StoryObj<{}> = {
render: () => {
return (
Expand Down
3 changes: 2 additions & 1 deletion charts/core/src/Chart/config/getDefaultChartOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@leafygreen-ui/tokens';

import { ChartOptions } from '../Chart.types';
import { TOOLBOX_ID, X_AXIS_ID, Y_AXIS_ID } from '../constants';
import { TOOLBOX_ID, TOOLTIP_ID, X_AXIS_ID, Y_AXIS_ID } from '../constants';

const commonAxisOptions = {
/**
Expand Down Expand Up @@ -98,6 +98,7 @@ export const getDefaultChartOptions = (

// Adds vertical dashed line on hover, even when no tooltip is shown
tooltip: {
id: TOOLTIP_ID,
axisPointer: {
z: 0, // Prevents dashed emphasis line from being rendered on top of mark lines and labels
},
Expand Down
2 changes: 2 additions & 0 deletions charts/core/src/Chart/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const TOOLBOX_ID = 'toolbox';
export const TOOLTIP_CLOSE_BTN_ID = 'chart_tooltip-close_button';
export const TOOLTIP_ID = 'tooltip';
export const X_AXIS_ID = 'x-axis';
export const Y_AXIS_ID = 'y-axis';
3 changes: 3 additions & 0 deletions charts/core/src/Chart/hooks/useChart.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ describe('@lg-echarts/core/hooks/useChart', () => {
expect(result.current).toEqual({
...mockEchartInstance,
ref: expect.any(Function),
setTooltipMounted: expect.any(Function),
state: undefined,
tooltipPinned: false,
});
});

Expand Down
24 changes: 5 additions & 19 deletions charts/core/src/Chart/hooks/useChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { EChartEvents } from '../../Echart';
import { getDefaultChartOptions } from '../config';

import type { ChartHookProps, ChartInstance } from './useChart.types';
import { useTooltipVisibility } from './useTooltipVisibility';

export function useChart({
onChartReady = () => {},
Expand All @@ -23,6 +24,7 @@ export function useChart({
* element only gets populated after render.
*/
const [container, setContainer] = useState<HTMLDivElement | null>(null);

const echart = useEchart({
container,
initialOptions,
Expand All @@ -32,7 +34,6 @@ export function useChart({
const {
addToGroup,
enableZoom,
hideTooltip,
off,
on,
ready,
Expand Down Expand Up @@ -91,24 +92,7 @@ export function useChart({
}
}, [ready, onZoomSelect, on]);

// We want to hide the tooltip when it's hovered over any `EventMarkerPoint`
useEffect(() => {
if (ready) {
on('mouseover', e => {
if (e.componentType === 'markPoint') {
hideTooltip();
on('mousemove', hideTooltip);
}
});

// Stop hiding once the mouse leaves the `EventMarkerPoint`
on('mouseout', e => {
if (e.componentType === 'markPoint') {
off('mousemove', hideTooltip);
}
});
}
}, [echart, hideTooltip, off, on, ready]);
const { tooltipPinned, setTooltipMounted } = useTooltipVisibility({ echart });
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abstracted all tooltip visibility logic into useTooltipVisibility hook


const initialRenderRef = useRef(true);

Expand Down Expand Up @@ -160,6 +144,8 @@ export function useChart({
return {
...echart,
ref: setContainer,
setTooltipMounted,
state,
tooltipPinned,
};
}
5 changes: 4 additions & 1 deletion charts/core/src/Chart/hooks/useChart.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Theme } from '@leafygreen-ui/lib';
import type { EChartsInstance, EChartZoomSelectionEvent } from '../../Echart';
import { ChartStates } from '../Chart.types';

import { UseTooltipVisibilityReturnObj } from './useTooltipVisibility.types';

export type ZoomSelect =
| {
xAxis: boolean;
Expand Down Expand Up @@ -47,6 +49,7 @@ export interface ChartHookProps {

export interface ChartInstance
extends EChartsInstance,
Pick<ChartHookProps, 'state'> {
Pick<ChartHookProps, 'state'>,
UseTooltipVisibilityReturnObj {
ref: RefCallback<HTMLDivElement>;
}
206 changes: 206 additions & 0 deletions charts/core/src/Chart/hooks/useTooltipVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useState } from 'react';

import { EChartEvents, EChartsInstance } from '../../Echart';
import { TOOLTIP_CLOSE_BTN_ID } from '../constants';

import { UseTooltipVisibilityReturnObj } from './useTooltipVisibility.types';

/**
* Hook to manage the visibility of the tooltip in the chart including pinning behavior.
*/
export const useTooltipVisibility = ({
echart,
}: {
echart: EChartsInstance;
}): UseTooltipVisibilityReturnObj => {
const [tooltipMounted, setTooltipMounted] = useState(false);
const [tooltipPinned, setTooltipPinned] = useState(false);

const { hideTooltip, off, on, ready, showTooltip } = echart;

/**
* Event listener callback added to the chart that is called on mouse move
* to show the tooltip.
*/
const showTooltipOnMouseMove = useCallback(
(params: any) => {
if (!tooltipMounted || tooltipPinned) {
return;
}

const x = params.offsetX;
const y = params.offsetY;
showTooltip(x, y);
},
[showTooltip, tooltipMounted, tooltipPinned],
);

/**
* Event listener callback added to the close button in the tooltip. When called,
* it hides the tooltip and sets the `tooltipPinned` state to false.
*/
const unpinTooltip = useCallback(() => {
setTooltipPinned(false);

/**
* We need to use requestAnimationFrame to ensure that the tooltip is hidden
* after the `tooltipPinned` state updates.
*/
requestAnimationFrame(() => {
hideTooltip();
});
}, [hideTooltip]);

/**
* Helper method to add the `unpinTooltip` event listener to the close button
* in the tooltip. The echarts tooltip `formatter` cannot pass the `onClick`
* event to the button, so we have to add it manually.
*/
const addUnpinCallbackToCloseButton = useCallback(() => {
const btn = document.getElementById(TOOLTIP_CLOSE_BTN_ID);

if (btn && !btn.dataset.bound) {
btn.addEventListener('click', unpinTooltip);
btn.dataset.bound = 'true'; // prevents duplicate listeners
}
}, [unpinTooltip]);

/**
* Event listener callback added to the chart that is called on click
* to show/pin the tooltip and set the `tooltipPinned` state to true.
*/
const pinTooltipOnClick = useCallback(
(params: any) => {
if (!tooltipMounted || tooltipPinned) {
return;
}

/**
* Remove the mouse move and click event listeners to prevent the tooltip
* from moving when it is pinned. User can unpin it by clicking the close
* button in the tooltip which will turn the listeners back on.
*/
off(EChartEvents.MouseMove, showTooltipOnMouseMove, {
useCanvasAsTrigger: true,
});
off(EChartEvents.Click, pinTooltipOnClick, { useCanvasAsTrigger: true });

const x = params.offsetX;
const y = params.offsetY;

setTooltipPinned(true);

/**
* We need to use requestAnimationFrame to ensure that the tooltip is shown
* after the `tooltipPinned` state updates.
*/
requestAnimationFrame(() => {
showTooltip(x, y);
addUnpinCallbackToCloseButton();
});
},
[
addUnpinCallbackToCloseButton,
showTooltipOnMouseMove,
off,
showTooltip,
tooltipMounted,
tooltipPinned,
],
);

/**
* Event listener callback that is called when mousing over a mark point.
* It hides the tooltip and disables the chart click event listener.
*/
const hideTooltipOnMouseOverMark = useCallback(
(params: any) => {
if (!tooltipMounted) {
return;
}

if (
params.componentType === 'markPoint' ||
params.componentType === 'markLine'
) {
hideTooltip();
on(EChartEvents.MouseMove, hideTooltip);
off(EChartEvents.Click, pinTooltipOnClick, {
useCanvasAsTrigger: true,
});
}
Comment on lines +122 to +131
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proactively updated this to apply to mark lines as well

},
[hideTooltip, off, on, pinTooltipOnClick, tooltipMounted],
);

/**
* Event listener callback that is called when mousing out of a mark point.
* It stops hiding the tooltip and re-enables the chart click event listener.
*/
const stopHideTooltipOnMouseOutMark = useCallback(
(params: any) => {
if (!tooltipMounted) {
return;
}

if (
params.componentType === 'markPoint' ||
params.componentType === 'markLine'
) {
off(EChartEvents.MouseMove, hideTooltip);
on(EChartEvents.Click, pinTooltipOnClick, {
useCanvasAsTrigger: true,
});
}
},
[hideTooltip, off, on, pinTooltipOnClick, tooltipMounted],
);

/**
* Effect to turn on the tooltip event listeners when the chart is ready and tooltip
* is not already pinned.
*/
useEffect(() => {
if (!ready) {
return;
}

if (tooltipPinned) {
return;
}

on(EChartEvents.MouseMove, showTooltipOnMouseMove, {
useCanvasAsTrigger: true,
});
on(EChartEvents.Click, pinTooltipOnClick, {
useCanvasAsTrigger: true,
});
}, [on, pinTooltipOnClick, ready, showTooltipOnMouseMove, tooltipPinned]);

/**
* Effect to add the event listeners to hide the tooltip when hovering a mark point.
*/
useEffect(() => {
if (!ready) {
return;
}

on(EChartEvents.MouseOver, hideTooltipOnMouseOverMark);
on(EChartEvents.MouseOut, stopHideTooltipOnMouseOutMark);
}, [
pinTooltipOnClick,
showTooltipOnMouseMove,
hideTooltipOnMouseOverMark,
stopHideTooltipOnMouseOutMark,
hideTooltip,
off,
on,
ready,
tooltipPinned,
]);

return {
setTooltipMounted,
tooltipPinned,
};
};
Loading