From a3b952b30b8e411e677cba4ac140db4698f29722 Mon Sep 17 00:00:00 2001 From: "Artem I. Panchuk" Date: Wed, 27 Dec 2023 15:03:42 +0300 Subject: [PATCH] Add shapes support for lines in D3 (#368) * 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 --- src/constants/index.ts | 2 + src/constants/widget-data.ts | 20 +++ .../d3/__stories__/Showcase.stories.tsx | 7 + src/plugins/d3/examples/line/Shapes.tsx | 122 ++++++++++++++++++ src/plugins/d3/renderer/components/Legend.tsx | 9 ++ .../hooks/useSeries/prepare-line-series.ts | 19 +++ .../d3/renderer/hooks/useSeries/types.ts | 4 + .../renderer/hooks/useShapes/line/index.tsx | 7 +- .../hooks/useShapes/line/prepare-data.ts | 8 +- .../d3/renderer/hooks/useShapes/line/types.ts | 3 + .../hooks/useShapes/scatter/index.tsx | 12 +- .../d3/renderer/hooks/useShapes/utils.ts | 22 ++++ src/types/widget-data/line.ts | 5 + src/types/widget-data/series.ts | 14 ++ 14 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 src/constants/widget-data.ts create mode 100644 src/plugins/d3/examples/line/Shapes.tsx diff --git a/src/constants/index.ts b/src/constants/index.ts index fe0e0e5e..36445340 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,3 @@ export {CHARTKIT_SCROLLABLE_NODE_CLASSNAME} from './common'; + +export * from './widget-data'; diff --git a/src/constants/widget-data.ts b/src/constants/widget-data.ts new file mode 100644 index 00000000..b6f66e02 --- /dev/null +++ b/src/constants/widget-data.ts @@ -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', +} diff --git a/src/plugins/d3/__stories__/Showcase.stories.tsx b/src/plugins/d3/__stories__/Showcase.stories.tsx index 84778a6c..016f0f5e 100644 --- a/src/plugins/d3/__stories__/Showcase.stories.tsx +++ b/src/plugins/d3/__stories__/Showcase.stories.tsx @@ -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'; @@ -52,6 +53,12 @@ const ShowcaseStory = () => { + + + Lines with different shapes + + + Area charts diff --git a/src/plugins/d3/examples/line/Shapes.tsx b/src/plugins/d3/examples/line/Shapes.tsx new file mode 100644 index 00000000..bdf1a8e8 --- /dev/null +++ b/src/plugins/d3/examples/line/Shapes.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 2a7c6d0f..bc2db53f 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -11,6 +11,8 @@ import type { LegendConfig, } from '../hooks'; +import {getLineDashArray} from '../hooks/useShapes/utils'; + const b = block('d3-legend'); type Props = { @@ -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': { diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts index c1d7fc9e..6211cbf2 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-line-series.ts @@ -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, @@ -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, @@ -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, @@ -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((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, @@ -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; diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index e282ac12..b4249f51 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -19,6 +19,7 @@ import { AreaSeriesData, } from '../../../../../types'; import type {SeriesOptionsDefaults} from '../../constants'; +import {DashStyle, LineCap} from '../../../../../constants'; export type RectLegendSymbol = { shape: 'rect'; @@ -44,6 +45,7 @@ export type LegendItem = { symbol: PreparedLegendSymbol; textWidth: number; visible?: boolean; + dashStyle?: DashStyle; }; export type LegendConfig = { @@ -155,6 +157,8 @@ export type PreparedLineSeries = { }; }; }; + dashStyle: DashStyle; + linecap: LineCap; } & BasePreparedSeries; export type PreparedAreaSeries = { diff --git a/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx index 053397ea..2d01124c 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/line/index.tsx @@ -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'); @@ -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); diff --git a/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts b/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts index e42f2743..6e323ed4 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/line/prepare-data.ts @@ -70,7 +70,7 @@ export const prepareLineData = (args: { })); } - acc.push({ + const result: PreparedLineData = { points, markers, labels, @@ -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; }, []); diff --git a/src/plugins/d3/renderer/hooks/useShapes/line/types.ts b/src/plugins/d3/renderer/hooks/useShapes/line/types.ts index 5321563e..9c1ed1e6 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/line/types.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/line/types.ts @@ -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; @@ -25,4 +26,6 @@ export type PreparedLineData = { hovered: boolean; active: boolean; labels: LabelData[]; + dashStyle: DashStyle; + linecap: LineCap; }; diff --git a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx index 8dd669f9..4073aa07 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx +++ b/src/plugins/d3/renderer/hooks/useShapes/scatter/index.tsx @@ -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) => { diff --git a/src/plugins/d3/renderer/hooks/useShapes/utils.ts b/src/plugins/d3/renderer/hooks/useShapes/utils.ts index 2cc52370..9bdb4158 100644 --- a/src/plugins/d3/renderer/hooks/useShapes/utils.ts +++ b/src/plugins/d3/renderer/hooks/useShapes/utils.ts @@ -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; @@ -64,3 +66,23 @@ export function setActiveState(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'); +}; diff --git a/src/types/widget-data/line.ts b/src/types/widget-data/line.ts index c5306586..f3cf85f7 100644 --- a/src/types/widget-data/line.ts +++ b/src/types/widget-data/line.ts @@ -1,6 +1,7 @@ import type {BaseSeries, BaseSeriesData} from './base'; import type {ChartKitWidgetLegend, RectLegendSymbolOptions} from './legend'; import type {PointMarkerOptions} from './marker'; +import {DashStyle, LineCap} from '../../constants'; export type LineSeriesData = BaseSeriesData & { /** @@ -45,4 +46,8 @@ export type LineSeries = BaseSeries & { }; /** Options for the point markers of line series */ marker?: LineMarkerOptions; + /** Option for line stroke style */ + dashStyle?: `${DashStyle}`; + /** Option for line cap style */ + linecap?: `${LineCap}`; }; diff --git a/src/types/widget-data/series.ts b/src/types/widget-data/series.ts index ec9b40a8..7fddefd0 100644 --- a/src/types/widget-data/series.ts +++ b/src/types/widget-data/series.ts @@ -7,6 +7,8 @@ import type {BarYSeries, BarYSeriesData} from './bar-y'; import type {PointMarkerOptions, PointMarkerHalo} from './marker'; import type {AreaSeries, AreaSeriesData} from './area'; +import {DashStyle, LineCap} from '../../constants'; + export type ChartKitWidgetSeries = | ScatterSeries | PieSeries @@ -173,6 +175,18 @@ export type ChartKitWidgetSeriesOptions = { }; /** Options for the point markers of line series */ marker?: LineMarkerOptions; + + /** Options for line style + * + * @default 'Solid' + * */ + dashStyle?: `${DashStyle}`; + + /** Options for line cap style + * + * @default 'round' when dashStyle is not 'solid', 'none' when dashStyle is not 'solid' + * */ + linecap?: `${LineCap}`; }; area?: { /** Pixel width of the graph line.