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 = {