Skip to content

Commit

Permalink
feat(D3 plugin): add multiple series hover support (#471)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): add multiple series hover support

* some fixes

* yet another fix

* fix review
  • Loading branch information
kuzmadom committed Apr 25, 2024
1 parent 4d8f877 commit 781708a
Show file tree
Hide file tree
Showing 14 changed files with 400 additions and 439 deletions.
63 changes: 39 additions & 24 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
import React from 'react';
import React, {MouseEventHandler} from 'react';

import {pointer} from 'd3';
import throttle from 'lodash/throttle';

import type {ChartKitWidgetData} from '../../../../types';
import {block} from '../../../../utils/cn';
import {getD3Dispatcher} from '../d3-dispatcher';
import {
useAxisScales,
useChartDimensions,
useChartOptions,
useSeries,
useShapes,
useTooltip,
} from '../hooks';
import {useAxisScales, useChartDimensions, useChartOptions, useSeries, useShapes} from '../hooks';
import {getWidthOccupiedByYAxis} from '../hooks/useChartDimensions/utils';
import {getPreparedXAxis} from '../hooks/useChartOptions/x-axis';
import {getPreparedYAxis} from '../hooks/useChartOptions/y-axis';
import {getClosestPoints} from '../utils/get-closest-data';

import {AxisX} from './AxisX';
import {AxisY} from './AxisY';
import {Legend} from './Legend';
import {Title} from './Title';
import {Tooltip, TooltipTriggerArea} from './Tooltip';
import {Tooltip} from './Tooltip';

import './styles.scss';

const b = block('d3');

const THROTTLE_DELAY = 50;

type Props = {
width: number;
height: number;
Expand Down Expand Up @@ -80,7 +79,6 @@ export const Chart = (props: Props) => {
xAxis,
yAxis,
});
const {hovered, pointerPosition} = useTooltip({dispatcher, tooltip});
const {shapes, shapesData} = useShapes({
boundsWidth,
boundsHeight,
Expand All @@ -91,7 +89,6 @@ export const Chart = (props: Props) => {
xScale,
yAxis,
yScale,
svgContainer: svgRef.current,
});

const clickHandler = data.chart?.events?.click;
Expand All @@ -108,9 +105,38 @@ export const Chart = (props: Props) => {
const boundsOffsetTop = chart.margin.top;
const boundsOffsetLeft = chart.margin.left + getWidthOccupiedByYAxis({preparedAxis: yAxis});

const handleMouseMove: MouseEventHandler<SVGSVGElement> = (event) => {
const [pointerX, pointerY] = pointer(event, svgRef.current);
const x = pointerX - boundsOffsetLeft;
const y = pointerY - boundsOffsetTop;
if (x < 0 || x > boundsWidth || y < 0 || y > boundsHeight) {
dispatcher.call('hover-shape', {}, undefined);
return;
}

const closest = getClosestPoints({
position: [x, y],
shapesData,
});
dispatcher.call('hover-shape', event.target, closest, [pointerX, pointerY]);
};
const throttledHandleMouseMove = throttle(handleMouseMove, THROTTLE_DELAY);

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

return (
<React.Fragment>
<svg ref={svgRef} className={b()} width={width} height={height}>
<svg
ref={svgRef}
className={b()}
width={width}
height={height}
onMouseMove={throttledHandleMouseMove}
onMouseLeave={handleMouseLeave}
>
{title && <Title {...title} chartWidth={width} />}
<g
width={boundsWidth}
Expand All @@ -136,15 +162,6 @@ export const Chart = (props: Props) => {
</React.Fragment>
)}
{shapes}
{tooltip?.enabled && Boolean(shapesData.length) && (
<TooltipTriggerArea
boundsWidth={boundsWidth}
boundsHeight={boundsHeight}
dispatcher={dispatcher}
shapesData={shapesData}
svgContainer={svgRef.current}
/>
)}
</g>
{preparedLegend.enabled && (
<Legend
Expand All @@ -163,8 +180,6 @@ export const Chart = (props: Props) => {
svgContainer={svgRef.current}
xAxis={xAxis}
yAxis={yAxis[0]}
hovered={hovered}
pointerPosition={pointerPosition}
/>
</React.Fragment>
);
Expand Down
68 changes: 42 additions & 26 deletions src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import type {
TooltipDataChunk,
TreemapSeriesData,
} from '../../../../../types';
import {block} from '../../../../../utils/cn';
import {formatNumber} from '../../../../shared';
import type {PreparedAxis, PreparedPieSeries} from '../../hooks';
import {getDataCategoryValue} from '../../utils';

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

type Props = {
hovered: TooltipDataChunk[];
xAxis: PreparedAxis;
Expand Down Expand Up @@ -47,54 +50,67 @@ const getXRowData = (xAxis: PreparedAxis, data: ChartKitWidgetSeriesData) =>
const getYRowData = (yAxis: PreparedAxis, data: ChartKitWidgetSeriesData) =>
getRowData('y', yAxis, data);

const getMeasureValue = (data: TooltipDataChunk[], xAxis: PreparedAxis, yAxis: PreparedAxis) => {
if (data.every((item) => item.series.type === 'pie' || item.series.type === 'treemap')) {
return null;
}

if (data.some((item) => item.series.type === 'bar-y')) {
return getYRowData(yAxis, data[0]?.data);
}

return getXRowData(xAxis, data[0]?.data);
};

export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => {
const measureValue = getMeasureValue(hovered, xAxis, yAxis);

return (
<>
{hovered.map(({data, series}, i) => {
const id = get(series, 'id', i);
{measureValue && <div>{measureValue}</div>}
{hovered.map(({data, series, closest}, i) => {
const id = `${get(series, 'id')}_${i}`;
const color = get(series, 'color');

switch (series.type) {
case 'scatter':
case 'line':
case 'area':
case 'bar-x': {
const xRow = getXRowData(xAxis, data);
const yRow = getYRowData(yAxis, data);

const value = (
<React.Fragment>
{series.name}: {getYRowData(yAxis, data)}
</React.Fragment>
);
return (
<div key={id}>
<div>{xRow}</div>
<div>
<span>
<b>{series.name}</b>: {yRow}
</span>
</div>
<div key={id} className={b('content-row')}>
<div className={b('color')} style={{backgroundColor: color}} />
<div>{closest ? <b>{value}</b> : <span>{value}</span>}</div>
</div>
);
}
case 'bar-y': {
const xRow = getXRowData(xAxis, data);
const yRow = getYRowData(yAxis, data);

const value = (
<React.Fragment>
{series.name}: {getXRowData(xAxis, data)}
</React.Fragment>
);
return (
<div key={id}>
<div>{yRow}</div>
<div>
<span>
<b>{series.name}</b>: {xRow}
</span>
</div>
<div key={id} className={b('content-row')}>
<div className={b('color')} style={{backgroundColor: color}} />
<div>{closest ? <b>{value}</b> : <span>{value}</span>}</div>
</div>
);
}
case 'pie':
case 'treemap': {
const pieSeriesData = data as PreparedPieSeries | TreemapSeriesData;
const seriesData = data as PreparedPieSeries | TreemapSeriesData;

return (
<div key={id}>
<span>{pieSeriesData.name || pieSeriesData.id}&nbsp;</span>
<span>{pieSeriesData.value}</span>
<div key={id} className={b('content-row')}>
<div className={b('color')} style={{backgroundColor: color}} />
<span>{seriesData.name || seriesData.id}&nbsp;</span>
<span>{seriesData.value}</span>
</div>
);
}
Expand Down

0 comments on commit 781708a

Please sign in to comment.