Skip to content

Commit

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

* fix legend

* fix legend symbol

* fix getLegendItems

* fix useSeries
  • Loading branch information
kuzmadom authored Aug 24, 2023
1 parent 8edb5e3 commit 8844b57
Show file tree
Hide file tree
Showing 17 changed files with 304 additions and 149 deletions.
14 changes: 6 additions & 8 deletions src/plugins/d3/renderer/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
useChartDimensions,
useChartEvents,
useChartOptions,
useLegend,
useAxisScales,
useSeries,
useShapes,
Expand All @@ -35,12 +34,10 @@ type Props = {
export const Chart = (props: Props) => {
const {top, left, width, height, data} = props;
// FIXME: add data validation
const {series} = data;
const svgRef = React.createRef<SVGSVGElement>();
const hasAxisRelatedSeries = series.data.some(isAxisRelatedSeries);
const {chartHovered, handleMouseEnter, handleMouseLeave} = useChartEvents();
const {chart, legend, title, tooltip, xAxis, yAxis} = useChartOptions(data);
const {boundsWidth, boundsHeight, legendHeight} = useChartDimensions({
const {boundsWidth, boundsHeight} = useChartDimensions({
width,
height,
margin: chart.margin,
Expand All @@ -49,8 +46,7 @@ export const Chart = (props: Props) => {
xAxis,
yAxis,
});
const {activeLegendItems, handleLegendItemClick} = useLegend({series: series.data});
const {chartSeries} = useSeries({activeLegendItems, series: series.data});
const {chartSeries, handleLegendItemClick} = useSeries({series: data.series, legend});
const {xScale, yScale} = useAxisScales({
boundsWidth,
boundsHeight,
Expand All @@ -75,6 +71,7 @@ export const Chart = (props: Props) => {
onSeriesMouseMove: handleSeriesMouseMove,
onSeriesMouseLeave: handleSeriesMouseLeave,
});
const hasAxisRelatedSeries = chartSeries.some(isAxisRelatedSeries);

return (
<React.Fragment>
Expand Down Expand Up @@ -119,8 +116,9 @@ export const Chart = (props: Props) => {
<Legend
width={boundsWidth}
offsetWidth={chart.margin.left}
height={legendHeight}
offsetHeight={height - legendHeight / 2}
height={legend.height}
legend={legend}
offsetHeight={height - legend.height / 2}
chartSeries={chartSeries}
onItemClick={handleLegendItemClick}
/>
Expand Down
105 changes: 83 additions & 22 deletions src/plugins/d3/renderer/components/Legend.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,83 @@
import React from 'react';
import {select} from 'd3';
import {select, sum} from 'd3';
import get from 'lodash/get';

import {block} from '../../../../utils/cn';
import type {ChartSeries, OnLegendItemClick} from '../hooks';
import type {
OnLegendItemClick,
PreparedLegend,
PreparedLegendSymbol,
PreparedSeries,
} from '../hooks';
import {isAxisRelatedSeries} from '../utils';

const b = block('d3-legend');

type Props = {
width: number;
height: number;
legend: PreparedLegend;
offsetWidth: number;
offsetHeight: number;
chartSeries: ChartSeries[];
chartSeries: PreparedSeries[];
onItemClick: OnLegendItemClick;
};

type LegendItem = {color: string; name: string; visible?: boolean};
type LegendItem = {
color: string;
name: string;
visible?: boolean;
symbol: PreparedLegendSymbol;
};

const getLegendItems = (series: ChartSeries[]) => {
const getLegendItems = (series: PreparedSeries[]) => {
return series.reduce<LegendItem[]>((acc, s) => {
const isAxisRelated = isAxisRelatedSeries(s);
const legendEnabled = get(s, 'legend.enabled', true);

if (isAxisRelated) {
acc.push(s);
} else if (!isAxisRelated && legendEnabled) {
acc.push(...(s.data as LegendItem[]));
if (legendEnabled) {
if (isAxisRelated) {
acc.push({
...s,
symbol: s.legend.symbol,
});
} else {
const legendItems = s.data.map((item) => {
return {
...item,
symbol: s.legend.symbol,
} as LegendItem;
});
acc.push(...legendItems);
}
}

return acc;
}, []);
};

function getLegendPosition(args: {
align: PreparedLegend['align'];
contentWidth: number;
width: number;
offsetWidth: number;
}) {
const {align, offsetWidth, width, contentWidth} = args;
const top = 0;

if (align === 'left') {
return {top, left: offsetWidth};
}

if (align === 'right') {
return {top, left: offsetWidth + width - contentWidth};
}

return {top, left: offsetWidth + width / 2 - contentWidth / 2};
}

export const Legend = (props: Props) => {
const {width, offsetWidth, height, offsetHeight, chartSeries, onItemClick} = props;
const {width, offsetWidth, height, offsetHeight, chartSeries, legend, onItemClick} = props;
const ref = React.useRef<SVGGElement>(null);

React.useEffect(() => {
Expand All @@ -44,7 +86,6 @@ export const Legend = (props: Props) => {
}

const legendItems = getLegendItems(chartSeries);
const size = 10;
const textWidths: number[] = [0];
const svgElement = select(ref.current);
svgElement.selectAll('*').remove();
Expand All @@ -71,27 +112,33 @@ export const Legend = (props: Props) => {

legendItemTemplate
.append('rect')
.attr('x', function (_d, i) {
.attr('x', function (legendItem, i) {
return (
offsetWidth +
i * size +
i * legendItem.symbol.width +
i * legend.itemDistance +
i * legendItem.symbol.padding +
textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0)
);
})
.attr('y', offsetHeight - size / 2)
.attr('width', size)
.attr('height', size)
.attr('y', (legendItem) => offsetHeight - legendItem.symbol.height / 2)
.attr('width', (legendItem) => {
return legendItem.symbol.width;
})
.attr('height', (legendItem) => legendItem.symbol.height)
.attr('rx', (legendItem) => legendItem.symbol.radius)
.attr('class', b('item-shape'))
.style('fill', function (d) {
return d.color;
});
legendItemTemplate
.append('text')
.attr('x', function (_d, i) {
.attr('x', function (legendItem, i) {
return (
offsetWidth +
i * size +
size +
i * legendItem.symbol.width +
i * legend.itemDistance +
i * legendItem.symbol.padding +
legendItem.symbol.width +
legendItem.symbol.padding +
textWidths.slice(0, i + 1).reduce((acc, tw) => acc + tw, 0)
);
})
Expand All @@ -104,7 +151,21 @@ export const Legend = (props: Props) => {
return ('name' in d && d.name) as string;
})
.style('alignment-baseline', 'middle');
}, [width, offsetWidth, height, offsetHeight, chartSeries, onItemClick]);

const contentWidth =
sum(textWidths) +
sum(legendItems, (item) => item.symbol.width + item.symbol.padding) +
legend.itemDistance * (legendItems.length - 1);

const {left} = getLegendPosition({
align: legend.align,
width,
offsetWidth,
contentWidth,
});

svgElement.attr('transform', `translate(${[left, 0].join(',')})`);
}, [width, offsetWidth, height, offsetHeight, chartSeries, onItemClick, legend]);

return <g ref={ref} width={width} height={height} />;
};
2 changes: 1 addition & 1 deletion src/plugins/d3/renderer/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ export * from './useChartDimensions';
export * from './useChartEvents';
export * from './useChartOptions';
export * from './useChartOptions/types';
export * from './useLegend';
export * from './useAxisScales';
export * from './useSeries';
export * from './useSeries/types';
export * from './useShapes';
export * from './useTooltip';
export * from './useTooltip/types';
11 changes: 4 additions & 7 deletions src/plugins/d3/renderer/hooks/useChartDimensions/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import type {ChartMargin} from '../../../../../types/widget-data';

import type {ChartOptions, PreparedAxis, PreparedTitle} from '../useChartOptions/types';

const LEGEND_LINE_HEIGHT = 15;
import type {PreparedAxis, PreparedLegend, PreparedTitle} from '../useChartOptions/types';

type Args = {
width: number;
height: number;
margin: ChartMargin;
legend: ChartOptions['legend'];
legend: PreparedLegend;
title?: PreparedTitle;
xAxis?: PreparedAxis;
yAxis?: PreparedAxis[];
};

export const useChartDimensions = (args: Args) => {
const {margin, legend, title, width, height, xAxis, yAxis} = args;
const legendHeight = legend.enabled ? LEGEND_LINE_HEIGHT : 0;
const titleHeight = title?.height || 0;
const xAxisTitleHeight = xAxis?.title.height || 0;
const yAxisTitleHeight =
Expand All @@ -26,7 +23,7 @@ export const useChartDimensions = (args: Args) => {

const boundsWidth = width - margin.right - margin.left - yAxisTitleHeight;
const boundsHeight =
height - margin.top - margin.bottom - legendHeight - titleHeight - xAxisTitleHeight;
height - margin.top - margin.bottom - legend.height - titleHeight - xAxisTitleHeight;

return {boundsWidth, boundsHeight, legendHeight};
return {boundsWidth, boundsHeight, legendHeight: legend.height};
};
10 changes: 8 additions & 2 deletions src/plugins/d3/renderer/hooks/useChartOptions/legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import type {ChartKitWidgetData} from '../../../../../types/widget-data';

import type {PreparedLegend} from './types';

const LEGEND_LINE_HEIGHT = 15;

export const getPreparedLegend = (args: {
legend: ChartKitWidgetData['legend'];
series: ChartKitWidgetData['series'];
}): PreparedLegend => {
const {legend, series} = args;
const enabled = legend?.enabled;
const enabled = typeof legend?.enabled === 'boolean' ? legend?.enabled : series.data.length > 1;
const height = enabled ? LEGEND_LINE_HEIGHT : 0;

return {
enabled: typeof enabled === 'boolean' ? enabled : series.data.length > 1,
align: legend?.align || 'center',
enabled,
itemDistance: legend?.itemDistance || 20,
height,
};
};
4 changes: 3 additions & 1 deletion src/plugins/d3/renderer/hooks/useChartOptions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export type PreparedChart = {
margin: ChartMargin;
};

export type PreparedLegend = Required<ChartKitWidgetLegend>;
export type PreparedLegend = Required<ChartKitWidgetLegend> & {
height: number;
};

export type PreparedAxis = Omit<ChartKitWidgetAxis, 'type' | 'labels'> & {
type: ChartKitWidgetAxisType;
Expand Down
79 changes: 0 additions & 79 deletions src/plugins/d3/renderer/hooks/useLegend/index.ts

This file was deleted.

Loading

0 comments on commit 8844b57

Please sign in to comment.