Skip to content

Commit

Permalink
feat(D3 plugin): basic waterfall chart (#475)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): basic waterfall chart

* fix label position

* Fix negative bar position

* fix types

* fix
  • Loading branch information
kuzmadom authored May 11, 2024
1 parent 67f30ee commit cff5a3a
Show file tree
Hide file tree
Showing 21 changed files with 803 additions and 29 deletions.
1 change: 1 addition & 0 deletions src/constants/widget-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const SeriesType = {
Pie: 'pie',
Scatter: 'scatter',
Treemap: 'treemap',
Waterfall: 'waterfall',
} as const;

export enum DashStyle {
Expand Down
116 changes: 116 additions & 0 deletions src/plugins/d3/__stories__/waterfall/Playground.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';

import {Button} from '@gravity-ui/uikit';
import {action} from '@storybook/addon-actions';
import {StoryObj} from '@storybook/react';

import {D3Plugin} from '../..';
import {ChartKit} from '../../../../components/ChartKit';
import {settings} from '../../../../libs';
import {ChartKitWidgetData} from '../../../../types';

function prepareData() {
const result: ChartKitWidgetData = {
series: {
data: [
{
type: 'waterfall',
data: [
{y: 100, x: 0},
{y: -20, x: 1},
{y: -15, x: 2},
{y: 30, x: 3},
{y: 45, x: 4},
{y: 10, x: 5},
{y: -120, x: 6},
{y: 30, x: 7},
{y: 10, x: 8},
{y: -20, x: 9},
{y: -5, x: 10},
{y: 35, x: 11},
{total: true, x: 12},
],
name: 'Profit',
dataLabels: {enabled: true},
},
],
},
xAxis: {
type: 'category',
categories: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
'Totals',
],
labels: {
enabled: true,
rotation: 30,
},
},
yAxis: [
{
labels: {
enabled: true,
rotation: -90,
},
ticks: {
pixelInterval: 120,
},
},
],
chart: {
events: {
click: action('chart.events.click'),
},
},
};

return result;
}

const ChartStory = ({data}: {data: ChartKitWidgetData}) => {
const [shown, setShown] = React.useState(false);

if (!shown) {
settings.set({plugins: [D3Plugin]});
return <Button onClick={() => setShown(true)}>Show chart</Button>;
}

return (
<div
style={{
height: '80vh',
width: '100%',
}}
>
<ChartKit type="d3" data={data} />
</div>
);
};

export const PlaygroundWaterfallChartStory: StoryObj<typeof ChartStory> = {
name: 'Playground',
args: {
data: prepareData(),
},
argTypes: {
data: {
control: 'object',
},
},
};

export default {
title: 'Plugins/D3/Waterfall',
component: ChartStory,
};
33 changes: 30 additions & 3 deletions src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import type {
ChartKitWidgetSeriesData,
TooltipDataChunk,
TreemapSeriesData,
WaterfallSeriesData,
} from '../../../../../types';
import {block} from '../../../../../utils/cn';
import {formatNumber} from '../../../../shared';
import type {PreparedAxis, PreparedPieSeries} from '../../hooks';
import {getDataCategoryValue} from '../../utils';
import type {PreparedAxis, PreparedPieSeries, PreparedWaterfallSeries} from '../../hooks';
import {getDataCategoryValue, getWaterfallPointSubtotal} from '../../utils';

const b = block('d3-tooltip');

Expand Down Expand Up @@ -51,7 +52,7 @@ const getYRowData = (yAxis: PreparedAxis, data: ChartKitWidgetSeriesData) =>
getRowData('y', yAxis, data);

const getMeasureValue = (data: TooltipDataChunk[], xAxis: PreparedAxis, yAxis: PreparedAxis) => {
if (data.every((item) => item.series.type === 'pie' || item.series.type === 'treemap')) {
if (data.every((item) => ['pie', 'treemap', 'waterfall'].includes(item.series.type))) {
return null;
}

Expand Down Expand Up @@ -89,6 +90,32 @@ export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => {
</div>
);
}
case 'waterfall': {
const isTotal = get(data, 'total', false);
const subTotal = getWaterfallPointSubtotal(
data as WaterfallSeriesData,
series as PreparedWaterfallSeries,
);

return (
<div key={`${id}_${get(data, 'x')}`}>
{!isTotal && (
<React.Fragment>
<div key={id} className={b('content-row')}>
<b>{getXRowData(xAxis, data)}</b>
</div>
<div className={b('content-row')}>
<span>{series.name}&nbsp;</span>
<span>{getYRowData(yAxis, data)}</span>
</div>
</React.Fragment>
)}
<div key={id} className={b('content-row')}>
{isTotal ? 'Total' : 'Subtotal'}: {subTotal}
</div>
</div>
);
}
case 'bar-y': {
const value = (
<React.Fragment>
Expand Down
21 changes: 20 additions & 1 deletion src/plugins/d3/renderer/constants/defaults/series-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ type DefaultBarYSeriesOptions = Partial<ChartKitWidgetSeriesOptions['bar-x']> &
'bar-y': {barMaxWidth: number; barPadding: number; groupPadding: number};
};

type DefaultWaterfallSeriesOptions = Partial<ChartKitWidgetSeriesOptions['waterfall']> & {
waterfall: {barMaxWidth: number; barPadding: number};
};

export type SeriesOptionsDefaults = Partial<ChartKitWidgetSeriesOptions> &
DefaultBarXSeriesOptions &
DefaultBarYSeriesOptions;
DefaultBarYSeriesOptions &
DefaultWaterfallSeriesOptions;

export const seriesOptionsDefaults: SeriesOptionsDefaults = {
'bar-x': {
Expand Down Expand Up @@ -103,4 +108,18 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = {
},
},
},
waterfall: {
barMaxWidth: 50,
barPadding: 0.1,
states: {
hover: {
enabled: true,
brightness: 0.3,
},
inactive: {
enabled: false,
opacity: 0.5,
},
},
},
};
24 changes: 21 additions & 3 deletions src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import {
getHorisontalSvgTextHeight,
getLabelsSize,
getScaleTicks,
getWaterfallPointSubtotal,
} from '../../utils';
import {createYScale} from '../useAxisScales';
import {PreparedSeries} from '../useSeries/types';
import type {PreparedSeries, PreparedWaterfallSeries} from '../useSeries/types';

import type {PreparedAxis} from './types';

Expand Down Expand Up @@ -50,10 +51,27 @@ const getAxisLabelMaxWidth = (args: {axis: PreparedAxis; series: ChartKitWidgetS

function getAxisMin(axis?: ChartKitWidgetAxis, series?: ChartKitWidgetSeries[]) {
const min = axis?.min;
const seriesWithVolume = ['bar-x', 'area'];
const seriesWithVolume = ['bar-x', 'area', 'waterfall'];

if (typeof min === 'undefined' && series?.some((s) => seriesWithVolume.includes(s.type))) {
return 0;
return series.reduce((minValue, s) => {
switch (s.type) {
case 'waterfall': {
const minSubTotal = s.data.reduce(
(res, d) =>
Math.min(
res,
getWaterfallPointSubtotal(d, s as PreparedWaterfallSeries) || 0,
),
0,
);
return Math.min(minValue, minSubTotal);
}
default: {
return minValue;
}
}
}, 0);
}

return min;
Expand Down
49 changes: 49 additions & 0 deletions src/plugins/d3/renderer/hooks/useSeries/prepare-waterfall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type {ScaleOrdinal} from 'd3';
import get from 'lodash/get';

import type {WaterfallSeries} from '../../../../../types';
import {getRandomCKId} from '../../../../../utils';
import {DEFAULT_PALETTE} from '../../constants';

import {DEFAULT_DATALABELS_PADDING, DEFAULT_DATALABELS_STYLE} from './constants';
import type {PreparedLegend, PreparedSeries, PreparedWaterfallSeries} from './types';
import {prepareLegendSymbol} from './utils';

type PrepareWaterfallSeriesArgs = {
colorScale: ScaleOrdinal<string, string>;
series: WaterfallSeries[];
legend: PreparedLegend;
};

export function prepareWaterfallSeries(args: PrepareWaterfallSeriesArgs): PreparedSeries[] {
const {colorScale, series: seriesList, legend} = args;
const [, negativeColor, positiveColor] = DEFAULT_PALETTE;

return seriesList.map<PreparedWaterfallSeries>((series) => {
const name = series.name || '';
const color = series.color || colorScale(name);

const prepared: PreparedWaterfallSeries = {
type: series.type,
color,
positiveColor: positiveColor,
negativeColor: negativeColor,
name,
id: getRandomCKId(),
visible: get(series, 'visible', true),
legend: {
enabled: get(series, 'legend.enabled', legend.enabled),
symbol: prepareLegendSymbol(series),
},
data: series.data,
dataLabels: {
enabled: series.dataLabels?.enabled || false,
style: Object.assign({}, DEFAULT_DATALABELS_STYLE, series.dataLabels?.style),
allowOverlap: series.dataLabels?.allowOverlap || false,
padding: get(series, 'dataLabels.padding', DEFAULT_DATALABELS_PADDING),
},
cursor: get(series, 'cursor', null),
};
return prepared;
}, []);
}
9 changes: 9 additions & 0 deletions src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
PieSeries,
ScatterSeries,
TreemapSeries,
WaterfallSeries,
} from '../../../../../types';

import {prepareArea} from './prepare-area';
Expand All @@ -20,6 +21,7 @@ import {prepareLineSeries} from './prepare-line';
import {preparePieSeries} from './prepare-pie';
import {prepareScatterSeries} from './prepare-scatter';
import {prepareTreemap} from './prepare-treemap';
import {prepareWaterfallSeries} from './prepare-waterfall';
import type {PreparedLegend, PreparedSeries} from './types';

export function prepareSeries(args: {
Expand Down Expand Up @@ -73,6 +75,13 @@ export function prepareSeries(args: {
colorScale,
});
}
case 'waterfall': {
return prepareWaterfallSeries({
series: series as WaterfallSeries[],
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`,
Expand Down
20 changes: 18 additions & 2 deletions src/plugins/d3/renderer/hooks/useSeries/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {DashStyle, LayoutAlgorithm, LineCap, SymbolType} from '../../../../../constants';
import {
import type {
AreaSeries,
AreaSeriesData,
BarXSeries,
Expand All @@ -21,6 +21,8 @@ import {
SymbolLegendSymbolOptions,
TreemapSeries,
TreemapSeriesData,
WaterfallSeries,
WaterfallSeriesData,
} from '../../../../../types';
import type {SeriesOptionsDefaults} from '../../constants';

Expand Down Expand Up @@ -246,14 +248,28 @@ export type PreparedTreemapSeries = {
} & BasePreparedSeries &
Omit<TreemapSeries, keyof BasePreparedSeries>;

export type PreparedWaterfallSeries = {
type: WaterfallSeries['type'];
data: WaterfallSeriesData[];
dataLabels: {
enabled: boolean;
style: BaseTextStyle;
allowOverlap: boolean;
padding: number;
};
positiveColor: string;
negativeColor: string;
} & BasePreparedSeries;

export type PreparedSeries =
| PreparedScatterSeries
| PreparedBarXSeries
| PreparedBarYSeries
| PreparedPieSeries
| PreparedLineSeries
| PreparedAreaSeries
| PreparedTreemapSeries;
| PreparedTreemapSeries
| PreparedWaterfallSeries;

export type PreparedSeriesOptions = SeriesOptionsDefaults;

Expand Down
Loading

0 comments on commit cff5a3a

Please sign in to comment.