From 49cdd02e5975af0d0060e6bf8628b5ba31d4303d Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Fri, 11 Oct 2024 17:18:09 +0300 Subject: [PATCH 1/4] feat(D3): add options for gradient legend --- .../pie/GradientLegend.stories.tsx | 76 +++++++ src/plugins/d3/renderer/components/Legend.tsx | 199 ++++++++++++------ .../d3/renderer/components/styles.scss | 6 + .../d3/renderer/constants/defaults/legend.ts | 5 + .../hooks/useSeries/prepare-legend.ts | 87 ++++++-- .../d3/renderer/hooks/useSeries/types.ts | 18 +- .../renderer/utils/axis-generators/bottom.ts | 37 ++-- src/plugins/d3/renderer/utils/axis.ts | 4 +- src/plugins/d3/renderer/utils/color.ts | 33 +++ src/plugins/d3/renderer/utils/index.ts | 2 + src/plugins/d3/renderer/utils/legend.ts | 33 +++ src/types/widget-data/legend.ts | 30 ++- 12 files changed, 429 insertions(+), 101 deletions(-) create mode 100644 src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx create mode 100644 src/plugins/d3/renderer/utils/color.ts create mode 100644 src/plugins/d3/renderer/utils/legend.ts diff --git a/src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx b/src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx new file mode 100644 index 00000000..832c3101 --- /dev/null +++ b/src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import type {StoryObj} from '@storybook/react'; +import {groups} from 'd3'; + +import {ChartKit} from '../../../../components/ChartKit'; +import {Loader} from '../../../../components/Loader/Loader'; +import {settings} from '../../../../libs'; +import type {ChartKitWidgetData, PieSeriesData} from '../../../../types'; +import {ExampleWrapper} from '../../examples/ExampleWrapper'; +import nintendoGames from '../../examples/nintendoGames'; +import {D3Plugin} from '../../index'; +import {getContinuesColorFn} from '../../renderer/utils'; + +const PieWithGradientLegend = () => { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + settings.set({plugins: [D3Plugin]}); + setLoading(false); + }, []); + + if (loading) { + return ; + } + + const colors = ['rgb(255, 61, 100)', 'rgb(255, 198, 54)', 'rgb(84, 165, 32)']; + const stops = [0, 0.5, 1]; + + const gamesByPlatform = groups(nintendoGames, (item) => item.platform); + const data: PieSeriesData[] = gamesByPlatform.map(([platform, games]) => ({ + name: platform, + value: games.length, + label: `${platform}(${games.length})`, + })); + const getColor = getContinuesColorFn({colors, stops, values: data.map((d) => d.value)}); + data.forEach((d) => { + d.color = getColor(d.value); + }); + + const widgetData: ChartKitWidgetData = { + series: { + data: [ + { + type: 'pie', + data, + }, + ], + }, + title: {text: 'Pie with gradient legend'}, + legend: { + enabled: true, + type: 'continuous', + title: {text: 'Games by platform'}, + colorScale: { + colors: colors, + stops, + }, + }, + }; + + return ( + + + + ); +}; + +export const PieWithGradientLegendStory: StoryObj = { + name: 'Gradient colored pie', +}; + +export default { + title: 'Plugins/D3/Pie', + component: PieWithGradientLegend, +}; diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index e70ca948..885b1aed 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import {BaseType, line as lineGenerator, select, symbol} from 'd3'; +import {BaseType, line as lineGenerator, scaleLinear, select, symbol} from 'd3'; import type {Selection} from 'd3'; import {block} from '../../../../utils/cn'; +import {GRADIENT_LEGEND_SIZE} from '../constants'; import type { LegendConfig, LegendItem, @@ -13,7 +14,8 @@ import type { SymbolLegendSymbol, } from '../hooks'; import {getLineDashArray} from '../hooks/useShapes/utils'; -import {getSymbol} from '../utils'; +import {createGradientRect, getContinuesColorFn, getLabelsSize, getSymbol} from '../utils'; +import {axisBottom} from '../utils/axis-generators'; const b = block('d3-legend'); @@ -208,81 +210,140 @@ export const Legend = (props: Props) => { const svgElement = select(ref.current); svgElement.selectAll('*').remove(); - const limit = config.pagination?.limit; - const pageItems = - typeof limit === 'number' - ? items.slice(paginationOffset * limit, paginationOffset * limit + limit) - : items; - pageItems.forEach((line, lineIndex) => { - const legendLine = svgElement.append('g').attr('class', b('line')); - const legendItemTemplate = legendLine - .selectAll('legend-history') - .data(line) - .enter() - .append('g') - .attr('class', b('item')) - .on('click', function (e, d) { - onItemClick({name: d.name, metaKey: e.metaKey}); - }); - const getXPosition = (i: number) => { - return line.slice(0, i).reduce((acc, legendItem) => { - return ( - acc + - legendItem.symbol.width + - legendItem.symbol.padding + - legendItem.textWidth + - legend.itemDistance - ); - }, 0); - }; + if (legend.type === 'discrete') { + const limit = config.pagination?.limit; + const pageItems = + typeof limit === 'number' + ? items.slice(paginationOffset * limit, paginationOffset * limit + limit) + : items; + pageItems.forEach((line, lineIndex) => { + const legendLine = svgElement.append('g').attr('class', b('line')); + const legendItemTemplate = legendLine + .selectAll('legend-history') + .data(line) + .enter() + .append('g') + .attr('class', b('item')) + .on('click', function (e, d) { + onItemClick({name: d.name, metaKey: e.metaKey}); + }); + + const getXPosition = (i: number) => { + return line.slice(0, i).reduce((acc, legendItem) => { + return ( + acc + + legendItem.symbol.width + + legendItem.symbol.padding + + legendItem.textWidth + + legend.itemDistance + ); + }, 0); + }; - renderLegendSymbol({selection: legendItemTemplate, legend}); + renderLegendSymbol({selection: legendItemTemplate, legend}); - legendItemTemplate - .append('text') - .attr('x', function (legendItem, i) { - return getXPosition(i) + legendItem.symbol.width + legendItem.symbol.padding; - }) - .attr('height', legend.lineHeight) - .attr('class', function (d) { - const mods = {selected: d.visible, unselected: !d.visible}; - return b('item-text', mods); - }) - .text(function (d) { - return ('name' in d && d.name) as string; - }) - .style('font-size', legend.itemStyle.fontSize); - - const contentWidth = legendLine.node()?.getBoundingClientRect().width || 0; - const {left} = getLegendPosition({ - align: legend.align, - width: boundsWidth, - offsetWidth: config.offset.left, - contentWidth, + legendItemTemplate + .append('text') + .attr('x', function (legendItem, i) { + return ( + getXPosition(i) + legendItem.symbol.width + legendItem.symbol.padding + ); + }) + .attr('height', legend.lineHeight) + .attr('class', function (d) { + const mods = {selected: d.visible, unselected: !d.visible}; + return b('item-text', mods); + }) + .text(function (d) { + return ('name' in d && d.name) as string; + }) + .style('font-size', legend.itemStyle.fontSize); + + const contentWidth = legendLine.node()?.getBoundingClientRect().width || 0; + const {left} = getLegendPosition({ + align: legend.align, + width: boundsWidth, + offsetWidth: 0, + contentWidth, + }); + const top = legend.lineHeight * lineIndex; + + legendLine.attr('transform', `translate(${[left, top].join(',')})`); }); - const top = config.offset.top + legend.lineHeight * lineIndex; - legendLine.attr('transform', `translate(${[left, top].join(',')})`); - }); + if (config.pagination) { + const transform = `translate(${[ + 0, + legend.lineHeight * config.pagination.limit + legend.lineHeight / 2, + ].join(',')})`; + appendPaginator({ + container: svgElement, + offset: paginationOffset, + maxPage: config.pagination.maxPage, + legend, + transform, + onArrowClick: setPaginationOffset, + }); + } + } else { + // gradient rect + const domain = legend.colorScale.domain ?? []; + const rectHeight = GRADIENT_LEGEND_SIZE.height; + svgElement.call(createGradientRect, { + y: legend.title.height + legend.title.margin, + height: rectHeight, + width: legend.width, + interpolator: getContinuesColorFn({ + values: [0, 1], + colors: legend.colorScale.colors, + stops: legend.colorScale.stops, + }), + }); - if (config.pagination) { - const transform = `translate(${[ - config.offset.left, - config.offset.top + - legend.lineHeight * config.pagination.limit + - legend.lineHeight / 2, - ].join(',')})`; - appendPaginator({ - container: svgElement, - offset: paginationOffset, - maxPage: config.pagination.maxPage, - legend, - transform, - onArrowClick: setPaginationOffset, + // ticks + const xAxisGenerator = axisBottom({ + scale: scaleLinear(domain, [0, legend.width]), + ticks: { + items: [[0, -rectHeight]], + labelsMargin: legend.ticks.labelsMargin, + labelsLineHeight: legend.ticks.labelsLineHeight, + maxTickCount: 4, + tickColor: '#fff', + }, }); + const tickTop = legend.title.height + legend.title.margin + rectHeight; + svgElement + .append('g') + .attr('transform', `translate(0, ${tickTop})`) + .call(xAxisGenerator); } + + if (legend.title.enable) { + const {maxWidth: labelWidth} = getLabelsSize({ + labels: [legend.title.text], + style: legend.title.style, + }); + svgElement + .append('g') + .attr('class', b('title')) + .append('text') + .attr('dx', legend.width / 2 - labelWidth / 2) + .attr('font-weight', legend.title.style.fontWeight ?? null) + .attr('font-size', legend.title.style.fontSize ?? null) + .attr('fill', legend.title.style.fontColor ?? null) + .style('alignment-baseline', 'before-edge') + .text(legend.title.text); + } + + const {left} = getLegendPosition({ + align: legend.align, + width: boundsWidth, + offsetWidth: config.offset.left, + contentWidth: svgElement.node()?.getBoundingClientRect().width || 0, + }); + svgElement.attr('transform', `translate(${[left, config.offset.top].join(',')})`); }, [boundsWidth, chartSeries, onItemClick, legend, items, config, paginationOffset]); - return ; + return ; }; diff --git a/src/plugins/d3/renderer/components/styles.scss b/src/plugins/d3/renderer/components/styles.scss index c571d932..3b673fd0 100644 --- a/src/plugins/d3/renderer/components/styles.scss +++ b/src/plugins/d3/renderer/components/styles.scss @@ -36,6 +36,12 @@ } .chartkit-d3-legend { + color: var(--g-color-text-secondary); + + &__title { + fill: var(--g-color-text-secondary); + } + &__item { cursor: pointer; user-select: none; diff --git a/src/plugins/d3/renderer/constants/defaults/legend.ts b/src/plugins/d3/renderer/constants/defaults/legend.ts index 680b660e..3e8fcc8c 100644 --- a/src/plugins/d3/renderer/constants/defaults/legend.ts +++ b/src/plugins/d3/renderer/constants/defaults/legend.ts @@ -11,3 +11,8 @@ export const legendDefaults: LegendDefaults = { fontSize: '12px', }, }; + +export const GRADIENT_LEGEND_SIZE = { + height: 12, + width: 200, +}; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts index c321f87f..512b460e 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -1,11 +1,15 @@ -import {select} from 'd3'; +import {range, select} from 'd3'; import clone from 'lodash/clone'; import get from 'lodash/get'; import merge from 'lodash/merge'; -import type {ChartKitWidgetData} from '../../../../../types'; -import {legendDefaults} from '../../constants'; -import {getHorisontalSvgTextHeight} from '../../utils'; +import type {BaseTextStyle, ChartKitWidgetData} from '../../../../../types'; +import {GRADIENT_LEGEND_SIZE, legendDefaults} from '../../constants'; +import { + getDomainForContinuousColorScale, + getHorisontalSvgTextHeight, + getLabelsSize, +} from '../../utils'; import {getBoundsWidth} from '../useChartDimensions'; import {getYAxisWidth} from '../useChartDimensions/utils'; import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; @@ -27,7 +31,48 @@ export const getPreparedLegend = (args: { const computedItemStyle = merge(defaultItemStyle, itemStyle); const lineHeight = getHorisontalSvgTextHeight({text: 'Tmp', style: computedItemStyle}); - const height = enabled ? lineHeight : 0; + const legendType = get(legend, 'type', 'discrete'); + const isTitleEnabled = Boolean(legend?.title?.text); + const titleMargin = isTitleEnabled ? get(legend, 'title.margin', 4) : 0; + const titleStyle: BaseTextStyle = { + fontSize: '12px', + fontWeight: 'bold', + ...get(legend, 'title.style', {}), + }; + const titleText = isTitleEnabled ? get(legend, 'title.text', '') : ''; + const titleHeight = isTitleEnabled + ? getLabelsSize({labels: [titleText], style: titleStyle}).maxHeight + : 0; + + const ticks = { + labelsMargin: 4, + labelsLineHeight: 12, + }; + + const colorScale: PreparedLegend['colorScale'] = { + colors: [], + domain: [], + stops: [], + }; + + let height = 0; + if (enabled) { + height += titleHeight + titleMargin; + if (legendType === 'continuous') { + height += GRADIENT_LEGEND_SIZE.height; + height += ticks.labelsLineHeight + ticks.labelsMargin; + + colorScale.colors = legend?.colorScale?.colors ?? []; + colorScale.stops = + legend?.colorScale?.stops ?? + range(colorScale.colors.length).map((d, i, list) => d / list.length); + colorScale.domain = getDomainForContinuousColorScale({series}); + } else { + height += lineHeight; + } + } + + const legendWidth = get(legend, 'width', GRADIENT_LEGEND_SIZE.width); return { align: get(legend, 'align', legendDefaults.align), @@ -37,6 +82,17 @@ export const getPreparedLegend = (args: { itemStyle: computedItemStyle, lineHeight, margin: get(legend, 'margin', legendDefaults.margin), + type: legendType, + title: { + enable: isTitleEnabled, + text: titleText, + margin: titleMargin, + style: titleStyle, + height: titleHeight, + }, + width: legendWidth, + ticks, + colorScale, }; }; @@ -116,18 +172,23 @@ export const getLegendComponents = (args: { items: flattenLegendItems, preparedLegend, }); - let legendHeight = preparedLegend.lineHeight * items.length; + let pagination: LegendConfig['pagination'] | undefined; - if (maxLegendHeight < legendHeight) { - // extra line for paginator - const limit = Math.floor(maxLegendHeight / preparedLegend.lineHeight) - 1; - const maxPage = Math.ceil(items.length / limit); - pagination = {limit, maxPage}; - legendHeight = maxLegendHeight; + if (preparedLegend.type === 'discrete') { + let legendHeight = preparedLegend.lineHeight * items.length; + + if (maxLegendHeight < legendHeight) { + // extra line for paginator + const limit = Math.floor(maxLegendHeight / preparedLegend.lineHeight) - 1; + const maxPage = Math.ceil(items.length / limit); + pagination = {limit, maxPage}; + legendHeight = maxLegendHeight; + } + + preparedLegend.height = legendHeight; } - preparedLegend.height = legendHeight; const top = chartHeight - chartMargin.bottom - preparedLegend.height; const offset: LegendConfig['offset'] = { left: chartMargin.left + getYAxisWidth(preparedYAxis[0]), diff --git a/src/plugins/d3/renderer/hooks/useSeries/types.ts b/src/plugins/d3/renderer/hooks/useSeries/types.ts index f9d74c4c..62b0a98b 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/types.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/types.ts @@ -42,9 +42,25 @@ export type SymbolLegendSymbol = { export type PreparedLegendSymbol = RectLegendSymbol | PathLegendSymbol | SymbolLegendSymbol; -export type PreparedLegend = Required & { +export type PreparedLegend = Required> & { height: number; lineHeight: number; + title: { + enable: boolean; + text: string; + margin: number; + style: BaseTextStyle; + height: number; + }; + ticks: { + labelsMargin: number; + labelsLineHeight: number; + }; + colorScale: { + colors: string[]; + domain: number[]; + stops: number[]; + }; }; export type OnLegendItemClick = (data: {name: string; metaKey: boolean}) => void; diff --git a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts index 4e8dfb1d..b6f02b6f 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -10,17 +10,18 @@ type AxisBottomArgs = { scale: AxisScale; ticks: { count?: number; - maxTickCount: number; - labelFormat: (value: any) => string; + maxTickCount?: number; + labelFormat?: (value: any) => string; labelsPaddings?: number; labelsMargin?: number; labelsStyle?: BaseTextStyle; labelsMaxWidth?: number; labelsLineHeight: number; - items: [number, number][]; - rotation: number; + items?: [number, number][]; + rotation?: number; + tickColor?: string; }; - domain: { + domain?: { size: number; color?: string; }; @@ -52,7 +53,7 @@ export function axisBottom(args: AxisBottomArgs) { const { scale, ticks: { - labelFormat, + labelFormat = (value: unknown) => String(value), labelsPaddings = 0, labelsMargin = 0, labelsMaxWidth = Infinity, @@ -61,9 +62,10 @@ export function axisBottom(args: AxisBottomArgs) { items: tickItems, count: ticksCount, maxTickCount, - rotation, + rotation = 0, + tickColor, }, - domain: {size: domainSize, color: domainColor}, + domain, } = args; const offset = getXAxisOffset(); const position = getXTickPosition({scale, offset}); @@ -75,8 +77,8 @@ export function axisBottom(args: AxisBottomArgs) { return function (selection: Selection) { const x = selection.node()?.getBoundingClientRect()?.x || 0; - const right = x + domainSize; - const top = -tickItems[0][0] || 0; + const right = x + domain?.size ?? 0; + const top = -tickItems?.[0]?.[0] || 0; let transform = `translate(0, ${labelHeight + labelsMargin - top}px)`; if (rotation) { @@ -89,7 +91,7 @@ export function axisBottom(args: AxisBottomArgs) { } const tickPath = path(); - tickItems.forEach(([start, end]) => { + tickItems?.forEach(([start, end]) => { tickPath.moveTo(0, start); tickPath.lineTo(0, end); }); @@ -100,7 +102,9 @@ export function axisBottom(args: AxisBottomArgs) { .order() .join((el) => { const tick = el.append('g').attr('class', 'tick'); - tick.append('path').attr('d', tickPath.toString()).attr('stroke', 'currentColor'); + tick.append('path') + .attr('d', tickPath.toString()) + .attr('stroke', tickColor ?? 'currentColor'); tick.append('text') .text(labelFormat) .attr('fill', 'currentColor') @@ -181,8 +185,11 @@ export function axisBottom(args: AxisBottomArgs) { }); } - selection - .call(addDomain, {size: domainSize, color: domainColor}) - .style('font-size', labelsStyle?.fontSize || ''); + if (domain) { + const {size: domainSize, color: domainColor} = domain; + selection + .call(addDomain, {size: domainSize, color: domainColor}) + .style('font-size', labelsStyle?.fontSize || ''); + } }; } diff --git a/src/plugins/d3/renderer/utils/axis.ts b/src/plugins/d3/renderer/utils/axis.ts index cf52ec21..7371358e 100644 --- a/src/plugins/d3/renderer/utils/axis.ts +++ b/src/plugins/d3/renderer/utils/axis.ts @@ -52,11 +52,11 @@ export function getXAxisItems({ }: { scale: AxisScale; count?: number; - maxCount: number; + maxCount?: number; }) { let values = getScaleTicks(scale, count); - if (values.length > maxCount) { + if (maxCount && values.length > maxCount) { const step = Math.ceil(values.length / maxCount); values = values.filter((_: AxisDomain, i: number) => i % step === 0); } diff --git a/src/plugins/d3/renderer/utils/color.ts b/src/plugins/d3/renderer/utils/color.ts new file mode 100644 index 00000000..a2e03f22 --- /dev/null +++ b/src/plugins/d3/renderer/utils/color.ts @@ -0,0 +1,33 @@ +import {range, scaleLinear} from 'd3'; + +import {ChartKitWidgetData} from '../../../../types'; + +export function getDomainForContinuousColorScale(args: { + series: ChartKitWidgetData['series']['data']; +}): number[] { + const {series} = args; + const values = series.reduce((acc, s) => { + switch (s.type) { + case 'pie': { + acc.push(...s.data.map((d) => d.value)); + } + } + + return acc; + }, []); + + return [Math.min(...values), Math.max(...values)]; +} + +export function getContinuesColorFn(args: {values: number[]; colors: string[]; stops?: number[]}) { + const {values, colors, stops: customStops} = args; + const min = Math.min(...values); + const max = Math.max(...values); + const stops = customStops ?? range(colors.length).map((d, i, list) => d / list.length); + const color = scaleLinear(stops, colors); + + return (value: number) => { + const colorValue = (value - min) / (max - min); + return color(colorValue); + }; +} diff --git a/src/plugins/d3/renderer/utils/index.ts b/src/plugins/d3/renderer/utils/index.ts index 522b9906..3e798267 100644 --- a/src/plugins/d3/renderer/utils/index.ts +++ b/src/plugins/d3/renderer/utils/index.ts @@ -21,8 +21,10 @@ export * from './text'; export * from './time'; export * from './axis'; export * from './labels'; +export * from './legend'; export * from './symbol'; export * from './series'; +export * from './color'; const CHARTS_WITHOUT_AXIS: ChartKitWidgetSeries['type'][] = ['pie', 'treemap']; export const CHART_SERIES_WITH_VOLUME_ON_Y_AXIS: ChartKitWidgetSeries['type'][] = [ diff --git a/src/plugins/d3/renderer/utils/legend.ts b/src/plugins/d3/renderer/utils/legend.ts new file mode 100644 index 00000000..bcb07d0e --- /dev/null +++ b/src/plugins/d3/renderer/utils/legend.ts @@ -0,0 +1,33 @@ +import {Selection} from 'd3'; + +export function createGradientRect( + container: Selection, + args: { + x?: number; + y?: number; + width: number; + height: number; + interpolator: (value: number) => string; + }, +) { + const {x = 0, y = 0, width, height, interpolator} = args; + + const n = 256; + const canvas = document.createElement('canvas'); + canvas.width = n; + canvas.height = 1; + const context2 = canvas.getContext('2d'); + for (let i = 0, j = n - 1; i < n; ++i) { + context2.fillStyle = interpolator(i / j); + context2.fillRect(i, 0, 1, height); + } + + return container + .append('image') + .attr('preserveAspectRatio', 'none') + .attr('height', height) + .attr('width', width) + .attr('x', x) + .attr('y', y) + .attr('xlink:href', canvas.toDataURL()); +} diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index be6f50a2..3c61a31f 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -2,7 +2,14 @@ import type {BaseTextStyle} from './base'; export type ChartKitWidgetLegend = { enabled?: boolean; - + /** + * Different types for different color schemes. + * If the color scheme is continuous, a gradient legend will be drawn. + * Otherwise, samples for different point values + * + * @default 'discrete' + */ + type: 'discrete' | 'continuous'; /** * The horizontal alignment of the legend box within the chart area. * @@ -24,6 +31,27 @@ export type ChartKitWidgetLegend = { * @default 15 */ margin?: number; + title?: { + text?: string; + /** CSS styles for the title */ + style?: Partial; + /** The distance(in pixels) between the main content of the legend and its title + * + * Defaults to 4 for horizontal axes, 8 for vertical. + * */ + margin?: number; + }; + /* Gradient color settings for continuous legend type */ + colorScale?: { + /* Color stops for the gradient. + * If not defined, it is distributed evenly according to the number of specified colors + * */ + stops?: number[]; + /* The colors that form the gradient */ + colors: string[]; + }; + /* Width of the legend */ + width?: number; }; export type BaseLegendSymbol = { From 1ec9fb80a3fb41a247ac1ca67620b6b940228036 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Fri, 11 Oct 2024 18:05:20 +0300 Subject: [PATCH 2/4] fix --- src/plugins/d3/renderer/components/Legend.tsx | 18 ++++++++++++----- .../d3/renderer/constants/defaults/legend.ts | 7 ++----- .../hooks/useSeries/prepare-legend.ts | 6 +++--- .../renderer/utils/axis-generators/bottom.ts | 20 +++++++++---------- src/plugins/d3/renderer/utils/color.ts | 6 +++++- src/plugins/d3/renderer/utils/legend.ts | 10 +++++++--- src/types/widget-data/legend.ts | 2 +- 7 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 885b1aed..88045e48 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import {BaseType, line as lineGenerator, scaleLinear, select, symbol} from 'd3'; -import type {Selection} from 'd3'; +import {line as lineGenerator, scaleLinear, select, symbol} from 'd3'; +import type {AxisDomain, AxisScale, BaseType, Selection} from 'd3'; import {block} from '../../../../utils/cn'; import {GRADIENT_LEGEND_SIZE} from '../constants'; @@ -211,6 +211,7 @@ export const Legend = (props: Props) => { const svgElement = select(ref.current); svgElement.selectAll('*').remove(); + let legendWidth = 0; if (legend.type === 'discrete') { const limit = config.pagination?.limit; const pageItems = @@ -264,13 +265,15 @@ export const Legend = (props: Props) => { const {left} = getLegendPosition({ align: legend.align, width: boundsWidth, - offsetWidth: 0, + offsetWidth: 0, //config.offset.left, contentWidth, }); + // const left = 0; const top = legend.lineHeight * lineIndex; legendLine.attr('transform', `translate(${[left, top].join(',')})`); }); + legendWidth = boundsWidth; if (config.pagination) { const transform = `translate(${[ @@ -303,7 +306,7 @@ export const Legend = (props: Props) => { // ticks const xAxisGenerator = axisBottom({ - scale: scaleLinear(domain, [0, legend.width]), + scale: scaleLinear(domain, [0, legend.width]) as AxisScale, ticks: { items: [[0, -rectHeight]], labelsMargin: legend.ticks.labelsMargin, @@ -311,12 +314,17 @@ export const Legend = (props: Props) => { maxTickCount: 4, tickColor: '#fff', }, + domain: { + size: legend.width, + color: 'transparent', + }, }); const tickTop = legend.title.height + legend.title.margin + rectHeight; svgElement .append('g') .attr('transform', `translate(0, ${tickTop})`) .call(xAxisGenerator); + legendWidth = legend.width; } if (legend.title.enable) { @@ -340,7 +348,7 @@ export const Legend = (props: Props) => { align: legend.align, width: boundsWidth, offsetWidth: config.offset.left, - contentWidth: svgElement.node()?.getBoundingClientRect().width || 0, + contentWidth: legendWidth, }); svgElement.attr('transform', `translate(${[left, config.offset.top].join(',')})`); }, [boundsWidth, chartSeries, onItemClick, legend, items, config, paginationOffset]); diff --git a/src/plugins/d3/renderer/constants/defaults/legend.ts b/src/plugins/d3/renderer/constants/defaults/legend.ts index 3e8fcc8c..9a66a10c 100644 --- a/src/plugins/d3/renderer/constants/defaults/legend.ts +++ b/src/plugins/d3/renderer/constants/defaults/legend.ts @@ -1,10 +1,7 @@ import type {ChartKitWidgetLegend} from '../../../../../types'; -type LegendDefaults = Required> & - Pick; - -export const legendDefaults: LegendDefaults = { - align: 'center', +export const legendDefaults = { + align: 'center' as Required['align'], itemDistance: 20, margin: 15, itemStyle: { diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts index 512b460e..a3c7e00e 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -1,4 +1,4 @@ -import {range, select} from 'd3'; +import {select} from 'd3'; import clone from 'lodash/clone'; import get from 'lodash/get'; import merge from 'lodash/merge'; @@ -6,6 +6,7 @@ import merge from 'lodash/merge'; import type {BaseTextStyle, ChartKitWidgetData} from '../../../../../types'; import {GRADIENT_LEGEND_SIZE, legendDefaults} from '../../constants'; import { + getDefaultColorStops, getDomainForContinuousColorScale, getHorisontalSvgTextHeight, getLabelsSize, @@ -64,8 +65,7 @@ export const getPreparedLegend = (args: { colorScale.colors = legend?.colorScale?.colors ?? []; colorScale.stops = - legend?.colorScale?.stops ?? - range(colorScale.colors.length).map((d, i, list) => d / list.length); + legend?.colorScale?.stops ?? getDefaultColorStops(colorScale.colors.length); colorScale.domain = getDomainForContinuousColorScale({series}); } else { height += lineHeight; diff --git a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts index b6f02b6f..e8a793ef 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -21,7 +21,7 @@ type AxisBottomArgs = { rotation?: number; tickColor?: string; }; - domain?: { + domain: { size: number; color?: string; }; @@ -76,9 +76,11 @@ export function axisBottom(args: AxisBottomArgs) { }).maxHeight; return function (selection: Selection) { - const x = selection.node()?.getBoundingClientRect()?.x || 0; - const right = x + domain?.size ?? 0; - const top = -tickItems?.[0]?.[0] || 0; + const rect = selection.node()?.getBoundingClientRect(); + const x = rect?.x || 0; + + const right = x + domain.size; + const top = -(tickItems?.[0]?.[0] ?? 0); let transform = `translate(0, ${labelHeight + labelsMargin - top}px)`; if (rotation) { @@ -185,11 +187,9 @@ export function axisBottom(args: AxisBottomArgs) { }); } - if (domain) { - const {size: domainSize, color: domainColor} = domain; - selection - .call(addDomain, {size: domainSize, color: domainColor}) - .style('font-size', labelsStyle?.fontSize || ''); - } + const {size: domainSize, color: domainColor} = domain; + selection + .call(addDomain, {size: domainSize, color: domainColor}) + .style('font-size', labelsStyle?.fontSize || ''); }; } diff --git a/src/plugins/d3/renderer/utils/color.ts b/src/plugins/d3/renderer/utils/color.ts index a2e03f22..ffc35330 100644 --- a/src/plugins/d3/renderer/utils/color.ts +++ b/src/plugins/d3/renderer/utils/color.ts @@ -19,11 +19,15 @@ export function getDomainForContinuousColorScale(args: { return [Math.min(...values), Math.max(...values)]; } +export function getDefaultColorStops(size: number) { + return range(size).map((d) => d / size); +} + export function getContinuesColorFn(args: {values: number[]; colors: string[]; stops?: number[]}) { const {values, colors, stops: customStops} = args; const min = Math.min(...values); const max = Math.max(...values); - const stops = customStops ?? range(colors.length).map((d, i, list) => d / list.length); + const stops = customStops ?? getDefaultColorStops(colors.length); const color = scaleLinear(stops, colors); return (value: number) => { diff --git a/src/plugins/d3/renderer/utils/legend.ts b/src/plugins/d3/renderer/utils/legend.ts index bcb07d0e..a811678e 100644 --- a/src/plugins/d3/renderer/utils/legend.ts +++ b/src/plugins/d3/renderer/utils/legend.ts @@ -16,10 +16,14 @@ export function createGradientRect( const canvas = document.createElement('canvas'); canvas.width = n; canvas.height = 1; - const context2 = canvas.getContext('2d'); + const context = canvas.getContext('2d'); + if (!context) { + throw Error("Couldn't get canvas context"); + } + for (let i = 0, j = n - 1; i < n; ++i) { - context2.fillStyle = interpolator(i / j); - context2.fillRect(i, 0, 1, height); + context.fillStyle = interpolator(i / j); + context.fillRect(i, 0, 1, height); } return container diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index 3c61a31f..d5beb3b4 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -9,7 +9,7 @@ export type ChartKitWidgetLegend = { * * @default 'discrete' */ - type: 'discrete' | 'continuous'; + type?: 'discrete' | 'continuous'; /** * The horizontal alignment of the legend box within the chart area. * From 3019ef827ee8f20ebdfdbdb691d2d94d79653fa5 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Fri, 11 Oct 2024 18:26:55 +0300 Subject: [PATCH 3/4] add story for bar-x series --- .../bar-x/GradientLegend.stories.tsx | 82 +++++++++++++++++++ src/plugins/d3/renderer/components/Legend.tsx | 3 +- .../hooks/useSeries/prepare-legend.ts | 3 +- src/plugins/d3/renderer/utils/color.ts | 13 +++ src/types/widget-data/legend.ts | 5 ++ 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx diff --git a/src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx b/src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx new file mode 100644 index 00000000..eb153992 --- /dev/null +++ b/src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import type {StoryObj} from '@storybook/react'; +import {groups} from 'd3'; + +import {ChartKit} from '../../../../components/ChartKit'; +import {Loader} from '../../../../components/Loader/Loader'; +import {settings} from '../../../../libs'; +import type {BarXSeriesData, ChartKitWidgetData} from '../../../../types'; +import {ExampleWrapper} from '../../examples/ExampleWrapper'; +import nintendoGames from '../../examples/nintendoGames'; +import {D3Plugin} from '../../index'; +import {getContinuesColorFn} from '../../renderer/utils'; + +const BarXWithGradientLegend = () => { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + settings.set({plugins: [D3Plugin]}); + setLoading(false); + }, []); + + if (loading) { + return ; + } + + const colors = ['rgb(255, 61, 100)', 'rgb(255, 198, 54)', 'rgb(84, 165, 32)']; + const stops = [0, 0.5, 1]; + + const gamesByPlatform = groups(nintendoGames, (item) => item.platform); + const categories = gamesByPlatform.map(([platform, _games]) => platform); + const data: BarXSeriesData[] = gamesByPlatform.map(([platform, games], index) => ({ + x: index, + y: games.length, + label: `${platform}(${games.length})`, + })); + const getColor = getContinuesColorFn({colors, stops, values: data.map((d) => Number(d.y))}); + data.forEach((d) => { + d.color = getColor(Number(d.y)); + }); + + const widgetData: ChartKitWidgetData = { + series: { + data: [ + { + type: 'bar-x', + name: 'Series 1', + data, + }, + ], + }, + xAxis: { + type: 'category', + categories, + }, + title: {text: 'Bar-x with gradient legend'}, + legend: { + enabled: true, + type: 'continuous', + title: {text: 'Games by platform'}, + colorScale: { + colors: colors, + stops, + }, + }, + }; + + return ( + + + + ); +}; + +export const BarXWithGradientLegendStory: StoryObj = { + name: 'Gradient colored bar-x chart', +}; + +export default { + title: 'Plugins/D3/Bar-x', + component: BarXWithGradientLegend, +}; diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 88045e48..8451e79a 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -265,10 +265,9 @@ export const Legend = (props: Props) => { const {left} = getLegendPosition({ align: legend.align, width: boundsWidth, - offsetWidth: 0, //config.offset.left, + offsetWidth: 0, contentWidth, }); - // const left = 0; const top = legend.lineHeight * lineIndex; legendLine.attr('transform', `translate(${[left, top].join(',')})`); diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts index a3c7e00e..aa4c5c5e 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -66,7 +66,8 @@ export const getPreparedLegend = (args: { colorScale.colors = legend?.colorScale?.colors ?? []; colorScale.stops = legend?.colorScale?.stops ?? getDefaultColorStops(colorScale.colors.length); - colorScale.domain = getDomainForContinuousColorScale({series}); + colorScale.domain = + legend?.colorScale?.domain ?? getDomainForContinuousColorScale({series}); } else { height += lineHeight; } diff --git a/src/plugins/d3/renderer/utils/color.ts b/src/plugins/d3/renderer/utils/color.ts index ffc35330..a210ee54 100644 --- a/src/plugins/d3/renderer/utils/color.ts +++ b/src/plugins/d3/renderer/utils/color.ts @@ -10,6 +10,19 @@ export function getDomainForContinuousColorScale(args: { switch (s.type) { case 'pie': { acc.push(...s.data.map((d) => d.value)); + break; + } + case 'bar-y': { + acc.push(...s.data.map((d) => Number(d.x))); + break; + } + case 'scatter': + case 'bar-x': + case 'waterfall': + case 'line': + case 'area': { + acc.push(...s.data.map((d) => Number(d.y))); + break; } } diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index d5beb3b4..e3e2531e 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -49,6 +49,11 @@ export type ChartKitWidgetLegend = { stops?: number[]; /* The colors that form the gradient */ colors: string[]; + /* Data that is displayed as ticks. + * It can be useful when the points are colored according to additional dimensions that are not involved in the chart display. + * By default, it is formed depending on the type of series ("y" for bar-x or "value" for pie series, for example). + **/ + domain?: number[]; }; /* Width of the legend */ width?: number; From 5c612759f45e975c20c97c13d30628c54d6f3bb0 Mon Sep 17 00:00:00 2001 From: "Irina V. Kuzmina" Date: Mon, 14 Oct 2024 18:01:20 +0300 Subject: [PATCH 4/4] fix --- ...Legend.stories.tsx => ContinuousLegend.stories.tsx} | 10 +++++----- ...Legend.stories.tsx => ContinuousLegend.stories.tsx} | 10 +++++----- src/plugins/d3/renderer/components/Legend.tsx | 4 ++-- src/plugins/d3/renderer/constants/defaults/legend.ts | 2 +- .../d3/renderer/hooks/useSeries/prepare-legend.ts | 8 ++++---- src/plugins/d3/renderer/utils/color.ts | 5 +++++ src/types/widget-data/legend.ts | 1 + 7 files changed, 23 insertions(+), 17 deletions(-) rename src/plugins/d3/__stories__/bar-x/{GradientLegend.stories.tsx => ContinuousLegend.stories.tsx} (89%) rename src/plugins/d3/__stories__/pie/{GradientLegend.stories.tsx => ContinuousLegend.stories.tsx} (88%) diff --git a/src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx b/src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx similarity index 89% rename from src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx rename to src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx index eb153992..27e488f8 100644 --- a/src/plugins/d3/__stories__/bar-x/GradientLegend.stories.tsx +++ b/src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx @@ -12,7 +12,7 @@ import nintendoGames from '../../examples/nintendoGames'; import {D3Plugin} from '../../index'; import {getContinuesColorFn} from '../../renderer/utils'; -const BarXWithGradientLegend = () => { +const BarXWithContinuousLegend = () => { const [loading, setLoading] = React.useState(true); React.useEffect(() => { @@ -53,7 +53,7 @@ const BarXWithGradientLegend = () => { type: 'category', categories, }, - title: {text: 'Bar-x with gradient legend'}, + title: {text: 'Bar-x with continues color'}, legend: { enabled: true, type: 'continuous', @@ -72,11 +72,11 @@ const BarXWithGradientLegend = () => { ); }; -export const BarXWithGradientLegendStory: StoryObj = { - name: 'Gradient colored bar-x chart', +export const BarXWithContinuousLegendStory: StoryObj = { + name: 'Continuous legend', }; export default { title: 'Plugins/D3/Bar-x', - component: BarXWithGradientLegend, + component: BarXWithContinuousLegend, }; diff --git a/src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx b/src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx similarity index 88% rename from src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx rename to src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx index 832c3101..32fcdec6 100644 --- a/src/plugins/d3/__stories__/pie/GradientLegend.stories.tsx +++ b/src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx @@ -12,7 +12,7 @@ import nintendoGames from '../../examples/nintendoGames'; import {D3Plugin} from '../../index'; import {getContinuesColorFn} from '../../renderer/utils'; -const PieWithGradientLegend = () => { +const PieWithContinuousLegend = () => { const [loading, setLoading] = React.useState(true); React.useEffect(() => { @@ -47,7 +47,7 @@ const PieWithGradientLegend = () => { }, ], }, - title: {text: 'Pie with gradient legend'}, + title: {text: 'Pie with continues color'}, legend: { enabled: true, type: 'continuous', @@ -66,11 +66,11 @@ const PieWithGradientLegend = () => { ); }; -export const PieWithGradientLegendStory: StoryObj = { - name: 'Gradient colored pie', +export const PieWithContinuousLegendStory: StoryObj = { + name: 'Pie with continuous color', }; export default { title: 'Plugins/D3/Pie', - component: PieWithGradientLegend, + component: PieWithContinuousLegend, }; diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index 8451e79a..e8e48bc6 100644 --- a/src/plugins/d3/renderer/components/Legend.tsx +++ b/src/plugins/d3/renderer/components/Legend.tsx @@ -4,7 +4,7 @@ import {line as lineGenerator, scaleLinear, select, symbol} from 'd3'; import type {AxisDomain, AxisScale, BaseType, Selection} from 'd3'; import {block} from '../../../../utils/cn'; -import {GRADIENT_LEGEND_SIZE} from '../constants'; +import {CONTINUOUS_LEGEND_SIZE} from '../constants'; import type { LegendConfig, LegendItem, @@ -291,7 +291,7 @@ export const Legend = (props: Props) => { } else { // gradient rect const domain = legend.colorScale.domain ?? []; - const rectHeight = GRADIENT_LEGEND_SIZE.height; + const rectHeight = CONTINUOUS_LEGEND_SIZE.height; svgElement.call(createGradientRect, { y: legend.title.height + legend.title.margin, height: rectHeight, diff --git a/src/plugins/d3/renderer/constants/defaults/legend.ts b/src/plugins/d3/renderer/constants/defaults/legend.ts index 9a66a10c..f3cc0ee1 100644 --- a/src/plugins/d3/renderer/constants/defaults/legend.ts +++ b/src/plugins/d3/renderer/constants/defaults/legend.ts @@ -9,7 +9,7 @@ export const legendDefaults = { }, }; -export const GRADIENT_LEGEND_SIZE = { +export const CONTINUOUS_LEGEND_SIZE = { height: 12, width: 200, }; diff --git a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts index aa4c5c5e..3effa534 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -4,7 +4,7 @@ import get from 'lodash/get'; import merge from 'lodash/merge'; import type {BaseTextStyle, ChartKitWidgetData} from '../../../../../types'; -import {GRADIENT_LEGEND_SIZE, legendDefaults} from '../../constants'; +import {CONTINUOUS_LEGEND_SIZE, legendDefaults} from '../../constants'; import { getDefaultColorStops, getDomainForContinuousColorScale, @@ -38,7 +38,7 @@ export const getPreparedLegend = (args: { const titleStyle: BaseTextStyle = { fontSize: '12px', fontWeight: 'bold', - ...get(legend, 'title.style', {}), + ...get(legend, 'title.style'), }; const titleText = isTitleEnabled ? get(legend, 'title.text', '') : ''; const titleHeight = isTitleEnabled @@ -60,7 +60,7 @@ export const getPreparedLegend = (args: { if (enabled) { height += titleHeight + titleMargin; if (legendType === 'continuous') { - height += GRADIENT_LEGEND_SIZE.height; + height += CONTINUOUS_LEGEND_SIZE.height; height += ticks.labelsLineHeight + ticks.labelsMargin; colorScale.colors = legend?.colorScale?.colors ?? []; @@ -73,7 +73,7 @@ export const getPreparedLegend = (args: { } } - const legendWidth = get(legend, 'width', GRADIENT_LEGEND_SIZE.width); + const legendWidth = get(legend, 'width', CONTINUOUS_LEGEND_SIZE.width); return { align: get(legend, 'align', legendDefaults.align), diff --git a/src/plugins/d3/renderer/utils/color.ts b/src/plugins/d3/renderer/utils/color.ts index a210ee54..c4d59f43 100644 --- a/src/plugins/d3/renderer/utils/color.ts +++ b/src/plugins/d3/renderer/utils/color.ts @@ -24,6 +24,11 @@ export function getDomainForContinuousColorScale(args: { acc.push(...s.data.map((d) => Number(d.y))); break; } + default: { + throw Error( + `The method for calculation a domain for a continuous color scale for the "${s.type}" series is not defined`, + ); + } } return acc; diff --git a/src/types/widget-data/legend.ts b/src/types/widget-data/legend.ts index e3e2531e..8affda16 100644 --- a/src/types/widget-data/legend.ts +++ b/src/types/widget-data/legend.ts @@ -31,6 +31,7 @@ export type ChartKitWidgetLegend = { * @default 15 */ margin?: number; + /* The title that will be added on top of the legend. */ title?: { text?: string; /** CSS styles for the title */