Skip to content

Commit

Permalink
feat(D3 plugin): move tooltip management to d3 events (#300)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): move tooltip management to d3 events

* fix: TooltipTriggerArea fixes

* fix: add window page offset

* fix: remove TooltipDataChunkType

* fix: fix d3 data management

* fix: other review fixes

* fix: remove scatter stroke on hover

* fix: remove redundant comment
  • Loading branch information
korvin89 authored Sep 25, 2023
1 parent 34b72ab commit a1a76f8
Show file tree
Hide file tree
Showing 31 changed files with 1,002 additions and 482 deletions.
2 changes: 1 addition & 1 deletion src/plugins/d3/__stories__/pie/Styled.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const Template: Story = () => {
},
title: {text: 'Styled pies'},
legend: {enabled: false},
tooltip: {enabled: false},
tooltip: {enabled: true},
};

if (!shown) {
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const shapeData = (): ChartKitWidgetData => {
itemDistance: number('Item distance', 20, undefined, 'legend'),
},
series: {
data: generateSeriesData(number('Amount of series', 100, undefined, 'legend')),
data: generateSeriesData(number('Amount of series', 1000, undefined, 'legend')),
},
xAxis: {
labels: {
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const shapeData = (data: Record<string, any>[]): ChartKitWidgetData<string> => {
],
tooltip: {
renderer: ({hovered}) => {
const d = hovered.data as ScatterSeriesData<string>;
const d = hovered[0].data as ScatterSeriesData<string>;
return <div style={{color: d.custom}}>{dateTime({input: d.x}).format('LL')}</div>;
},
},
Expand Down
7 changes: 6 additions & 1 deletion src/plugins/d3/examples/scatter/Basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ export const Basic = () => {
},
tooltip: {
renderer: (d) => {
const point = d.hovered.data as ScatterSeriesData;
const point = d.hovered[0]?.data as ScatterSeriesData;

if (!point) {
return null;
}

const title = point.custom.title;
const score = point.custom.user_score;
const date = dateTime({input: point.custom.date}).format('DD MMM YYYY');
Expand Down
68 changes: 39 additions & 29 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import React from 'react';
import type {ChartKitWidgetData} from '../../../../types';
import {block} from '../../../../utils/cn';

import {getD3Dispatcher} from '../d3-dispatcher';
import {
useAxisScales,
useChartDimensions,
useChartEvents,
useChartOptions,
useSeries,
useShapes,
Expand All @@ -16,7 +16,7 @@ import {AxisY} from './AxisY';
import {AxisX} from './AxisX';
import {Legend} from './Legend';
import {Title} from './Title';
import {Tooltip} from './Tooltip';
import {Tooltip, TooltipTriggerArea} from './Tooltip';

import './styles.scss';

Expand All @@ -34,19 +34,27 @@ export const Chart = (props: Props) => {
// FIXME: add data validation
const {top, left, width, height, data} = props;
const svgRef = React.createRef<SVGSVGElement>();
const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents();
const dispatcher = React.useMemo(() => {
return getD3Dispatcher();
}, []);
const {chart, title, tooltip, xAxis, yAxis} = useChartOptions({
data,
});
const {legendItems, legendConfig, preparedSeries, preparedLegend, handleLegendItemClick} =
useSeries({
chartWidth: width,
chartHeight: height,
chartMargin: chart.margin,
series: data.series,
legend: data.legend,
preparedYAxis: yAxis,
});
const {
legendItems,
legendConfig,
preparedSeries,
preparedSeriesOptions,
preparedLegend,
handleLegendItemClick,
} = useSeries({
chartWidth: width,
chartHeight: height,
chartMargin: chart.margin,
series: data.series,
legend: data.legend,
preparedYAxis: yAxis,
});
const {boundsWidth, boundsHeight} = useChartDimensions({
width,
height,
Expand All @@ -63,35 +71,25 @@ export const Chart = (props: Props) => {
xAxis,
yAxis,
});
const {hovered, pointerPosition, handleSeriesMouseMove, handleSeriesMouseLeave} = useTooltip({
tooltip,
});
const {shapes} = useShapes({
const {hovered, pointerPosition} = useTooltip({dispatcher, tooltip});
const {shapes, shapesData} = useShapes({
top,
left,
boundsWidth,
boundsHeight,
dispatcher,
series: preparedSeries,
seriesOptions: data.series.options,
seriesOptions: preparedSeriesOptions,
xAxis,
xScale,
yAxis,
yScale,
svgContainer: svgRef.current,
onSeriesMouseMove: handleSeriesMouseMove,
onSeriesMouseLeave: handleSeriesMouseLeave,
});

return (
<React.Fragment>
<svg
ref={svgRef}
className={b({hovered: chartHovered})}
width={width}
height={height}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<svg ref={svgRef} className={b()} width={width} height={height}>
{title && <Title {...title} chartWidth={width} />}
<g
width={boundsWidth}
Expand All @@ -117,6 +115,17 @@ export const Chart = (props: Props) => {
</React.Fragment>
)}
{shapes}
{tooltip?.enabled && Boolean(shapesData.length) && (
<TooltipTriggerArea
boundsWidth={boundsWidth}
boundsHeight={boundsHeight}
dispatcher={dispatcher}
offsetLeft={left}
offsetTop={top}
shapesData={shapesData}
svgContainer={svgRef.current}
/>
)}
</g>
{preparedLegend.enabled && (
<Legend
Expand All @@ -130,11 +139,12 @@ export const Chart = (props: Props) => {
)}
</svg>
<Tooltip
hovered={hovered}
pointerPosition={pointerPosition}
dispatcher={dispatcher}
tooltip={tooltip}
xAxis={xAxis}
yAxis={yAxis[0]}
hovered={hovered}
pointerPosition={pointerPosition}
/>
</React.Fragment>
);
Expand Down
16 changes: 13 additions & 3 deletions src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import React from 'react';
import get from 'lodash/get';
import {dateTime} from '@gravity-ui/date-utils';
import type {ChartKitWidgetSeriesData, TooltipHoveredData} from '../../../../../types';
import type {ChartKitWidgetSeriesData, TooltipDataChunk} from '../../../../../types';
import {formatNumber} from '../../../../shared';
import type {PreparedAxis} from '../../hooks';
import type {PreparedAxis, PreparedPieSeries} from '../../hooks';
import {getDataCategoryValue} from '../../utils';

type Props = {
hovered: TooltipHoveredData;
hovered: TooltipDataChunk;
xAxis: PreparedAxis;
yAxis: PreparedAxis;
};
Expand Down Expand Up @@ -75,6 +75,16 @@ export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => {
</div>
);
}
case 'pie': {
const pieSeries = series as PreparedPieSeries;

return (
<div>
<span>{pieSeries.name || pieSeries.id}&nbsp;</span>
<span>{pieSeries.value}</span>
</div>
);
}
default: {
return null;
}
Expand Down
110 changes: 110 additions & 0 deletions src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import throttle from 'lodash/throttle';
import {bisector, pointer, sort} from 'd3';
import type {Dispatch} from 'd3';

import type {ShapeData, PreparedBarXData, PointerPosition} from '../../hooks';
import {extractD3DataFromNode, isNodeContainsD3Data} from '../../utils';
import type {NodeWithD3Data} from '../../utils';

const THROTTLE_DELAY = 50;

type Args = {
boundsWidth: number;
boundsHeight: number;
dispatcher: Dispatch<object>;
offsetTop: number;
offsetLeft: number;
shapesData: ShapeData[];
svgContainer: SVGSVGElement | null;
};

type CalculationType = 'x-primary' | 'none';

const isNodeContainsData = (node?: Element): node is NodeWithD3Data<ShapeData> => {
return isNodeContainsD3Data(node);
};

const getCalculationType = (shapesData: ShapeData[]): CalculationType => {
if (shapesData.every((d) => d.series.type === 'bar-x')) {
return 'x-primary';
}

return 'none';
};

export const TooltipTriggerArea = (args: Args) => {
const {boundsWidth, boundsHeight, dispatcher, offsetTop, offsetLeft, shapesData, svgContainer} =
args;
const rectRef = React.useRef<SVGRectElement>(null);
const calculationType = React.useMemo(() => {
return getCalculationType(shapesData);
}, [shapesData]);
const xData = React.useMemo(() => {
return calculationType === 'x-primary'
? sort(new Set((shapesData as PreparedBarXData[]).map((d) => d.x)))
: [];
}, [shapesData, calculationType]);

const handleXprimaryMouseMove: React.MouseEventHandler<SVGRectElement> = (e) => {
const {left, top} = rectRef.current?.getBoundingClientRect() || {left: 0, top: 0};
const [pointerX, pointerY] = pointer(e, svgContainer);
const barWidthOffset = (shapesData[0] as PreparedBarXData).width / 2;
const xPosition = pointerX - left - barWidthOffset - window.pageXOffset;
const xDataIndex = bisector((d) => d).center(xData, xPosition);
const xNodes = Array.from(
rectRef.current?.parentElement?.querySelectorAll(`[x="${xData[xDataIndex]}"]`) || [],
);

let hoverShapeData: ShapeData[] | undefined;

if (xNodes.length === 1 && isNodeContainsData(xNodes[0])) {
hoverShapeData = [extractD3DataFromNode(xNodes[0])];
} else if (xNodes.length > 1 && xNodes.every(isNodeContainsData)) {
const yPosition = pointerY - top - window.pageYOffset;
const xyNode = xNodes.find((node, i) => {
const {y, height} = extractD3DataFromNode(node) as PreparedBarXData;
if (i === xNodes.length - 1) {
return yPosition <= y + height;
}
return yPosition >= y && yPosition <= y + height;
});

if (xyNode) {
hoverShapeData = [extractD3DataFromNode(xyNode)];
}
}

if (hoverShapeData) {
const position: PointerPosition = [pointerX - offsetLeft, pointerY - offsetTop];
dispatcher.call('hover-shape', e.target, hoverShapeData, position);
}
};

const handleMouseMove: React.MouseEventHandler<SVGRectElement> = (e) => {
switch (calculationType) {
case 'x-primary': {
handleXprimaryMouseMove(e);
return;
}
}
};

const throttledHandleMouseMove = throttle(handleMouseMove, THROTTLE_DELAY);

const handleMouseLeave = () => {
throttledHandleMouseMove.cancel();
dispatcher.call('hover-shape', {}, undefined);
};

return (
<rect
ref={rectRef}
width={boundsWidth}
height={boundsHeight}
fill="transparent"
onMouseMove={throttledHandleMouseMove}
onMouseLeave={handleMouseLeave}
/>
);
};
13 changes: 9 additions & 4 deletions src/plugins/d3/renderer/components/Tooltip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,37 @@
import React from 'react';
import isNil from 'lodash/isNil';
import type {Dispatch} from 'd3';

import type {TooltipHoveredData} from '../../../../../types/widget-data';
import type {TooltipDataChunk} from '../../../../../types/widget-data';
import {block} from '../../../../../utils/cn';

import type {PointerPosition, PreparedAxis, PreparedTooltip} from '../../hooks';
import {DefaultContent} from './DefaultContent';

export * from './TooltipTriggerArea';

const b = block('d3-tooltip');
const POINTER_OFFSET_X = 20;

type TooltipProps = {
dispatcher: Dispatch<object>;
tooltip: PreparedTooltip;
xAxis: PreparedAxis;
yAxis: PreparedAxis;
hovered?: TooltipHoveredData;
hovered?: TooltipDataChunk[];
pointerPosition?: PointerPosition;
};

export const Tooltip = (props: TooltipProps) => {
const {hovered, pointerPosition, tooltip, xAxis, yAxis} = props;
const {tooltip, xAxis, yAxis, hovered, pointerPosition} = props;
const ref = React.useRef<HTMLDivElement>(null);
const size = React.useMemo(() => {
if (ref.current && hovered) {
const {width, height} = ref.current.getBoundingClientRect();
return {width, height};
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hovered, pointerPosition]);
const position = React.useMemo(() => {
if (hovered && pointerPosition && size) {
Expand Down Expand Up @@ -54,7 +59,7 @@ export const Tooltip = (props: TooltipProps) => {
const customTooltip = tooltip.renderer?.({hovered});

return isNil(customTooltip) ? (
<DefaultContent hovered={hovered} xAxis={xAxis} yAxis={yAxis} />
<DefaultContent hovered={hovered[0]} xAxis={xAxis} yAxis={yAxis} />
) : (
customTooltip
);
Expand Down
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/constants/defaults/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './axis';
export * from './legend';
export * from './series-options';
Loading

0 comments on commit a1a76f8

Please sign in to comment.