Skip to content

Commit 6aea3bc

Browse files
authored
feat: yAxis plotLines (#45)
1 parent c602e65 commit 6aea3bc

File tree

13 files changed

+173
-40
lines changed

13 files changed

+173
-40
lines changed

src/__stories__/__data__/bar-x/playground.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ function prepareData(): ChartData {
4343
ticks: {
4444
pixelInterval: 120,
4545
},
46+
plotLines: [
47+
{
48+
value: 100,
49+
width: 2,
50+
color: 'red',
51+
dashStyle: 'Dash',
52+
layerPlacement: 'after',
53+
},
54+
{
55+
value: 200,
56+
width: 1,
57+
layerPlacement: 'before',
58+
},
59+
],
4660
},
4761
],
4862
chart: {

src/components/Axis/AxisY.tsx

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react';
33
import {axisLeft, axisRight, line, select} from 'd3';
44
import type {Axis, AxisDomain, AxisScale, BaseType, Selection} from 'd3';
55

6-
import type {ChartScale, PreparedAxis, PreparedSplit} from '../../hooks';
6+
import type {ChartScale, PreparedAxis, PreparedAxisPlotLine, PreparedSplit} from '../../hooks';
77
import {
88
block,
99
calculateCos,
@@ -12,6 +12,7 @@ import {
1212
getAxisHeight,
1313
getAxisTitleRows,
1414
getClosestPointsRange,
15+
getLineDashArray,
1516
getScaleTicks,
1617
getTicksCount,
1718
handleOverflowingText,
@@ -30,6 +31,7 @@ type Props = {
3031
width: number;
3132
height: number;
3233
split: PreparedSplit;
34+
plotRef?: React.MutableRefObject<SVGGElement | null>;
3335
};
3436

3537
function transformLabel(args: {node: Element; axis: PreparedAxis}) {
@@ -130,8 +132,12 @@ function getTitlePosition(args: {axis: PreparedAxis; axisHeight: number; rowCoun
130132
return {x, y};
131133
}
132134

135+
type PlotLineData = {
136+
transform: string;
137+
} & PreparedAxisPlotLine;
138+
133139
export const AxisY = (props: Props) => {
134-
const {axes, width, height: totalHeight, scale, split} = props;
140+
const {axes, width, height: totalHeight, scale, split, plotRef} = props;
135141
const height = getAxisHeight({split, boundsHeight: totalHeight});
136142
const ref = React.useRef<SVGGElement | null>(null);
137143

@@ -143,19 +149,36 @@ export const AxisY = (props: Props) => {
143149
const svgElement = select(ref.current);
144150
svgElement.selectAll('*').remove();
145151

152+
const getAxisPosition = (axis: PreparedAxis) => {
153+
const top = split.plots[axis.plotIndex]?.top || 0;
154+
if (axis.position === 'left') {
155+
return `translate(0, ${top}px)`;
156+
}
157+
158+
return `translate(${width}px, 0)`;
159+
};
160+
161+
const plotLines = axes.reduce<PlotLineData[]>((acc, axis) => {
162+
if (axis.plotLines.length) {
163+
acc.push(
164+
...axis.plotLines.map((plotLine) => {
165+
return {
166+
...plotLine,
167+
transform: getAxisPosition(axis),
168+
};
169+
}),
170+
);
171+
}
172+
173+
return acc;
174+
}, []);
175+
146176
const axisSelection = svgElement
147177
.selectAll('axis')
148178
.data(axes)
149179
.join('g')
150180
.attr('class', b())
151-
.style('transform', (d) => {
152-
const top = split.plots[d.plotIndex]?.top || 0;
153-
if (d.position === 'left') {
154-
return `translate(0, ${top}px)`;
155-
}
156-
157-
return `translate(${width}px, 0)`;
158-
});
181+
.style('transform', (d) => getAxisPosition(d));
159182

160183
axisSelection.each((d, index, node) => {
161184
const seriesScale = scale[index];
@@ -165,11 +188,9 @@ export const AxisY = (props: Props) => {
165188
BaseType,
166189
unknown
167190
>;
191+
const axisScale = seriesScale as AxisScale<AxisDomain>;
168192
const yAxisGenerator = getAxisGenerator({
169-
axisGenerator:
170-
d.position === 'left'
171-
? axisLeft(seriesScale as AxisScale<AxisDomain>)
172-
: axisRight(seriesScale as AxisScale<AxisDomain>),
193+
axisGenerator: d.position === 'left' ? axisLeft(axisScale) : axisRight(axisScale),
173194
preparedAxis: d,
174195
height,
175196
width,
@@ -215,6 +236,47 @@ export const AxisY = (props: Props) => {
215236
.remove();
216237
}
217238

239+
if (plotRef && d.plotLines.length > 0) {
240+
const plotLineClassName = b('plotLine');
241+
const plotLineContainer = select(plotRef.current);
242+
plotLineContainer.selectAll(`.${plotLineClassName}`).remove();
243+
244+
const plotLinesSelection = plotLineContainer
245+
.selectAll(`.${plotLineClassName}`)
246+
.data(plotLines)
247+
.join('g')
248+
.attr('class', plotLineClassName)
249+
.style('transform', (plotLine) => plotLine.transform);
250+
251+
plotLinesSelection
252+
.append('path')
253+
.attr('d', (plotLine) => {
254+
const plotLineValue = Number(axisScale(plotLine.value));
255+
const points: [number, number][] = [
256+
[0, plotLineValue],
257+
[width, plotLineValue],
258+
];
259+
260+
return line()(points);
261+
})
262+
.attr('stroke', (plotLine) => plotLine.color)
263+
.attr('stroke-width', (plotLine) => plotLine.width)
264+
.attr('stroke-dasharray', (plotLine) =>
265+
getLineDashArray(plotLine.dashStyle, plotLine.width),
266+
)
267+
.attr('opacity', (plotLine) => plotLine.opacity);
268+
269+
plotLinesSelection.each((plotLineData, i, nodes) => {
270+
const plotLineSelection = select(nodes[i]);
271+
272+
if (plotLineData.layerPlacement === 'before') {
273+
plotLineSelection.lower();
274+
} else {
275+
plotLineSelection.raise();
276+
}
277+
});
278+
}
279+
218280
return axisItem;
219281
});
220282

src/components/ChartInner/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const ChartInner = (props: ChartInnerProps) => {
2020
const {width, height, data} = props;
2121
const svgRef = React.useRef<SVGSVGElement | null>(null);
2222
const htmlLayerRef = React.useRef<HTMLDivElement | null>(null);
23+
const plotRef = React.useRef<SVGGElement | null>(null);
2324
const dispatcher = React.useMemo(() => getD3Dispatcher(), []);
2425
const {
2526
boundsHeight,
@@ -112,6 +113,7 @@ export const ChartInner = (props: ChartInnerProps) => {
112113
width={boundsWidth}
113114
height={boundsHeight}
114115
transform={`translate(${[boundsOffsetLeft, boundsOffsetTop].join(',')})`}
116+
ref={plotRef}
115117
>
116118
{xScale && yScale?.length && (
117119
<React.Fragment>
@@ -121,6 +123,7 @@ export const ChartInner = (props: ChartInnerProps) => {
121123
height={boundsHeight}
122124
scale={yScale}
123125
split={preparedSplit}
126+
plotRef={plotRef}
124127
/>
125128
<g transform={`translate(0, ${boundsHeight})`}>
126129
<AxisX

src/components/Legend/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import type {
1212
PreparedSeries,
1313
SymbolLegendSymbol,
1414
} from '../../hooks';
15-
import {getLineDashArray} from '../../hooks/useShapes/utils';
1615
import {formatNumber} from '../../libs';
1716
import {
1817
block,
1918
createGradientRect,
2019
getContinuesColorFn,
2120
getLabelsSize,
21+
getLineDashArray,
2222
getSymbol,
2323
} from '../../utils';
2424
import {axisBottom} from '../../utils/chart/axis-generators';

src/hooks/useChartOptions/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import type {DashStyle} from 'src/constants';
2+
13
import type {
4+
AxisPlotLine,
25
BaseTextStyle,
36
ChartAxis,
47
ChartAxisLabels,
@@ -22,6 +25,15 @@ export type PreparedChart = {
2225
margin: ChartMargin;
2326
};
2427

28+
export type PreparedAxisPlotLine = {
29+
value: number;
30+
color: string;
31+
width: number;
32+
dashStyle: DashStyle;
33+
opacity: number;
34+
layerPlacement: AxisPlotLine['layerPlacement'];
35+
};
36+
2537
export type PreparedAxis = Omit<ChartAxis, 'type' | 'labels'> & {
2638
type: ChartAxisType;
2739
labels: PreparedAxisLabels;
@@ -44,6 +56,7 @@ export type PreparedAxis = Omit<ChartAxis, 'type' | 'labels'> & {
4456
};
4557
position: 'left' | 'right' | 'top' | 'bottom';
4658
plotIndex: number;
59+
plotLines: PreparedAxisPlotLine[];
4760
};
4861

4962
export type PreparedTitle = ChartData['title'] & {

src/hooks/useChartOptions/x-axis.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export const getPreparedXAxis = ({
153153
},
154154
position: 'bottom',
155155
plotIndex: 0,
156+
plotLines: [],
156157
};
157158

158159
const {height, rotation} = getLabelSettings({

src/hooks/useChartOptions/y-axis.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import get from 'lodash/get';
44
import {
55
DEFAULT_AXIS_LABEL_FONT_SIZE,
66
DEFAULT_AXIS_TYPE,
7+
DashStyle,
78
axisLabelsDefaults,
89
yAxisTitleDefaults,
910
} from '../../constants';
@@ -166,6 +167,14 @@ export const getPreparedYAxis = ({
166167
},
167168
position: get(axisItem, 'position', defaultAxisPosition),
168169
plotIndex: get(axisItem, 'plotIndex', 0),
170+
plotLines: get(axisItem, 'plotLines', []).map((d) => ({
171+
value: get(d, 'value', 0),
172+
color: get(d, 'color', 'var(--g-color-base-brand)'),
173+
width: get(d, 'width', 1),
174+
dashStyle: get(d, 'dashStyle', DashStyle.Solid) as DashStyle,
175+
opacity: get(d, 'opacity', 1),
176+
layerPlacement: get(d, 'layerPlacement', 'before'),
177+
})),
169178
};
170179

171180
if (labelsEnabled) {

src/hooks/useShapes/line/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {color, line as lineGenerator, select} from 'd3';
55
import get from 'lodash/get';
66

77
import type {LabelData, TooltipDataChunkLine} from '../../../types';
8-
import {block, filterOverlappingLabels} from '../../../utils';
8+
import {block, filterOverlappingLabels, getLineDashArray} from '../../../utils';
99
import type {PreparedSeriesOptions} from '../../useSeries/types';
1010
import {HtmlLayer} from '../HtmlLayer';
1111
import {
@@ -16,7 +16,7 @@ import {
1616
selectMarkerSymbol,
1717
setMarker,
1818
} from '../marker';
19-
import {getLineDashArray, setActiveState} from '../utils';
19+
import {setActiveState} from '../utils';
2020

2121
import type {MarkerData, PointData, PreparedLineData} from './types';
2222

src/hooks/useShapes/utils.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {BaseType, ScaleBand, ScaleLinear, ScaleTime} from 'd3';
22
import {select} from 'd3';
33
import get from 'lodash/get';
44

5-
import type {DashStyle} from '../../constants';
65
import type {BasicInactiveState} from '../../types';
76
import {getDataCategoryValue} from '../../utils';
87
import type {ChartScale} from '../useAxisScales';
@@ -67,23 +66,3 @@ export function setActiveState<T extends {active?: boolean}>(args: {
6766

6867
return datum;
6968
}
70-
71-
export function getLineDashArray(dashStyle: DashStyle, strokeWidth = 2) {
72-
const value = dashStyle.toLowerCase();
73-
74-
const arrayValue = value
75-
.replace('shortdashdotdot', '3,1,1,1,1,1,')
76-
.replace('shortdashdot', '3,1,1,1')
77-
.replace('shortdot', '1,1,')
78-
.replace('shortdash', '3,1,')
79-
.replace('longdash', '8,3,')
80-
.replace(/dot/g, '1,3,')
81-
.replace('dash', '4,3,')
82-
.replace(/,$/, '')
83-
.split(',')
84-
.map((part) => {
85-
return `${parseInt(part, 10) * strokeWidth}`;
86-
});
87-
88-
return arrayValue.join(',').replace(/NaN/g, 'none');
89-
}

src/hooks/useShapes/waterfall/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ import get from 'lodash/get';
66

77
import {DashStyle} from '../../../constants';
88
import type {LabelData} from '../../../types';
9-
import {block, filterOverlappingLabels, getWaterfallPointColor} from '../../../utils';
9+
import {
10+
block,
11+
filterOverlappingLabels,
12+
getLineDashArray,
13+
getWaterfallPointColor,
14+
} from '../../../utils';
1015
import type {PreparedSeriesOptions} from '../../useSeries/types';
1116
import {HtmlLayer} from '../HtmlLayer';
12-
import {getLineDashArray} from '../utils';
1317

1418
import type {PreparedWaterfallData} from './types';
1519

0 commit comments

Comments
 (0)