Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shapes support for lines in D3 #368

Merged
merged 11 commits into from
Dec 27, 2023
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) {
kuzmadom marked this conversation as resolved.
Show resolved Hide resolved
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));
kuzmadom marked this conversation as resolved.
Show resolved Hide resolved

let dataLabels = preparedData.reduce((acc, d) => {
return acc.concat(d.labels);
Expand Down
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
Loading