Skip to content

Commit

Permalink
Add shapes support for lines in D3 (#368)
Browse files Browse the repository at this point in the history
* Add shapes support for lines in D3

* Fix dashStyle type

* Add linecap option, fix story

* Add legend dash style

* Fix legend types

* Fix story, default value and minor issues

* Fix dashStyle passing from seriesOptions

* Fix linecap passing from seriesOptions

* Remove unneccessary story

* Minor pr fixes
  • Loading branch information
artemipanchuk authored Dec 27, 2023
1 parent 3fbb47c commit a3b952b
Show file tree
Hide file tree
Showing 14 changed files with 244 additions and 10 deletions.
2 changes: 2 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export {CHARTKIT_SCROLLABLE_NODE_CLASSNAME} from './common';

export * from './widget-data';
20 changes: 20 additions & 0 deletions src/constants/widget-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export enum DashStyle {
Dash = 'Dash',
DashDot = 'DashDot',
Dot = 'Dot',
LongDash = 'LongDash',
LongDashDot = 'LongDashDot',
LongDashDotDot = 'LongDashDotDot',
ShortDash = 'ShortDash',
ShortDashDot = 'ShortDashDot',
ShortDashDotDot = 'ShortDashDotDot',
ShortDot = 'ShortDot',
Solid = 'Solid',
}

export enum LineCap {
Butt = 'butt',
Round = 'round',
Square = 'square',
None = 'none',
}
7 changes: 7 additions & 0 deletions src/plugins/d3/__stories__/Showcase.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {BasicPie} from '../examples/pie/Basic';
import {Basic as BasicScatter} from '../examples/scatter/Basic';
import {Basic as BasicLine} from '../examples/line/Basic';
import {Basic as BasicArea} from '../examples/area/Basic';
import {LinesWithShapes} from '../examples/line/Shapes';
import {DataLabels as LineWithDataLabels} from '../examples/line/DataLabels';
import {Donut} from '../examples/pie/Donut';
import {LineAndBarXCombinedChart} from '../examples/combined/LineAndBarX';
Expand Down Expand Up @@ -52,6 +53,12 @@ const ShowcaseStory = () => {
<LineWithMarkers />
</Col>
</Row>
<Row space={1} style={{minHeight: 280}}>
<Col>
<Text variant="subheader-1">Lines with different shapes</Text>
<LinesWithShapes />
</Col>
</Row>
<Row space={1}>
<Text variant="header-2">Area charts</Text>
</Row>
Expand Down
122 changes: 122 additions & 0 deletions src/plugins/d3/examples/line/Shapes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React from 'react';
import {ChartKitWidgetData, LineSeriesData, LineSeries} from '../../../../types';
import {ChartKit} from '../../../../components/ChartKit';
import nintendoGames from '../../examples/nintendoGames';
import {DashStyle} from '../../../../constants';

const SHAPES = {
[DashStyle.Solid]: 1,
[DashStyle.Dash]: 2,
[DashStyle.Dot]: 3,
[DashStyle.ShortDashDot]: 4,
[DashStyle.LongDash]: 5,
[DashStyle.LongDashDot]: 6,
[DashStyle.ShortDot]: 7,
[DashStyle.LongDashDotDot]: 8,
[DashStyle.ShortDash]: 9,
[DashStyle.DashDot]: 10,
[DashStyle.ShortDashDotDot]: 11,
};

const selectShapes = (): DashStyle[] => Object.values(DashStyle);
const getShapesOrder = () => selectShapes().sort((a, b) => SHAPES[a] - SHAPES[b]);

const SHAPES_IN_ORDER = getShapesOrder();

function prepareData(): ChartKitWidgetData {
const games = nintendoGames.filter((d) => {
return d.date && d.user_score;
});

const byGenre = (genre: string) => {
return games
.filter((d) => d.genres.includes(genre))
.map((d) => {
return {
x: d.date,
y: d.user_score,
label: d.title,
};
}) as LineSeriesData[];
};

return {
series: {
options: {
line: {
lineWidth: 2,
},
},
data: [
{
name: '3D',
type: 'line',
data: byGenre('3D'),
dataLabels: {
enabled: true,
},
},
{
name: '2D',
type: 'line',
data: byGenre('2D'),
dataLabels: {
enabled: true,
},
},
{
name: 'Strategy',
type: 'line',
data: byGenre('Strategy'),
dataLabels: {
enabled: true,
},
},
{
name: 'Shooter',
type: 'line',
data: byGenre('Shooter'),
dataLabels: {
enabled: true,
},
},
],
},
xAxis: {
type: 'datetime',
title: {
text: 'Release date',
},
},
yAxis: [
{
title: {text: 'User score'},
labels: {
enabled: true,
},
ticks: {
pixelInterval: 120,
},
},
],
};
}

export const LinesWithShapes = () => {
const data = prepareData();

(data.series.data as LineSeries[]).forEach((graph, i) => {
graph.dashStyle = SHAPES_IN_ORDER[i % SHAPES_IN_ORDER.length];
});

return (
<div
style={{
height: '80vh',
width: '100%',
}}
>
<ChartKit type="d3" data={data} />
</div>
);
};
9 changes: 9 additions & 0 deletions src/plugins/d3/renderer/components/Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
LegendConfig,
} from '../hooks';

import {getLineDashArray} from '../hooks/useShapes/utils';

const b = block('d3-legend');

type Props = {
Expand Down Expand Up @@ -139,6 +141,13 @@ function renderLegendSymbol(args: {
.attr('class', className)
.style('stroke', color);

if (d.dashStyle) {
element.attr(
'stroke-dasharray',
getLineDashArray(d.dashStyle, d.symbol.strokeWidth),
);
}

break;
}
case 'rect': {
Expand Down
19 changes: 19 additions & 0 deletions src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {ScaleOrdinal} from 'd3';
import get from 'lodash/get';
import merge from 'lodash/merge';

import {DashStyle, LineCap} from '../../../../../constants';

import {
ChartKitWidgetSeries,
ChartKitWidgetSeriesOptions,
Expand All @@ -20,6 +22,7 @@ import {getRandomCKId} from '../../../../../utils';

export const DEFAULT_LEGEND_SYMBOL_SIZE = 16;
export const DEFAULT_LINE_WIDTH = 1;
export const DEFAULT_DASH_STYLE = DashStyle.Solid;

export const DEFAULT_MARKER = {
enabled: false,
Expand All @@ -36,6 +39,17 @@ type PrepareLineSeriesArgs = {
legend: PreparedLegend;
};

function prepareLinecap(
dashStyle: DashStyle,
series: LineSeries,
seriesOptions?: ChartKitWidgetSeriesOptions,
) {
const defaultLineCap = dashStyle === DashStyle.Solid ? LineCap.Round : LineCap.None;
const lineCapFromSeriesOptions = get(seriesOptions, 'line.linecap', defaultLineCap);

return get(series, 'linecap', lineCapFromSeriesOptions);
}

function prepareLineLegendSymbol(
series: ChartKitWidgetSeries,
seriesOptions?: ChartKitWidgetSeriesOptions,
Expand Down Expand Up @@ -77,12 +91,15 @@ function prepareMarker(series: LineSeries, seriesOptions?: ChartKitWidgetSeriesO

export function prepareLineSeries(args: PrepareLineSeriesArgs) {
const {colorScale, series: seriesList, seriesOptions, legend} = args;

const defaultLineWidth = get(seriesOptions, 'line.lineWidth', DEFAULT_LINE_WIDTH);
const defaultDashStyle = get(seriesOptions, 'line.dashStyle', DEFAULT_DASH_STYLE);

return seriesList.map<PreparedLineSeries>((series) => {
const id = getRandomCKId();
const name = series.name || '';
const color = series.color || colorScale(name);
const dashStyle = get(series, 'dashStyle', defaultDashStyle);

const prepared: PreparedLineSeries = {
type: series.type,
Expand All @@ -103,6 +120,8 @@ export function prepareLineSeries(args: PrepareLineSeriesArgs) {
allowOverlap: get(series, 'dataLabels.allowOverlap', false),
},
marker: prepareMarker(series, seriesOptions),
dashStyle: dashStyle as DashStyle,
linecap: prepareLinecap(dashStyle as DashStyle, series, seriesOptions) as LineCap,
};

return prepared;
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/d3/renderer/hooks/useSeries/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AreaSeriesData,
} from '../../../../../types';
import type {SeriesOptionsDefaults} from '../../constants';
import {DashStyle, LineCap} from '../../../../../constants';

export type RectLegendSymbol = {
shape: 'rect';
Expand All @@ -44,6 +45,7 @@ export type LegendItem = {
symbol: PreparedLegendSymbol;
textWidth: number;
visible?: boolean;
dashStyle?: DashStyle;
};

export type LegendConfig = {
Expand Down Expand Up @@ -155,6 +157,8 @@ export type PreparedLineSeries = {
};
};
};
dashStyle: DashStyle;
linecap: LineCap;
} & BasePreparedSeries;

export type PreparedAreaSeries = {
Expand Down
7 changes: 4 additions & 3 deletions src/plugins/d3/renderer/hooks/useShapes/line/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {MarkerData, PointData, PreparedLineData} from './types';
import type {TooltipDataChunkLine} from '../../../../../../types';
import type {LabelData} from '../../../types';
import {filterOverlappingLabels} from '../../../utils';
import {setActiveState} from '../utils';
import {getLineDashArray, setActiveState} from '../utils';

const b = block('d3-line');

Expand Down Expand Up @@ -88,8 +88,9 @@ export const LineSeriesShapes = (args: Args) => {
.attr('fill', 'none')
.attr('stroke', (d) => d.color)
.attr('stroke-width', (d) => d.width)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round');
.attr('stroke-linejoin', (d) => d.linecap)
.attr('stroke-linecap', (d) => d.linecap)
.attr('stroke-dasharray', (d) => getLineDashArray(d.dashStyle, d.width));

let dataLabels = preparedData.reduce((acc, d) => {
return acc.concat(d.labels);
Expand Down
8 changes: 6 additions & 2 deletions src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const prepareLineData = (args: {
}));
}

acc.push({
const result: PreparedLineData = {
points,
markers,
labels,
Expand All @@ -80,7 +80,11 @@ export const prepareLineData = (args: {
hovered: false,
active: true,
id: s.id,
});
dashStyle: s.dashStyle,
linecap: s.linecap,
};

acc.push(result);

return acc;
}, []);
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/d3/renderer/hooks/useShapes/line/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {PreparedLineSeries} from '../../useSeries/types';
import {LineSeriesData} from '../../../../../../types';
import {LabelData} from '../../../types';
import {DashStyle, LineCap} from '../../../../../../constants';

export type PointData = {
x: number;
Expand All @@ -25,4 +26,6 @@ export type PreparedLineData = {
hovered: boolean;
active: boolean;
labels: LabelData[];
dashStyle: DashStyle;
linecap: LineCap;
};
12 changes: 7 additions & 5 deletions src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,19 @@ export function ScatterSeriesShape(props: ScatterSeriesShapeProps) {
const inactiveOptions = get(seriesOptions, 'scatter.states.inactive');

const selection = svgElement
.selectAll(`circle`)
.selectAll('point')
.data(preparedData, shapeKey)
.join(
(enter) => enter.append('circle').attr('class', b('point')),
(enter) => enter.append('rect').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('r', (d) => d.data.radius || DEFAULT_SCATTER_POINT_RADIUS)
.attr('x', (d) => d.cx)
.attr('y', (d) => d.cy)
.attr('width', () => DEFAULT_SCATTER_POINT_RADIUS)
.attr('height', () => DEFAULT_SCATTER_POINT_RADIUS);

svgElement
.on('mousemove', (e) => {
Expand Down
22 changes: 22 additions & 0 deletions src/plugins/d3/renderer/hooks/useShapes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {PreparedAxis} from '../useChartOptions/types';

import type {PreparedLineData} from './line/types';
import type {PreparedScatterData} from './scatter';
import {DashStyle} from '../../../../../constants';

export function getXValue(args: {
point: {x?: number | string};
xAxis: PreparedAxis;
Expand Down Expand Up @@ -64,3 +66,23 @@ export function setActiveState<T extends {active?: boolean}>(args: {

return datum;
}

export const getLineDashArray = (dashStyle: DashStyle, strokeWidth = 2) => {
const value = dashStyle.toLowerCase();

const arrayValue = value
.replace('shortdashdotdot', '3,1,1,1,1,1,')
.replace('shortdashdot', '3,1,1,1')
.replace('shortdot', '1,1,')
.replace('shortdash', '3,1,')
.replace('longdash', '8,3,')
.replace(/dot/g, '1,3,')
.replace('dash', '4,3,')
.replace(/,$/, '')
.split(',')
.map((part) => {
return `${parseInt(part, 10) * strokeWidth}`;
});

return arrayValue.join(',').replace(/NaN/g, 'none');
};
Loading

0 comments on commit a3b952b

Please sign in to comment.