diff --git a/src/constants/widget-data.ts b/src/constants/widget-data.ts index 3fbbe560..2dddbe2b 100644 --- a/src/constants/widget-data.ts +++ b/src/constants/widget-data.ts @@ -5,6 +5,7 @@ export const SeriesType = { Line: 'line', Pie: 'pie', Scatter: 'scatter', + Treemap: 'treemap', } as const; export enum DashStyle { @@ -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', +} diff --git a/src/i18n/keysets/en.json b/src/i18n/keysets/en.json index 34c273c2..eaf2cea9 100644 --- a/src/i18n/keysets/en.json +++ b/src/i18n/keysets/en.json @@ -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", diff --git a/src/i18n/keysets/ru.json b/src/i18n/keysets/ru.json index b0e4e6f5..fa6ce219 100644 --- a/src/i18n/keysets/ru.json +++ b/src/i18n/keysets/ru.json @@ -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": "Сбросить увеличение", diff --git a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx new file mode 100644 index 00000000..ca332ad8 --- /dev/null +++ b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx @@ -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(); + + if (!shown) { + settings.set({plugins: [D3Plugin]}); + return ; + } + + return ( +
+ +
+ ); +}; + +export const TreemapPlayground: StoryObj = { + name: 'Playground', + args: {data: prepareData()}, + argTypes: { + data: { + control: 'object', + }, + }, +}; + +export default { + title: 'Plugins/D3/Treemap', + component: ChartStory, +}; diff --git a/src/plugins/d3/renderer/components/Chart.tsx b/src/plugins/d3/renderer/components/Chart.tsx index 9959ab54..555b7579 100644 --- a/src/plugins/d3/renderer/components/Chart.tsx +++ b/src/plugins/d3/renderer/components/Chart.tsx @@ -32,7 +32,6 @@ type Props = { }; export const Chart = (props: Props) => { - // FIXME: add data validation const {width, height, data} = props; const svgRef = React.useRef(null); const dispatcher = React.useMemo(() => { @@ -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], diff --git a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx index 259816b5..8817aacd 100644 --- a/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx +++ b/src/plugins/d3/renderer/components/Tooltip/DefaultContent.tsx @@ -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'; @@ -81,8 +85,9 @@ export const DefaultContent = ({hovered, xAxis, yAxis}: Props) => { ); } - case 'pie': { - const pieSeriesData = data as PreparedPieSeries; + case 'pie': + case 'treemap': { + const pieSeriesData = data as PreparedPieSeries | TreemapSeriesData; return (
diff --git a/src/plugins/d3/renderer/constants/defaults/series-options.ts b/src/plugins/d3/renderer/constants/defaults/series-options.ts index e1d7b907..5069929c 100644 --- a/src/plugins/d3/renderer/constants/defaults/series-options.ts +++ b/src/plugins/d3/renderer/constants/defaults/series-options.ts @@ -91,4 +91,16 @@ export const seriesOptionsDefaults: SeriesOptionsDefaults = { }, }, }, + treemap: { + states: { + hover: { + enabled: true, + brightness: 0.3, + }, + inactive: { + enabled: false, + opacity: 0.5, + }, + }, + }, }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index dbfbd9c9..06443317 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -62,9 +62,7 @@ export const useSeries = (args: Args) => { getActiveLegendItems(preparedSeries), ); const chartSeries = React.useMemo(() => { - return preparedSeries.map((singleSeries, i) => { - singleSeries.id = `Series ${i + 1}`; - + return preparedSeries.map((singleSeries) => { if (singleSeries.legend.enabled) { return { ...singleSeries, @@ -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[]; @@ -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]; } diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts new file mode 100644 index 00000000..1716b0c8 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts @@ -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; + legend: PreparedLegend; + series: TreemapSeries[]; + seriesOptions?: ChartKitWidgetSeriesOptions; +}; + +export function prepareTreemap(args: PrepareTreemapSeriesArgs) { + const {colorScale, legend, series} = args; + + return series.map((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), + }; + }); +} diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index 87b26ce4..ba8fd13f 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -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']; @@ -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`, diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 3500f467..8d95983a 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -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'; @@ -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; diff --git a/src/plugins/d3/renderer/hooks/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index ee9204b0..f6f0f57d 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -11,6 +11,7 @@ import type { PreparedLineSeries, PreparedPieSeries, PreparedScatterSeries, + PreparedTreemapSeries, PreparedSeries, PreparedSeriesOptions, } from '../'; @@ -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'; @@ -189,7 +192,6 @@ export const useShapes = (args: Args) => { boundsWidth, boundsHeight, }); - acc.push( { 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( + , + ); } } return acc; diff --git a/src/plugins/d3/renderer/hooks/useShapes/styles.scss b/src/plugins/d3/renderer/hooks/useShapes/styles.scss index d4c264b1..6a81d03c 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/styles.scss +++ b/src/plugins/d3/renderer/hooks/useShapes/styles.scss @@ -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; + } +} diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx new file mode 100644 index 00000000..b7482c4e --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import {color, pointer, select} from 'd3'; +import type {BaseType, Dispatch, HierarchyRectangularNode} from 'd3'; +import get from 'lodash/get'; + +import type {TooltipDataChunkTreemap, TreemapSeriesData} from '../../../../../../types'; +import {setEllipsisForOverflowTexts} from '../../../utils'; +import {block} from '../../../../../../utils/cn'; + +import {PreparedSeriesOptions} from '../../useSeries/types'; +import type {PreparedTreemapData, TreemapLabelData} from './types'; + +const b = block('d3-treemap'); + +type ShapeProps = { + dispatcher: Dispatch; + preparedData: PreparedTreemapData; + seriesOptions: PreparedSeriesOptions; + svgContainer: SVGSVGElement | null; +}; + +export const TreemapSeriesShape = (props: ShapeProps) => { + const {dispatcher, preparedData, seriesOptions, svgContainer} = props; + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + const svgElement = select(ref.current); + svgElement.selectAll('*').remove(); + const {labelData, leaves, series} = preparedData; + const leaf = svgElement + .selectAll('g') + .data(leaves) + .join('g') + .attr('transform', (d) => `translate(${d.x0},${d.y0})`); + const rectSelection = leaf + .append('rect') + .attr('id', (d) => d.id || d.name) + .attr('fill', (d) => { + if (d.data.color) { + return d.data.color; + } + + const levelOptions = series.levels?.find((l) => l.index === d.depth); + return levelOptions?.color || series.color; + }) + .attr('width', (d) => d.x1 - d.x0) + .attr('height', (d) => d.y1 - d.y0); + const labelSelection = svgElement + .selectAll('tspan') + .data(labelData) + .join('text') + .text((d) => d.text) + .attr('class', b('label')) + .attr('x', (d) => d.x) + .attr('y', (d) => d.y) + .style('font-size', () => series.dataLabels.style.fontSize) + .style('font-weight', () => series.dataLabels.style?.fontWeight || null) + .style('fill', () => series.dataLabels.style?.fontColor || null) + .call(setEllipsisForOverflowTexts, (d) => d.width); + + const eventName = `hover-shape.pie`; + const hoverOptions = get(seriesOptions, 'treemap.states.hover'); + const inactiveOptions = get(seriesOptions, 'treemap.states.inactive'); + svgElement + .on('mousemove', (e) => { + const hoveredRect = select>( + e.target, + ); + const datum = hoveredRect.datum(); + dispatcher.call( + 'hover-shape', + {}, + [{data: datum.data, series}], + pointer(e, svgContainer), + ); + }) + .on('mouseleave', () => { + dispatcher.call('hover-shape', {}, undefined); + }); + + dispatcher.on(eventName, (data?: TooltipDataChunkTreemap[]) => { + const hoverEnabled = hoverOptions?.enabled; + const inactiveEnabled = inactiveOptions?.enabled; + const hoveredData = data?.[0].data; + rectSelection.datum((d, index, list) => { + const currentRect = select>( + list[index], + ); + const hovered = Boolean(hoverEnabled && hoveredData === d.data); + const inactive = Boolean(inactiveEnabled && hoveredData && !hovered); + currentRect + .attr('fill', (currentD) => { + const levelOptions = series.levels?.find((l) => l.index === currentD.depth); + const initialColor = levelOptions?.color || d.data.color || series.color; + if (hovered) { + return ( + color(initialColor) + ?.brighter(hoverOptions?.brightness) + .toString() || initialColor + ); + } + return initialColor; + }) + .attr('opacity', () => { + if (inactive) { + return inactiveOptions?.opacity || null; + } + return null; + }); + + return d; + }); + labelSelection.datum((d, index, list) => { + const currentLabel = select(list[index]); + const hovered = Boolean(hoverEnabled && hoveredData === d.nodeData); + const inactive = Boolean(inactiveEnabled && hoveredData && !hovered); + currentLabel.attr('opacity', () => { + if (inactive) { + return inactiveOptions?.opacity || null; + } + return null; + }); + return d; + }); + }); + + return () => { + dispatcher.on(eventName, null); + }; + }, [dispatcher, preparedData, seriesOptions, svgContainer]); + + return ; +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts new file mode 100644 index 00000000..305948bf --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts @@ -0,0 +1,95 @@ +import { + stratify, + treemap, + treemapBinary, + treemapDice, + treemapSlice, + treemapSliceDice, + treemapSquarify, +} from 'd3'; +import type {HierarchyRectangularNode} from 'd3'; + +import {LayoutAlgorithm} from '../../../../../../constants'; +import type {TreemapSeriesData} from '../../../../../../types'; + +import type {PreparedTreemapSeries} from '../../useSeries/types'; +import type {PreparedTreemapData, TreemapLabelData} from './types'; + +const DEFAULT_PADDING = 1; + +function getLabelData(data: HierarchyRectangularNode[]): TreemapLabelData[] { + return data.map((d) => { + const text = d.data.name; + + return { + text, + x: d.x0, + y: d.y0, + width: d.x1 - d.x0, + nodeData: d.data, + }; + }); +} + +export function prepareTreemapData(args: { + series: PreparedTreemapSeries; + width: number; + height: number; +}): PreparedTreemapData { + const {series, width, height} = args; + const dataWithRootNode = getSeriesDataWithRootNode(series); + const hierarchy = stratify() + .id((d) => d.id || d.name) + .parentId((d) => d.parentId)(dataWithRootNode) + .sum((d) => d.value || 0); + const treemapInstance = treemap(); + + switch (series.layoutAlgorithm) { + case LayoutAlgorithm.Binary: { + treemapInstance.tile(treemapBinary); + break; + } + case LayoutAlgorithm.Dice: { + treemapInstance.tile(treemapDice); + break; + } + case LayoutAlgorithm.Slice: { + treemapInstance.tile(treemapSlice); + break; + } + case LayoutAlgorithm.SliceDice: { + treemapInstance.tile(treemapSliceDice); + break; + } + case LayoutAlgorithm.Squarify: { + treemapInstance.tile(treemapSquarify); + break; + } + } + + const root = treemapInstance.size([width, height]).paddingInner((d) => { + const levelOptions = series.levels?.find((l) => l.index === d.depth + 1); + return levelOptions?.padding ?? DEFAULT_PADDING; + })(hierarchy); + const leaves = root.leaves(); + const labelData: TreemapLabelData[] = series.dataLabels?.enabled ? getLabelData(leaves) : []; + + return {labelData, leaves, series}; +} + +function getSeriesDataWithRootNode(series: PreparedTreemapSeries) { + return series.data.reduce( + (acc, d) => { + const dataChunk = Object.assign({}, d); + + if (!dataChunk.parentId) { + dataChunk.parentId = series.id; + } + + acc.push(dataChunk); + + return acc; + }, + [{name: series.name, id: series.id}], + ); +} diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts b/src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts new file mode 100644 index 00000000..d5fab99d --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts @@ -0,0 +1,18 @@ +import type {HierarchyRectangularNode} from 'd3'; + +import type {TreemapSeriesData} from '../../../../../../types'; +import type {PreparedTreemapSeries} from '../../useSeries/types'; + +export type TreemapLabelData = { + text: string; + x: number; + y: number; + width: number; + nodeData: TreemapSeriesData; +}; + +export type PreparedTreemapData = { + labelData: TreemapLabelData[]; + leaves: HierarchyRectangularNode>[]; + series: PreparedTreemapSeries; +}; diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 2e2c506c..ba1dc62a 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -22,7 +22,7 @@ export * from './axis'; export * from './labels'; export * from './symbol'; -const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie']; +const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie', 'treemap']; export type AxisDirection = 'x' | 'y'; diff --git a/src/plugins/d3/renderer/validation/__tests__/validation.test.ts b/src/plugins/d3/renderer/validation/__tests__/validation.test.ts index 1c93ba19..f05d0a75 100644 --- a/src/plugins/d3/renderer/validation/__tests__/validation.test.ts +++ b/src/plugins/d3/renderer/validation/__tests__/validation.test.ts @@ -89,4 +89,45 @@ describe('plugins/d3/validation', () => { expect(error?.code).toEqual(CHARTKIT_ERROR_CODE.INVALID_DATA); }, ); + + test.each([ + [[{name: '1'} /* error */]], + [[{name: '1'}, {name: '2', parentId: '1'} /* error */]], + [ + [ + {name: '1', value: 1}, // error + {name: '2', parentId: '1', value: 1}, + ], + ], + [ + [ + {name: '1'}, + {name: '2', parentId: '1', value: 1}, // error + {name: '3', parentId: '2', value: 1}, + {name: '4', parentId: '2', value: 1}, + ], + ], + ])( + '[Treemap Series] validateData should throw an error in case of invalid data (data: %j)', + (data) => { + let error: ChartKitError | null = null; + + try { + validateData({ + series: { + data: [ + { + type: 'treemap', + data, + }, + ] as ChartKitWidgetData['series']['data'], + }, + }); + } catch (e) { + error = e as ChartKitError; + } + + expect(error?.code).toEqual(CHARTKIT_ERROR_CODE.INVALID_DATA); + }, + ); }); diff --git a/src/plugins/d3/renderer/validation/index.ts b/src/plugins/d3/renderer/validation/index.ts index f8d18df6..e7704a51 100644 --- a/src/plugins/d3/renderer/validation/index.ts +++ b/src/plugins/d3/renderer/validation/index.ts @@ -13,6 +13,7 @@ import { LineSeries, PieSeries, ScatterSeries, + TreemapSeries, } from '../../../../types'; import {i18n} from '../../../../i18n'; @@ -137,6 +138,38 @@ const validateStacking = ({series}: {series: AreaSeries | BarXSeries | BarYSerie } }; +const validateTreemapSeries = ({series}: {series: TreemapSeries}) => { + const parentIds: Record = {}; + series.data.forEach((d) => { + if (d.parentId && !parentIds[d.parentId]) { + parentIds[d.parentId] = true; + } + }); + series.data.forEach((d) => { + const idOrName = d.id || d.name; + + if (parentIds[idOrName] && typeof d.value === 'number') { + throw new ChartKitError({ + code: CHARTKIT_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-treemap-redundant-value', { + id: d.id, + name: d.name, + }), + }); + } + + if (!parentIds[idOrName] && typeof d.value !== 'number') { + throw new ChartKitError({ + code: CHARTKIT_ERROR_CODE.INVALID_DATA, + message: i18n('error', 'label_invalid-treemap-missing-value', { + id: d.id, + name: d.name, + }), + }); + } + }); +}; + const validateSeries = (args: { series: ChartKitWidgetSeries; xAxis?: ChartKitWidgetAxis; @@ -168,10 +201,30 @@ const validateSeries = (args: { } case 'pie': { validatePieSeries({series}); + break; + } + case 'treemap': { + validateTreemapSeries({series}); } } }; +const countSeriesByType = (args: { + series: ChartKitWidgetSeries[]; + type: ChartKitWidgetSeries['type']; +}) => { + const {series, type} = args; + let count = 0; + + series.forEach((s) => { + if (s.type === type) { + count += 1; + } + }); + + return count; +}; + export const validateData = (data?: ChartKitWidgetData) => { if (isEmpty(data) || isEmpty(data.series) || isEmpty(data.series.data)) { throw new ChartKitError({ @@ -187,6 +240,18 @@ export const validateData = (data?: ChartKitWidgetData) => { }); } + const treemapSeriesCount = countSeriesByType({ + series: data.series.data, + type: SeriesType.Treemap, + }); + + if (treemapSeriesCount > 1) { + throw new ChartKitError({ + code: CHARTKIT_ERROR_CODE.INVALID_DATA, + message: 'It looks like you are trying to define more than one "treemap" series.', + }); + } + data.series.data.forEach((series) => { validateSeries({series, yAxis: data.yAxis?.[0], xAxis: data.xAxis}); }); diff --git a/src/types/widget-data/index.ts b/src/types/widget-data/index.ts index f4021510..031a3afa 100644 --- a/src/types/widget-data/index.ts +++ b/src/types/widget-data/index.ts @@ -19,6 +19,7 @@ export * from './series'; export * from './title'; export * from './tooltip'; export * from './halo'; +export * from './treemap'; export type ChartKitWidgetData = { chart?: ChartKitWidgetChart; diff --git a/src/types/widget-data/series.ts b/src/types/widget-data/series.ts index 916fdf33..12eeb46d 100644 --- a/src/types/widget-data/series.ts +++ b/src/types/widget-data/series.ts @@ -6,6 +6,7 @@ import type {LineSeries, LineSeriesData} from './line'; import type {BarYSeries, BarYSeriesData} from './bar-y'; import type {PointMarkerOptions} from './marker'; import type {AreaSeries, AreaSeriesData} from './area'; +import type {TreemapSeries, TreemapSeriesData} from './treemap'; import type {Halo} from './halo'; import {DashStyle, LineCap} from '../../constants'; @@ -16,7 +17,8 @@ export type ChartKitWidgetSeries = | BarXSeries | BarYSeries | LineSeries - | AreaSeries; + | AreaSeries + | TreemapSeries; export type ChartKitWidgetSeriesData = | ScatterSeriesData @@ -24,7 +26,8 @@ export type ChartKitWidgetSeriesData = | BarXSeriesData | BarYSeriesData | LineSeriesData - | AreaSeriesData; + | AreaSeriesData + | TreemapSeriesData; export type DataLabelRendererData = { data: ChartKitWidgetSeriesData; @@ -64,7 +67,6 @@ export type BasicInactiveState = { }; export type ChartKitWidgetSeriesOptions = { - // todo /** Individual data label for each point. */ dataLabels?: { /** Enable or disable the data labels */ @@ -216,4 +218,11 @@ export type ChartKitWidgetSeriesOptions = { /** Options for the point markers of line series */ marker?: PointMarkerOptions; }; + treemap?: { + /** Options for the series states that provide additional styling information to the series. */ + states?: { + hover?: BasicHoverState; + inactive?: BasicInactiveState; + }; + }; }; diff --git a/src/types/widget-data/tooltip.ts b/src/types/widget-data/tooltip.ts index cf07d92d..82457b08 100644 --- a/src/types/widget-data/tooltip.ts +++ b/src/types/widget-data/tooltip.ts @@ -4,6 +4,7 @@ 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'; +import type {TreemapSeries, TreemapSeriesData} from './treemap'; export type TooltipDataChunkBarX = { data: BarXSeriesData; @@ -51,13 +52,19 @@ export type TooltipDataChunkArea = { }; }; +export type TooltipDataChunkTreemap = { + data: TreemapSeriesData; + series: TreemapSeries; +}; + export type TooltipDataChunk = | TooltipDataChunkBarX | TooltipDataChunkBarY | TooltipDataChunkPie | TooltipDataChunkScatter | TooltipDataChunkLine - | TooltipDataChunkArea; + | TooltipDataChunkArea + | TooltipDataChunkTreemap; export type ChartKitWidgetTooltip = { enabled?: boolean; diff --git a/src/types/widget-data/treemap.ts b/src/types/widget-data/treemap.ts new file mode 100644 index 00000000..b26282ec --- /dev/null +++ b/src/types/widget-data/treemap.ts @@ -0,0 +1,40 @@ +import {LayoutAlgorithm, SeriesType} from '../../constants'; +import type {BaseSeries, BaseSeriesData} from './base'; +import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; + +export type TreemapSeriesData = BaseSeriesData & { + /** The name of the node (used in legend, tooltip etc). */ + name: string; + /** The value of the node. All nodes should have this property except nodes that have children. */ + value?: number; + /** An id for the node. Used to group children. */ + id?: string; + /** + * Parent id. Used to build a tree structure. The value should be the id of the node which is the parent. + * If no nodes has a matching id, or this option is undefined, then the parent will be set to the root. + */ + parentId?: string; +}; + +export type TreemapSeries = BaseSeries & { + type: typeof SeriesType.Treemap; + data: TreemapSeriesData[]; + /** The name of the series (used in legend, tooltip etc). */ + name: string; + /** The main color of the series (hex, rgba). */ + color?: string; + /** Individual series legend options. Has higher priority than legend options in widget data. */ + legend?: ChartKitWidgetLegend & { + symbol?: RectLegendSymbolOptions; + }; + /** Set options on specific levels. Takes precedence over series options, but not point options. */ + levels?: { + /** Decides which level takes effect from the options set in the levels object. */ + index: number; + /** Can set the padding between all points which lies on the same level. */ + padding?: number; + /** Can set a color on all points which lies on the same level. */ + color?: string; + }[]; + layoutAlgorithm?: `${LayoutAlgorithm}`; +};