Skip to content

Commit

Permalink
feat(D3): add options for gradient legend
Browse files Browse the repository at this point in the history
  • Loading branch information
kuzmadom committed Oct 11, 2024
1 parent 9bddfef commit 49cdd02
Show file tree
Hide file tree
Showing 12 changed files with 429 additions and 101 deletions.
76 changes: 76 additions & 0 deletions src/plugins/d3/__stories__/pie/GradientLegend.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 PieWithGradientLegend = () => {
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/GradientLegend.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 gradient legend'},
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 PieWithGradientLegendStory: StoryObj<typeof PieWithGradientLegend> = {
name: 'Gradient colored pie',
};

export default {
title: 'Plugins/D3/Pie',
component: PieWithGradientLegend,
};
199 changes: 130 additions & 69 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 {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,
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,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 <g ref={ref} width={boundsWidth} height={legend.height} />;
return <g className={b()} ref={ref} width={boundsWidth} height={legend.height} />;
};
6 changes: 6 additions & 0 deletions src/plugins/d3/renderer/components/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/plugins/d3/renderer/constants/defaults/legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ export const legendDefaults: LegendDefaults = {
fontSize: '12px',
},
};

export const GRADIENT_LEGEND_SIZE = {
height: 12,
width: 200,
};
Loading

0 comments on commit 49cdd02

Please sign in to comment.