From d0ec520897f397da9319c6dcd5250b4f6622ec87 Mon Sep 17 00:00:00 2001 From: "Artem I. Panchuk" Date: Fri, 12 Jan 2024 14:47:33 +0300 Subject: [PATCH] Add shapes support for scatter chart (#380) * Add basics for shapes support for scatter chart * Add triangleDown symbol and legend symbols support * Fix default symbol for non-linear legend * Add point size support, more readable size handling * Fix update issue * Fix unselected style * Fix appending paths * Add series index to preserve scatter symbol style * Fix index type * Fix PR issues * Fix SymbolType everywhere * Remove index from prepared series base type * Fix position issue * Fix ScatterSeries type * Fix triangle down draw function * Fix symbol size constant --- src/constants/widget-data.ts | 8 ++++ src/plugins/d3/renderer/components/Legend.tsx | 26 +++++++++++- .../d3/renderer/components/styles.scss | 4 ++ .../renderer/hooks/useSeries/prepareSeries.ts | 25 +++++++---- .../d3/renderer/hooks/useSeries/types.ts | 11 ++++- .../d3/renderer/hooks/useSeries/utils.ts | 18 ++++---- .../hooks/useShapes/scatter/index.tsx | 27 ++++++++---- .../hooks/useShapes/scatter/prepare-data.ts | 7 ++++ src/plugins/d3/renderer/utils/index.ts | 1 + src/plugins/d3/renderer/utils/symbol.ts | 41 +++++++++++++++++++ src/types/widget-data/legend.ts | 9 ++++ src/types/widget-data/scatter.ts | 4 +- 12 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 src/plugins/d3/renderer/utils/symbol.ts diff --git a/src/constants/widget-data.ts b/src/constants/widget-data.ts index 131c6804..3fbbe560 100644 --- a/src/constants/widget-data.ts +++ b/src/constants/widget-data.ts @@ -21,6 +21,14 @@ export enum DashStyle { Solid = 'Solid', } +export enum SymbolType { + Circle = 'circle', + Diamond = 'diamond', + Square = 'square', + Triangle = 'triangle', + TriangleDown = 'triangle-down', +} + export enum LineCap { Butt = 'butt', Round = 'round', diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index bc2db53f..3cb05cbc 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import {BaseType, select, line as lineGenerator} from 'd3'; +import {symbol, BaseType, select, line as lineGenerator} from 'd3'; import type {Selection} from 'd3'; +import {getSymbol} from '../utils'; import {block} from '../../../../utils/cn'; import type { OnLegendItemClick, @@ -9,6 +10,7 @@ import type { PreparedSeries, LegendItem, LegendConfig, + SymbolLegendSymbol, } from '../hooks'; import {getLineDashArray} from '../hooks/useShapes/utils'; @@ -164,6 +166,28 @@ function renderLegendSymbol(args: { break; } + case 'symbol': { + const y = legend.lineHeight / 2; + + element + .append('svg:path') + .attr('d', () => { + const scatterSymbol = getSymbol( + (d.symbol as SymbolLegendSymbol).symbolType, + ); + + // D3 takes size as square pixels, so we need to make square pixels size by multiplying + // https://d3js.org/d3-shape/symbol#symbol + return symbol(scatterSymbol, d.symbol.width * d.symbol.width)(); + }) + .attr('transform', () => { + return 'translate(' + x + ',' + y + ')'; + }) + .attr('class', className) + .style('fill', color); + + break; + } } }); } diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index 9ef8b469..28083445 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -32,6 +32,10 @@ &_shape_path#{&}_unselected { stroke: var(--g-color-text-hint); } + + &_shape_symbol#{&}_unselected { + fill: var(--g-color-text-hint); + } } &__item-text { diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts index 283e2ee3..781b613f 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts @@ -11,8 +11,12 @@ import type { LineSeries, PieSeries, } from '../../../../../types'; +import {SymbolType} from '../../../../../constants'; -import type {PreparedLegend, PreparedSeries} from './types'; +import {getSymbolType} from '../../utils'; +import {ScatterSeries} from '../../../../../types/widget-data'; + +import type {PreparedLegend, PreparedSeries, PreparedScatterSeries} from './types'; import {prepareLineSeries} from './prepare-line-series'; import {prepareBarXSeries} from './prepare-bar-x'; import {prepareBarYSeries} from './prepare-bar-y'; @@ -25,18 +29,23 @@ type PrepareAxisRelatedSeriesArgs = { colorScale: ScaleOrdinal; series: ChartKitWidgetSeries; legend: PreparedLegend; + index: number; }; -function prepareAxisRelatedSeries(args: PrepareAxisRelatedSeriesArgs): PreparedSeries[] { - const {colorScale, series, legend} = args; - const preparedSeries = cloneDeep(series) as PreparedSeries; +function prepareAxisRelatedSeries(args: PrepareAxisRelatedSeriesArgs): PreparedScatterSeries[] { + const {colorScale, series, legend, index} = args; + const preparedSeries = cloneDeep(series) as PreparedScatterSeries; const name = 'name' in series && series.name ? series.name : ''; + + const symbolType = ((series as ScatterSeries).symbolType || getSymbolType(index)) as SymbolType; + + preparedSeries.symbolType = symbolType; preparedSeries.color = 'color' in series && series.color ? series.color : colorScale(name); preparedSeries.name = name; preparedSeries.visible = get(preparedSeries, 'visible', true); preparedSeries.legend = { enabled: get(preparedSeries, 'legend.enabled', legend.enabled), - symbol: prepareLegendSymbol(series), + symbol: prepareLegendSymbol(series, symbolType), }; return [preparedSeries]; @@ -67,8 +76,10 @@ export function prepareSeries(args: { return prepareBarYSeries({series: series as BarYSeries[], legend, colorScale}); } case 'scatter': { - return series.reduce((acc, singleSeries) => { - acc.push(...prepareAxisRelatedSeries({series: singleSeries, legend, colorScale})); + return series.reduce((acc, singleSeries, index) => { + acc.push( + ...prepareAxisRelatedSeries({series: singleSeries, legend, colorScale, index}), + ); return acc; }, []); } diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index 845def58..5c582122 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -15,11 +15,12 @@ import { ConnectorShape, ConnectorCurve, PathLegendSymbolOptions, + SymbolLegendSymbolOptions, AreaSeries, AreaSeriesData, } from '../../../../../types'; import type {SeriesOptionsDefaults} from '../../constants'; -import {DashStyle, LineCap} from '../../../../../constants'; +import {DashStyle, LineCap, SymbolType} from '../../../../../constants'; export type RectLegendSymbol = { shape: 'rect'; @@ -30,7 +31,12 @@ export type PathLegendSymbol = { strokeWidth: number; } & Required; -export type PreparedLegendSymbol = RectLegendSymbol | PathLegendSymbol; +export type SymbolLegendSymbol = { + shape: 'symbol'; + symbolType: SymbolType; +} & Required; + +export type PreparedLegendSymbol = RectLegendSymbol | PathLegendSymbol | SymbolLegendSymbol; export type PreparedLegend = Required & { height: number; @@ -79,6 +85,7 @@ type BasePreparedSeries = { export type PreparedScatterSeries = { type: ScatterSeries['type']; data: ScatterSeriesData[]; + symbolType: SymbolType; } & BasePreparedSeries; export type PreparedBarXSeries = { diff --git a/src/plugins/d3/renderer/hooks/useSeries/utils.ts b/src/plugins/d3/renderer/hooks/useSeries/utils.ts index 1c6b2dd3..dcb85456 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/utils.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/utils.ts @@ -1,8 +1,9 @@ import memoize from 'lodash/memoize'; import {PreparedLegendSymbol, PreparedSeries, StackedSeries} from './types'; -import {ChartKitWidgetSeries, RectLegendSymbolOptions} from '../../../../../types'; -import {DEFAULT_LEGEND_SYMBOL_PADDING, DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; +import {ChartKitWidgetSeries} from '../../../../../types'; import {getRandomCKId} from '../../../../../utils'; +import {DEFAULT_LEGEND_SYMBOL_PADDING, DEFAULT_LEGEND_SYMBOL_SIZE} from './constants'; +import {SymbolType} from '../../../../../constants'; export const getActiveLegendItems = (series: PreparedSeries[]) => { return series.reduce((acc, s) => { @@ -18,15 +19,16 @@ export const getAllLegendItems = (series: PreparedSeries[]) => { return series.map((s) => s.name); }; -export function prepareLegendSymbol(series: ChartKitWidgetSeries): PreparedLegendSymbol { - const symbolOptions: RectLegendSymbolOptions = series.legend?.symbol || {}; - const symbolHeight = symbolOptions?.height || DEFAULT_LEGEND_SYMBOL_SIZE; +export function prepareLegendSymbol( + series: ChartKitWidgetSeries, + symbolType?: SymbolType, +): PreparedLegendSymbol { + const symbolOptions = series.legend?.symbol || {}; return { - shape: 'rect', + shape: 'symbol', + symbolType: symbolType || SymbolType.Circle, width: symbolOptions?.width || DEFAULT_LEGEND_SYMBOL_SIZE, - height: symbolHeight, - radius: symbolOptions?.radius || symbolHeight / 2, padding: symbolOptions?.padding || DEFAULT_LEGEND_SYMBOL_PADDING, }; } diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx index 097a4191..9ff7b563 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -1,15 +1,16 @@ import React from 'react'; import get from 'lodash/get'; -import {color, pointer, select} from 'd3'; +import {symbol, color, pointer, select} from 'd3'; import type {BaseType, Dispatch, Selection} from 'd3'; import {block} from '../../../../../../utils/cn'; -import {extractD3DataFromNode, isNodeContainsD3Data} from '../../../utils'; +import {extractD3DataFromNode, isNodeContainsD3Data, getSymbol} from '../../../utils'; import type {NodeWithD3Data} from '../../../utils'; import {PreparedSeriesOptions} from '../../useSeries/types'; import type {PreparedScatterData} from './prepare-data'; import {shapeKey} from '../utils'; +import {SymbolType} from '../../../../../../constants'; export {prepareScatterData} from './prepare-data'; export type {PreparedScatterData} from './prepare-data'; @@ -22,7 +23,7 @@ type ScatterSeriesShapeProps = { }; const b = block('d3-scatter'); -const DEFAULT_SCATTER_POINT_RADIUS = 4; + const EMPTY_SELECTION = null as unknown as Selection< BaseType, PreparedScatterData, @@ -48,17 +49,25 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) { const inactiveOptions = get(seriesOptions, 'scatter.states.inactive'); const selection = svgElement - .selectAll('circle') + .selectAll('path') .data(preparedData, shapeKey) .join( - (enter) => enter.append('circle').attr('class', b('point')), + (enter) => enter.append('path').attr('class', b('point')), (update) => update, (exit) => exit.remove(), ) - .attr('fill', (d) => d.data.color || d.series.color || '') - .attr('r', (d) => d.data.radius || DEFAULT_SCATTER_POINT_RADIUS) - .attr('cx', (d) => d.cx) - .attr('cy', (d) => d.cy); + .attr('d', (d) => { + const symbolType = d.series.symbolType || SymbolType.Circle; + const scatterSymbol = getSymbol(symbolType); + + // D3 takes size as square pixels, so we need to make square pixels size by multiplying + // https://d3js.org/d3-shape/symbol#symbol + return symbol(scatterSymbol, d.size * d.size)(); + }) + .attr('transform', (d) => { + return 'translate(' + d.cx + ',' + d.cy + ')'; + }) + .attr('fill', (d) => d.data.color || d.series.color || ''); svgElement .on('mousemove', (e) => { diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts index 991415c0..cf12cabf 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/prepare-data.ts @@ -5,6 +5,8 @@ import type {PreparedAxis} from '../../useChartOptions/types'; import {PreparedScatterSeries} from '../../useSeries/types'; import {getXValue, getYValue} from '../utils'; +const DEFAULT_SCATTER_POINT_SIZE = 7; + export type PreparedScatterData = Omit & { cx: number; cy: number; @@ -12,6 +14,7 @@ export type PreparedScatterData = Omit & { hovered: boolean; active: boolean; id: number; + size: number; }; const getFilteredLinearScatterData = (data: ScatterSeriesData[]) => { @@ -32,7 +35,10 @@ export const prepareScatterData = (args: { xAxis.type === 'category' || yAxis.type === 'category' ? s.data : getFilteredLinearScatterData(s.data); + filteredData.forEach((d) => { + const size = d.radius ? d.radius * 2 : DEFAULT_SCATTER_POINT_SIZE; + acc.push({ data: d, series: s, @@ -41,6 +47,7 @@ export const prepareScatterData = (args: { hovered: false, active: true, id: acc.length - 1, + size, }); }); diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 057cecb3..2e2c506c 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -20,6 +20,7 @@ export * from './text'; export * from './time'; export * from './axis'; export * from './labels'; +export * from './symbol'; const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie']; diff --git a/src/plugins/d3/renderer/utils/symbol.ts b/src/plugins/d3/renderer/utils/symbol.ts new file mode 100644 index 00000000..12df24ca --- /dev/null +++ b/src/plugins/d3/renderer/utils/symbol.ts @@ -0,0 +1,41 @@ +import {symbolDiamond2, symbolCircle, symbolSquare, symbolTriangle2} from 'd3'; + +import {SymbolType} from '../../../../constants'; + +export const getSymbolType = (index: number) => { + const scatterStyles = Object.values(SymbolType); + + return scatterStyles[index % scatterStyles.length]; +}; + +// This is an inverted triangle +// Based on https://github.com/d3/d3-shape/blob/main/src/symbol/triangle2.js +const sqrt3 = Math.sqrt(3); +const triangleDown = { + draw: (context: CanvasPath, size: number) => { + const s = Math.sqrt(size) * 0.6824; + const t = s / 2; + const u = (s * sqrt3) / 2; + context.moveTo(0, s); + context.lineTo(u, -t); + context.lineTo(-u, -t); + context.closePath(); + }, +}; + +export const getSymbol = (symbolType: SymbolType) => { + switch (symbolType) { + case SymbolType.Diamond: + return symbolDiamond2; + case SymbolType.Circle: + return symbolCircle; + case SymbolType.Square: + return symbolSquare; + case SymbolType.Triangle: + return symbolTriangle2; + case SymbolType.TriangleDown: + return triangleDown; + default: + return symbolCircle; + } +}; diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index 528f9a2c..be6f50a2 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -66,3 +66,12 @@ export type PathLegendSymbolOptions = BaseLegendSymbol & { * */ width?: number; }; + +export type SymbolLegendSymbolOptions = BaseLegendSymbol & { + /** + * The pixel width of the symbol for series types that use a symbol in the legend + * + * @default 8 + * */ + width?: number; +}; diff --git a/src/types/widget-data/scatter.ts b/src/types/widget-data/scatter.ts index c543a123..5036e365 100644 --- a/src/types/widget-data/scatter.ts +++ b/src/types/widget-data/scatter.ts @@ -1,4 +1,4 @@ -import {SeriesType} from '../../constants'; +import {SeriesType, SymbolType} from '../../constants'; import type {BaseSeries, BaseSeriesData} from './base'; import type {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; @@ -34,7 +34,7 @@ export type ScatterSeries = BaseSeries & { /** The main color of the series (hex, rgba) */ color?: string; /** A predefined shape or symbol for the dot */ - symbol?: string; + symbolType?: `${SymbolType}`; // yAxisIndex?: number; /** Individual series legend options. Has higher priority than legend options in widget data */