From 76c275e83fdc13d7d48b263a17f771bbd9a146fe Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Tue, 6 Feb 2024 15:25:03 +0300 Subject: [PATCH 1/5] feat(D3 plugin): add treemap chart --- src/constants/widget-data.ts | 9 + .../treemap/Playground.stories.tsx | 68 +++++++ src/plugins/d3/renderer/components/Chart.tsx | 2 - .../components/Tooltip/DefaultContent.tsx | 11 +- .../constants/defaults/series-options.ts | 12 ++ .../d3/renderer/hooks/useSeries/index.ts | 4 +- .../hooks/useSeries/prepare-treemap.ts | 48 +++++ .../renderer/hooks/useSeries/prepareSeries.ts | 12 +- .../d3/renderer/hooks/useSeries/types.ts | 20 +- .../d3/renderer/hooks/useShapes/index.tsx | 23 ++- .../d3/renderer/hooks/useShapes/styles.scss | 9 + .../hooks/useShapes/treemap/index.tsx | 188 ++++++++++++++++++ .../hooks/useShapes/treemap/prepare-data.ts | 34 ++++ .../renderer/hooks/useShapes/treemap/types.ts | 20 ++ .../renderer/hooks/useShapes/treemap/utils.ts | 12 ++ src/plugins/d3/renderer/utils/index.ts | 2 +- src/plugins/d3/renderer/validation/index.ts | 28 +++ src/types/widget-data/index.ts | 1 + src/types/widget-data/series.ts | 15 +- src/types/widget-data/tooltip.ts | 9 +- src/types/widget-data/treemap.ts | 46 +++++ 21 files changed, 556 insertions(+), 17 deletions(-) create mode 100644 src/plugins/d3/__stories__/treemap/Playground.stories.tsx create mode 100644 src/plugins/d3/renderer/hooks/useSeries/prepare-treemap.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx create mode 100644 src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts create mode 100644 src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts create mode 100644 src/types/widget-data/treemap.ts 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/plugins/d3/__stories__/treemap/Playground.stories.tsx b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx new file mode 100644 index 00000000..44fc2315 --- /dev/null +++ b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx @@ -0,0 +1,68 @@ +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['series']['data'] => { + return [ + { + 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, parent: 'Four'}, + {name: 'Four-2', parent: 'Four'}, + {name: 'Four-3', value: 4, parent: 'Four'}, + {name: 'Four-2-1', value: 5, parent: 'Four-2'}, + {name: 'Four-2-2', value: 7, parent: 'Four-2'}, + {name: 'Four-2-3', value: 10, parent: 'Four-2'}, + ], + }, + ]; +}; + +const ChartStory = ({data}: {data: ChartKitWidgetData['series']['data']}) => { + const [shown, setShown] = React.useState(false); + const chartkitRef = React.useRef(); + const widgetData: ChartKitWidgetData = { + series: {data}, + }; + + 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..4e6ebd96 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, 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..c0c3a5c8 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, + }); + 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..3bff825f --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -0,0 +1,188 @@ +import React from 'react'; +import { + color, + pointer, + select, + treemap, + treemapBinary, + treemapDice, + treemapSlice, + treemapSliceDice, + treemapSquarify, +} from 'd3'; +import type {BaseType, Dispatch, HierarchyRectangularNode} from 'd3'; +import get from 'lodash/get'; + +import {LayoutAlgorithm} from '../../../../../../constants'; +import type {TooltipDataChunkTreemap} from '../../../../../../types'; +import {setEllipsisForOverflowTexts} from '../../../utils'; +import {block} from '../../../../../../utils/cn'; + +import {PreparedSeriesOptions} from '../../useSeries/types'; +import type {PreparedTreemapData, PreparedTreemapSeriesData, TreemapLabelData} from './types'; +import {getLabelData} from './utils'; + +const b = block('d3-treemap'); +const DEFAULT_PADDING = 1; + +type ShapeProps = { + dispatcher: Dispatch; + preparedData: PreparedTreemapData; + seriesOptions: PreparedSeriesOptions; + svgContainer: SVGSVGElement | null; + width: number; + height: number; +}; + +export const TreemapSeriesShape = (props: ShapeProps) => { + const {dispatcher, preparedData, seriesOptions, svgContainer, width, height} = props; + const ref = React.useRef(null); + + React.useEffect(() => { + if (!ref.current) { + return () => {}; + } + + const svgElement = select(ref.current); + svgElement.selectAll('*').remove(); + const {hierarchy, series} = preparedData; + 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.sum((d) => d.value || 0)); + + const leaf = svgElement + .selectAll('g') + .data(root.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 labelData: TreemapLabelData[] = series.dataLabels?.enabled + ? getLabelData(leaf.data()) + : []; + 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< + BaseType, + HierarchyRectangularNode + >(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 selectedId = (data?.[0].data as PreparedTreemapSeriesData | undefined)?._nodeId; + rectSelection.datum((d, index, list) => { + const currentRect = select< + BaseType, + HierarchyRectangularNode + >(list[index]); + const hovered = Boolean(hoverEnabled && d.data._nodeId === selectedId); + const inactive = Boolean(inactiveEnabled && selectedId && !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 && d.id === selectedId); + const inactive = Boolean(inactiveEnabled && selectedId && !hovered); + currentLabel.attr('opacity', () => { + if (inactive) { + return inactiveOptions?.opacity || null; + } + return null; + }); + return d; + }); + }); + + return () => { + dispatcher.on(eventName, null); + }; + }, [dispatcher, preparedData, seriesOptions, svgContainer, width, height]); + + 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..290a43a3 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts @@ -0,0 +1,34 @@ +import {stratify} from 'd3'; + +import {getRandomCKId} from '../../../../../../utils'; + +import type {PreparedTreemapSeries} from '../../useSeries/types'; +import type {PreparedTreemapData, PreparedTreemapSeriesData} from './types'; + +export function prepareTreemapData(args: {series: PreparedTreemapSeries}): PreparedTreemapData { + const {series} = args; + const dataWithRootNode = getSeriesDataWithRootNode(series); + const hierarchy = stratify() + .id((d) => d.id || d.name) + .parentId((d) => d.parent)(dataWithRootNode); + + return {hierarchy, series}; +} + +function getSeriesDataWithRootNode(series: PreparedTreemapSeries) { + return series.data.reduce( + (acc, d) => { + const dataChunk = Object.assign({_nodeId: getRandomCKId()}, d); + + if (!dataChunk.parent) { + dataChunk.parent = series.id; + } + + acc.push(dataChunk); + + return acc; + }, + // We do not need _nodeId in root + [{name: series.name, id: series.id} as PreparedTreemapSeriesData], + ); +} 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..9cf2d74f --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts @@ -0,0 +1,20 @@ +import type {HierarchyNode} from 'd3'; +import {TreemapSeriesData} from '../../../../../../types'; +import {PreparedTreemapSeries} from '../../useSeries/types'; + +export type PreparedTreemapSeriesData = TreemapSeriesData & { + _nodeId: string; +}; + +export type PreparedTreemapData = { + hierarchy: HierarchyNode; + series: PreparedTreemapSeries; +}; + +export type TreemapLabelData = { + id: string; + text: string; + x: number; + y: number; + width: number; +}; diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts b/src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts new file mode 100644 index 00000000..34aa0574 --- /dev/null +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts @@ -0,0 +1,12 @@ +import type {HierarchyRectangularNode} from 'd3'; + +import type {TreemapLabelData, PreparedTreemapSeriesData} from './types'; + +export function getLabelData( + data: HierarchyRectangularNode[], +): TreemapLabelData[] { + return data.map((d) => { + const text = d.data.name; + return {text, id: d.data._nodeId, x: d.x0, y: d.y0, width: d.x1 - d.x0}; + }); +} 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/index.ts b/src/plugins/d3/renderer/validation/index.ts index f8d18df6..07d2fc2c 100644 --- a/src/plugins/d3/renderer/validation/index.ts +++ b/src/plugins/d3/renderer/validation/index.ts @@ -172,6 +172,22 @@ const validateSeries = (args: { } }; +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 +203,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..5a563a71 --- /dev/null +++ b/src/types/widget-data/treemap.ts @@ -0,0 +1,46 @@ +import {LayoutAlgorithm, SeriesType} from '../../constants'; +import type {BaseSeries, BaseSeriesData} from './base'; +import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; + +export type TreemapSeriesData = BaseSeriesData & { + /** The name of the point (used in legend, tooltip etc). */ + name: string; + /** + * The value of the point. + * + * Note: don't set this value for node with children, еhis may lead to incorrect chart display. + * */ + value?: number; + /** Initial visibility of the point. */ + visible?: boolean; + /** An id for the point. Used to group child points. */ + id?: string; + /** + * Parent id. Used to build a tree structure. The value should be the id of the point which is the parent. + * If no points has a matching id, or this option is undefined, then the parent will be set to the root. + */ + parent?: 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}`; +}; From 5fe8c37fb076696579bf42fff81de53400dd4292 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Tue, 6 Feb 2024 15:29:14 +0300 Subject: [PATCH 2/5] fix: typo fixes --- src/types/widget-data/treemap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/widget-data/treemap.ts b/src/types/widget-data/treemap.ts index 5a563a71..775be4e9 100644 --- a/src/types/widget-data/treemap.ts +++ b/src/types/widget-data/treemap.ts @@ -8,7 +8,7 @@ export type TreemapSeriesData = BaseSeriesData & { /** * The value of the point. * - * Note: don't set this value for node with children, еhis may lead to incorrect chart display. + * Note: don't set this property for node with children, this may lead to incorrect chart display. * */ value?: number; /** Initial visibility of the point. */ From f9d71b94d877d5d8e558d54a3855e92239a86209 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 7 Feb 2024 00:32:34 +0300 Subject: [PATCH 3/5] fix: review fixes --- src/i18n/keysets/en.json | 4 +- src/i18n/keysets/ru.json | 4 +- .../treemap/Playground.stories.tsx | 12 +++--- .../hooks/useShapes/treemap/index.tsx | 2 +- .../hooks/useShapes/treemap/prepare-data.ts | 7 ++-- .../validation/__tests__/validation.test.ts | 41 +++++++++++++++++++ src/plugins/d3/renderer/validation/index.ts | 37 +++++++++++++++++ src/types/widget-data/treemap.ts | 4 +- 8 files changed, 96 insertions(+), 15 deletions(-) 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 index 44fc2315..68bacbfe 100644 --- a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx @@ -22,12 +22,12 @@ const prepareData = (): ChartKitWidgetData['series']['data'] => { {name: 'Two', value: 10}, {name: 'Three', value: 15}, {name: 'Four'}, - {name: 'Four-1', value: 5, parent: 'Four'}, - {name: 'Four-2', parent: 'Four'}, - {name: 'Four-3', value: 4, parent: 'Four'}, - {name: 'Four-2-1', value: 5, parent: 'Four-2'}, - {name: 'Four-2-2', value: 7, parent: 'Four-2'}, - {name: 'Four-2-3', value: 10, parent: 'Four-2'}, + {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'}, ], }, ]; diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx index 3bff825f..4f39db46 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -74,7 +74,7 @@ export const TreemapSeriesShape = (props: ShapeProps) => { 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.sum((d) => d.value || 0)); + })(hierarchy); const leaf = svgElement .selectAll('g') diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts index 290a43a3..debdab6f 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts @@ -10,7 +10,8 @@ export function prepareTreemapData(args: {series: PreparedTreemapSeries}): Prepa const dataWithRootNode = getSeriesDataWithRootNode(series); const hierarchy = stratify() .id((d) => d.id || d.name) - .parentId((d) => d.parent)(dataWithRootNode); + .parentId((d) => d.parentId)(dataWithRootNode) + .sum((d) => d.value || 0); return {hierarchy, series}; } @@ -20,8 +21,8 @@ function getSeriesDataWithRootNode(series: PreparedTreemapSeries) { (acc, d) => { const dataChunk = Object.assign({_nodeId: getRandomCKId()}, d); - if (!dataChunk.parent) { - dataChunk.parent = series.id; + if (!dataChunk.parentId) { + dataChunk.parentId = series.id; } acc.push(dataChunk); 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 07d2fc2c..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,6 +201,10 @@ const validateSeries = (args: { } case 'pie': { validatePieSeries({series}); + break; + } + case 'treemap': { + validateTreemapSeries({series}); } } }; diff --git a/src/types/widget-data/treemap.ts b/src/types/widget-data/treemap.ts index 775be4e9..0dadc157 100644 --- a/src/types/widget-data/treemap.ts +++ b/src/types/widget-data/treemap.ts @@ -11,15 +11,13 @@ export type TreemapSeriesData = BaseSeriesData & { * Note: don't set this property for node with children, this may lead to incorrect chart display. * */ value?: number; - /** Initial visibility of the point. */ - visible?: boolean; /** An id for the point. Used to group child points. */ id?: string; /** * Parent id. Used to build a tree structure. The value should be the id of the point which is the parent. * If no points has a matching id, or this option is undefined, then the parent will be set to the root. */ - parent?: string; + parentId?: string; }; export type TreemapSeries = BaseSeries & { From cd55353e6e0bfb4704aca14717f39c0dac457616 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 7 Feb 2024 00:39:12 +0300 Subject: [PATCH 4/5] fix: fix jsdoc --- src/types/widget-data/treemap.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/types/widget-data/treemap.ts b/src/types/widget-data/treemap.ts index 0dadc157..b26282ec 100644 --- a/src/types/widget-data/treemap.ts +++ b/src/types/widget-data/treemap.ts @@ -3,19 +3,15 @@ import type {BaseSeries, BaseSeriesData} from './base'; import {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; export type TreemapSeriesData = BaseSeriesData & { - /** The name of the point (used in legend, tooltip etc). */ + /** The name of the node (used in legend, tooltip etc). */ name: string; - /** - * The value of the point. - * - * Note: don't set this property for node with children, this may lead to incorrect chart display. - * */ + /** The value of the node. All nodes should have this property except nodes that have children. */ value?: number; - /** An id for the point. Used to group child points. */ + /** 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 point which is the parent. - * If no points has a matching id, or this option is undefined, then the parent will be set to the root. + * 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; }; From ff997d70efe79c03dd6c0eac336cb22deac93610 Mon Sep 17 00:00:00 2001 From: Evgeny Alaev Date: Wed, 7 Feb 2024 19:10:43 +0300 Subject: [PATCH 5/5] fix: review fixes 2 --- .../treemap/Playground.stories.tsx | 53 +++++------ .../d3/renderer/hooks/useSeries/index.ts | 5 +- .../d3/renderer/hooks/useShapes/index.tsx | 4 +- .../hooks/useShapes/treemap/index.tsx | 87 ++++--------------- .../hooks/useShapes/treemap/prepare-data.ts | 82 ++++++++++++++--- .../renderer/hooks/useShapes/treemap/types.ts | 22 +++-- .../renderer/hooks/useShapes/treemap/utils.ts | 12 --- 7 files changed, 132 insertions(+), 133 deletions(-) delete mode 100644 src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts diff --git a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx index 68bacbfe..ca332ad8 100644 --- a/src/plugins/d3/__stories__/treemap/Playground.stories.tsx +++ b/src/plugins/d3/__stories__/treemap/Playground.stories.tsx @@ -7,38 +7,39 @@ import type {ChartKitRef} from '../../../../types'; import type {ChartKitWidgetData} from '../../../../types/widget-data'; import {D3Plugin} from '../..'; -const prepareData = (): ChartKitWidgetData['series']['data'] => { - return [ - { - type: 'treemap', - name: 'Example', - dataLabels: { - enabled: true, - }, - layoutAlgorithm: 'binary', - levels: [{index: 1}, {index: 2}, {index: 3}], +const prepareData = (): ChartKitWidgetData => { + return { + series: { 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'}, + { + 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['series']['data']}) => { +const ChartStory = ({data}: {data: ChartKitWidgetData}) => { const [shown, setShown] = React.useState(false); const chartkitRef = React.useRef(); - const widgetData: ChartKitWidgetData = { - series: {data}, - }; if (!shown) { settings.set({plugins: [D3Plugin]}); @@ -47,7 +48,7 @@ const ChartStory = ({data}: {data: ChartKitWidgetData['series']['data']}) => { return (
- +
); }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/index.ts b/src/plugins/d3/renderer/hooks/useSeries/index.ts index 4e6ebd96..06443317 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/index.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/index.ts @@ -86,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[]; @@ -94,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/useShapes/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/index.tsx index c0c3a5c8..f6f0f57d 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/index.tsx @@ -208,6 +208,8 @@ export const useShapes = (args: Args) => { // 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( { preparedData={preparedData} seriesOptions={seriesOptions} svgContainer={svgContainer} - width={boundsWidth} - height={boundsHeight} />, ); } diff --git a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx index 4f39db46..b7482c4e 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/index.tsx @@ -1,41 +1,26 @@ import React from 'react'; -import { - color, - pointer, - select, - treemap, - treemapBinary, - treemapDice, - treemapSlice, - treemapSliceDice, - treemapSquarify, -} from 'd3'; +import {color, pointer, select} from 'd3'; import type {BaseType, Dispatch, HierarchyRectangularNode} from 'd3'; import get from 'lodash/get'; -import {LayoutAlgorithm} from '../../../../../../constants'; -import type {TooltipDataChunkTreemap} from '../../../../../../types'; +import type {TooltipDataChunkTreemap, TreemapSeriesData} from '../../../../../../types'; import {setEllipsisForOverflowTexts} from '../../../utils'; import {block} from '../../../../../../utils/cn'; import {PreparedSeriesOptions} from '../../useSeries/types'; -import type {PreparedTreemapData, PreparedTreemapSeriesData, TreemapLabelData} from './types'; -import {getLabelData} from './utils'; +import type {PreparedTreemapData, TreemapLabelData} from './types'; const b = block('d3-treemap'); -const DEFAULT_PADDING = 1; type ShapeProps = { dispatcher: Dispatch; preparedData: PreparedTreemapData; seriesOptions: PreparedSeriesOptions; svgContainer: SVGSVGElement | null; - width: number; - height: number; }; export const TreemapSeriesShape = (props: ShapeProps) => { - const {dispatcher, preparedData, seriesOptions, svgContainer, width, height} = props; + const {dispatcher, preparedData, seriesOptions, svgContainer} = props; const ref = React.useRef(null); React.useEffect(() => { @@ -45,40 +30,10 @@ export const TreemapSeriesShape = (props: ShapeProps) => { const svgElement = select(ref.current); svgElement.selectAll('*').remove(); - const {hierarchy, series} = preparedData; - 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 {labelData, leaves, series} = preparedData; const leaf = svgElement .selectAll('g') - .data(root.leaves()) + .data(leaves) .join('g') .attr('transform', (d) => `translate(${d.x0},${d.y0})`); const rectSelection = leaf @@ -94,10 +49,6 @@ export const TreemapSeriesShape = (props: ShapeProps) => { }) .attr('width', (d) => d.x1 - d.x0) .attr('height', (d) => d.y1 - d.y0); - - const labelData: TreemapLabelData[] = series.dataLabels?.enabled - ? getLabelData(leaf.data()) - : []; const labelSelection = svgElement .selectAll('tspan') .data(labelData) @@ -116,10 +67,9 @@ export const TreemapSeriesShape = (props: ShapeProps) => { const inactiveOptions = get(seriesOptions, 'treemap.states.inactive'); svgElement .on('mousemove', (e) => { - const hoveredRect = select< - BaseType, - HierarchyRectangularNode - >(e.target); + const hoveredRect = select>( + e.target, + ); const datum = hoveredRect.datum(); dispatcher.call( 'hover-shape', @@ -135,14 +85,13 @@ export const TreemapSeriesShape = (props: ShapeProps) => { dispatcher.on(eventName, (data?: TooltipDataChunkTreemap[]) => { const hoverEnabled = hoverOptions?.enabled; const inactiveEnabled = inactiveOptions?.enabled; - const selectedId = (data?.[0].data as PreparedTreemapSeriesData | undefined)?._nodeId; + const hoveredData = data?.[0].data; rectSelection.datum((d, index, list) => { - const currentRect = select< - BaseType, - HierarchyRectangularNode - >(list[index]); - const hovered = Boolean(hoverEnabled && d.data._nodeId === selectedId); - const inactive = Boolean(inactiveEnabled && selectedId && !hovered); + 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); @@ -167,8 +116,8 @@ export const TreemapSeriesShape = (props: ShapeProps) => { }); labelSelection.datum((d, index, list) => { const currentLabel = select(list[index]); - const hovered = Boolean(hoverEnabled && d.id === selectedId); - const inactive = Boolean(inactiveEnabled && selectedId && !hovered); + const hovered = Boolean(hoverEnabled && hoveredData === d.nodeData); + const inactive = Boolean(inactiveEnabled && hoveredData && !hovered); currentLabel.attr('opacity', () => { if (inactive) { return inactiveOptions?.opacity || null; @@ -182,7 +131,7 @@ export const TreemapSeriesShape = (props: ShapeProps) => { return () => { dispatcher.on(eventName, null); }; - }, [dispatcher, preparedData, seriesOptions, svgContainer, width, height]); + }, [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 index debdab6f..305948bf 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/prepare-data.ts @@ -1,25 +1,86 @@ -import {stratify} from 'd3'; +import { + stratify, + treemap, + treemapBinary, + treemapDice, + treemapSlice, + treemapSliceDice, + treemapSquarify, +} from 'd3'; +import type {HierarchyRectangularNode} from 'd3'; -import {getRandomCKId} from '../../../../../../utils'; +import {LayoutAlgorithm} from '../../../../../../constants'; +import type {TreemapSeriesData} from '../../../../../../types'; import type {PreparedTreemapSeries} from '../../useSeries/types'; -import type {PreparedTreemapData, PreparedTreemapSeriesData} from './types'; +import type {PreparedTreemapData, TreemapLabelData} from './types'; -export function prepareTreemapData(args: {series: PreparedTreemapSeries}): PreparedTreemapData { - const {series} = args; +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() + 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 {hierarchy, series}; + return {labelData, leaves, series}; } function getSeriesDataWithRootNode(series: PreparedTreemapSeries) { - return series.data.reduce( + return series.data.reduce( (acc, d) => { - const dataChunk = Object.assign({_nodeId: getRandomCKId()}, d); + const dataChunk = Object.assign({}, d); if (!dataChunk.parentId) { dataChunk.parentId = series.id; @@ -29,7 +90,6 @@ function getSeriesDataWithRootNode(series: PreparedTreemapSeries) { return acc; }, - // We do not need _nodeId in root - [{name: series.name, id: series.id} as PreparedTreemapSeriesData], + [{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 index 9cf2d74f..d5fab99d 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/treemap/types.ts @@ -1,20 +1,18 @@ -import type {HierarchyNode} from 'd3'; -import {TreemapSeriesData} from '../../../../../../types'; -import {PreparedTreemapSeries} from '../../useSeries/types'; +import type {HierarchyRectangularNode} from 'd3'; -export type PreparedTreemapSeriesData = TreemapSeriesData & { - _nodeId: string; -}; - -export type PreparedTreemapData = { - hierarchy: HierarchyNode; - series: PreparedTreemapSeries; -}; +import type {TreemapSeriesData} from '../../../../../../types'; +import type {PreparedTreemapSeries} from '../../useSeries/types'; export type TreemapLabelData = { - id: string; 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/hooks/useShapes/treemap/utils.ts b/src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts deleted file mode 100644 index 34aa0574..00000000 --- a/src/plugins/d3/renderer/hooks/useShapes/treemap/utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {HierarchyRectangularNode} from 'd3'; - -import type {TreemapLabelData, PreparedTreemapSeriesData} from './types'; - -export function getLabelData( - data: HierarchyRectangularNode[], -): TreemapLabelData[] { - return data.map((d) => { - const text = d.data.name; - return {text, id: d.data._nodeId, x: d.x0, y: d.y0, width: d.x1 - d.x0}; - }); -}