From 5fe8bb220f18d13b425f82d98568e5c4bb87d1b9 Mon Sep 17 00:00:00 2001 From: Irina Kuzmina Date: Mon, 14 Oct 2024 18:10:39 +0300 Subject: [PATCH] feat(D3 plugin): add options for gradient legend (#529) * feat(D3): add options for gradient legend * fix * add story for bar-x series * fix --- .../bar-x/ContinuousLegend.stories.tsx | 82 +++++++ .../pie/ContinuousLegend.stories.tsx | 76 +++++++ src/plugins/d3/renderer/components/Legend.tsx | 208 ++++++++++++------ .../d3/renderer/components/styles.scss | 6 + .../d3/renderer/constants/defaults/legend.ts | 12 +- .../hooks/useSeries/prepare-legend.ts | 86 +++++++- .../d3/renderer/hooks/useSeries/types.ts | 18 +- .../renderer/utils/axis-generators/bottom.ts | 31 ++- src/plugins/d3/renderer/utils/axis.ts | 4 +- src/plugins/d3/renderer/utils/color.ts | 55 +++++ src/plugins/d3/renderer/utils/index.ts | 2 + src/plugins/d3/renderer/utils/legend.ts | 37 ++++ src/types/widget-data/legend.ts | 36 ++- 13 files changed, 550 insertions(+), 103 deletions(-) create mode 100644 src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx create mode 100644 src/plugins/d3/__stories__/pie/ContinuousLegend.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__/bar-x/ContinuousLegend.stories.tsx b/src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx new file mode 100644 index 00000000..27e488f8 --- /dev/null +++ b/src/plugins/d3/__stories__/bar-x/ContinuousLegend.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 BarXWithContinuousLegend = () => { + 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 continues color'}, + legend: { + enabled: true, + type: 'continuous', + title: {text: 'Games by platform'}, + colorScale: { + colors: colors, + stops, + }, + }, + }; + + return ( + + + + ); +}; + +export const BarXWithContinuousLegendStory: StoryObj = { + name: 'Continuous legend', +}; + +export default { + title: 'Plugins/D3/Bar-x', + component: BarXWithContinuousLegend, +}; diff --git a/src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx b/src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx new file mode 100644 index 00000000..32fcdec6 --- /dev/null +++ b/src/plugins/d3/__stories__/pie/ContinuousLegend.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 PieWithContinuousLegend = () => { + 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 continues color'}, + legend: { + enabled: true, + type: 'continuous', + title: {text: 'Games by platform'}, + colorScale: { + colors: colors, + stops, + }, + }, + }; + + return ( + + + + ); +}; + +export const PieWithContinuousLegendStory: StoryObj = { + name: 'Pie with continuous color', +}; + +export default { + title: 'Plugins/D3/Pie', + component: PieWithContinuousLegend, +}; diff --git a/src/plugins/d3/renderer/components/Legend.tsx b/src/plugins/d3/renderer/components/Legend.tsx index e70ca948..e8e48bc6 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 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 {CONTINUOUS_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,147 @@ 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); - }; + let legendWidth = 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(',')})`); + }); + legendWidth = boundsWidth; + + 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 = CONTINUOUS_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, + }), }); - const top = config.offset.top + legend.lineHeight * lineIndex; - legendLine.attr('transform', `translate(${[left, top].join(',')})`); - }); + // ticks + const xAxisGenerator = axisBottom({ + scale: scaleLinear(domain, [0, legend.width]) as AxisScale, + ticks: { + items: [[0, -rectHeight]], + labelsMargin: legend.ticks.labelsMargin, + labelsLineHeight: legend.ticks.labelsLineHeight, + 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 (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, + 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: legendWidth, + }); + 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..f3cc0ee1 100644 --- a/src/plugins/d3/renderer/constants/defaults/legend.ts +++ b/src/plugins/d3/renderer/constants/defaults/legend.ts @@ -1,13 +1,15 @@ 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: { fontSize: '12px', }, }; + +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 c321f87f..3effa534 100644 --- a/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts +++ b/src/plugins/d3/renderer/hooks/useSeries/prepare-legend.ts @@ -3,9 +3,14 @@ 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 {CONTINUOUS_LEGEND_SIZE, legendDefaults} from '../../constants'; +import { + getDefaultColorStops, + getDomainForContinuousColorScale, + getHorisontalSvgTextHeight, + getLabelsSize, +} from '../../utils'; import {getBoundsWidth} from '../useChartDimensions'; import {getYAxisWidth} from '../useChartDimensions/utils'; import type {PreparedAxis, PreparedChart} from '../useChartOptions/types'; @@ -27,7 +32,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 += CONTINUOUS_LEGEND_SIZE.height; + height += ticks.labelsLineHeight + ticks.labelsMargin; + + colorScale.colors = legend?.colorScale?.colors ?? []; + colorScale.stops = + legend?.colorScale?.stops ?? getDefaultColorStops(colorScale.colors.length); + colorScale.domain = + legend?.colorScale?.domain ?? getDomainForContinuousColorScale({series}); + } else { + height += lineHeight; + } + } + + const legendWidth = get(legend, 'width', CONTINUOUS_LEGEND_SIZE.width); return { align: get(legend, 'align', legendDefaults.align), @@ -37,6 +83,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 +173,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..e8a793ef 100644 --- a/src/plugins/d3/renderer/utils/axis-generators/bottom.ts +++ b/src/plugins/d3/renderer/utils/axis-generators/bottom.ts @@ -10,15 +10,16 @@ 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: { size: number; @@ -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}); @@ -74,9 +76,11 @@ export function axisBottom(args: AxisBottomArgs) { }).maxHeight; return function (selection: Selection) { - const x = selection.node()?.getBoundingClientRect()?.x || 0; - const right = x + domainSize; - 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) { @@ -89,7 +93,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 +104,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,6 +187,7 @@ export function axisBottom(args: AxisBottomArgs) { }); } + 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..c4d59f43 --- /dev/null +++ b/src/plugins/d3/renderer/utils/color.ts @@ -0,0 +1,55 @@ +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)); + 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; + } + default: { + throw Error( + `The method for calculation a domain for a continuous color scale for the "${s.type}" series is not defined`, + ); + } + } + + return acc; + }, []); + + 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 ?? getDefaultColorStops(colors.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..a811678e --- /dev/null +++ b/src/plugins/d3/renderer/utils/legend.ts @@ -0,0 +1,37 @@ +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 context = canvas.getContext('2d'); + if (!context) { + throw Error("Couldn't get canvas context"); + } + + for (let i = 0, j = n - 1; i < n; ++i) { + context.fillStyle = interpolator(i / j); + context.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..8affda16 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,33 @@ 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 */ + 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[]; + /* 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; }; export type BaseLegendSymbol = {