Skip to content

Commit

Permalink
feat(D3 plugin): add options for gradient legend (#529)
Browse files Browse the repository at this point in the history
* feat(D3): add options for gradient legend

* fix

* add story for bar-x series

* fix
  • Loading branch information
kuzmadom authored Oct 14, 2024
1 parent 9bddfef commit 5fe8bb2
Show file tree
Hide file tree
Showing 13 changed files with 550 additions and 103 deletions.
82 changes: 82 additions & 0 deletions src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <Loader />;
}

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));

Check warning on line 39 in src/plugins/d3/__stories__/bar-x/ContinuousLegend.stories.tsx

View workflow job for this annotation

GitHub Actions / Verify Files

Assignment to property of function parameter 'd'
});

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 (
<ExampleWrapper styles={{minHeight: '400px'}}>
<ChartKit type="d3" data={widgetData} />
</ExampleWrapper>
);
};

export const BarXWithContinuousLegendStory: StoryObj<typeof BarXWithContinuousLegend> = {
name: 'Continuous legend',
};

export default {
title: 'Plugins/D3/Bar-x',
component: BarXWithContinuousLegend,
};
76 changes: 76 additions & 0 deletions src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <Loader />;
}

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);

Check warning on line 38 in src/plugins/d3/__stories__/pie/ContinuousLegend.stories.tsx

View workflow job for this annotation

GitHub Actions / Verify Files

Assignment to property of function parameter 'd'
});

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 (
<ExampleWrapper styles={{minHeight: '400px'}}>
<ChartKit type="d3" data={widgetData} />
</ExampleWrapper>
);
};

export const PieWithContinuousLegendStory: StoryObj<typeof PieWithContinuousLegend> = {
name: 'Pie with continuous color',
};

export default {
title: 'Plugins/D3/Pie',
component: PieWithContinuousLegend,
};
208 changes: 138 additions & 70 deletions src/plugins/d3/renderer/components/Legend.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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');

Expand Down Expand Up @@ -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<AxisDomain>,
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 <g ref={ref} width={boundsWidth} height={legend.height} />;
return <g className={b()} ref={ref} width={boundsWidth} height={legend.height} />;
};
Loading

0 comments on commit 5fe8bb2

Please sign in to comment.