Skip to content

Commit

Permalink
feat(D3 plugin): add treemap chart (#408)
Browse files Browse the repository at this point in the history
* feat(D3 plugin): add treemap chart

* fix: typo fixes

* fix: review fixes

* fix: fix jsdoc

* fix: review fixes 2
  • Loading branch information
korvin89 authored Feb 7, 2024
1 parent 20068d2 commit 7b55f2f
Show file tree
Hide file tree
Showing 23 changed files with 635 additions and 20 deletions.
9 changes: 9 additions & 0 deletions src/constants/widget-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const SeriesType = {
Line: 'line',
Pie: 'pie',
Scatter: 'scatter',
Treemap: 'treemap',
} as const;

export enum DashStyle {
Expand Down Expand Up @@ -35,3 +36,11 @@ export enum LineCap {
Square = 'square',
None = 'none',
}

export enum LayoutAlgorithm {
Binary = 'binary',
Dice = 'dice',
Slice = 'slice',
SliceDice = 'slice-dice',
Squarify = 'squarify',
}
4 changes: 3 additions & 1 deletion src/i18n/keysets/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
"label_invalid-axis-linear-data-point": "It seems you are trying to use inappropriate data type for \"{{key}}\" value in series \"{{seriesName}}\" for axis with type \"linear\". Numbers and nulls are allowed.",
"label_invalid-pie-data-value": "It seems you are trying to use inappropriate data type for \"value\" value. Only numbers are allowed.",
"label_invalid-series-type": "It seems you haven't defined \"series.type\" property, or defined it incorrectly. Available values: [{{types}}].",
"label_invalid-series-property": "It seems you are trying to use inappropriate value for \"{{key}}\", or defined it incorrectly. Available values: [{{values}}]."
"label_invalid-series-property": "It seems you are trying to use inappropriate value for \"{{key}}\", or defined it incorrectly. Available values: [{{values}}].",
"label_invalid-treemap-redundant-value": "It seems you are trying to set \"value\" for container node. Check node with this properties: { id: \"{{id}}\", name: \"{{name}}\" }",
"label_invalid-treemap-missing-value": "It seems you are trying to use node without \"value\". Check node with this properties: { id: \"{{id}}\", name: \"{{name}}\" }"
},
"highcharts": {
"reset-zoom-title": "Reset zoom",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/keysets/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"label_invalid-axis-linear-data-point": "Похоже, что вы пытаетесь использовать недопустимый тип данных для значения \"{{key}}\" в серии \"{{seriesName}}\" для оси с типом \"linear\". Допускается использование чисел и значений null.",
"label_invalid-pie-data-value": "Похоже, что вы пытаетесь использовать недопустимый тип данных для значения \"value\". Допускается только использование чисел.",
"label_invalid-series-type": "Похоже, что вы не указали значение \"series.type\" или указали его неверно. Доступные значения: [{{types}}].",
"label_invalid-series-property": "Похоже, что вы пытаетесь использовать недопустимое значение для \"{{key}}\", или указали его неверно. Доступные значения: [{{values}}]."
"label_invalid-series-property": "Похоже, что вы пытаетесь использовать недопустимое значение для \"{{key}}\", или указали его неверно. Доступные значения: [{{values}}].",
"label_invalid-treemap-redundant-value": "Похоже, что вы пытаетесь установить значение \"value\" для узла, используемого в качестве контейнера. Проверьте узел с этими свойствами: { id: \"{{id}}\", name: \"{{name}}\" }",
"label_invalid-treemap-missing-value": "Похоже, что вы пытаетесь использовать узел без значения \"value\". Проверьте узел с этими свойствами: { id: \"{{id}}\", name: \"{{name}}\" }"
},
"highcharts": {
"reset-zoom-title": "Сбросить увеличение",
Expand Down
69 changes: 69 additions & 0 deletions src/plugins/d3/__stories__/treemap/Playground.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import {StoryObj} from '@storybook/react';
import {Button} from '@gravity-ui/uikit';
import {settings} from '../../../../libs';
import {ChartKit} from '../../../../components/ChartKit';
import type {ChartKitRef} from '../../../../types';
import type {ChartKitWidgetData} from '../../../../types/widget-data';
import {D3Plugin} from '../..';

const prepareData = (): ChartKitWidgetData => {
return {
series: {
data: [
{
type: 'treemap',
name: 'Example',
dataLabels: {
enabled: true,
},
layoutAlgorithm: 'binary',
levels: [{index: 1}, {index: 2}, {index: 3}],
data: [
{name: 'One', value: 15},
{name: 'Two', value: 10},
{name: 'Three', value: 15},
{name: 'Four'},
{name: 'Four-1', value: 5, parentId: 'Four'},
{name: 'Four-2', parentId: 'Four'},
{name: 'Four-3', value: 4, parentId: 'Four'},
{name: 'Four-2-1', value: 5, parentId: 'Four-2'},
{name: 'Four-2-2', value: 7, parentId: 'Four-2'},
{name: 'Four-2-3', value: 10, parentId: 'Four-2'},
],
},
],
},
};
};

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

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

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

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

export default {
title: 'Plugins/D3/Treemap',
component: ChartStory,
};
2 changes: 0 additions & 2 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ type Props = {
};

export const Chart = (props: Props) => {
// FIXME: add data validation
const {width, height, data} = props;
const svgRef = React.useRef<SVGSVGElement>(null);
const dispatcher = React.useMemo(() => {
Expand All @@ -45,7 +44,6 @@ export const Chart = (props: Props) => {
() => getPreparedXAxis({xAxis: data.xAxis, width, series: data.series.data}),
[data, width],
);

const yAxis = React.useMemo(
() => getPreparedYAxis({series: data.series.data, yAxis: data.yAxis}),
[data, width],
Expand Down
11 changes: 8 additions & 3 deletions src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from 'react';
import get from 'lodash/get';
import {dateTime} from '@gravity-ui/date-utils';
import type {ChartKitWidgetSeriesData, TooltipDataChunk} from '../../../../../types';
import type {
ChartKitWidgetSeriesData,
TooltipDataChunk,
TreemapSeriesData,
} from '../../../../../types';
import {formatNumber} from '../../../../shared';
import type {PreparedAxis, PreparedPieSeries} from '../../hooks';
import {getDataCategoryValue} from '../../utils';
Expand Down Expand Up @@ -81,8 +85,9 @@ export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => {
</div>
);
}
case 'pie': {
const pieSeriesData = data as PreparedPieSeries;
case 'pie':
case 'treemap': {
const pieSeriesData = data as PreparedPieSeries | TreemapSeriesData;

return (
<div key={id}>
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 @@ -91,4 +91,16 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = {
},
},
},
treemap: {
states: {
hover: {
enabled: true,
brightness: 0.3,
},
inactive: {
enabled: false,
opacity: 0.5,
},
},
},
};
9 changes: 5 additions & 4 deletions src/plugins/d3/renderer/hooks/useSeries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,7 @@ export const useSeries = (args: Args) => {
getActiveLegendItems(preparedSeries),
);
const chartSeries = React.useMemo<PreparedSeries[]>(() => {
return preparedSeries.map((singleSeries, i) => {
singleSeries.id = `Series ${i + 1}`;

return preparedSeries.map((singleSeries) => {
if (singleSeries.legend.enabled) {
return {
...singleSeries,
Expand All @@ -88,6 +86,7 @@ export const useSeries = (args: Args) => {

const handleLegendItemClick: OnLegendItemClick = React.useCallback(
({name, metaKey}) => {
const allItems = getAllLegendItems(preparedSeries);
const onlyItemSelected =
activeLegendItems.length === 1 && activeLegendItems.includes(name);
let nextActiveLegendItems: string[];
Expand All @@ -96,8 +95,10 @@ export const useSeries = (args: Args) => {
nextActiveLegendItems = activeLegendItems.filter((item) => item !== name);
} else if (metaKey && !activeLegendItems.includes(name)) {
nextActiveLegendItems = activeLegendItems.concat(name);
} else if (onlyItemSelected && allItems.length === 1) {
nextActiveLegendItems = [];
} else if (onlyItemSelected) {
nextActiveLegendItems = getAllLegendItems(preparedSeries);
nextActiveLegendItems = allItems;
} else {
nextActiveLegendItems = [name];
}
Expand Down
48 changes: 48 additions & 0 deletions src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type {ScaleOrdinal} from 'd3';
import get from 'lodash/get';

import {LayoutAlgorithm} from '../../../../../constants';
import type {ChartKitWidgetSeriesOptions, TreemapSeries} from '../../../../../types';
import {getRandomCKId} from '../../../../../utils';

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

type PrepareTreemapSeriesArgs = {
colorScale: ScaleOrdinal<string, string>;
legend: PreparedLegend;
series: TreemapSeries[];
seriesOptions?: ChartKitWidgetSeriesOptions;
};

export function prepareTreemap(args: PrepareTreemapSeriesArgs) {
const {colorScale, legend, series} = args;

return series.map<PreparedTreemapSeries>((s) => {
const id = getRandomCKId();
const name = s.name || '';
const color = s.color || colorScale(name);

return {
color,
data: s.data,
dataLabels: {
enabled: get(s, 'dataLabels.enabled', true),
style: Object.assign({}, DEFAULT_DATALABELS_STYLE, s.dataLabels?.style),
padding: get(s, 'dataLabels.padding', DEFAULT_DATALABELS_PADDING),
allowOverlap: get(s, 'dataLabels.allowOverlap', false),
},
id,
type: s.type,
name,
visible: get(s, 'visible', true),
legend: {
enabled: get(s, 'legend.enabled', legend.enabled),
symbol: prepareLegendSymbol(s),
},
levels: s.levels,
layoutAlgorithm: get(s, 'layoutAlgorithm', LayoutAlgorithm.Binary),
};
});
}
12 changes: 11 additions & 1 deletion src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ import type {
LineSeries,
PieSeries,
ScatterSeries,
TreemapSeries,
} from '../../../../../types';
import {ChartKitError} from '../../../../../libs';

import type {PreparedLegend, PreparedSeries} from './types';
import {prepareLineSeries} from './prepare-line';
import {prepareBarXSeries} from './prepare-bar-x';
import {prepareBarYSeries} from './prepare-bar-y';
import {ChartKitError} from '../../../../../libs';
import {preparePieSeries} from './prepare-pie';
import {prepareArea} from './prepare-area';
import {prepareScatterSeries} from './prepare-scatter';
import {prepareTreemap} from './prepare-treemap';

export function prepareSeries(args: {
type: ChartKitWidgetSeries['type'];
Expand Down Expand Up @@ -63,6 +65,14 @@ export function prepareSeries(args: {
colorScale,
});
}
case 'treemap': {
return prepareTreemap({
series: series as TreemapSeries[],
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`,
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
Expand Up @@ -18,9 +18,11 @@ import {
SymbolLegendSymbolOptions,
AreaSeries,
AreaSeriesData,
TreemapSeries,
TreemapSeriesData,
} from '../../../../../types';
import type {SeriesOptionsDefaults} from '../../constants';
import {DashStyle, LineCap, SymbolType} from '../../../../../constants';
import {DashStyle, LayoutAlgorithm, LineCap, SymbolType} from '../../../../../constants';

export type RectLegendSymbol = {
shape: 'rect';
Expand Down Expand Up @@ -228,13 +230,27 @@ export type PreparedAreaSeries = {
};
} & BasePreparedSeries;

export type PreparedTreemapSeries = {
type: TreemapSeries['type'];
data: TreemapSeriesData[];
dataLabels: {
enabled: boolean;
style: BaseTextStyle;
padding: number;
allowOverlap: boolean;
};
layoutAlgorithm: `${LayoutAlgorithm}`;
} & BasePreparedSeries &
TreemapSeries;

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

export type PreparedSeriesOptions = SeriesOptionsDefaults;

Expand Down
23 changes: 22 additions & 1 deletion src/plugins/d3/renderer/hooks/useShapes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
PreparedLineSeries,
PreparedPieSeries,
PreparedScatterSeries,
PreparedTreemapSeries,
PreparedSeries,
PreparedSeriesOptions,
} from '../';
Expand All @@ -31,6 +32,8 @@ export type {PreparedScatterData} from './scatter/types';
import {AreaSeriesShapes} from './area';
import {prepareAreaData} from './area/prepare-data';
import type {PreparedAreaData} from './area/types';
import {TreemapSeriesShape} from './treemap';
import {prepareTreemapData} from './treemap/prepare-data';

import './styles.scss';

Expand Down Expand Up @@ -189,7 +192,6 @@ export const useShapes = (args: Args) => {
boundsWidth,
boundsHeight,
});

acc.push(
<PieSeriesShapes
key="pie"
Expand All @@ -199,6 +201,25 @@ export const useShapes = (args: Args) => {
svgContainer={svgContainer}
/>,
);
break;
}
case 'treemap': {
const preparedData = prepareTreemapData({
// We should have exactly one series with "treemap" type
// Otherwise data validation should emit an error
series: chartSeries[0] as PreparedTreemapSeries,
width: boundsWidth,
height: boundsHeight,
});
acc.push(
<TreemapSeriesShape
key="treemap"
dispatcher={dispatcher}
preparedData={preparedData}
seriesOptions={seriesOptions}
svgContainer={svgContainer}
/>,
);
}
}
return acc;
Expand Down
9 changes: 9 additions & 0 deletions src/plugins/d3/renderer/hooks/useShapes/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,12 @@
alignment-baseline: after-edge;
}
}

.chartkit-d3-treemap {
&__label {
fill: var(--g-color-text-complementary);
alignment-baseline: text-before-edge;
user-select: none;
pointer-events: none;
}
}
Loading

0 comments on commit 7b55f2f

Please sign in to comment.