From 9762964c6de442f8c531b8fc0340a6cfd933dcf9 Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Tue, 19 Dec 2023 11:36:41 +0200 Subject: [PATCH] feat(D3 plugin): basic area chart (#363) * feat(D3 plugin): basic area chart * fix calculating axis domain for stacking series * stacked area * fix yAxis min value for area series * review fixes --- .../d3/__stories__/Showcase.stories.tsx | 16 ++ .../d3/__stories__/area/Basic.stories.tsx | 48 ++++ src/plugins/d3/examples/area/Basic.tsx | 41 +++ src/plugins/d3/examples/area/StackedArea.tsx | 67 +++++ .../components/Tooltip/DefaultContent.tsx | 1 + .../components/Tooltip/TooltipTriggerArea.tsx | 2 +- .../constants/defaults/series-options.ts | 12 + .../renderer/hooks/useChartOptions/y-axis.ts | 3 +- .../d3/renderer/hooks/useSeries/constants.ts | 9 +- .../renderer/hooks/useSeries/prepare-area.ts | 93 +++++++ .../renderer/hooks/useSeries/prepare-bar-x.ts | 10 +- .../renderer/hooks/useSeries/prepare-bar-y.ts | 10 +- .../hooks/useSeries/prepare-line-series.ts | 7 +- .../renderer/hooks/useSeries/prepareSeries.ts | 10 + .../d3/renderer/hooks/useSeries/types.ts | 44 ++- .../d3/renderer/hooks/useSeries/utils.ts | 16 +- .../renderer/hooks/useShapes/area/index.tsx | 252 ++++++++++++++++++ .../hooks/useShapes/area/prepare-data.ts | 147 ++++++++++ .../d3/renderer/hooks/useShapes/area/types.ts | 30 +++ .../d3/renderer/hooks/useShapes/index.tsx | 28 +- src/plugins/d3/renderer/utils/index.ts | 33 ++- src/types/widget-data/area.ts | 61 +++++ src/types/widget-data/index.ts | 1 + src/types/widget-data/line.ts | 6 +- src/types/widget-data/marker.ts | 9 + src/types/widget-data/series.ts | 34 ++- src/types/widget-data/tooltip.ts | 13 +- 27 files changed, 951 insertions(+), 52 deletions(-) create mode 100644 src/plugins/d3/__stories__/area/Basic.stories.tsx create mode 100644 src/plugins/d3/examples/area/Basic.tsx create mode 100644 src/plugins/d3/examples/area/StackedArea.tsx create mode 100644 src/plugins/d3/renderer/hooks/useSeries/prepare-area.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/area/index.tsx create mode 100644 src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/area/types.ts create mode 100644 src/types/widget-data/area.ts diff --git a/src/plugins/d3/__stories__/Showcase.stories.tsx b/src/plugins/d3/__stories__/Showcase.stories.tsx index 95e92930..84778a6c 100644 --- a/src/plugins/d3/__stories__/Showcase.stories.tsx +++ b/src/plugins/d3/__stories__/Showcase.stories.tsx @@ -15,10 +15,12 @@ import {Container, Row, Col, Text} from '@gravity-ui/uikit'; import {BasicPie} from '../examples/pie/Basic'; import {Basic as BasicScatter} from '../examples/scatter/Basic'; import {Basic as BasicLine} from '../examples/line/Basic'; +import {Basic as BasicArea} from '../examples/area/Basic'; import {DataLabels as LineWithDataLabels} from '../examples/line/DataLabels'; import {Donut} from '../examples/pie/Donut'; import {LineAndBarXCombinedChart} from '../examples/combined/LineAndBarX'; import {LineWithMarkers} from '../examples/line/LineWithMarkers'; +import {StackedArea} from '../examples/area/StackedArea'; const ShowcaseStory = () => { const [loading, setLoading] = React.useState(true); @@ -50,6 +52,20 @@ const ShowcaseStory = () => { + + Area charts + + + + Basic area chart + + + + Stacked area + + + + Bar-x charts diff --git a/src/plugins/d3/__stories__/area/Basic.stories.tsx b/src/plugins/d3/__stories__/area/Basic.stories.tsx new file mode 100644 index 00000000..4b3fc707 --- /dev/null +++ b/src/plugins/d3/__stories__/area/Basic.stories.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import {StoryObj} from '@storybook/react'; +import {withKnobs} from '@storybook/addon-knobs'; +import {Button} from '@gravity-ui/uikit'; +import {settings} from '../../../../libs'; +import {D3Plugin} from '../..'; +import {Basic} from '../../examples/area/Basic'; +import {StackedArea} from '../../examples/area/StackedArea'; + +const ChartStory = ({Chart}: {Chart: React.FC}) => { + const [shown, setShown] = React.useState(false); + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ +
+ ); +}; + +export const BasicAreaChartStory: StoryObj = { + name: 'Basic', + args: { + Chart: Basic, + }, +}; + +export const StackedAreaChartStory: StoryObj = { + name: 'Stacked', + args: { + Chart: StackedArea, + }, +}; + +export default { + title: 'Plugins/D3/Area', + decorators: [withKnobs], + component: ChartStory, +}; diff --git a/src/plugins/d3/examples/area/Basic.tsx b/src/plugins/d3/examples/area/Basic.tsx new file mode 100644 index 00000000..fe9fc75f --- /dev/null +++ b/src/plugins/d3/examples/area/Basic.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData, AreaSeries, AreaSeriesData} from '../../../../types'; +import nintendoGames from '../nintendoGames'; + +function prepareData(): AreaSeries[] { + const games = nintendoGames.filter((d) => { + return d.date && d.user_score && d.genres.includes('Puzzle'); + }); + + return [ + { + name: 'User score', + type: 'area', + data: games.map((d) => { + return { + x: Number(d.date), + y: Number(d.user_score), + }; + }), + }, + ]; +} + +export const Basic = () => { + const series = prepareData(); + + const widgetData: ChartKitWidgetData = { + title: { + text: 'User score (puzzle genre)', + }, + series: { + data: series, + }, + xAxis: { + type: 'datetime', + }, + }; + + return ; +}; diff --git a/src/plugins/d3/examples/area/StackedArea.tsx b/src/plugins/d3/examples/area/StackedArea.tsx new file mode 100644 index 00000000..57397164 --- /dev/null +++ b/src/plugins/d3/examples/area/StackedArea.tsx @@ -0,0 +1,67 @@ +import {groups} from 'd3'; +import React from 'react'; +import {ChartKit} from '../../../../components/ChartKit'; +import type {ChartKitWidgetData, AreaSeries, AreaSeriesData} from '../../../../types'; +import nintendoGames from '../nintendoGames'; + +const years = Array.from( + new Set( + nintendoGames.map((d) => + d.date ? String(new Date(d.date as number).getFullYear()) : 'unknown', + ), + ), +).sort(); + +function prepareData() { + const grouped = groups( + nintendoGames, + (d) => d.platform, + (d) => (d.date ? String(new Date(d.date as number).getFullYear()) : 'unknown'), + ); + const series = grouped.map(([platform, gamesByYear]) => { + const platformGames = Object.fromEntries(gamesByYear) || {}; + return { + name: platform, + data: years.reduce((acc, year) => { + if (year in platformGames) { + acc.push({ + x: year, + y: platformGames[year].length, + }); + } + + return acc; + }, []), + }; + }); + + return {series}; +} + +export const StackedArea = () => { + const {series} = prepareData(); + + const data = series.map((s) => { + return { + type: 'area', + stacking: 'normal', + name: s.name, + data: s.data, + } as AreaSeries; + }); + + const widgetData: ChartKitWidgetData = { + series: { + data: data, + }, + xAxis: { + type: 'category', + categories: years, + title: { + text: 'Release year', + }, + }, + }; + + return ; +}; diff --git a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx index 72716a67..259816b5 100644 --- a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx @@ -50,6 +50,7 @@ export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => { switch (series.type) { case 'scatter': case 'line': + case 'area': case 'bar-x': { const xRow = getXRowData(xAxis, data); const yRow = getYRowData(yAxis, data); diff --git a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx index 298b2e05..2366de42 100644 --- a/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/TooltipTriggerArea.tsx @@ -127,7 +127,7 @@ export const TooltipTriggerArea = (args: Args) => { const xLineData = React.useMemo(() => { const result = shapesData - .filter((sd) => sd.series.type === 'line') + .filter((sd) => ['line', 'area'].includes(sd.series.type)) .reduce((acc, sd) => { return acc.concat( (sd as PreparedLineData).points.map((d) => ({ diff --git a/src/plugins/d3/renderer/constants/defaults/series-options.ts b/src/plugins/d3/renderer/constants/defaults/series-options.ts index 07badc17..77eecc7b 100644 --- a/src/plugins/d3/renderer/constants/defaults/series-options.ts +++ b/src/plugins/d3/renderer/constants/defaults/series-options.ts @@ -79,4 +79,16 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = { }, }, }, + area: { + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: true, + opacity: 0.5, + }, + }, + }, }; diff --git a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts index f12989f4..06acfb53 100644 --- a/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts +++ b/src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts @@ -49,8 +49,9 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS function getAxisMin(axis?: ChartKitWidgetAxis, series?: ChartKitWidgetSeries[]) { const min = axis?.min; + const seriesWithVolume = ['bar-x', 'area']; - if (typeof min === 'undefined' && series?.some((s) => s.type === 'bar-x')) { + if (typeof min === 'undefined' && series?.some((s) => seriesWithVolume.includes(s.type))) { return 0; } diff --git a/src/plugins/d3/renderer/hooks/useSeries/constants.ts b/src/plugins/d3/renderer/hooks/useSeries/constants.ts index ec15021e..40377902 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/constants.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/constants.ts @@ -1,4 +1,5 @@ -import {BaseTextStyle} from '../../../../../types'; +import type {BaseTextStyle} from '../../../../../types'; +import type {PointMarkerHalo} from '../../../../../types/widget-data/marker'; export const DEFAULT_LEGEND_SYMBOL_SIZE = 8; @@ -11,3 +12,9 @@ export const DEFAULT_DATALABELS_STYLE: BaseTextStyle = { fontWeight: 'bold', fontColor: 'var(--d3-data-labels)', }; + +export const DEFAULT_HALO_OPTIONS: Required = { + enabled: true, + opacity: 0.25, + radius: 10, +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-area.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-area.ts new file mode 100644 index 00000000..51bb660b --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-area.ts @@ -0,0 +1,93 @@ +import {ScaleOrdinal} from 'd3'; +import get from 'lodash/get'; +import merge from 'lodash/merge'; + +import {ChartKitWidgetSeriesOptions, AreaSeries} from '../../../../../types'; +import {PreparedAreaSeries, PreparedLegend} from './types'; + +import { + DEFAULT_DATALABELS_PADDING, + DEFAULT_DATALABELS_STYLE, + DEFAULT_HALO_OPTIONS, +} from './constants'; +import {getRandomCKId} from '../../../../../utils'; +import {getSeriesStackId, prepareLegendSymbol} from './utils'; + +export const DEFAULT_LINE_WIDTH = 1; + +export const DEFAULT_MARKER = { + enabled: false, + symbol: 'circle', + radius: 4, + borderWidth: 0, + borderColor: '', +}; + +type PrepareAreaSeriesArgs = { + colorScale: ScaleOrdinal; + series: AreaSeries[]; + seriesOptions?: ChartKitWidgetSeriesOptions; + legend: PreparedLegend; +}; + +function prepareMarker(series: AreaSeries, seriesOptions?: ChartKitWidgetSeriesOptions) { + const seriesHoverState = get(seriesOptions, 'area.states.hover'); + const markerNormalState = Object.assign( + {}, + DEFAULT_MARKER, + seriesOptions?.area?.marker, + series.marker, + ); + const hoveredMarkerDefaultOptions = { + enabled: true, + radius: markerNormalState.radius, + borderWidth: 1, + borderColor: '#ffffff', + halo: DEFAULT_HALO_OPTIONS, + }; + + return { + states: { + normal: markerNormalState, + hover: merge(hoveredMarkerDefaultOptions, seriesHoverState?.marker), + }, + }; +} + +export function prepareArea(args: PrepareAreaSeriesArgs) { + const {colorScale, series: seriesList, seriesOptions, legend} = args; + const defaultAreaWidth = get(seriesOptions, 'area.lineWidth', DEFAULT_LINE_WIDTH); + const defaultOpacity = get(seriesOptions, 'area.opacity', 0.75); + + return seriesList.map((series) => { + const id = getRandomCKId(); + const name = series.name || ''; + const color = series.color || colorScale(name); + + const prepared: PreparedAreaSeries = { + type: series.type, + color, + opacity: get(series, 'opacity', defaultOpacity), + lineWidth: get(series, 'lineWidth', defaultAreaWidth), + name, + id, + visible: get(series, 'visible', true), + legend: { + enabled: get(series, 'legend.enabled', legend.enabled), + symbol: prepareLegendSymbol(series), + }, + data: series.data, + stacking: series.stacking, + stackId: getSeriesStackId(series), + dataLabels: { + enabled: series.dataLabels?.enabled || false, + style: Object.assign({}, DEFAULT_DATALABELS_STYLE, series.dataLabels?.style), + padding: get(series, 'dataLabels.padding', DEFAULT_DATALABELS_PADDING), + allowOverlap: get(series, 'dataLabels.allowOverlap', false), + }, + marker: prepareMarker(series, seriesOptions), + }; + + return prepared; + }, []); +} diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-x.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-x.ts index c84c4eb0..643d1712 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-x.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-x.ts @@ -3,7 +3,7 @@ import get from 'lodash/get'; import type {BarXSeries} from '../../../../../types'; import type {PreparedBarXSeries, PreparedLegend, PreparedSeries} from './types'; import {getRandomCKId} from '../../../../../utils'; -import {prepareLegendSymbol} from './utils'; +import {getSeriesStackId, prepareLegendSymbol} from './utils'; import {DEFAULT_DATALABELS_PADDING, DEFAULT_DATALABELS_STYLE} from './constants'; type PrepareBarXSeriesArgs = { @@ -14,17 +14,11 @@ type PrepareBarXSeriesArgs = { export function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] { const {colorScale, series: seriesList, legend} = args; - const commonStackId = getRandomCKId(); return seriesList.map((series) => { const name = series.name || ''; const color = series.color || colorScale(name); - let stackId = series.stackId; - if (!stackId) { - stackId = series.stacking === 'normal' ? commonStackId : getRandomCKId(); - } - return { type: series.type, color, @@ -37,7 +31,7 @@ export function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] }, data: series.data, stacking: series.stacking, - stackId, + stackId: getSeriesStackId(series), dataLabels: { enabled: series.dataLabels?.enabled || false, inside: diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-y.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-y.ts index f1aba37f..495c1a7d 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-y.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-bar-y.ts @@ -3,7 +3,7 @@ import get from 'lodash/get'; import type {BarYSeries} from '../../../../../types'; import type {PreparedBarYSeries, PreparedLegend, PreparedSeries} from './types'; import {getRandomCKId} from '../../../../../utils'; -import {prepareLegendSymbol} from './utils'; +import {getSeriesStackId, prepareLegendSymbol} from './utils'; import {DEFAULT_DATALABELS_STYLE} from './constants'; import {getLabelsSize} from '../../utils'; @@ -34,17 +34,11 @@ function prepareDataLabels(series: BarYSeries) { export function prepareBarYSeries(args: PrepareBarYSeriesArgs): PreparedSeries[] { const {colorScale, series: seriesList, legend} = args; - const commonStackId = getRandomCKId(); return seriesList.map((series) => { const name = series.name || ''; const color = series.color || colorScale(name); - let stackId = series.stackId; - if (!stackId) { - stackId = series.stacking === 'normal' ? commonStackId : getRandomCKId(); - } - return { type: series.type, color, @@ -57,7 +51,7 @@ export function prepareBarYSeries(args: PrepareBarYSeriesArgs): PreparedSeries[] }, data: series.data, stacking: series.stacking, - stackId, + stackId: getSeriesStackId(series), dataLabels: prepareDataLabels(series), }; }, []); diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts index 5e7ac7cb..c1d7fc9e 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts @@ -13,6 +13,7 @@ import {PreparedLineSeries, PreparedLegend, PreparedLegendSymbol} from './types' import { DEFAULT_DATALABELS_PADDING, DEFAULT_DATALABELS_STYLE, + DEFAULT_HALO_OPTIONS, DEFAULT_LEGEND_SYMBOL_PADDING, } from './constants'; import {getRandomCKId} from '../../../../../utils'; @@ -63,11 +64,7 @@ function prepareMarker(series: LineSeries, seriesOptions?: ChartKitWidgetSeriesO radius: markerNormalState.radius, borderWidth: 1, borderColor: '#ffffff', - halo: { - enabled: true, - opacity: 0.25, - radius: 10, - }, + halo: DEFAULT_HALO_OPTIONS, }; return { diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index 0d3c1419..0ff16b13 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -3,6 +3,7 @@ import get from 'lodash/get'; import type {ScaleOrdinal} from 'd3'; import type { + AreaSeries, BarXSeries, BarYSeries, ChartKitWidgetSeries, @@ -18,6 +19,7 @@ import {prepareBarYSeries} from './prepare-bar-y'; import {prepareLegendSymbol} from './utils'; import {ChartKitError} from '../../../../../libs'; import {preparePieSeries} from './prepare-pie'; +import {prepareArea} from './prepare-area'; type PrepareAxisRelatedSeriesArgs = { colorScale: ScaleOrdinal; @@ -76,6 +78,14 @@ export function prepareSeries(args: { colorScale, }); } + case 'area': { + return prepareArea({ + series: series as AreaSeries[], + seriesOptions, + legend, + colorScale, + }); + } default: { throw new ChartKitError({ message: `Series type "${type}" does not support data preparation for series that do not support the presence of axes`, diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 60239049..e282ac12 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -15,6 +15,8 @@ import { ConnectorShape, ConnectorCurve, PathLegendSymbolOptions, + AreaSeries, + AreaSeriesData, } from '../../../../../types'; import type {SeriesOptionsDefaults} from '../../constants'; @@ -155,11 +157,51 @@ export type PreparedLineSeries = { }; } & BasePreparedSeries; +export type PreparedAreaSeries = { + type: AreaSeries['type']; + data: AreaSeriesData[]; + stacking: AreaSeries['stacking']; + stackId: string; + lineWidth: number; + opacity: number; + dataLabels: { + enabled: boolean; + style: BaseTextStyle; + padding: number; + allowOverlap: boolean; + }; + marker: { + states: { + normal: { + symbol: string; + enabled: boolean; + radius: number; + borderWidth: number; + borderColor: string; + }; + hover: { + enabled: boolean; + radius: number; + borderWidth: number; + borderColor: string; + halo: { + enabled: boolean; + opacity: number; + radius: number; + }; + }; + }; + }; +} & BasePreparedSeries; + export type PreparedSeries = | PreparedScatterSeries | PreparedBarXSeries | PreparedBarYSeries | PreparedPieSeries - | PreparedLineSeries; + | PreparedLineSeries + | PreparedAreaSeries; export type PreparedSeriesOptions = SeriesOptionsDefaults; + +export type StackedSeries = BarXSeries | AreaSeries | BarYSeries; diff --git a/src/plugins/d3/renderer/hooks/useSeries/utils.ts b/src/plugins/d3/renderer/hooks/useSeries/utils.ts index 50e4eda3..1c6b2dd3 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/utils.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/utils.ts @@ -1,6 +1,8 @@ -import {PreparedLegendSymbol, PreparedSeries} from './types'; +import memoize from 'lodash/memoize'; +import {PreparedLegendSymbol, PreparedSeries, StackedSeries} from './types'; import {ChartKitWidgetSeries, RectLegendSymbolOptions} from '../../../../../types'; import {DEFAULT_LEGEND_SYMBOL_PADDING, DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; +import {getRandomCKId} from '../../../../../utils'; export const getActiveLegendItems = (series: PreparedSeries[]) => { return series.reduce((acc, s) => { @@ -28,3 +30,15 @@ export function prepareLegendSymbol(series: ChartKitWidgetSeries): PreparedLegen padding: symbolOptions?.padding || DEFAULT_LEGEND_SYMBOL_PADDING, }; } + +const getCommonStackId = memoize(getRandomCKId); + +export function getSeriesStackId(series: StackedSeries) { + let stackId = series.stackId; + + if (!stackId) { + stackId = series.stacking === 'normal' ? getCommonStackId() : getRandomCKId(); + } + + return stackId; +} diff --git a/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx new file mode 100644 index 00000000..4182dc32 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/area/index.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import type {Dispatch, Selection, BaseType} from 'd3'; +import { + color, + line as lineGenerator, + area as areaGenerator, + select, + symbol, + symbolCircle, + symbolSquare, +} from 'd3'; +import get from 'lodash/get'; + +import {block} from '../../../../../../utils/cn'; +import type {PreparedSeriesOptions} from '../../useSeries/types'; +import type {MarkerData, PointData, PreparedAreaData} from './types'; +import type {TooltipDataChunkArea} from '../../../../../../types'; +import type {LabelData} from '../../../types'; +import {filterOverlappingLabels} from '../../../utils'; +import {setActiveState} from '../utils'; + +const b = block('d3-area'); + +type Args = { + dispatcher: Dispatch; + preparedData: PreparedAreaData[]; + seriesOptions: PreparedSeriesOptions; +}; + +function setMarker( + selection: Selection, + state: 'normal' | 'hover', +) { + selection + .attr('d', (d) => { + const radius = + d.point.series.marker.states[state].radius + + d.point.series.marker.states[state].borderWidth; + return getMarkerSymbol(d.point.series.marker.states.normal.symbol, radius); + }) + .attr('stroke-width', (d) => d.point.series.marker.states[state].borderWidth) + .attr('stroke', (d) => d.point.series.marker.states[state].borderColor); +} + +function getMarkerSymbol(type: string, radius: number) { + switch (type) { + case 'square': { + const size = Math.pow(radius, 2) * Math.PI; + return symbol(symbolSquare, size)(); + } + case 'circle': + default: { + const size = Math.pow(radius, 2) * Math.PI; + return symbol(symbolCircle, size)(); + } + } +} + +const getMarkerVisibility = (d: MarkerData) => { + const markerStates = d.point.series.marker.states; + const enabled = (markerStates.hover.enabled && d.hovered) || markerStates.normal.enabled; + return enabled ? '' : 'hidden'; +}; + +const getMarkerHaloVisibility = (d: MarkerData) => { + const markerStates = d.point.series.marker.states; + const enabled = markerStates.hover.halo.enabled && d.hovered; + return enabled ? '' : 'hidden'; +}; + +export const AreaSeriesShapes = (args: Args) => { + const {dispatcher, preparedData, seriesOptions} = args; + + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + const svgElement = select(ref.current); + const hoverOptions = get(seriesOptions, 'area.states.hover'); + const inactiveOptions = get(seriesOptions, 'area.states.inactive'); + + const line = lineGenerator() + .x((d) => d.x) + .y((d) => d.y); + + svgElement.selectAll('*').remove(); + + const shapeSelection = svgElement + .selectAll('shape') + .data(preparedData) + .join('g') + .attr('class', b('series')); + + shapeSelection + .append('path') + .attr('class', b('line')) + .attr('d', (d) => line(d.points)) + .attr('fill', 'none') + .attr('stroke', (d) => d.color) + .attr('stroke-width', (d) => d.width) + .attr('stroke-linejoin', 'round') + .attr('stroke-linecap', 'round'); + + const area = areaGenerator() + .x((d) => d.x) + .y0((d) => d.y0) + .y1((d) => d.y); + shapeSelection + .append('path') + .attr('class', b('region')) + .attr('d', (d) => area(d.points)) + .attr('fill', (d) => d.color) + .attr('opacity', (d) => d.opacity); + + let dataLabels = preparedData.reduce((acc, d) => { + return acc.concat(d.labels); + }, [] as LabelData[]); + + if (!preparedData[0]?.series.dataLabels.allowOverlap) { + dataLabels = filterOverlappingLabels(dataLabels); + } + + const labelsSelection = svgElement + .selectAll('text') + .data(dataLabels) + .join('text') + .text((d) => d.text) + .attr('class', b('label')) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .attr('text-anchor', (d) => d.textAnchor) + .style('font-size', (d) => d.style.fontSize) + .style('font-weight', (d) => d.style.fontWeight || null) + .style('fill', (d) => d.style.fontColor || null); + + const markers = preparedData.reduce((acc, d) => acc.concat(d.markers), []); + const markerSelection = svgElement + .selectAll('marker') + .data(markers) + .join('g') + .attr('class', b('marker')) + .attr('visibility', getMarkerVisibility) + .attr('transform', (d) => { + return `translate(${d.point.x},${d.point.y})`; + }); + markerSelection + .append('path') + .attr('class', b('marker-halo')) + .attr('d', (d) => { + const type = d.point.series.marker.states.normal.symbol; + const radius = d.point.series.marker.states.hover.halo.radius; + return getMarkerSymbol(type, radius); + }) + .attr('fill', (d) => d.point.series.color) + .attr('opacity', (d) => d.point.series.marker.states.hover.halo.opacity) + .attr('z-index', -1) + .attr('visibility', getMarkerHaloVisibility); + markerSelection + .append('path') + .attr('class', b('marker-symbol')) + .call(setMarker, 'normal') + .attr('fill', (d) => d.point.series.color); + + const hoverEnabled = hoverOptions?.enabled; + const inactiveEnabled = inactiveOptions?.enabled; + + dispatcher.on('hover-shape.area', (data?: TooltipDataChunkArea[]) => { + const selected = data?.find((d) => d.series.type === 'area'); + const selectedDataItem = selected?.data; + const selectedSeriesId = selected?.series?.id; + + shapeSelection.datum((d, index, list) => { + const elementSelection = select(list[index]); + + const hovered = Boolean(hoverEnabled && d.id === selectedSeriesId); + if (d.hovered !== hovered) { + d.hovered = hovered; + + let strokeColor = d.color || ''; + if (d.hovered) { + strokeColor = + color(strokeColor)?.brighter(hoverOptions?.brightness).toString() || + strokeColor; + } + + elementSelection.selectAll(`.${b('line')}`).attr('stroke', strokeColor); + elementSelection.selectAll(`.${b('region')}`).attr('fill', strokeColor); + } + + return setActiveState({ + element: list[index], + state: inactiveOptions, + active: Boolean( + !inactiveEnabled || !selectedSeriesId || selectedSeriesId === d.id, + ), + datum: d, + }); + }); + + labelsSelection.datum((d, index, list) => { + return setActiveState({ + element: list[index], + state: inactiveOptions, + active: Boolean( + !inactiveEnabled || !selectedSeriesId || selectedSeriesId === d.series.id, + ), + datum: d, + }); + }); + + markerSelection.datum((d, index, list) => { + const elementSelection = select(list[index]); + + const hovered = Boolean(hoverEnabled && d.point.data === selectedDataItem); + if (d.hovered !== hovered) { + d.hovered = hovered; + elementSelection.attr('visibility', getMarkerVisibility(d)); + elementSelection + .select(`.${b('marker-halo')}`) + .attr('visibility', getMarkerHaloVisibility); + elementSelection + .select(`.${b('marker-symbol')}`) + .call(setMarker, hovered ? 'hover' : 'normal'); + } + + if (d.point.series.marker.states.normal.enabled) { + const isActive = Boolean( + !inactiveEnabled || + !selectedSeriesId || + selectedSeriesId === d.point.series.id, + ); + setActiveState({ + element: list[index], + state: inactiveOptions, + active: isActive, + datum: d, + }); + } + return d; + }); + }); + + return () => { + dispatcher.on('hover-shape.area', null); + }; + }, [dispatcher, preparedData, seriesOptions]); + + return ; +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts new file mode 100644 index 00000000..5f35288e --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/area/prepare-data.ts @@ -0,0 +1,147 @@ +import {group, sort} from 'd3'; +import type {PreparedAreaSeries} from '../../useSeries/types'; +import type {PreparedAxis} from '../../useChartOptions/types'; +import type {ChartScale} from '../../useAxisScales'; +import type {MarkerData, PointData, PreparedAreaData} from './types'; +import {getXValue, getYValue} from '../utils'; +import {getLabelsSize, getLeftPosition} from '../../../utils'; +import type {LabelData} from '../../../types'; +import type {AreaSeriesData} from '../../../../../../types'; + +function getLabelData(point: PointData, series: PreparedAreaSeries, xMax: number) { + const text = String(point.data.label || point.data.y); + const style = series.dataLabels.style; + const size = getLabelsSize({labels: [text], style}); + + const labelData: LabelData = { + text, + x: point.x, + y: point.y - series.dataLabels.padding, + style, + size: {width: size.maxWidth, height: size.maxHeight}, + textAnchor: 'middle', + series: series, + active: true, + }; + + const left = getLeftPosition(labelData); + if (left < 0) { + labelData.x = labelData.x + Math.abs(left); + } else { + const right = left + labelData.size.width; + if (right > xMax) { + labelData.x = labelData.x - xMax - right; + } + } + + return labelData; +} + +function getXValues(series: PreparedAreaSeries[], xAxis: PreparedAxis, xScale: ChartScale) { + const xValues = series.reduce>((acc, s) => { + s.data.forEach((d) => { + const key = String(d.x); + if (!acc.has(key)) { + acc.set(key, getXValue({point: d, xAxis, xScale})); + } + }); + return acc; + }, new Map()); + + if (xAxis.type === 'category') { + return (xAxis.categories || []).reduce<[string, number][]>((acc, category) => { + const xValue = xValues.get(category); + if (typeof xValue === 'number') { + acc.push([category, xValue]); + } + + return acc; + }, []); + } + + return sort(Array.from(xValues), ([_x, xValue]) => xValue); +} + +export const prepareAreaData = (args: { + series: PreparedAreaSeries[]; + xAxis: PreparedAxis; + xScale: ChartScale; + yAxis: PreparedAxis[]; + yScale: ChartScale; +}): PreparedAreaData[] => { + const {series, xAxis, xScale, yScale} = args; + const yAxis = args.yAxis[0]; + const [_xMin, xRangeMax] = xScale.range(); + const xMax = xRangeMax / (1 - xAxis.maxPadding); + const [yMin, _yMax] = yScale.range(); + + return Array.from(group(series, (s) => s.stackId)).reduce( + (result, [_stackId, seriesStack]) => { + const xValues = getXValues(seriesStack, xAxis, xScale); + + const accumulatedYValues = new Map(); + xValues.forEach(([key]) => { + accumulatedYValues.set(key, 0); + }); + + const seriesStackData = seriesStack.reduce((acc, s) => { + const seriesData = s.data.reduce>((m, d) => { + return m.set(String(d.x), d); + }, new Map()); + const points = xValues.reduce((pointsAcc, [x, xValue]) => { + const accumulatedYValue = accumulatedYValues.get(x) || 0; + const d = + seriesData.get(x) || + ({ + x, + // FIXME: think about how to break the series into separate areas(null Y values) + y: 0, + } as AreaSeriesData); + const yValue = getYValue({point: d, yAxis, yScale}) - accumulatedYValue; + accumulatedYValues.set(x, yMin - yValue); + + pointsAcc.push({ + y0: yMin - accumulatedYValue, + x: xValue, + y: yValue, + data: d, + series: s, + }); + return pointsAcc; + }, []); + + let labels: LabelData[] = []; + if (s.dataLabels.enabled) { + labels = points.map((p) => getLabelData(p, s, xMax)); + } + + let markers: MarkerData[] = []; + if (s.marker.states.normal.enabled || s.marker.states.hover.enabled) { + markers = points.map((p) => ({ + point: p, + active: true, + hovered: false, + })); + } + + acc.push({ + points, + markers, + labels, + color: s.color, + opacity: s.opacity, + width: s.lineWidth, + series: s, + hovered: false, + active: true, + id: s.id, + }); + + return acc; + }, []); + + return result.concat(seriesStackData); + }, + [], + ); +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/area/types.ts b/src/plugins/d3/renderer/hooks/useShapes/area/types.ts new file mode 100644 index 00000000..c883e854 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/area/types.ts @@ -0,0 +1,30 @@ +import {PreparedAreaSeries} from '../../useSeries/types'; +import {AreaSeriesData} from '../../../../../../types'; +import {LabelData} from '../../../types'; + +export type PointData = { + y0: number; + x: number; + y: number; + data: AreaSeriesData; + series: PreparedAreaSeries; +}; + +export type MarkerData = { + point: PointData; + active: boolean; + hovered: boolean; +}; + +export type PreparedAreaData = { + id: string; + points: PointData[]; + markers: MarkerData[]; + color: string; + opacity: number; + width: number; + series: PreparedAreaSeries; + hovered: boolean; + active: boolean; + labels: LabelData[]; +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index 74cb5bf0..77f2914c 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -5,6 +5,7 @@ import {getOnlyVisibleSeries} from '../../utils'; import type {PreparedAxis} from '../useChartOptions/types'; import type {ChartScale} from '../useAxisScales'; import type { + PreparedAreaSeries, PreparedBarXSeries, PreparedBarYSeries, PreparedLineSeries, @@ -27,6 +28,9 @@ import {BarYSeriesShapes, prepareBarYData} from './bar-y'; import type {PreparedBarYData} from './bar-y/types'; export type {PreparedBarXData} from './bar-x'; export type {PreparedScatterData} from './scatter'; +import {AreaSeriesShapes} from './area'; +import {prepareAreaData} from './area/prepare-data'; +import type {PreparedAreaData} from './area/types'; import './styles.scss'; @@ -35,7 +39,8 @@ export type ShapeData = | PreparedBarYData | PreparedScatterData | PreparedLineData - | PreparedPieData; + | PreparedPieData + | PreparedAreaData; type Args = { boundsWidth: number; @@ -136,6 +141,27 @@ export const useShapes = (args: Args) => { } break; } + case 'area': { + if (xScale && yScale) { + const preparedData = prepareAreaData({ + series: chartSeries as PreparedAreaSeries[], + xAxis, + xScale, + yAxis, + yScale, + }); + acc.push( + , + ); + shapesData.push(...preparedData); + } + break; + } case 'scatter': { if (xScale && yScale) { const preparedData = prepareScatterData({ diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 54067d89..057cecb3 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -7,13 +7,13 @@ import type { BaseTextStyle, ChartKitWidgetSeries, ChartKitWidgetSeriesData, - BarXSeries, -} from '../../../../types/widget-data'; +} from '../../../../types'; import {formatNumber} from '../../../shared'; import {DEFAULT_AXIS_LABEL_FONT_SIZE} from '../constants'; import {getNumberUnitRate} from '../../../shared/format-number/format-number'; -import {PreparedAxis} from '../hooks'; +import {PreparedAxis, StackedSeries} from '../hooks'; import {getDefaultDateFormat} from './time'; +import {getSeriesStackId} from '../hooks/useSeries/utils'; export * from './math'; export * from './text'; @@ -75,24 +75,31 @@ export const getDomainDataYBySeries = (series: UnknownSeries[]) => { return Array.from(groupedSeries).reduce((acc, [type, seriesList]) => { switch (type) { + case 'area': case 'bar-x': { - const barXSeries = seriesList as BarXSeries[]; - const stackedSeries = group(barXSeries, (item) => item.stackId); - - Array.from(stackedSeries).forEach(([, stack]) => { + const stackedSeries = group(seriesList as StackedSeries[], getSeriesStackId); + Array.from(stackedSeries).forEach(([_stackId, seriesStack]) => { const values: Record = {}; - stack.forEach((singleSeries) => { + seriesStack.forEach((singleSeries) => { + const data = new Map(); singleSeries.data.forEach((point) => { - const key = String(point.x || point.category); + const key = String(point.x); + let value = 0; - if (typeof values[key] === 'undefined') { - values[key] = 0; + if (point.y && typeof point.y === 'number') { + value = point.y; } - if (point.y && typeof point.y === 'number') { - values[key] += point.y; + if (data.has(key)) { + value = Math.max(value, data.get(key)); } + + data.set(key, value); + }); + + Array.from(data).forEach(([key, value]) => { + values[key] = (values[key] || 0) + value; }); }); diff --git a/src/types/widget-data/area.ts b/src/types/widget-data/area.ts new file mode 100644 index 00000000..28884611 --- /dev/null +++ b/src/types/widget-data/area.ts @@ -0,0 +1,61 @@ +import type {BaseSeries, BaseSeriesData} from './base'; +import type {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; +import type {PointMarkerOptions} from './marker'; + +export type AreaSeriesData = BaseSeriesData & { + /** + * The `x` value of the point. Depending on the context , it may represents: + * - numeric value (for `linear` x axis) + * - timestamp value (for `datetime` x axis) + * - x axis category value (for `category` x axis). If the type is a string, then it is a category value itself. If the type is a number, then it is the index of an element in the array of categories described in `xAxis.categories` + */ + x?: string | number; + /** + * The `y` value of the point. Depending on the context , it may represents: + * - numeric value (for `linear` y axis) + * - timestamp value (for `datetime` y axis) + * - y axis category value (for `category` y axis). If the type is a string, then it is a category value itself. If the type is a number, then it is the index of an element in the array of categories described in `yAxis[0].categories` + */ + y?: string | number; + /** Data label value of the point. If not specified, the y value is used. */ + label?: string | number; +}; + +export type AreaMarkerSymbol = 'circle' | 'square'; + +export type AreaMarkerOptions = PointMarkerOptions & { + symbol?: AreaMarkerSymbol; +}; + +export type AreaSeries = BaseSeries & { + type: 'area'; + data: AreaSeriesData[]; + /** The name of the series (used in legend, tooltip etc) */ + name: string; + /** Whether to stack the values of each series on top of each other. + * Possible values are undefined to disable, "normal" to stack by value + * + * @default undefined + * */ + stacking?: 'normal'; + /** This option allows grouping series in a stacked chart */ + stackId?: string; + /** The main color of the series (hex, rgba) */ + color?: string; + /** Fill opacity for the area + * + * @default 0.75 + * */ + opacity?: number; + /** Pixel width of the graph line. + * + * @default 1 + * */ + lineWidth?: number; + /** Individual series legend options. Has higher priority than legend options in widget data */ + legend?: ChartKitWidgetLegend & { + symbol?: RectLegendSymbolOptions; + }; + /** Options for the point markers of line in area series */ + marker?: AreaMarkerOptions; +}; diff --git a/src/types/widget-data/index.ts b/src/types/widget-data/index.ts index 8222b22e..22d35314 100644 --- a/src/types/widget-data/index.ts +++ b/src/types/widget-data/index.ts @@ -13,6 +13,7 @@ export * from './pie'; export * from './scatter'; export * from './bar-x'; export * from './bar-y'; +export * from './area'; export * from './line'; export * from './series'; export * from './title'; diff --git a/src/types/widget-data/line.ts b/src/types/widget-data/line.ts index c1dc9681..c5306586 100644 --- a/src/types/widget-data/line.ts +++ b/src/types/widget-data/line.ts @@ -1,6 +1,6 @@ -import {BaseSeries, BaseSeriesData} from './base'; -import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; -import {PointMarkerOptions} from './marker'; +import type {BaseSeries, BaseSeriesData} from './base'; +import type {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; +import type {PointMarkerOptions} from './marker'; export type LineSeriesData = BaseSeriesData & { /** diff --git a/src/types/widget-data/marker.ts b/src/types/widget-data/marker.ts index c0535db3..efd72757 100644 --- a/src/types/widget-data/marker.ts +++ b/src/types/widget-data/marker.ts @@ -8,3 +8,12 @@ export type PointMarkerOptions = { /** The width of the point marker's border */ borderWidth?: number; }; + +export type PointMarkerHalo = { + /** Enable or disable the halo appearing around the point */ + enabled?: boolean; + /** The Opacity of the point halo */ + opacity?: number; + /** The radius of the point halo */ + radius?: number; +}; diff --git a/src/types/widget-data/series.ts b/src/types/widget-data/series.ts index 341bbaa6..ec9b40a8 100644 --- a/src/types/widget-data/series.ts +++ b/src/types/widget-data/series.ts @@ -4,21 +4,24 @@ import type {ScatterSeries, ScatterSeriesData} from './scatter'; import type {BarXSeries, BarXSeriesData} from './bar-x'; import type {LineSeries, LineSeriesData, LineMarkerOptions} from './line'; import type {BarYSeries, BarYSeriesData} from './bar-y'; -import {PointMarkerOptions} from './marker'; +import type {PointMarkerOptions, PointMarkerHalo} from './marker'; +import type {AreaSeries, AreaSeriesData} from './area'; export type ChartKitWidgetSeries = | ScatterSeries | PieSeries | BarXSeries | BarYSeries - | LineSeries; + | LineSeries + | AreaSeries; export type ChartKitWidgetSeriesData = | ScatterSeriesData | PieSeriesData | BarXSeriesData | BarYSeriesData - | LineSeriesData; + | LineSeriesData + | AreaSeriesData; export type DataLabelRendererData = { data: ChartKitWidgetSeriesData; @@ -163,11 +166,26 @@ export type ChartKitWidgetSeriesOptions = { hover?: BasicHoverState & { marker?: PointMarkerOptions & { /** Options for the halo appearing around the hovered point */ - halo?: { - enabled?: boolean; - opacity?: number; - radius?: number; - }; + halo?: PointMarkerHalo; + }; + }; + inactive?: BasicInactiveState; + }; + /** Options for the point markers of line series */ + marker?: LineMarkerOptions; + }; + area?: { + /** Pixel width of the graph line. + * + * @default 1 + * */ + lineWidth?: number; + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState & { + marker?: PointMarkerOptions & { + /** Options for the halo appearing around the hovered point */ + halo?: PointMarkerHalo; }; }; inactive?: BasicInactiveState; diff --git a/src/types/widget-data/tooltip.ts b/src/types/widget-data/tooltip.ts index 2816c841..152a90c8 100644 --- a/src/types/widget-data/tooltip.ts +++ b/src/types/widget-data/tooltip.ts @@ -3,6 +3,7 @@ import type {PieSeries, PieSeriesData} from './pie'; import type {ScatterSeries, ScatterSeriesData} from './scatter'; import type {LineSeries, LineSeriesData} from './line'; import type {BarYSeries, BarYSeriesData} from './bar-y'; +import type {AreaSeries, AreaSeriesData} from './area'; export type TooltipDataChunkBarX = { data: BarXSeriesData; @@ -37,12 +38,22 @@ export type TooltipDataChunkLine = { }; }; +export type TooltipDataChunkArea = { + data: AreaSeriesData; + series: { + type: AreaSeries['type']; + id: string; + name: string; + }; +}; + export type TooltipDataChunk = | TooltipDataChunkBarX | TooltipDataChunkBarY | TooltipDataChunkPie | TooltipDataChunkScatter - | TooltipDataChunkLine; + | TooltipDataChunkLine + | TooltipDataChunkArea; export type ChartKitWidgetTooltip = { enabled?: boolean;