From bc2dd16e41630f0f781391eaea9b7137eaedd3f4 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Fri, 22 Sep 2023 01:58:50 +0300 Subject: [PATCH] feat: tmp --- src/constants/index.ts | 1 + src/constants/widget-data.ts | 5 + .../bar-x/DatetimeAxis.stories.tsx | 8 +- .../d3/__stories__/pie/BasicDonut.stories.tsx | 2 +- .../d3/__stories__/pie/BasicPie.stories.tsx | 2 +- .../d3/__stories__/pie/Styled.stories.tsx | 2 +- .../__stories__/scatter/BigLegend.stories.tsx | 2 +- .../__stories__/scatter/Timestamp.stories.tsx | 2 +- src/plugins/d3/renderer/components/Chart.tsx | 56 +++--- .../components/Tooltip/DefaultContent.tsx | 12 +- .../components/Tooltip/TooltipArea.tsx | 151 +++++++++++++++ .../d3/renderer/components/Tooltip/index.tsx | 9 +- .../d3/renderer/constants/defaults/index.ts | 1 + .../constants/defaults/series-options.ts | 48 +++++ .../d3/renderer/hooks/useSeries/index.ts | 11 +- .../hooks/useSeries/prepare-options.ts | 14 ++ .../renderer/hooks/useSeries/prepareSeries.ts | 19 +- .../d3/renderer/hooks/useSeries/types.ts | 8 +- .../renderer/hooks/useShapes/bar-x/index.tsx | 95 +++++++++ .../{bar-x.tsx => bar-x/prepare-data.ts} | 182 ++++-------------- .../d3/renderer/hooks/useShapes/defaults.ts | 5 - .../d3/renderer/hooks/useShapes/index.tsx | 77 +++++--- .../d3/renderer/hooks/useShapes/pie.tsx | 166 ++++++++++++++-- .../d3/renderer/hooks/useShapes/scatter.tsx | 137 ------------- .../hooks/useShapes/scatter/index.tsx | 89 +++++++++ .../hooks/useShapes/scatter/prepare-data.ts | 87 +++++++++ .../d3/renderer/hooks/useShapes/styles.scss | 3 +- .../d3/renderer/hooks/useTooltip/index.ts | 15 +- .../d3/renderer/hooks/useTooltip/types.ts | 4 +- src/types/widget-data/series.ts | 65 ++++++- src/types/widget-data/tooltip.ts | 36 +++- 31 files changed, 923 insertions(+), 391 deletions(-) create mode 100644 src/constants/widget-data.ts create mode 100644 src/plugins/d3/renderer/components/Tooltip/TooltipArea.tsx create mode 100644 src/plugins/d3/renderer/constants/defaults/series-options.ts create mode 100644 src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx rename src/plugins/d3/renderer/hooks/useShapes/{bar-x.tsx => bar-x/prepare-data.ts} (51%) delete mode 100644 src/plugins/d3/renderer/hooks/useShapes/defaults.ts delete mode 100644 src/plugins/d3/renderer/hooks/useShapes/scatter.tsx create mode 100644 src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx create mode 100644 src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts diff --git a/src/constants/index.ts b/src/constants/index.ts index fe0e0e5e..2f4c3968 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,2 @@ export {CHARTKIT_SCROLLABLE_NODE_CLASSNAME} from './common'; +export * from './widget-data'; diff --git a/src/constants/widget-data.ts b/src/constants/widget-data.ts new file mode 100644 index 00000000..d99f5615 --- /dev/null +++ b/src/constants/widget-data.ts @@ -0,0 +1,5 @@ +export const TooltipDataChunkType = { + BAR_X: 0, + PIE: 1, + SCATTER: 2, +} as const; diff --git a/src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx b/src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx index f37f39b3..4daaa905 100644 --- a/src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/DatetimeAxis.stories.tsx @@ -37,10 +37,10 @@ const Template: Story = () => { x: Number(new Date(2022, 10, 10)), y: 100, }, - { - x: Number(new Date(2023, 2, 5)), - y: 80, - }, + // { + // x: Number(new Date(2023, 2, 5)), + // y: 80, + // }, ], name: 'AB', }, diff --git a/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx b/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx index 314a6b01..5866ba67 100644 --- a/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx +++ b/src/plugins/d3/__stories__/pie/BasicDonut.stories.tsx @@ -36,7 +36,7 @@ const Template: Story = () => { }, title: {text: 'Basic donut'}, legend: {enabled: false}, - tooltip: {enabled: false}, + tooltip: {enabled: true}, }; if (!shown) { diff --git a/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx b/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx index 1e8a43bf..a441d1e4 100644 --- a/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx +++ b/src/plugins/d3/__stories__/pie/BasicPie.stories.tsx @@ -44,7 +44,7 @@ const Template: Story = () => { }, title: {text: 'Basic pie'}, legend: {enabled: true}, - tooltip: {enabled: false}, + tooltip: {enabled: true}, }; if (!shown) { diff --git a/src/plugins/d3/__stories__/pie/Styled.stories.tsx b/src/plugins/d3/__stories__/pie/Styled.stories.tsx index 85374892..fde3aa9d 100644 --- a/src/plugins/d3/__stories__/pie/Styled.stories.tsx +++ b/src/plugins/d3/__stories__/pie/Styled.stories.tsx @@ -63,7 +63,7 @@ const Template: Story = () => { }, title: {text: 'Styled pies'}, legend: {enabled: false}, - tooltip: {enabled: false}, + tooltip: {enabled: true}, }; if (!shown) { diff --git a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx index fb5256d7..6d1eadf9 100644 --- a/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx +++ b/src/plugins/d3/__stories__/scatter/BigLegend.stories.tsx @@ -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: { diff --git a/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx b/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx index d0127d38..1bfb0437 100644 --- a/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx +++ b/src/plugins/d3/__stories__/scatter/Timestamp.stories.tsx @@ -79,7 +79,7 @@ const shapeData = (data: Record[]): ChartKitWidgetData => { ], tooltip: { renderer: ({hovered}) => { - const d = hovered.data as ScatterSeriesData; + const d = hovered[0].data as ScatterSeriesData; return
{dateTime({input: d.x}).format('LL')}
; }, }, diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 13260133..8e6a1edc 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -6,7 +6,7 @@ import {block} from '../../../../utils/cn'; import { useAxisScales, useChartDimensions, - useChartEvents, + // useChartEvents, useChartOptions, useSeries, useShapes, @@ -17,7 +17,7 @@ import {AxisY} from './AxisY'; import {AxisX} from './AxisX'; import {Legend} from './Legend'; import {Title} from './Title'; -import {Tooltip} from './Tooltip'; +import {Tooltip, TooltipArea} from './Tooltip'; import './styles.scss'; @@ -35,19 +35,24 @@ export const Chart = (props: Props) => { // FIXME: add data validation const {top, left, width, height, data} = props; const svgRef = React.createRef(); - const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents(); 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({ hasAxisRelatedSeries: data.series.data.some(isAxisRelatedSeries), width, @@ -67,13 +72,14 @@ export const Chart = (props: Props) => { const {hovered, pointerPosition, handleSeriesMouseMove, handleSeriesMouseLeave} = useTooltip({ tooltip, }); - const {shapes} = useShapes({ + const {shapes, shapesData} = useShapes({ top, left, boundsWidth, boundsHeight, + hovered, series: preparedSeries, - seriesOptions: data.series.options, + seriesOptions: preparedSeriesOptions, xAxis, xScale, yAxis, @@ -85,14 +91,7 @@ export const Chart = (props: Props) => { return ( - + {title && } { )} {shapes} + {tooltip?.enabled && Boolean(shapesData.length) && ( + + )} {preparedLegend.enabled && ( { ); } + case 'pie': { + return ( +
+ {series.name || series.innerName}  + {data.value} +
+ ); + } default: { return null; } diff --git a/src/plugins/d3/renderer/components/Tooltip/TooltipArea.tsx b/src/plugins/d3/renderer/components/Tooltip/TooltipArea.tsx new file mode 100644 index 00000000..49935a6a --- /dev/null +++ b/src/plugins/d3/renderer/components/Tooltip/TooltipArea.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import throttle from 'lodash/throttle'; +import {Delaunay, bisector, pointer, sort} from 'd3'; + +import type { + ChartScale, + OnSeriesMouseMove, + OnSeriesMouseLeave, + ShapeData, + PreparedBarXData, + PreparedScatterData, +} from '../../hooks'; + +type Args = { + boundsWidth: number; + boundsHeight: number; + offsetTop: number; + offsetLeft: number; + shapesData: ShapeData[]; + svgContainer: SVGSVGElement | null; + xScale?: ChartScale; + onSeriesMouseMove?: OnSeriesMouseMove; + onSeriesMouseLeave?: OnSeriesMouseLeave; +}; + +type CalculationType = 'x-primary' | 'delaunay'; + +// https://d3js.org/d3-selection/joining#selection_data +const isNodeContainsData = (node?: Element): node is Element & {__data__: ShapeData} => { + return Boolean(node && '__data__' in node); +}; + +const getCalculationType = (shapesData: ShapeData[]): CalculationType => { + if (shapesData.every((d) => d.series.type === 'bar-x')) { + return 'x-primary'; + } + + if (shapesData.every((d) => d.series.type === 'scatter')) { + return 'delaunay'; + } + + throw new Error('This type of series does not supported for tooltip yet'); +}; + +export const TooltipArea = (args: Args) => { + const { + boundsWidth, + boundsHeight, + offsetTop, + offsetLeft, + shapesData, + svgContainer, + xScale, + onSeriesMouseMove, + onSeriesMouseLeave, + } = args; + const rectRef = React.useRef(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]); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const delaunay = React.useMemo(() => { + return calculationType === 'delaunay' + ? new Delaunay( + new Float64Array( + (shapesData as PreparedScatterData[]).map((d) => [d.cx, d.cy]).flat(), + ), + ) + : undefined; + }, [shapesData, calculationType]); + + const handleXprimaryMouseMove: React.MouseEventHandler = (e) => { + const {left, top} = rectRef.current?.getBoundingClientRect() || {left: 0, top: 0}; + const [x, y] = pointer(e, svgContainer); + const isXLinearOrTimeScale = xScale && 'invert' in xScale; + const xPosition = x - left - (isXLinearOrTimeScale ? 0 : offsetLeft); + const xDataIndex = bisector((d) => d).center(xData, xPosition); + const xNodes = Array.from( + rectRef.current?.parentElement?.querySelectorAll(`[x="${xData[xDataIndex]}"]`) || [], + ); + + if (xNodes.length === 1 && isNodeContainsData(xNodes[0])) { + onSeriesMouseMove?.({ + hovered: [xNodes[0].__data__], + pointerPosition: [x - offsetLeft, y - offsetTop], + }); + } else if (xNodes.length > 1 && xNodes.every(isNodeContainsData)) { + const yData = xNodes.map((node) => (node.__data__ as PreparedBarXData).y); + const yPosition = y - top; + const yDataIndex = bisector((d) => d).center(yData, yPosition); + + if (xNodes[yDataIndex]) { + xNodes.reverse(); + onSeriesMouseMove?.({ + hovered: [xNodes[yDataIndex].__data__], + pointerPosition: [x - offsetLeft, y - offsetTop], + }); + } + } + }; + + const handleDelaunayMouseMove: React.MouseEventHandler = (e) => { + console.log(document.elementFromPoint(e.clientX, e.clientY)); + // const {left, top} = rectRef.current?.getBoundingClientRect() || {left: 0, top: 0}; + // const [x, y] = pointer(e, svgContainer); + // const dataIndex = delaunay?.find(x - left, y - top, prevIndex) || -1; + // if (shapesData[dataIndex]) { + // prevIndex = dataIndex; + // // console.log(shapesData[dataIndex]); + // onSeriesMouseMove?.({ + // hovered: [shapesData[dataIndex]], + // pointerPosition: [x - offsetLeft, y - offsetTop], + // }); + // } + }; + + const handleMouseMove: React.MouseEventHandler = (e) => { + switch (calculationType) { + case 'x-primary': { + handleXprimaryMouseMove(e); + return; + } + case 'delaunay': { + handleDelaunayMouseMove(e); + } + } + }; + + const throttledHandleMouseMove = throttle(handleMouseMove, 50); + + const handleMouseLeave = () => { + throttledHandleMouseMove.cancel(); + onSeriesMouseLeave?.(); + }; + + return ( + + ); +}; diff --git a/src/plugins/d3/renderer/components/Tooltip/index.tsx b/src/plugins/d3/renderer/components/Tooltip/index.tsx index c8053c62..a2107bbc 100644 --- a/src/plugins/d3/renderer/components/Tooltip/index.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/index.tsx @@ -1,12 +1,14 @@ import React from 'react'; import isNil from 'lodash/isNil'; -import type {TooltipHoveredData} from '../../../../../types/widget-data'; +import type {TooltipDataChunkNext} from '../../../../../types/widget-data'; import {block} from '../../../../../utils/cn'; import type {PointerPosition, PreparedAxis, PreparedTooltip} from '../../hooks'; import {DefaultContent} from './DefaultContent'; +export * from './TooltipArea'; + const b = block('d3-tooltip'); const POINTER_OFFSET_X = 20; @@ -14,7 +16,7 @@ type TooltipProps = { tooltip: PreparedTooltip; xAxis: PreparedAxis; yAxis: PreparedAxis; - hovered?: TooltipHoveredData; + hovered?: TooltipDataChunkNext[]; pointerPosition?: PointerPosition; }; @@ -27,6 +29,7 @@ export const Tooltip = (props: TooltipProps) => { return {width, height}; } return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hovered, pointerPosition]); const position = React.useMemo(() => { if (hovered && pointerPosition && size) { @@ -54,7 +57,7 @@ export const Tooltip = (props: TooltipProps) => { const customTooltip = tooltip.renderer?.({hovered}); return isNil(customTooltip) ? ( - + ) : ( customTooltip ); diff --git a/src/plugins/d3/renderer/constants/defaults/index.ts b/src/plugins/d3/renderer/constants/defaults/index.ts index a0482efa..4ccef8bb 100644 --- a/src/plugins/d3/renderer/constants/defaults/index.ts +++ b/src/plugins/d3/renderer/constants/defaults/index.ts @@ -1 +1,2 @@ export * from './legend'; +export * from './series-options'; diff --git a/src/plugins/d3/renderer/constants/defaults/series-options.ts b/src/plugins/d3/renderer/constants/defaults/series-options.ts new file mode 100644 index 00000000..0e5abba8 --- /dev/null +++ b/src/plugins/d3/renderer/constants/defaults/series-options.ts @@ -0,0 +1,48 @@ +import type {ChartKitWidgetSeriesOptions} from '../../../../../types'; + +type DefauleBarXSeriesOptions = Partial & { + 'bar-x': {barMaxWidth: number; barPadding: number; groupPadding: number}; +}; + +export type SeriesOptionsDefaults = Partial & DefauleBarXSeriesOptions; + +export const seriesOptionsDefaults: SeriesOptionsDefaults = { + 'bar-x': { + barMaxWidth: 50, + barPadding: 0.1, + groupPadding: 0.2, + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, + pie: { + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, + scatter: { + states: { + hover: { + enabled: true, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index a6e2c4ce..a34ffb76 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -9,6 +9,7 @@ import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; import {getActiveLegendItems, getAllLegendItems} from './utils'; import type {PreparedSeries, OnLegendItemClick} from './types'; import {getPreparedLegend, getLegendComponents} from './prepare-legend'; +import {getPreparedOptions} from './prepare-options'; import {prepareSeries} from './prepareSeries'; type Args = { @@ -27,7 +28,7 @@ export const useSeries = (args: Args) => { chartMargin, legend, preparedYAxis, - series: {data: series}, + series: {data: series, options: seriesOptions}, } = args; const preparedLegend = React.useMemo( () => getPreparedLegend({legend, series}), @@ -53,11 +54,16 @@ export const useSeries = (args: Args) => { [], ); }, [series, preparedLegend]); + const preparedSeriesOptions = React.useMemo(() => { + return getPreparedOptions(seriesOptions); + }, [seriesOptions]); const [activeLegendItems, setActiveLegendItems] = React.useState( getActiveLegendItems(preparedSeries), ); const chartSeries = React.useMemo(() => { - return preparedSeries.map((singleSeries) => { + return preparedSeries.map((singleSeries, i) => { + singleSeries.innerName = `Series ${i + 1}`; + if (singleSeries.legend.enabled) { return { ...singleSeries, @@ -110,6 +116,7 @@ export const useSeries = (args: Args) => { legendConfig, preparedLegend, preparedSeries: chartSeries, + preparedSeriesOptions, handleLegendItemClick, }; }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts new file mode 100644 index 00000000..bb6f9345 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-options.ts @@ -0,0 +1,14 @@ +import cloneDeep from 'lodash/cloneDeep'; +import merge from 'lodash/merge'; + +import type {ChartKitWidgetSeriesOptions} from '../../../../../types/widget-data'; + +import {seriesOptionsDefaults} from '../../constants'; +import type {PreparedSeriesOptions} from './types'; + +export const getPreparedOptions = ( + options?: ChartKitWidgetSeriesOptions, +): PreparedSeriesOptions => { + const defaultOptions = cloneDeep(seriesOptionsDefaults); + return merge(defaultOptions, options); +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index fd378427..5fd7046a 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -1,12 +1,19 @@ +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; import type {ScaleOrdinal} from 'd3'; import {scaleOrdinal} from 'd3'; + import type { BarXSeries, ChartKitWidgetSeries, PieSeries, RectLegendSymbolOptions, } from '../../../../../types/widget-data'; -import cloneDeep from 'lodash/cloneDeep'; +import {getRandomCKId} from '../../../../../utils'; +import {BaseTextStyle} from '../../../../../types/widget-data'; + +import {DEFAULT_PALETTE} from '../../constants'; +import {DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; import type { PreparedBarXSeries, PreparedLegend, @@ -14,11 +21,6 @@ import type { PreparedPieSeries, PreparedSeries, } from './types'; -import get from 'lodash/get'; -import {DEFAULT_PALETTE} from '../../constants'; -import {DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; -import {getRandomCKId} from '../../../../../utils'; -import {BaseTextStyle} from '../../../../../types/widget-data'; const DEFAULT_DATALABELS_STYLE: BaseTextStyle = { fontSize: '11px', @@ -87,6 +89,7 @@ function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] { type: series.type, color: color, name: name, + innerName: '', visible: get(series, 'visible', true), legend: { enabled: get(series, 'legend.enabled', legend.enabled), @@ -121,13 +124,15 @@ function preparePieSeries(args: PreparePieSeriesArgs) { const preparedSeries: PreparedSeries[] = series.data.map((dataItem) => { const result: PreparedPieSeries = { type: 'pie', - data: dataItem.value, + data: dataItem, dataLabels: { enabled: get(series, 'dataLabels.enabled', true), }, label: dataItem.label, + value: dataItem.value, visible: typeof dataItem.visible === 'boolean' ? dataItem.visible : true, name: dataItem.name, + innerName: '', color: dataItem.color || colorScale(dataItem.name), legend: { enabled: get(series, 'legend.enabled', legend.enabled), diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 066ced75..a8fa9758 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -10,6 +10,8 @@ import { ScatterSeriesData, } from '../../../../../types/widget-data'; +import type {SeriesOptionsDefaults} from '../../constants'; + export type RectLegendSymbol = { shape: 'rect'; } & Required; @@ -45,6 +47,7 @@ export type LegendConfig = { type BasePreparedSeries = { color: string; name: string; + innerName: string; visible: boolean; legend: { enabled: boolean; @@ -70,9 +73,12 @@ export type PreparedBarXSeries = { export type PreparedPieSeries = BasePreparedSeries & Required> & { - data: PieSeriesData['value']; + data: PieSeriesData; + value: PieSeriesData['value']; stackId: string; label?: PieSeriesData['label']; }; export type PreparedSeries = PreparedScatterSeries | PreparedBarXSeries | PreparedPieSeries; + +export type PreparedSeriesOptions = SeriesOptionsDefaults; diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx new file mode 100644 index 00000000..96919fc1 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x/index.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import get from 'lodash/get'; +import {color, select} from 'd3'; + +import {block} from '../../../../../../utils/cn'; + +import type {PreparedSeriesOptions} from '../../useSeries/types'; +import type {PreparedBarXData} from './prepare-data'; + +export {prepareBarXData} from './prepare-data'; +export type {PreparedBarXData} from './prepare-data'; + +const DEFAULT_LABEL_PADDING = 7; + +const b = block('d3-bar-x'); + +type Args = { + preparedData: PreparedBarXData[]; + seriesOptions: PreparedSeriesOptions; +}; + +const getFillColor = ( + data: PreparedBarXData, + hoverOptions?: NonNullable['hover'], +) => { + const fillColor = data.data.color || data.series.color; + + if (hoverOptions?.enabled && data.data.y === data.hoveredDataValue) { + return color(fillColor)?.brighter(hoverOptions?.brightness).toString() || fillColor; + } + return fillColor; +}; + +const getOpacity = ( + data: PreparedBarXData, + inactiveOptions?: NonNullable['inactive'], +) => { + if (inactiveOptions?.enabled) { + return (data.inactive && inactiveOptions?.opacity) || null; + } + + return null; +}; + +export const BarXSeriesShapes = (args: Args) => { + const {preparedData, seriesOptions} = args; + + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return; + } + + const svgElement = select(ref.current); + const hoverOptions = get(seriesOptions, 'bar-x.states.hover'); + const inactiveOptions = get(seriesOptions, 'bar-x.states.inactive'); + svgElement.selectAll('*').remove(); + svgElement + .selectAll('allRects') + .data(preparedData) + .join('rect') + .attr('class', b('segment')) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .attr('height', (d) => d.height) + .attr('width', (d) => d.width) + .attr('fill', (d) => getFillColor(d, hoverOptions)) + .attr('opacity', (d) => getOpacity(d, inactiveOptions)); + + const dataLabels = preparedData.filter((d) => d.series.dataLabels.enabled); + + svgElement + .selectAll('allLabels') + .data(dataLabels) + .join('text') + .text((d) => String(d.data.label || d.data.y)) + .attr('class', b('label')) + .attr('x', (d) => d.x + d.width / 2) + .attr('y', (d) => { + if (d.series.dataLabels.inside) { + return d.y + d.height / 2; + } + + return d.y - DEFAULT_LABEL_PADDING; + }) + .attr('text-anchor', 'middle') + .attr('opacity', (d) => getOpacity(d, inactiveOptions)) + .style('font-size', (d) => d.series.dataLabels.style.fontSize) + .style('font-weight', (d) => d.series.dataLabels.style.fontWeight || null) + .style('fill', (d) => d.series.dataLabels.style.fontColor || null); + }, [preparedData, seriesOptions]); + + return ; +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts similarity index 51% rename from src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx rename to src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts index be30be7c..57b2c3ea 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/bar-x.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/bar-x/prepare-data.ts @@ -1,66 +1,44 @@ -import {ascending, descending, max, pointer, select, sort} from 'd3'; +import {ascending, descending, max, sort} from 'd3'; import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; -import React from 'react'; import get from 'lodash/get'; -import type {BarXSeriesData, ChartKitWidgetSeriesOptions} from '../../../../../types'; -import {block} from '../../../../../utils/cn'; +import {TooltipDataChunkType} from '../../../../../../constants'; +import type { + BarXSeriesData, + TooltipDataChunkNext, + TooltipDataChunkBarX, +} from '../../../../../../types'; -import {getDataCategoryValue} from '../../utils'; -import type {ChartScale} from '../useAxisScales'; -import type {ChartOptions} from '../useChartOptions/types'; -import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; -import type {PreparedBarXSeries} from '../useSeries/types'; -import {DEFAULT_BAR_X_SERIES_OPTIONS} from './defaults'; +import {getDataCategoryValue} from '../../../utils'; +import type {ChartScale} from '../../useAxisScales'; +import type {ChartOptions} from '../../useChartOptions/types'; +import type {PreparedBarXSeries, PreparedSeriesOptions} from '../../useSeries/types'; const MIN_RECT_GAP = 1; const MIN_GROUP_GAP = 1; -const DEFAULT_LABEL_PADDING = 7; -const b = block('d3-bar-x'); - -type Args = { - top: number; - left: number; - series: PreparedBarXSeries[]; - seriesOptions?: ChartKitWidgetSeriesOptions; - xAxis: ChartOptions['xAxis']; - xScale: ChartScale; - yAxis: ChartOptions['yAxis']; - yScale: ChartScale; - onSeriesMouseMove?: OnSeriesMouseMove; - onSeriesMouseLeave?: OnSeriesMouseLeave; - svgContainer: SVGSVGElement | null; -}; - -type ShapeData = { +export type PreparedBarXData = Omit & { x: number; y: number; width: number; height: number; - data: BarXSeriesData; series: PreparedBarXSeries; }; -function prepareData(args: { +export const prepareBarXData = (args: { + hovered?: TooltipDataChunkNext[]; series: PreparedBarXSeries[]; - seriesOptions?: ChartKitWidgetSeriesOptions; + seriesOptions: PreparedSeriesOptions; xAxis: ChartOptions['xAxis']; xScale: ChartScale; yAxis: ChartOptions['yAxis']; yScale: ChartScale; -}) { - const {series, seriesOptions, xAxis, xScale, yScale} = args; +}): PreparedBarXData[] => { + const {hovered, series, seriesOptions, xAxis, xScale, yScale} = args; const categories = get(xAxis, 'categories', [] as string[]); - const { - barMaxWidth: defaultBarMaxWidth, - barPadding: defaultBarPadding, - groupPadding: defaultGroupPadding, - } = DEFAULT_BAR_X_SERIES_OPTIONS; - const barMaxWidth = get(seriesOptions, 'bar-x.barMaxWidth', defaultBarMaxWidth); - const barPadding = get(seriesOptions, 'bar-x.barPadding', defaultBarPadding); - const groupPadding = get(seriesOptions, 'bar-x.groupPadding', defaultGroupPadding); - + const barMaxWidth = get(seriesOptions, 'bar-x.barMaxWidth'); + const barPadding = get(seriesOptions, 'bar-x.barPadding'); + const groupPadding = get(seriesOptions, 'bar-x.groupPadding'); const sortingOptions = get(seriesOptions, 'bar-x.dataSorting'); const comparator = sortingOptions?.direction === 'desc' ? descending : ascending; const sortKey = (() => { @@ -81,7 +59,15 @@ function prepareData(args: { string | number, Record > = {}; + const mapSeriesNameToState: Record = {}; series.forEach((s) => { + const inactive = hovered + ? !hovered?.some((chunk) => { + const chunkSeriesName = (chunk.series as PreparedBarXSeries).innerName; + return chunkSeriesName === s.innerName; + }) + : false; + mapSeriesNameToState[s.innerName] = {inactive}; s.data.forEach((d) => { const xValue = xAxis.type === 'category' @@ -132,7 +118,7 @@ function prepareData(args: { const rectGap = Math.max(bandWidth * barPadding, MIN_RECT_GAP); const rectWidth = Math.min(groupWidth / maxGroupSize - rectGap, barMaxWidth); - const result: ShapeData[] = []; + const result: PreparedBarXData[] = []; Object.entries(data).forEach(([xValue, val]) => { const stacks = Object.values(val); @@ -159,14 +145,26 @@ function prepareData(args: { const yLinearScale = yScale as ScaleLinear; const y = yLinearScale(yValue.data.y as number); const height = yLinearScale(yLinearScale.domain()[0]) - y; + const inactive = mapSeriesNameToState[yValue.series.innerName].inactive; + let hoveredDataValue: TooltipDataChunkBarX['hoveredDataValue']; + + if (!inactive) { + const chunk = hovered?.find( + (h) => h.type === TooltipDataChunkType.BAR_X && h.data.y === yValue.data.y, + ) as TooltipDataChunkBarX | undefined; + hoveredDataValue = chunk?.data.y; + } result.push({ + type: TooltipDataChunkType.BAR_X, x, y: y - stackHeight, width: rectWidth, height, data: yValue.data, series: yValue.series, + inactive, + hoveredDataValue, }); stackHeight += height + 1; @@ -175,100 +173,4 @@ function prepareData(args: { }); return result; -} - -export function BarXSeriesShapes(args: Args) { - const { - top, - left, - series, - seriesOptions, - xAxis, - xScale, - yAxis, - yScale, - onSeriesMouseMove, - onSeriesMouseLeave, - svgContainer, - } = args; - - const ref = React.useRef(null); - - React.useEffect(() => { - if (!ref.current) { - return; - } - - const svgElement = select(ref.current); - svgElement.selectAll('*').remove(); - - const shapes = prepareData({ - series, - seriesOptions, - xAxis, - xScale, - yAxis, - yScale, - }); - - svgElement - .selectAll('allRects') - .data(shapes) - .join('rect') - .attr('class', b('segment')) - .attr('x', (d) => d.x) - .attr('y', (d) => d.y) - .attr('height', (d) => d.height) - .attr('width', (d) => d.width) - .attr('fill', (d) => d.data.color || d.series.color) - .on('mousemove', (e, d) => { - const [x, y] = pointer(e, svgContainer); - onSeriesMouseMove?.({ - hovered: { - data: d.data, - series: d.series, - }, - pointerPosition: [x - left, y - top], - }); - }) - .on('mouseleave', () => { - if (onSeriesMouseLeave) { - onSeriesMouseLeave(); - } - }); - - const dataLabels = shapes.filter((s) => s.series.dataLabels.enabled); - - svgElement - .selectAll('allLabels') - .data(dataLabels) - .join('text') - .text((d) => String(d.data.label || d.data.y)) - .attr('class', b('label')) - .attr('x', (d) => d.x + d.width / 2) - .attr('y', (d) => { - if (d.series.dataLabels.inside) { - return d.y + d.height / 2; - } - - return d.y - DEFAULT_LABEL_PADDING; - }) - .attr('text-anchor', 'middle') - .style('font-size', (d) => d.series.dataLabels.style.fontSize) - .style('font-weight', (d) => d.series.dataLabels.style.fontWeight || null) - .style('fill', (d) => d.series.dataLabels.style.fontColor || null); - }, [ - onSeriesMouseMove, - onSeriesMouseLeave, - svgContainer, - xAxis, - xScale, - yAxis, - yScale, - series, - left, - top, - ]); - - return ; -} +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/defaults.ts b/src/plugins/d3/renderer/hooks/useShapes/defaults.ts deleted file mode 100644 index a6b8fd4c..00000000 --- a/src/plugins/d3/renderer/hooks/useShapes/defaults.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const DEFAULT_BAR_X_SERIES_OPTIONS = { - barMaxWidth: 50, - barPadding: 0.1, - groupPadding: 0.2, -}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index adf732eb..572314f1 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -1,29 +1,42 @@ import React from 'react'; import {group} from 'd3'; -import type {ChartKitWidgetSeriesOptions, ScatterSeries} from '../../../../../types'; +import type {TooltipDataChunkNext} from '../../../../../types'; import {getOnlyVisibleSeries} from '../../utils'; import type {ChartOptions} from '../useChartOptions/types'; import type {ChartScale} from '../useAxisScales'; -import type {PreparedBarXSeries, PreparedPieSeries, PreparedSeries} from '../'; +import type { + PreparedBarXSeries, + PreparedPieSeries, + PreparedScatterSeries, + PreparedSeries, + PreparedSeriesOptions, +} from '../'; import type {OnSeriesMouseMove, OnSeriesMouseLeave} from '../useTooltip/types'; -import {BarXSeriesShapes} from './bar-x'; -import {ScatterSeriesShape} from './scatter'; +import {BarXSeriesShapes, prepareBarXData} from './bar-x'; +import type {PreparedBarXData} from './bar-x'; +import {ScatterSeriesShape, prepareScatterData} from './scatter'; +import type {PreparedScatterData} from './scatter'; import {PieSeriesComponent} from './pie'; import './styles.scss'; +export type {PreparedBarXData} from './bar-x'; +export type {PreparedScatterData} from './scatter'; +export type ShapeData = PreparedBarXData | PreparedScatterData; + type Args = { top: number; left: number; boundsWidth: number; boundsHeight: number; series: PreparedSeries[]; - seriesOptions?: ChartKitWidgetSeriesOptions; + seriesOptions: PreparedSeriesOptions; xAxis: ChartOptions['xAxis']; yAxis: ChartOptions['yAxis']; svgContainer: SVGSVGElement | null; + hovered?: TooltipDataChunkNext[]; onSeriesMouseMove?: OnSeriesMouseMove; onSeriesMouseLeave?: OnSeriesMouseLeave; xScale?: ChartScale; @@ -36,6 +49,7 @@ export const useShapes = (args: Args) => { left, boundsWidth, boundsHeight, + hovered, series, seriesOptions, xAxis, @@ -47,47 +61,51 @@ export const useShapes = (args: Args) => { onSeriesMouseLeave, } = args; - const shapes = React.useMemo(() => { + const shapesComponents = React.useMemo(() => { const visibleSeries = getOnlyVisibleSeries(series); const groupedSeries = group(visibleSeries, (item) => item.type); - - return Array.from(groupedSeries).reduce((acc, item) => { + const shapesData: ShapeData[] = []; + const shapes = Array.from(groupedSeries).reduce((acc, item) => { const [seriesType, chartSeries] = item; switch (seriesType) { case 'bar-x': { if (xScale && yScale) { + const preparedData = prepareBarXData({ + hovered, + series: chartSeries as PreparedBarXSeries[], + seriesOptions, + xAxis, + xScale, + yAxis, + yScale, + }); acc.push( , ); + shapesData.push(...preparedData); } break; } case 'scatter': { if (xScale && yScale) { - const scatterShapes = chartSeries.map((scatterSeries, i) => { + const preparedDatas = prepareScatterData({ + series: chartSeries as PreparedScatterSeries[], + xAxis, + xScale, + yAxis: yAxis[0], + yScale, + }); + const scatterShapes = preparedDatas.map((preparedData, i) => { return ( { ); }); acc.push(...scatterShapes); + // shapesData.push(...preparedDatas.flat()); } break; } @@ -110,7 +129,11 @@ export const useShapes = (args: Args) => { key={`pie-${key}`} boundsWidth={boundsWidth} boundsHeight={boundsHeight} + hovered={hovered} + top={top} + left={left} series={pieSeries} + seriesOptions={seriesOptions} onSeriesMouseMove={onSeriesMouseMove} onSeriesMouseLeave={onSeriesMouseLeave} svgContainer={svgContainer} @@ -122,10 +145,14 @@ export const useShapes = (args: Args) => { } return acc; }, []); + + return {shapes, shapesData}; }, [ boundsWidth, boundsHeight, + hovered, series, + seriesOptions, xAxis, xScale, yAxis, @@ -137,5 +164,5 @@ export const useShapes = (args: Args) => { onSeriesMouseLeave, ]); - return {shapes}; + return {shapes: shapesComponents.shapes, shapesData: shapesComponents.shapesData}; }; diff --git a/src/plugins/d3/renderer/hooks/useShapes/pie.tsx b/src/plugins/d3/renderer/hooks/useShapes/pie.tsx index d115829b..973f201f 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/pie.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/pie.tsx @@ -1,24 +1,61 @@ import React from 'react'; -import {arc, pie, select} from 'd3'; -import type {PieArcDatum} from 'd3'; +import get from 'lodash/get'; +import {arc, color, pie, pointer, select} from 'd3'; +import type {PieArcDatum, Selection} from 'd3'; -import type {PieSeries} from '../../../../../types/widget-data'; +import {TooltipDataChunkType} from '../../../../../constants'; +import type { + PieSeries, + TooltipDataChunkNext, + TooltipDataChunkPie, +} from '../../../../../types/widget-data'; import {block} from '../../../../../utils/cn'; import {calculateNumericProperty, getHorisontalSvgTextHeight} from '../../utils'; import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; -import {PreparedPieSeries} from '../useSeries/types'; +import {PreparedPieSeries, PreparedSeriesOptions} from '../useSeries/types'; + +const b = block('d3-pie'); type PreparePieSeriesArgs = { boundsWidth: number; boundsHeight: number; + top: number; + left: number; series: PreparedPieSeries[]; + seriesOptions: PreparedSeriesOptions; svgContainer: SVGSVGElement | null; + hovered?: TooltipDataChunkNext[]; onSeriesMouseMove?: OnSeriesMouseMove; onSeriesMouseLeave?: OnSeriesMouseLeave; }; -const b = block('d3-pie'); +type PreparedPieXData = Omit & { + series: PreparedPieSeries; +}; + +const preparePieData = (args: { + series: PreparedPieSeries[]; + hovered?: TooltipDataChunkNext[]; +}): PreparedPieXData[] => { + const {series, hovered} = args; + + return series.map((s) => { + const inactive = hovered + ? !hovered?.some((chunk) => { + const chunkSeriesName = (chunk.series as PreparedPieSeries).innerName; + return chunkSeriesName === s.innerName; + }) + : false; + + return { + type: TooltipDataChunkType.PIE, + series: s, + data: s.data, + inactive, + }; + }); +}; const getCenter = ( boundsWidth: number, @@ -39,9 +76,36 @@ const getCenter = ( return [resultX, resultY]; }; +const getOpacity = ( + data: PreparedPieXData, + inactiveOptions?: NonNullable['inactive'], +) => { + if (inactiveOptions?.enabled) { + return (data.inactive && inactiveOptions?.opacity) || null; + } + + return null; +}; + +const isNodeContainsData = ( + node?: Element, +): node is Element & {__data__: PieArcDatum} => { + return Boolean(node && '__data__' in node); +}; + export function PieSeriesComponent(args: PreparePieSeriesArgs) { - const {boundsWidth, boundsHeight, series, onSeriesMouseMove, onSeriesMouseLeave, svgContainer} = - args; + const { + boundsWidth, + boundsHeight, + hovered, + top, + left, + series, + seriesOptions, + onSeriesMouseMove, + onSeriesMouseLeave, + svgContainer, + } = args; const ref = React.useRef(null); const [x, y] = getCenter(boundsWidth, boundsHeight, series[0]?.center); @@ -51,6 +115,8 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { } const svgElement = select(ref.current); + const hoverOptions = get(seriesOptions, 'pie.states.hover'); + const inactiveOptions = get(seriesOptions, 'pie.states.inactive'); const isLabelsEnabled = series[0]?.dataLabels?.enabled; let radiusRelatedToChart = Math.min(boundsWidth, boundsHeight) / 2; @@ -71,13 +137,14 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { const innerRadius = calculateNumericProperty({value: series[0].innerRadius, base: radius}) ?? 0; - const pieGenerator = pie().value((d) => d.data); - const visibleData = series.filter((d) => d.visible); + const preparedData = preparePieData({series, hovered}); + const pieGenerator = pie().value((d) => d.data.value); + const visibleData = preparedData.filter((d) => d.series.visible); const dataReady = pieGenerator(visibleData); - const arcGenerator = arc>() + const arcGenerator = arc>() .innerRadius(innerRadius) .outerRadius(radius) - .cornerRadius((d) => d.data.borderRadius); + .cornerRadius((d) => d.data.series.borderRadius); svgElement.selectAll('*').remove(); svgElement @@ -87,27 +154,33 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { .append('path') .attr('d', arcGenerator) .attr('class', b('segment')) - .attr('fill', (d) => d.data.color || '') + .attr('fill', (d) => d.data.series.color) + .attr('opacity', (d) => { + return getOpacity(d.data, inactiveOptions); + }) .style('stroke', series[0]?.borderColor || '') .style('stroke-width', series[0]?.borderWidth ?? 1); if (series[0]?.dataLabels?.enabled) { const labelHeight = getHorisontalSvgTextHeight({text: 'tmp'}); - const outerArc = arc>() + const outerArc = arc>() .innerRadius(labelsArcRadius) .outerRadius(labelsArcRadius); + const polylineArc = arc>() + .innerRadius(radius) + .outerRadius(radius); // Add the polylines between chart and labels svgElement .selectAll('allPolylines') .data(dataReady) .enter() .append('polyline') - .attr('stroke', (d) => d.data.color || '') - .style('fill', 'none') + .attr('stroke', (d) => d.data.series.color || '') .attr('stroke-width', 1) .attr('points', (d) => { // Line insertion in the slice - const posA = arcGenerator.centroid(d); + // const posA = arcGenerator.centroid(d); + const posA = polylineArc.centroid(d); // Line break: we use the other arc generator that has been built only for that const posB = outerArc.centroid(d); const posC = outerArc.centroid(d); @@ -136,14 +209,19 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { } return result.join(' '); - }); + }) + .attr('pointer-events', 'none') + .attr('opacity', (d) => { + return getOpacity(d.data, inactiveOptions); + }) + .style('fill', 'none'); // Add the polylines between chart and labels svgElement .selectAll('allLabels') .data(dataReady) .join('text') - .text((d) => d.data.label || d.value) + .text((d) => d.data.series.label || d.value) .attr('class', b('label')) .attr('transform', (d) => { const pos = outerArc.centroid(d); @@ -152,12 +230,62 @@ export function PieSeriesComponent(args: PreparePieSeriesArgs) { pos[1] += labelHeight / 4; return `translate(${pos})`; }) + .attr('pointer-events', 'none') + .attr('opacity', (d) => { + return getOpacity(d.data, inactiveOptions); + }) .style('text-anchor', (d) => { const midangle = d.startAngle + (d.endAngle - d.startAngle) / 2; return midangle < Math.PI ? 'start' : 'end'; }); } - }, [boundsWidth, boundsHeight, series, onSeriesMouseMove, onSeriesMouseLeave, svgContainer]); + + svgElement + .on('mousemove', (e) => { + const segment = e.target; + + if (!isNodeContainsData(segment)) { + return; + } + + const [pointerX, pointerY] = pointer(e, svgContainer); + const segmentData = segment.__data__.data; + const segmentSelection = select(segment) as Selection< + any, + PieArcDatum, + null, + undefined + >; + segmentSelection.attr('fill', (d) => { + const fillColor = d.data.series.color; + if (hoverOptions?.enabled) { + return ( + color(fillColor)?.brighter(hoverOptions?.brightness).toString() || + fillColor + ); + } + return fillColor; + }); + onSeriesMouseMove?.({ + hovered: [segmentData], + pointerPosition: [pointerX - left, pointerY - top], + }); + }) + .on('mouseleave', () => { + onSeriesMouseLeave?.(); + }); + }, [ + boundsWidth, + boundsHeight, + hovered, + top, + left, + series, + seriesOptions, + onSeriesMouseMove, + onSeriesMouseLeave, + svgContainer, + ]); return ; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx deleted file mode 100644 index 54bbc17a..00000000 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from 'react'; -import {pointer, select} from 'd3'; -import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; -import get from 'lodash/get'; - -import type {ScatterSeries, ScatterSeriesData} from '../../../../../types/widget-data'; -import {block} from '../../../../../utils/cn'; - -import {getDataCategoryValue} from '../../utils'; -import type {ChartScale} from '../useAxisScales'; -import type {PreparedAxis} from '../useChartOptions/types'; -import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../useTooltip/types'; - -type ScatterSeriesShapeProps = { - top: number; - left: number; - series: ScatterSeries; - xAxis: PreparedAxis; - xScale: ChartScale; - yAxis: PreparedAxis[]; - yScale: ChartScale; - svgContainer: SVGSVGElement | null; - onSeriesMouseMove?: OnSeriesMouseMove; - onSeriesMouseLeave?: OnSeriesMouseLeave; -}; - -const b = block('d3-scatter'); -const DEFAULT_SCATTER_POINT_RADIUS = 4; - -const prepareLinearScatterData = (data: ScatterSeriesData[]) => { - return data.filter((d) => typeof d.x === 'number' && typeof d.y === 'number'); -}; - -const getCxAttr = (args: {point: ScatterSeriesData; xAxis: PreparedAxis; xScale: ChartScale}) => { - const {point, xAxis, xScale} = args; - - let cx: number; - - if (xAxis.type === 'category') { - const xBandScale = xScale as ScaleBand; - const categories = get(xAxis, 'categories', [] as string[]); - const dataCategory = getDataCategoryValue({axisDirection: 'x', categories, data: point}); - cx = (xBandScale(dataCategory) || 0) + xBandScale.step() / 2; - } else { - const xLinearScale = xScale as ScaleLinear | ScaleTime; - cx = xLinearScale(point.x as number); - } - - return cx; -}; - -const getCyAttr = (args: {point: ScatterSeriesData; yAxis: PreparedAxis; yScale: ChartScale}) => { - const {point, yAxis, yScale} = args; - - let cy: number; - - if (yAxis.type === 'category') { - const yBandScale = yScale as ScaleBand; - const categories = get(yAxis, 'categories', [] as string[]); - const dataCategory = getDataCategoryValue({axisDirection: 'y', categories, data: point}); - cy = (yBandScale(dataCategory) || 0) + yBandScale.step() / 2; - } else { - const yLinearScale = yScale as ScaleLinear | ScaleTime; - cy = yLinearScale(point.y as number); - } - - return cy; -}; - -export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { - const { - series, - xAxis, - xScale, - yAxis, - yScale, - svgContainer, - left, - top, - onSeriesMouseMove, - onSeriesMouseLeave, - } = props; - const ref = React.useRef(null); - - React.useEffect(() => { - if (!ref.current) { - return; - } - - const svgElement = select(ref.current); - const preparedData = - xAxis.type === 'category' || yAxis[0]?.type === 'category' - ? series.data - : prepareLinearScatterData(series.data); - - svgElement - .selectAll('circle') - .data(preparedData) - .join( - (enter) => enter.append('circle').attr('class', b('point')), - (update) => update, - (exit) => exit.remove(), - ) - .attr('fill', (d) => d.color || series.color || '') - .attr('r', (d) => d.radius || DEFAULT_SCATTER_POINT_RADIUS) - .attr('cx', (d) => getCxAttr({point: d, xAxis, xScale})) - .attr('cy', (d) => getCyAttr({point: d, yAxis: yAxis[0], yScale})) - .on('mousemove', (e, d) => { - const [x, y] = pointer(e, svgContainer); - onSeriesMouseMove?.({ - hovered: { - data: d, - series, - }, - pointerPosition: [x - left, y - top], - }); - }) - .on('mouseleave', () => { - if (onSeriesMouseLeave) { - onSeriesMouseLeave(); - } - }); - }, [ - series, - xAxis, - xScale, - yAxis, - yScale, - svgContainer, - left, - top, - onSeriesMouseMove, - onSeriesMouseLeave, - ]); - - return ; -} diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx new file mode 100644 index 00000000..c070454e --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import {pointer, select} from 'd3'; +// import type {PieArcDatum, Selection} from 'd3'; + +import {block} from '../../../../../../utils/cn'; + +import type {OnSeriesMouseLeave, OnSeriesMouseMove} from '../../useTooltip/types'; +import type {PreparedScatterData} from './prepare-data'; + +export {prepareScatterData} from './prepare-data'; +export type {PreparedScatterData} from './prepare-data'; + +type ScatterSeriesShapeProps = { + top: number; + left: number; + preparedData: PreparedScatterData[]; + svgContainer: SVGSVGElement | null; + onSeriesMouseMove?: OnSeriesMouseMove; + onSeriesMouseLeave?: OnSeriesMouseLeave; +}; + +const b = block('d3-scatter'); +const DEFAULT_SCATTER_POINT_RADIUS = 4; + +const isNodeContainsData = (node?: Element): node is Element & {__data__: PreparedScatterData} => { + return Boolean(node && '__data__' in node); +}; + +export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { + const {top, left, preparedData, svgContainer, onSeriesMouseMove, onSeriesMouseLeave} = props; + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return; + } + + const svgElement = select(ref.current); + svgElement + .selectAll('circle') + .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); + + svgElement + .on('mousemove', (e) => { + const point = e.target; + + if (!isNodeContainsData(point)) { + return; + } + + const [pointerX, pointerY] = pointer(e, svgContainer); + const pointData = point.__data__; + // const segmentSelection = select(pointData) as Selection< + // any, + // PreparedScatterData, + // null, + // undefined + // >; + // segmentSelection.attr('fill', (d) => { + // const fillColor = d.data.series.color; + // if (hoverOptions?.enabled) { + // return ( + // color(fillColor)?.brighter(hoverOptions?.brightness).toString() || + // fillColor + // ); + // } + // return fillColor; + // }); + onSeriesMouseMove?.({ + hovered: [pointData], + pointerPosition: [pointerX - left, pointerY - top], + }); + }) + .on('mouseleave', () => { + onSeriesMouseLeave?.(); + }); + }, [top, left, preparedData, svgContainer, onSeriesMouseMove, onSeriesMouseLeave]); + + 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 new file mode 100644 index 00000000..dccca9f0 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts @@ -0,0 +1,87 @@ +import type {ScaleBand, ScaleLinear, ScaleTime} from 'd3'; +import get from 'lodash/get'; + +import {TooltipDataChunkType} from '../../../../../../constants'; +import type {TooltipDataChunkScatter, ScatterSeriesData} from '../../../../../../types/widget-data'; + +import {getDataCategoryValue} from '../../../utils'; +import type {ChartScale} from '../../useAxisScales'; +import type {PreparedAxis} from '../../useChartOptions/types'; +import {PreparedScatterSeries} from '../../useSeries/types'; + +export type PreparedScatterData = Omit & { + cx: number; + cy: number; + series: PreparedScatterSeries; +}; + +const getCxAttr = (args: {point: ScatterSeriesData; xAxis: PreparedAxis; xScale: ChartScale}) => { + const {point, xAxis, xScale} = args; + + let cx: number; + + if (xAxis.type === 'category') { + const xBandScale = xScale as ScaleBand; + const categories = get(xAxis, 'categories', [] as string[]); + const dataCategory = getDataCategoryValue({axisDirection: 'x', categories, data: point}); + cx = (xBandScale(dataCategory) || 0) + xBandScale.step() / 2; + } else { + const xLinearScale = xScale as ScaleLinear | ScaleTime; + cx = xLinearScale(point.x as number); + } + + return cx; +}; + +const getCyAttr = (args: {point: ScatterSeriesData; yAxis: PreparedAxis; yScale: ChartScale}) => { + const {point, yAxis, yScale} = args; + + let cy: number; + + if (yAxis.type === 'category') { + const yBandScale = yScale as ScaleBand; + const categories = get(yAxis, 'categories', [] as string[]); + const dataCategory = getDataCategoryValue({axisDirection: 'y', categories, data: point}); + cy = (yBandScale(dataCategory) || 0) + yBandScale.step() / 2; + } else { + const yLinearScale = yScale as ScaleLinear | ScaleTime; + cy = yLinearScale(point.y as number); + } + + return cy; +}; + +const getFilteredLinearScatterData = (data: ScatterSeriesData[]) => { + return data.filter((d) => typeof d.x === 'number' && typeof d.y === 'number'); +}; + +export const prepareScatterData = (args: { + series: PreparedScatterSeries[]; + xAxis: PreparedAxis; + xScale: ChartScale; + yAxis: PreparedAxis; + yScale: ChartScale; +}): PreparedScatterData[][] => { + const {series, xAxis, xScale, yAxis, yScale} = args; + + 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 { + type: TooltipDataChunkType.SCATTER, + data: d, + series: s, + cx: getCxAttr({point: d, xAxis, xScale}), + cy: getCyAttr({point: d, yAxis, yScale}), + inactive: false, + }; + }); + + acc.push(preparedData); + + return acc; + }, []); +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/styles.scss b/src/plugins/d3/renderer/hooks/useShapes/styles.scss index 9da1ec72..e34447dd 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/styles.scss +++ b/src/plugins/d3/renderer/hooks/useShapes/styles.scss @@ -7,7 +7,7 @@ } &:hover { - stroke: #fff; + stroke: var(--g-color-text-primary); opacity: 1; } } @@ -28,5 +28,6 @@ .chartkit-d3-bar-x { &__label { fill: var(--g-color-text-complementary); + user-select: none; } } diff --git a/src/plugins/d3/renderer/hooks/useTooltip/index.ts b/src/plugins/d3/renderer/hooks/useTooltip/index.ts index cb6f499d..28593671 100644 --- a/src/plugins/d3/renderer/hooks/useTooltip/index.ts +++ b/src/plugins/d3/renderer/hooks/useTooltip/index.ts @@ -1,6 +1,7 @@ import React from 'react'; +import isEqual from 'lodash/isEqual'; -import type {TooltipHoveredData} from '../../../../../types/widget-data'; +import type {TooltipDataChunkNext} from '../../../../../types/widget-data'; import {PreparedTooltip} from '../useChartOptions/types'; import type {PointerPosition, OnSeriesMouseMove, OnSeriesMouseLeave} from './types'; @@ -9,25 +10,29 @@ type Args = { tooltip: PreparedTooltip; }; +// FIXME: rename to useSeriesHandlers export const useTooltip = ({tooltip}: Args) => { - const [hovered, setTooltipHoveredData] = React.useState(); + const hoveredRef = React.useRef(); const [pointerPosition, setPointerPosition] = React.useState(); const handleSeriesMouseMove = React.useCallback( ({pointerPosition: nextPointerPosition, hovered: nextHovered}) => { - setTooltipHoveredData(nextHovered); + if (!isEqual(hoveredRef.current, nextHovered)) { + hoveredRef.current = nextHovered; + } + setPointerPosition(nextPointerPosition); }, [], ); const handleSeriesMouseLeave = React.useCallback(() => { - setTooltipHoveredData(undefined); + hoveredRef.current = undefined; setPointerPosition(undefined); }, []); return { - hovered, + hovered: hoveredRef.current, pointerPosition, handleSeriesMouseMove: tooltip.enabled ? handleSeriesMouseMove : undefined, handleSeriesMouseLeave: tooltip.enabled ? handleSeriesMouseLeave : undefined, diff --git a/src/plugins/d3/renderer/hooks/useTooltip/types.ts b/src/plugins/d3/renderer/hooks/useTooltip/types.ts index b02d8740..7174f1e2 100644 --- a/src/plugins/d3/renderer/hooks/useTooltip/types.ts +++ b/src/plugins/d3/renderer/hooks/useTooltip/types.ts @@ -1,9 +1,9 @@ -import type {TooltipHoveredData} from '../../../../../types/widget-data'; +import type {TooltipDataChunkNext} from '../../../../../types/widget-data'; export type PointerPosition = [number, number]; export type OnSeriesMouseMove = (args: { - hovered: TooltipHoveredData; + hovered: TooltipDataChunkNext[]; pointerPosition?: PointerPosition; }) => void; diff --git a/src/types/widget-data/series.ts b/src/types/widget-data/series.ts index 31d35caf..629e4be6 100644 --- a/src/types/widget-data/series.ts +++ b/src/types/widget-data/series.ts @@ -14,17 +14,48 @@ export type DataLabelRendererData = { data: ChartKitWidgetSeriesData; }; +type BasicHoverState = { + /** + * Enable separate styles for the hovered series. + * + * @default true + * */ + enabled?: boolean; + /** + * How much to brighten/darken the point on hover. Use positive to brighten, negative to darken. + * The behavior of this property is dependent on the implementing color space ([more details](https://d3js.org/d3-color#color_brighter)). + * For example in case of using rgb color you can use floating point number from `-5.0` to `5.0`. + * Rgb color space is used by default. + * + * @default 0.3 + */ + brightness?: number; +}; + +type BasicInactiveState = { + /** + * Enable separate styles for the inactive series. + * + * @default true + * */ + enabled?: boolean; + /** + * Opacity of series elements (bars, data labels) + * + * @default 0.5 + * */ + opacity?: number; +}; + export type ChartKitWidgetSeriesOptions = { // todo /** Individual data label for each point. */ dataLabels?: { /** Enable or disable the data labels */ enabled?: boolean; - /** Callback function to render the data label */ renderer?: (args: DataLabelRendererData) => React.SVGTextElementAttributes; }; - 'bar-x'?: { /** The maximum allowed pixel width for a column. * This prevents the columns from becoming too wide when there is a small number of points in the chart. @@ -32,19 +63,16 @@ export type ChartKitWidgetSeriesOptions = { * @default 50 */ barMaxWidth?: number; - /** Padding between each column or bar, in x axis units. * * @default 0.1 * */ barPadding?: number; - /** Padding between each value groups, in x axis units * * @default 0.2 */ groupPadding?: number; - dataSorting?: { /** Determines what data value should be used to sort by. * Possible values are undefined to disable, "name" to sort by series name or "y" @@ -52,12 +80,37 @@ export type ChartKitWidgetSeriesOptions = { * @default undefined * */ key?: 'name' | 'y' | undefined; - /** Sorting direction. * * @default 'asc' * */ direction?: 'asc' | 'desc'; }; + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + inactive?: BasicInactiveState; + }; + }; + pie?: { + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + inactive?: BasicInactiveState; + }; + }; + scatter?: { + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: { + /** + * Enable separate styles for the hovered series. + * + * @default true + * */ + enabled?: boolean; + }; + inactive?: BasicInactiveState; + }; }; }; diff --git a/src/types/widget-data/tooltip.ts b/src/types/widget-data/tooltip.ts index f056a694..49885ec3 100644 --- a/src/types/widget-data/tooltip.ts +++ b/src/types/widget-data/tooltip.ts @@ -1,12 +1,38 @@ -import type {ChartKitWidgetSeries, ChartKitWidgetSeriesData} from './series'; +import {TooltipDataChunkType} from '../../constants'; -export type TooltipHoveredData = { - data: ChartKitWidgetSeriesData; - series: ChartKitWidgetSeries; +import type {BarXSeries, BarXSeriesData} from './bar-x'; +import type {PieSeries, PieSeriesData} from './pie'; +import type {ScatterSeries, ScatterSeriesData} from './scatter'; + +export type TooltipDataChunkBarX = { + type: typeof TooltipDataChunkType.BAR_X; + data: BarXSeriesData; + series: BarXSeries; + hoveredDataValue?: string | number; + inactive?: boolean; +}; + +export type TooltipDataChunkPie = { + type: typeof TooltipDataChunkType.PIE; + data: PieSeriesData; + series: Omit, 'data'>; + inactive?: boolean; }; +export type TooltipDataChunkScatter = { + type: typeof TooltipDataChunkType.SCATTER; + data: ScatterSeriesData; + series: ScatterSeries; + inactive?: boolean; +}; + +export type TooltipDataChunkNext = + | TooltipDataChunkBarX + | TooltipDataChunkPie + | TooltipDataChunkScatter; + export type ChartKitWidgetTooltip = { enabled?: boolean; /** Specifies the renderer for the tooltip. If returned null default tooltip renderer will be used. */ - renderer?: (data: {hovered: TooltipHoveredData}) => React.ReactElement; + renderer?: (data: {hovered: TooltipDataChunkNext[]}) => React.ReactElement; };