Skip to content

Commit

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

* fix calculating axis domain for stacking series

* stacked area

* fix yAxis min value for area series

* review fixes
  • Loading branch information
kuzmadom authored Dec 19, 2023
1 parent 4b95adb commit 9762964
Show file tree
Hide file tree
Showing 27 changed files with 951 additions and 52 deletions.
16 changes: 16 additions & 0 deletions src/plugins/d3/__stories__/Showcase.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -50,6 +52,20 @@ const ShowcaseStory = () => {
<LineWithMarkers />
</Col>
</Row>
<Row space={1}>
<Text variant="header-2">Area charts</Text>
</Row>
<Row space={3} style={{minHeight: 280}}>
<Col>
<Text variant="subheader-1">Basic area chart</Text>
<BasicArea />
</Col>
<Col>
<Text variant="subheader-1">Stacked area</Text>
<StackedArea />
</Col>
<Col></Col>
</Row>
<Row space={1}>
<Text variant="header-2">Bar-x charts</Text>
</Row>
Expand Down
48 changes: 48 additions & 0 deletions src/plugins/d3/__stories__/area/Basic.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <Button onClick={() => setShown(true)}>Show chart</Button>;
}

return (
<div
style={{
height: '80vh',
width: '100%',
}}
>
<Chart />
</div>
);
};

export const BasicAreaChartStory: StoryObj<typeof ChartStory> = {
name: 'Basic',
args: {
Chart: Basic,
},
};

export const StackedAreaChartStory: StoryObj<typeof ChartStory> = {
name: 'Stacked',
args: {
Chart: StackedArea,
},
};

export default {
title: 'Plugins/D3/Area',
decorators: [withKnobs],
component: ChartStory,
};
41 changes: 41 additions & 0 deletions src/plugins/d3/examples/area/Basic.tsx
Original file line number Diff line number Diff line change
@@ -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<AreaSeriesData>((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 <ChartKit type="d3" data={widgetData} />;
};
67 changes: 67 additions & 0 deletions src/plugins/d3/examples/area/StackedArea.tsx
Original file line number Diff line number Diff line change
@@ -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<AreaSeriesData[]>((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 <ChartKit type="d3" data={widgetData} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<XLineData>((d) => ({
Expand Down
12 changes: 12 additions & 0 deletions src/plugins/d3/renderer/constants/defaults/series-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,16 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = {
},
},
},
area: {
states: {
hover: {
enabled: true,
brightness: 0.3,
},
inactive: {
enabled: true,
opacity: 0.5,
},
},
},
};
3 changes: 2 additions & 1 deletion src/plugins/d3/renderer/hooks/useChartOptions/y-axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 8 additions & 1 deletion src/plugins/d3/renderer/hooks/useSeries/constants.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,3 +12,9 @@ export const DEFAULT_DATALABELS_STYLE: BaseTextStyle = {
fontWeight: 'bold',
fontColor: 'var(--d3-data-labels)',
};

export const DEFAULT_HALO_OPTIONS: Required<PointMarkerHalo> = {
enabled: true,
opacity: 0.25,
radius: 10,
};
93 changes: 93 additions & 0 deletions src/plugins/d3/renderer/hooks/useSeries/prepare-area.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<PreparedAreaSeries>((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;
}, []);
}
10 changes: 2 additions & 8 deletions src/plugins/d3/renderer/hooks/useSeries/prepare-bar-x.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -14,17 +14,11 @@ type PrepareBarXSeriesArgs = {

export function prepareBarXSeries(args: PrepareBarXSeriesArgs): PreparedSeries[] {
const {colorScale, series: seriesList, legend} = args;
const commonStackId = getRandomCKId();

return seriesList.map<PreparedBarXSeries>((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,
Expand All @@ -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:
Expand Down
Loading

0 comments on commit 9762964

Please sign in to comment.