Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(D3 plugin): basic waterfall chart #475

Merged
merged 5 commits into from
May 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 && (
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
<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(
korvin89 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading