Skip to content

Commit

Permalink
refactor(D3 plugin): handle scatter hover event (#317)
Browse files Browse the repository at this point in the history
* refactor(D3 plugin): handle hover event

* remove deepClone
  • Loading branch information
kuzmadom authored Sep 27, 2023
1 parent 04eb226 commit 50da93a
Show file tree
Hide file tree
Showing 3 changed files with 85 additions and 101 deletions.
4 changes: 2 additions & 2 deletions src/plugins/d3/renderer/hooks/useShapes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const useShapes = (args: Args) => {
}
case 'scatter': {
if (xScale && yScale) {
const preparedDatas = prepareScatterData({
const preparedData = prepareScatterData({
series: chartSeries as PreparedScatterSeries[],
xAxis,
xScale,
Expand All @@ -98,7 +98,7 @@ export const useShapes = (args: Args) => {
dispatcher={dispatcher}
top={top}
left={left}
preparedDatas={preparedDatas}
preparedData={preparedData}
seriesOptions={seriesOptions}
svgContainer={svgContainer}
/>,
Expand Down
164 changes: 72 additions & 92 deletions src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,29 @@ type ScatterSeriesShapeProps = {
dispatcher: Dispatch<object>;
top: number;
left: number;
preparedDatas: PreparedScatterData[][];
preparedData: PreparedScatterData[];
seriesOptions: PreparedSeriesOptions;
svgContainer: SVGSVGElement | null;
};

type SeriesState = Record<
string,
{
selection: Selection<BaseType, any, SVGGElement, PreparedScatterData>;
hovered: boolean;
inactive: boolean;
}
>;

type ChartState = {
hoveredSelections: Selection<BaseType, any, SVGGElement, PreparedScatterData>[];
seriesState: SeriesState;
};

const b = block('d3-scatter');
const DEFAULT_SCATTER_POINT_RADIUS = 4;
const EMPTY_SELECTION = null as unknown as Selection<
BaseType,
PreparedScatterData,
SVGGElement,
unknown
>;

const key = (d: unknown) => (d as PreparedScatterData).id || -1;

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

export function ScatterSeriesShape(props: ScatterSeriesShapeProps) {
const {dispatcher, top, left, preparedDatas, seriesOptions, svgContainer} = props;
const {dispatcher, top, left, preparedData, seriesOptions, svgContainer} = props;
const ref = React.useRef<SVGGElement>(null);
const stateRef = React.useRef<ChartState>({hoveredSelections: [], seriesState: {}});

React.useEffect(() => {
if (!ref.current) {
Expand All @@ -56,27 +49,19 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) {
const svgElement = select(ref.current);
const hoverOptions = get(seriesOptions, 'scatter.states.hover');
const inactiveOptions = get(seriesOptions, 'scatter.states.inactive');
svgElement.selectAll('*').remove();
preparedDatas.forEach((preparedData, i) => {
const selection = svgElement
.selectAll(`points-${i}`)
.data(preparedData)
.join(
(enter) => enter.append('circle').attr('class', b('point')),
(update) => update,
(exit) => exit.remove(),
)
.attr('fill', (d) => d.data.color || d.series.color || '')
.attr('r', (d) => d.data.radius || DEFAULT_SCATTER_POINT_RADIUS)
.attr('cx', (d) => d.cx)
.attr('cy', (d) => d.cy);

stateRef.current.seriesState[preparedData[0].series.id] = {
selection,
hovered: false,
inactive: false,
};
});

const selection = svgElement
.selectAll(`circle`)
.data(preparedData, key)
.join(
(enter) => enter.append('circle').attr('class', b('point')),
(update) => update,
(exit) => exit.remove(),
)
.attr('fill', (d) => d.data.color || d.series.color || '')
.attr('r', (d) => d.data.radius || DEFAULT_SCATTER_POINT_RADIUS)
.attr('cx', (d) => d.cx)
.attr('cy', (d) => d.cy);

svgElement
.on('mousemove', (e) => {
Expand All @@ -99,72 +84,67 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) {
dispatcher.call('hover-shape', {}, undefined);
});

const hoverEnabled = hoverOptions?.enabled;
const inactiveEnabled = inactiveOptions?.enabled;

dispatcher.on('hover-shape.scatter', (data?: PreparedScatterData[]) => {
const hoverEnabled = hoverOptions?.enabled;
const inactiveEnabled = inactiveOptions?.enabled;

if (hoverEnabled) {
stateRef.current.hoveredSelections.forEach((selection) => {
selection.attr('fill', (d) => d.series.color);
});
}

if (data?.[0]) {
const className = b('point');
const points = svgElement.selectAll<BaseType, PreparedScatterData>(
`.${className}[cx="${data[0].cx}"][cy="${data[0].cy}"]`,
const selectedPoint: PreparedScatterData | undefined = data?.[0];

const updates: PreparedScatterData[] = [];
preparedData.forEach((p) => {
const hovered = Boolean(
hoverEnabled &&
selectedPoint &&
p.cx === selectedPoint.cx &&
p.cy === selectedPoint.cy,
);

if (hoverEnabled) {
points.attr('fill', (d) => {
const fillColor = d.series.color;
return (
color(fillColor)?.brighter(hoverOptions?.brightness).toString() ||
fillColor
);
});
if (p.hovered !== hovered) {
p.hovered = hovered;
updates.push(p);
}

stateRef.current.hoveredSelections = [points];

// Hovered and inactive styles uses only in case of multiple series
if (Object.keys(stateRef.current.seriesState).length > 1) {
const hoveredSeriesName = data[0].series.id;

Object.entries(stateRef.current.seriesState).forEach(([name, state]) => {
if (hoveredSeriesName === name && !state.hovered) {
stateRef.current.seriesState[name].hovered = true;
stateRef.current.seriesState[name].inactive = false;
const active = Boolean(
!inactiveEnabled || !selectedPoint || selectedPoint.series.id === p.series.id,
);
if (p.active !== active) {
p.active = active;
updates.push(p);
}
});

if (inactiveEnabled) {
state.selection.attr('opacity', null);
selection.data(updates, key).join(
() => EMPTY_SELECTION,
(update) => {
update
.attr('fill', (d) => {
const initialColor = d.data.color || d.series.color || '';
if (d.hovered) {
return (
color(initialColor)
?.brighter(hoverOptions?.brightness)
.toString() || initialColor
);
}
} else if (hoveredSeriesName !== name) {
stateRef.current.seriesState[name].hovered = false;
stateRef.current.seriesState[name].inactive = true;

if (inactiveEnabled) {
state.selection.attr('opacity', inactiveOptions.opacity || null);
return initialColor;
})
.attr('opacity', function (d) {
if (!d.active) {
return inactiveOptions?.opacity || null;
}
}
});
}
} else if (!data) {
Object.entries(stateRef.current.seriesState).forEach(([name, state]) => {
if (inactiveEnabled) {
state.selection.attr('opacity', 1);
}

stateRef.current.seriesState[name].hovered = false;
stateRef.current.seriesState[name].inactive = false;
});
}

return null;
});

return update;
},
(exit) => exit,
);
});

return () => {
dispatcher.on('hover-shape.scatter', null);
};
}, [dispatcher, top, left, preparedDatas, seriesOptions, svgContainer]);
}, [dispatcher, top, left, preparedData, seriesOptions, svgContainer]);

return <g ref={ref} className={b()} />;
}
18 changes: 11 additions & 7 deletions src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export type PreparedScatterData = Omit<TooltipDataChunkScatter, 'series'> & {
cx: number;
cy: number;
series: PreparedScatterSeries;
hovered: boolean;
active: boolean;
id: number;
};

const getCxAttr = (args: {point: ScatterSeriesData; xAxis: PreparedAxis; xScale: ChartScale}) => {
Expand Down Expand Up @@ -60,25 +63,26 @@ export const prepareScatterData = (args: {
xScale: ChartScale;
yAxis: PreparedAxis;
yScale: ChartScale;
}): PreparedScatterData[][] => {
}): PreparedScatterData[] => {
const {series, xAxis, xScale, yAxis, yScale} = args;

return series.reduce<PreparedScatterData[][]>((acc, s) => {
return series.reduce<PreparedScatterData[]>((acc, s) => {
const filteredData =
xAxis.type === 'category' || yAxis.type === 'category'
? s.data
: getFilteredLinearScatterData(s.data);
const preparedData: PreparedScatterData[] = filteredData.map((d) => {
return {
filteredData.forEach((d) => {
acc.push({
data: d,
series: s,
cx: getCxAttr({point: d, xAxis, xScale}),
cy: getCyAttr({point: d, yAxis, yScale}),
};
hovered: false,
active: true,
id: acc.length - 1,
});
});

acc.push(preparedData);

return acc;
}, []);
};

0 comments on commit 50da93a

Please sign in to comment.