Skip to content

Commit

Permalink
Add shapes support for scatter chart (#380)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
artemipanchuk authored Jan 12, 2024
1 parent e69921b commit d0ec520
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 29 deletions.
8 changes: 8 additions & 0 deletions src/constants/widget-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 25 additions & 1 deletion src/plugins/d3/renderer/components/Legend.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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,
PreparedLegend,
PreparedSeries,
LegendItem,
LegendConfig,
SymbolLegendSymbol,
} from '../hooks';

import {getLineDashArray} from '../hooks/useShapes/utils';
Expand Down Expand Up @@ -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;
}
}
});
}
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/d3/renderer/components/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
&_shape_path#{&}_unselected {
stroke: var(--g-color-text-hint);
}

&_shape_symbol#{&}_unselected {
fill: var(--g-color-text-hint);
}
}

&__item-text {
Expand Down
25 changes: 18 additions & 7 deletions src/plugins/d3/renderer/hooks/useSeries/prepareSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,18 +29,23 @@ type PrepareAxisRelatedSeriesArgs = {
colorScale: ScaleOrdinal<string, string>;
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];
Expand Down Expand Up @@ -67,8 +76,10 @@ export function prepareSeries(args: {
return prepareBarYSeries({series: series as BarYSeries[], legend, colorScale});
}
case 'scatter': {
return series.reduce<PreparedSeries[]>((acc, singleSeries) => {
acc.push(...prepareAxisRelatedSeries({series: singleSeries, legend, colorScale}));
return series.reduce<PreparedSeries[]>((acc, singleSeries, index) => {
acc.push(
...prepareAxisRelatedSeries({series: singleSeries, legend, colorScale, index}),
);
return acc;
}, []);
}
Expand Down
11 changes: 9 additions & 2 deletions src/plugins/d3/renderer/hooks/useSeries/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,7 +31,12 @@ export type PathLegendSymbol = {
strokeWidth: number;
} & Required<PathLegendSymbolOptions>;

export type PreparedLegendSymbol = RectLegendSymbol | PathLegendSymbol;
export type SymbolLegendSymbol = {
shape: 'symbol';
symbolType: SymbolType;
} & Required<SymbolLegendSymbolOptions>;

export type PreparedLegendSymbol = RectLegendSymbol | PathLegendSymbol | SymbolLegendSymbol;

export type PreparedLegend = Required<ChartKitWidgetLegend> & {
height: number;
Expand Down Expand Up @@ -79,6 +85,7 @@ type BasePreparedSeries = {
export type PreparedScatterSeries = {
type: ScatterSeries['type'];
data: ScatterSeriesData[];
symbolType: SymbolType;
} & BasePreparedSeries;

export type PreparedBarXSeries = {
Expand Down
18 changes: 10 additions & 8 deletions src/plugins/d3/renderer/hooks/useSeries/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>((acc, s) => {
Expand All @@ -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,
};
}
Expand Down
27 changes: 18 additions & 9 deletions src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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<TooltipDataChunkScatter, 'series'> & {
cx: number;
cy: number;
series: PreparedScatterSeries;
hovered: boolean;
active: boolean;
id: number;
size: number;
};

const getFilteredLinearScatterData = (data: ScatterSeriesData[]) => {
Expand All @@ -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,
Expand All @@ -41,6 +47,7 @@ export const prepareScatterData = (args: {
hovered: false,
active: true,
id: acc.length - 1,
size,
});
});

Expand Down
1 change: 1 addition & 0 deletions src/plugins/d3/renderer/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
41 changes: 41 additions & 0 deletions src/plugins/d3/renderer/utils/symbol.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
9 changes: 9 additions & 0 deletions src/types/widget-data/legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
4 changes: 2 additions & 2 deletions src/types/widget-data/scatter.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -34,7 +34,7 @@ export type ScatterSeries<T = any> = 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 */
Expand Down

0 comments on commit d0ec520

Please sign in to comment.