From 5376d96b9fdebd13d9e422e12d20c0d89459d3f5 Mon Sep 17 00:00:00 2001 From: "Mr.Dr.Professor Patrick" Date: Tue, 20 Feb 2024 19:17:37 +0100 Subject: [PATCH] feat(D3 plugin): add chart click event (#430) feat(D3 plugin): add chart click event (#418) * feat(D3 plugin): add chart click event * fix import * add scatter playground story * fix review --- .../__stories__/area/Playground.stories.tsx | 95 +++++++++++++++++++ .../__stories__/bar-x/Playground.stories.tsx | 7 +- .../__stories__/bar-y/Playground.stories.tsx | 5 + .../__stories__/line/Playground.stories.tsx | 5 + .../d3/__stories__/pie/Playground.stories.tsx | 5 + .../scatter/Playground.stories.tsx | 89 +++++++++++++++++ .../treemap/Playground.stories.tsx | 51 +++++----- src/plugins/d3/renderer/components/Chart.tsx | 9 ++ .../components/Tooltip/TooltipTriggerArea.tsx | 30 +++++- src/plugins/d3/renderer/d3-dispatcher.ts | 2 +- .../d3/renderer/hooks/useShapes/pie/index.tsx | 34 ++++--- .../hooks/useShapes/scatter/index.tsx | 17 +++- .../hooks/useShapes/treemap/index.tsx | 16 +++- src/types/widget-data/chart.ts | 3 + 14 files changed, 321 insertions(+), 47 deletions(-) create mode 100644 src/plugins/d3/__stories__/area/Playground.stories.tsx create mode 100644 src/plugins/d3/__stories__/scatter/Playground.stories.tsx diff --git a/src/plugins/d3/__stories__/area/Playground.stories.tsx b/src/plugins/d3/__stories__/area/Playground.stories.tsx new file mode 100644 index 00000000..1096e306 --- /dev/null +++ b/src/plugins/d3/__stories__/area/Playground.stories.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import {Button} from '@gravity-ui/uikit'; +import {action} from '@storybook/addon-actions'; +import {StoryObj} from '@storybook/react'; + +import {D3Plugin} from '../..'; +import {ChartKit} from '../../../../components/ChartKit'; +import {settings} from '../../../../libs'; +import {ChartKitWidgetData} from '../../../../types'; +import {HighchartsPlugin} from '../../../highcharts'; + +function prepareData(): ChartKitWidgetData { + return { + series: { + options: { + line: { + lineWidth: 2, + }, + }, + data: [ + { + name: 'A', + type: 'area', + data: [ + {x: 1, y: 200}, + {x: 2, y: 220}, + {x: 3, y: 180}, + ], + stacking: 'normal', + dataLabels: { + enabled: true, + }, + }, + { + name: 'B', + type: 'area', + data: [ + {x: 1, y: 30}, + {x: 2, y: 25}, + {x: 3, y: 45}, + ], + stacking: 'normal', + dataLabels: { + enabled: true, + }, + }, + ], + }, + chart: { + events: { + click: action('chart.events.click'), + }, + }, + }; +} + +const ChartStory = ({data}: {data: ChartKitWidgetData}) => { + const [shown, setShown] = React.useState(false); + + if (!shown) { + settings.set({plugins: [D3Plugin, HighchartsPlugin]}); + return ; + } + + return ( + <> +
+ +
+ + ); +}; + +export const PlaygroundLineChartStory: StoryObj = { + name: 'Playground', + args: { + data: prepareData(), + }, + argTypes: { + data: { + control: 'object', + }, + }, +}; + +export default { + title: 'Plugins/D3/Area', + component: ChartStory, +}; diff --git a/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx b/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx index 2289f5b2..e4d9fefc 100644 --- a/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/Playground.stories.tsx @@ -27,7 +27,7 @@ function prepareData(): ChartKitWidgetData { }, xAxis: { type: 'category', - categories: gamesByPlatform.map(([key]) => key), + categories: gamesByPlatform.map(([key, _group]) => key), title: { text: 'Game Platforms', }, @@ -48,6 +48,11 @@ function prepareData(): ChartKitWidgetData { }, }, ], + chart: { + events: { + click: action('chart.events.click'), + }, + }, }; } diff --git a/src/plugins/d3/__stories__/bar-y/Playground.stories.tsx b/src/plugins/d3/__stories__/bar-y/Playground.stories.tsx index e5376282..760e4f1e 100644 --- a/src/plugins/d3/__stories__/bar-y/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/bar-y/Playground.stories.tsx @@ -49,6 +49,11 @@ function prepareData(): ChartKitWidgetData { }, }, ], + chart: { + events: { + click: action('chart.events.click'), + }, + }, }; } diff --git a/src/plugins/d3/__stories__/line/Playground.stories.tsx b/src/plugins/d3/__stories__/line/Playground.stories.tsx index 1f364255..d6963164 100644 --- a/src/plugins/d3/__stories__/line/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/line/Playground.stories.tsx @@ -84,6 +84,11 @@ function prepareData(): ChartKitWidgetData { }, }, ], + chart: { + events: { + click: action('chart.events.click'), + }, + }, }; } diff --git a/src/plugins/d3/__stories__/pie/Playground.stories.tsx b/src/plugins/d3/__stories__/pie/Playground.stories.tsx index a009451c..ca50e719 100644 --- a/src/plugins/d3/__stories__/pie/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/pie/Playground.stories.tsx @@ -31,6 +31,11 @@ function prepareData(): ChartKitWidgetData { ], }, legend: {enabled: true}, + chart: { + events: { + click: action('chart.events.click'), + }, + }, }; } diff --git a/src/plugins/d3/__stories__/scatter/Playground.stories.tsx b/src/plugins/d3/__stories__/scatter/Playground.stories.tsx new file mode 100644 index 00000000..eb920b95 --- /dev/null +++ b/src/plugins/d3/__stories__/scatter/Playground.stories.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import {Button} from '@gravity-ui/uikit'; +import {action} from '@storybook/addon-actions'; +import {StoryObj} from '@storybook/react'; + +import {D3Plugin} from '../..'; +import {ChartKit} from '../../../../components/ChartKit'; +import {settings} from '../../../../libs'; +import {ChartKitWidgetData} from '../../../../types'; +import nintendoGames from '../../examples/nintendoGames'; + +function prepareData() { + const dataset = nintendoGames.filter((d) => d.date && d.user_score); + const data = dataset.map((d) => ({ + x: d.date || undefined, + y: d.user_score || undefined, + custom: d, + })); + + const widgetData: ChartKitWidgetData = { + series: { + data: [ + { + type: 'scatter', + data, + name: 'Scatter series', + }, + ], + }, + yAxis: [ + { + title: { + text: 'User score', + }, + }, + ], + xAxis: { + type: 'datetime', + title: { + text: 'Release dates', + }, + }, + chart: { + events: { + click: action('chart.events.click'), + }, + }, + }; + + return widgetData; +} + +const ChartStory = ({data}: {data: ChartKitWidgetData}) => { + const [shown, setShown] = React.useState(false); + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ +
+ ); +}; + +export const PlaygroundBarYChartStory: StoryObj = { + name: 'Playground', + args: { + data: prepareData(), + }, + argTypes: { + data: { + control: 'object', + }, + }, +}; + +export default { + title: 'Plugins/D3/Scatter', + component: ChartStory, +}; diff --git a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx index ca332ad8..3d5cea15 100644 --- a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx @@ -8,31 +8,36 @@ import type {ChartKitWidgetData} from '../../../../types/widget-data'; import {D3Plugin} from '../..'; const prepareData = (): ChartKitWidgetData => { + const treemapSeries: TreemapSeries = { + type: 'treemap', + name: 'Example', + dataLabels: { + enabled: true, + }, + layoutAlgorithm: 'binary', + levels: [{index: 1}, {index: 2}, {index: 3}], + data: [ + {name: 'One', value: 15}, + {name: 'Two', value: 10}, + {name: 'Three', value: 15}, + {name: 'Four'}, + {name: 'Four-1', value: 5, parentId: 'Four'}, + {name: 'Four-2', parentId: 'Four'}, + {name: 'Four-3', value: 4, parentId: 'Four'}, + {name: 'Four-2-1', value: 5, parentId: 'Four-2'}, + {name: 'Four-2-2', value: 7, parentId: 'Four-2'}, + {name: 'Four-2-3', value: 10, parentId: 'Four-2'}, + ], + }; + return { series: { - data: [ - { - type: 'treemap', - name: 'Example', - dataLabels: { - enabled: true, - }, - layoutAlgorithm: 'binary', - levels: [{index: 1}, {index: 2}, {index: 3}], - data: [ - {name: 'One', value: 15}, - {name: 'Two', value: 10}, - {name: 'Three', value: 15}, - {name: 'Four'}, - {name: 'Four-1', value: 5, parentId: 'Four'}, - {name: 'Four-2', parentId: 'Four'}, - {name: 'Four-3', value: 4, parentId: 'Four'}, - {name: 'Four-2-1', value: 5, parentId: 'Four-2'}, - {name: 'Four-2-2', value: 7, parentId: 'Four-2'}, - {name: 'Four-2-3', value: 10, parentId: 'Four-2'}, - ], - }, - ], + data: [treemapSeries], + }, + chart: { + events: { + click: action('chart.events.click'), + }, }, }; }; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 555b7579..e85acabd 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -93,6 +93,15 @@ export const Chart = (props: Props) => { yScale, svgContainer: svgRef.current, }); + React.useEffect(() => { + if (data.chart?.events?.click) { + dispatcher.on('click-chart', data.chart?.events?.click); + } + + return () => { + dispatcher.on('click-chart', null); + }; + }, [dispatcher]); const boundsOffsetTop = chart.margin.top; const boundsOffsetLeft = chart.margin.left + getWidthOccupiedByYAxis({preparedAxis: yAxis}); diff --git a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx index 8dfdb3bc..2341bf34 100644 --- a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx @@ -169,7 +169,7 @@ export const TooltipTriggerArea = (args: Args) => { return sort(result, (item) => item.y); }, [shapesData]); - const handleMouseMove: React.MouseEventHandler = (e) => { + const getShapeData = (point: [number, number]) => { const {left: ownLeft, top: ownTop} = rectRef.current?.getBoundingClientRect() || { left: 0, top: 0, @@ -178,10 +178,10 @@ export const TooltipTriggerArea = (args: Args) => { left: 0, top: 0, }; - const [pointerX, pointerY] = pointer(e, svgContainer); - const hoverShapeData = []; + const [pointerX, pointerY] = point; //pointer(e, svgContainer); + const result = []; - hoverShapeData?.push( + result?.push( ...getBarXShapeData({ shapesData, point: [pointerX, pointerY], @@ -200,6 +200,13 @@ export const TooltipTriggerArea = (args: Args) => { }), ); + return result; + }; + + const handleMouseMove: React.MouseEventHandler = (e) => { + const [pointerX, pointerY] = pointer(e, svgContainer); + const hoverShapeData = getShapeData([pointerX, pointerY]); + if (hoverShapeData.length) { const position: PointerPosition = [pointerX, pointerY]; dispatcher.call('hover-shape', e.target, hoverShapeData, position); @@ -213,6 +220,20 @@ export const TooltipTriggerArea = (args: Args) => { dispatcher.call('hover-shape', {}, undefined); }; + const handleClick: React.MouseEventHandler = (e) => { + const [pointerX, pointerY] = pointer(e, svgContainer); + const shapeData = getShapeData([pointerX, pointerY]); + + if (shapeData.length) { + dispatcher.call( + 'click-chart', + undefined, + {point: get(shapeData, '[0].data'), series: get(shapeData, '[0].series')}, + e, + ); + } + }; + return ( { fill="transparent" onMouseMove={throttledHandleMouseMove} onMouseLeave={handleMouseLeave} + onClick={handleClick} /> ); }; diff --git a/src/plugins/d3/renderer/d3-dispatcher.ts b/src/plugins/d3/renderer/d3-dispatcher.ts index 19d62a72..91ae411e 100644 --- a/src/plugins/d3/renderer/d3-dispatcher.ts +++ b/src/plugins/d3/renderer/d3-dispatcher.ts @@ -1,5 +1,5 @@ import {dispatch} from 'd3'; export const getD3Dispatcher = () => { - return dispatch('hover-shape'); + return dispatch('hover-shape', 'click-chart'); }; diff --git a/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx index b24648bd..d539774c 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/pie/index.tsx @@ -143,23 +143,22 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) { } }); + const getSelectedSegment = (element: Element) => { + const datum = select | PieLabelData>( + element, + ).datum(); + const seriesId = get(datum, 'data.series.id', get(datum, 'series.id')); + return preparedData.reduce((result, pie) => { + return result || pie.segments.find((s) => s.data.series.id === seriesId)?.data; + }, undefined); + }; + const eventName = `hover-shape.pie`; const hoverOptions = get(seriesOptions, 'pie.states.hover'); const inactiveOptions = get(seriesOptions, 'pie.states.inactive'); svgElement .on('mousemove', (e) => { - const datum = select | PieLabelData>( - e.target, - ).datum(); - const seriesId = get(datum, 'data.series.id', get(datum, 'series.id')); - const currentSegment = preparedData.reduce( - (result, pie) => { - return ( - result || pie.segments.find((s) => s.data.series.id === seriesId)?.data - ); - }, - undefined, - ); + const currentSegment = getSelectedSegment(e.target); if (currentSegment) { const data: TooltipDataChunkPie = { @@ -176,6 +175,17 @@ export function PieSeriesShapes(args: PreparePieSeriesArgs) { }) .on('mouseleave', () => { dispatcher.call('hover-shape', {}, undefined); + }) + .on('click', (e) => { + const selectedSegment = getSelectedSegment(e.target); + if (selectedSegment) { + dispatcher.call( + 'click-chart', + undefined, + {point: selectedSegment.series.data, series: selectedSegment.series}, + e, + ); + } }); dispatcher.on(eventName, (data?: TooltipDataChunkPie[]) => { diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx index 6f03ad93..86b46759 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -48,9 +48,13 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { .call(renderMarker) .attr('fill', (d) => d.point.data.color || d.point.series.color || ''); + const getSelectedPoint = (element: Element) => { + return select(element).datum(); + }; + svgElement .on('mousemove', (e) => { - const datum = select(e.target).datum(); + const datum = getSelectedPoint(e.target); if (!datum) { return; @@ -69,6 +73,17 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { }) .on('mouseleave', () => { dispatcher.call('hover-shape', {}, undefined); + }) + .on('click', (e) => { + const datum = getSelectedPoint(e.target); + if (datum) { + dispatcher.call( + 'click-chart', + undefined, + {point: datum.point.data, series: datum.point.series}, + e, + ); + } }); const hoverEnabled = hoverOptions?.enabled; diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx index b7482c4e..bf0478e4 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -62,15 +62,17 @@ export const TreemapSeriesShape = (props: ShapeProps) => { .style('fill', () => series.dataLabels.style?.fontColor || null) .call(setEllipsisForOverflowTexts, (d) => d.width); - const eventName = `hover-shape.pie`; + const getSelectedPart = (node: Element) => { + const hoveredRect = select>(node); + return hoveredRect.datum(); + }; + + const eventName = `hover-shape.treemap`; const hoverOptions = get(seriesOptions, 'treemap.states.hover'); const inactiveOptions = get(seriesOptions, 'treemap.states.inactive'); svgElement .on('mousemove', (e) => { - const hoveredRect = select>( - e.target, - ); - const datum = hoveredRect.datum(); + const datum = getSelectedPart(e.target); dispatcher.call( 'hover-shape', {}, @@ -80,6 +82,10 @@ export const TreemapSeriesShape = (props: ShapeProps) => { }) .on('mouseleave', () => { dispatcher.call('hover-shape', {}, undefined); + }) + .on('click', (e) => { + const datum = getSelectedPart(e.target); + dispatcher.call('click-chart', undefined, {point: datum.data, series}, e); }); dispatcher.on(eventName, (data?: TooltipDataChunkTreemap[]) => { diff --git a/src/types/widget-data/chart.ts b/src/types/widget-data/chart.ts index 73eabafe..c579a7af 100644 --- a/src/types/widget-data/chart.ts +++ b/src/types/widget-data/chart.ts @@ -7,4 +7,7 @@ export type ChartMargin = { export type ChartKitWidgetChart = { margin?: Partial; + events?: { + click?: (data: {point: unknown; series: unknown}, event: PointerEvent) => void; + }; };