diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 04492a2d..98675ad2 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -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, @@ -98,7 +98,7 @@ export const useShapes = (args: Args) => { dispatcher={dispatcher} top={top} left={left} - preparedDatas={preparedDatas} + preparedData={preparedData} seriesOptions={seriesOptions} svgContainer={svgContainer} />, diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx index c37d37b5..3ac7b2a0 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -17,36 +17,29 @@ type ScatterSeriesShapeProps = { dispatcher: Dispatch; top: number; left: number; - preparedDatas: PreparedScatterData[][]; + preparedData: PreparedScatterData[]; seriesOptions: PreparedSeriesOptions; svgContainer: SVGSVGElement | null; }; -type SeriesState = Record< - string, - { - selection: Selection; - hovered: boolean; - inactive: boolean; - } ->; - -type ChartState = { - hoveredSelections: Selection[]; - 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 => { 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(null); - const stateRef = React.useRef({hoveredSelections: [], seriesState: {}}); React.useEffect(() => { if (!ref.current) { @@ -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) => { @@ -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( - `.${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 ; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts index e6b4d990..8133e18e 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts @@ -12,6 +12,9 @@ export type PreparedScatterData = Omit & { cx: number; cy: number; series: PreparedScatterSeries; + hovered: boolean; + active: boolean; + id: number; }; const getCxAttr = (args: {point: ScatterSeriesData; xAxis: PreparedAxis; xScale: ChartScale}) => { @@ -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((acc, s) => { + return series.reduce((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; }, []); };