Add datalinks support to PieChart v2 (#31642)

* Remove default value for multiSelect

* Add data links support for PieChart v2

* Temporarily fix story

* Refactor PieSlice, deduplicate colors

* Add field config to context menu of pie chart

* Add custom key to legend

This is a bit hacky. When there are multiple DisplayValues with the same
title the react keys collide and LegendListItems are not cleared
correctly.

* Forgot to add the interface

* Fix missing tooltip in edit mode
This commit is contained in:
Oscar Kilhed 2021-03-10 23:02:04 +01:00 committed by GitHub
parent 477a54ae54
commit e7757b0175
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 135 additions and 73 deletions

View File

@ -186,7 +186,6 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
) { ) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
defaultValue: config.defaultValue ?? [],
id: config.path, id: config.path,
editor: standardEditorsRegistry.get('multi-select').editor as any, editor: standardEditorsRegistry.get('multi-select').editor as any,
}); });

View File

@ -12,7 +12,7 @@ interface DataLinksContextMenuProps {
} }
export interface DataLinksContextMenuApi { export interface DataLinksContextMenuApi {
openMenu?: React.MouseEventHandler<HTMLElement>; openMenu?: React.MouseEventHandler<HTMLOrSVGElement>;
targetClassName?: string; targetClassName?: string;
} }

View File

@ -2,6 +2,7 @@ import React from 'react';
import { object, select, number, boolean } from '@storybook/addon-knobs'; import { object, select, number, boolean } from '@storybook/addon-knobs';
import { PieChart, PieChartType } from '@grafana/ui'; import { PieChart, PieChartType } from '@grafana/ui';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { FieldConfig } from '@grafana/data';
export default { export default {
title: 'Visualizations/PieChart', title: 'Visualizations/PieChart',
@ -9,14 +10,25 @@ export default {
component: PieChart, component: PieChart,
}; };
const fieldConfig: FieldConfig = {
displayName: '',
min: 0,
max: 10,
decimals: 10,
thresholds: {} as any,
noValue: 'no value',
unit: 'km/s',
links: {} as any,
};
const getKnobs = () => { const getKnobs = () => {
return { return {
datapoints: object('datapoints', [ datapoints: object('datapoints', [
{ numeric: 100, text: '100', title: 'USA' }, { field: fieldConfig, hasLinks: false, name: 'USA', display: { numeric: 100, text: '100', title: 'USA' } },
{ numeric: 200, text: '200', title: 'Canada' }, { field: fieldConfig, hasLinks: false, name: 'Canada', display: { numeric: 200, text: '200', title: 'Canada' } },
{ numeric: 20, text: '20', title: 'Sweden' }, { field: fieldConfig, hasLinks: false, name: 'Sweden', display: { numeric: 20, text: '20', title: 'Sweden' } },
{ numeric: 50, text: '50', title: 'Spain' }, { field: fieldConfig, hasLinks: false, name: 'Spain', display: { numeric: 50, text: '50', title: 'Spain' } },
{ numeric: 70, text: '70', title: 'Germeny' }, { field: fieldConfig, hasLinks: false, name: 'Germany', display: { numeric: 70, text: '70', title: 'Germeny' } },
]), ]),
width: number('Width', 500), width: number('Width', 500),
height: number('Height', 500), height: number('Height', 500),
@ -30,11 +42,11 @@ const getKnobs = () => {
export const basic = () => { export const basic = () => {
const { datapoints, pieType, width, height } = getKnobs(); const { datapoints, pieType, width, height } = getKnobs();
return <PieChart width={width} height={height} values={datapoints} pieType={pieType} />; return <PieChart width={width} height={height} fieldDisplayValues={datapoints} pieType={pieType} />;
}; };
export const donut = () => { export const donut = () => {
const { datapoints, width, height } = getKnobs(); const { datapoints, width, height } = getKnobs();
return <PieChart width={width} height={height} values={datapoints} pieType={PieChartType.Donut} />; return <PieChart width={width} height={height} fieldDisplayValues={datapoints} pieType={PieChartType.Donut} />;
}; };

View File

@ -1,8 +1,8 @@
import React, { FC } from 'react'; import React, { FC, ReactNode } from 'react';
import { DisplayValue, FALLBACK_COLOR, formattedValueToString, GrafanaTheme } from '@grafana/data'; import { DisplayValue, FALLBACK_COLOR, FieldDisplay, formattedValueToString, GrafanaTheme } from '@grafana/data';
import { useStyles, useTheme } from '../../themes/ThemeContext'; import { useStyles, useTheme } from '../../themes/ThemeContext';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import Pie, { PieArcDatum } from '@visx/shape/lib/shapes/Pie'; import Pie, { PieArcDatum, ProvidedProps } from '@visx/shape/lib/shapes/Pie';
import { Group } from '@visx/group'; import { Group } from '@visx/group';
import { RadialGradient } from '@visx/gradient'; import { RadialGradient } from '@visx/gradient';
import { localPoint } from '@visx/event'; import { localPoint } from '@visx/event';
@ -12,6 +12,8 @@ import { css } from 'emotion';
import { VizLegend, VizLegendItem } from '..'; import { VizLegend, VizLegendItem } from '..';
import { VizLayout } from '../VizLayout/VizLayout'; import { VizLayout } from '../VizLayout/VizLayout';
import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/types'; import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/types';
import { DataLinksContextMenu } from '../DataLinks/DataLinksContextMenu';
import { UseTooltipParams } from '@visx/tooltip/lib/hooks/useTooltip';
export enum PieChartLabels { export enum PieChartLabels {
Name = 'name', Name = 'name',
@ -27,7 +29,7 @@ export enum PieChartLegendValues {
interface SvgProps { interface SvgProps {
height: number; height: number;
width: number; width: number;
values: DisplayValue[]; fieldDisplayValues: FieldDisplay[];
pieType: PieChartType; pieType: PieChartType;
displayLabels?: PieChartLabels[]; displayLabels?: PieChartLabels[];
useGradients?: boolean; useGradients?: boolean;
@ -54,24 +56,26 @@ const defaultLegendOptions: PieChartLegendOptions = {
}; };
export const PieChart: FC<Props> = ({ export const PieChart: FC<Props> = ({
values, fieldDisplayValues,
legendOptions = defaultLegendOptions, legendOptions = defaultLegendOptions,
onSeriesColorChange, onSeriesColorChange,
width, width,
height, height,
...restProps ...restProps
}) => { }) => {
const getLegend = (values: DisplayValue[], legendOptions: PieChartLegendOptions) => { const getLegend = (fields: FieldDisplay[], legendOptions: PieChartLegendOptions) => {
if (legendOptions.displayMode === LegendDisplayMode.Hidden) { if (legendOptions.displayMode === LegendDisplayMode.Hidden) {
return undefined; return undefined;
} }
const values = fields.map((v) => v.display);
const total = values.reduce((acc, item) => item.numeric + acc, 0); const total = values.reduce((acc, item) => item.numeric + acc, 0);
const legendItems = values.map<VizLegendItem>((value) => { const legendItems = values.map<VizLegendItem>((value, idx) => {
return { return {
label: value.title ?? '', label: value.title ?? '',
color: value.color ?? FALLBACK_COLOR, color: value.color ?? FALLBACK_COLOR,
yAxis: 1, yAxis: 1,
getItemKey: () => (value.title ?? '') + idx,
getDisplayValues: () => { getDisplayValues: () => {
const valuesToShow = legendOptions.values ?? []; const valuesToShow = legendOptions.values ?? [];
let displayValues = []; let displayValues = [];
@ -108,16 +112,18 @@ export const PieChart: FC<Props> = ({
}; };
return ( return (
<VizLayout width={width} height={height} legend={getLegend(values, legendOptions)}> <VizLayout width={width} height={height} legend={getLegend(fieldDisplayValues, legendOptions)}>
{(vizWidth: number, vizHeight: number) => { {(vizWidth: number, vizHeight: number) => {
return <PieChartSvg width={vizWidth} height={vizHeight} values={values} {...restProps} />; return (
<PieChartSvg width={vizWidth} height={vizHeight} fieldDisplayValues={fieldDisplayValues} {...restProps} />
);
}} }}
</VizLayout> </VizLayout>
); );
}; };
export const PieChartSvg: FC<SvgProps> = ({ export const PieChartSvg: FC<SvgProps> = ({
values, fieldDisplayValues,
pieType, pieType,
width, width,
height, height,
@ -127,44 +133,37 @@ export const PieChartSvg: FC<SvgProps> = ({
const theme = useTheme(); const theme = useTheme();
const componentInstanceId = useComponentInstanceId('PieChart'); const componentInstanceId = useComponentInstanceId('PieChart');
const styles = useStyles(getStyles); const styles = useStyles(getStyles);
const { tooltipData, tooltipLeft, tooltipTop, tooltipOpen, showTooltip, hideTooltip } = useTooltip<DisplayValue>(); const tooltip = useTooltip<DisplayValue>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({ const { containerRef, TooltipInPortal } = useTooltipInPortal({
detectBounds: true, detectBounds: true,
scroll: true, scroll: true,
}); });
if (values.length < 0) { if (fieldDisplayValues.length < 0) {
return <div>No data</div>; return <div>No data</div>;
} }
const getValue = (d: DisplayValue) => d.numeric; const getValue = (d: FieldDisplay) => d.display.numeric;
const getGradientId = (color: string) => `${componentInstanceId}-${color}`; const getGradientId = (color: string) => `${componentInstanceId}-${color}`;
const getGradientColor = (color: string) => { const getGradientColor = (color: string) => {
return `url(#${getGradientId(color)})`; return `url(#${getGradientId(color)})`;
}; };
const onMouseMoveOverArc = (event: any, datum: any) => {
const coords = localPoint(event.target.ownerSVGElement, event);
showTooltip({
tooltipLeft: coords!.x,
tooltipTop: coords!.y,
tooltipData: datum,
});
};
const showLabel = displayLabels.length > 0; const showLabel = displayLabels.length > 0;
const total = values.reduce((acc, item) => item.numeric + acc, 0); const total = fieldDisplayValues.reduce((acc, item) => item.display.numeric + acc, 0);
const layout = getPieLayout(width, height, pieType); const layout = getPieLayout(width, height, pieType);
const colors = [
...new Set(fieldDisplayValues.map((fieldDisplayValue) => fieldDisplayValue.display.color ?? FALLBACK_COLOR)),
];
return ( return (
<div className={styles.container}> <div className={styles.container}>
<svg width={layout.size} height={layout.size} ref={containerRef}> <svg width={layout.size} height={layout.size} ref={containerRef}>
<Group top={layout.position} left={layout.position}> <Group top={layout.position} left={layout.position}>
{values.map((value) => { {colors.map((color) => {
const color = value.color ?? FALLBACK_COLOR;
return ( return (
<RadialGradient <RadialGradient
key={value.color} key={color}
id={getGradientId(color)} id={getGradientId(color)}
from={getGradientColorFrom(color, theme)} from={getGradientColorFrom(color, theme)}
to={getGradientColorTo(color, theme)} to={getGradientColorTo(color, theme)}
@ -178,7 +177,7 @@ export const PieChartSvg: FC<SvgProps> = ({
); );
})} })}
<Pie <Pie
data={values} data={fieldDisplayValues}
pieValue={getValue} pieValue={getValue}
outerRadius={layout.outerRadius} outerRadius={layout.outerRadius}
innerRadius={layout.innerRadius} innerRadius={layout.innerRadius}
@ -187,47 +186,95 @@ export const PieChartSvg: FC<SvgProps> = ({
> >
{(pie) => { {(pie) => {
return pie.arcs.map((arc) => { return pie.arcs.map((arc) => {
return ( const color = arc.data.display.color ?? FALLBACK_COLOR;
<g const label = showLabel ? (
key={arc.data.title} <PieLabel
className={styles.svgArg} arc={arc}
onMouseMove={(event) => onMouseMoveOverArc(event, arc.data)} outerRadius={layout.outerRadius}
onMouseOut={hideTooltip} innerRadius={layout.innerRadius}
> displayLabels={displayLabels}
<path total={total}
d={pie.path({ ...arc })!} color={theme.colors.text}
fill={useGradients ? getGradientColor(arc.data.color ?? FALLBACK_COLOR) : arc.data.color} />
stroke={theme.colors.panelBg} ) : undefined;
strokeWidth={1} if (arc.data.hasLinks && arc.data.getLinks) {
/> return (
{showLabel && ( <DataLinksContextMenu config={arc.data.field} key={arc.index} links={arc.data.getLinks}>
<PieLabel {(api) => (
arc={arc} <PieSlice
outerRadius={layout.outerRadius} tooltip={tooltip}
innerRadius={layout.innerRadius} arc={arc}
displayLabels={displayLabels} pie={pie}
total={total} fill={getGradientColor(color)}
color={theme.colors.text} openMenu={api.openMenu}
/> >
)} {label}
</g> </PieSlice>
); )}
</DataLinksContextMenu>
);
} else {
return (
<PieSlice key={arc.index} tooltip={tooltip} arc={arc} pie={pie} fill={getGradientColor(color)}>
{label}
</PieSlice>
);
}
}); });
}} }}
</Pie> </Pie>
</Group> </Group>
</svg> </svg>
{tooltipOpen && ( {tooltip.tooltipOpen && (
<TooltipInPortal key={Math.random()} top={tooltipTop} left={tooltipLeft}> <TooltipInPortal
{tooltipData!.title} {formattedValueToString(tooltipData!)} key={Math.random()}
top={tooltip.tooltipTop}
className={styles.tooltipPortal}
left={tooltip.tooltipLeft}
>
{tooltip.tooltipData!.title} {formattedValueToString(tooltip.tooltipData!)}
</TooltipInPortal> </TooltipInPortal>
)} )}
</div> </div>
); );
}; };
const PieSlice: FC<{
children: ReactNode;
arc: PieArcDatum<FieldDisplay>;
pie: ProvidedProps<FieldDisplay>;
fill: string;
tooltip: UseTooltipParams<DisplayValue>;
openMenu?: (event: React.MouseEvent<SVGElement>) => void;
}> = ({ arc, children, pie, openMenu, fill, tooltip }) => {
const theme = useTheme();
const styles = useStyles(getStyles);
const onMouseMoveOverArc = (event: any, datum: any) => {
const coords = localPoint(event.target.ownerSVGElement, event);
tooltip.showTooltip({
tooltipLeft: coords!.x,
tooltipTop: coords!.y,
tooltipData: datum,
});
};
return (
<g
key={arc.data.display.title}
className={styles.svgArg}
onMouseMove={(event) => onMouseMoveOverArc(event, arc.data.display)}
onMouseOut={tooltip.hideTooltip}
onClick={openMenu}
>
<path d={pie.path({ ...arc })!} fill={fill} stroke={theme.colors.panelBg} strokeWidth={1} />
{children}
</g>
);
};
const PieLabel: FC<{ const PieLabel: FC<{
arc: PieArcDatum<DisplayValue>; arc: PieArcDatum<FieldDisplay>;
outerRadius: number; outerRadius: number;
innerRadius: number; innerRadius: number;
displayLabels: PieChartLabels[]; displayLabels: PieChartLabels[];
@ -259,17 +306,17 @@ const PieLabel: FC<{
> >
{displayLabels.includes(PieChartLabels.Name) && ( {displayLabels.includes(PieChartLabels.Name) && (
<tspan x={labelX} dy="1.2em"> <tspan x={labelX} dy="1.2em">
{arc.data.title} {arc.data.display.title}
</tspan> </tspan>
)} )}
{displayLabels.includes(PieChartLabels.Value) && ( {displayLabels.includes(PieChartLabels.Value) && (
<tspan x={labelX} dy="1.2em"> <tspan x={labelX} dy="1.2em">
{formattedValueToString(arc.data)} {formattedValueToString(arc.data.display)}
</tspan> </tspan>
)} )}
{displayLabels.includes(PieChartLabels.Percent) && ( {displayLabels.includes(PieChartLabels.Percent) && (
<tspan x={labelX} dy="1.2em"> <tspan x={labelX} dy="1.2em">
{((arc.data.numeric / total) * 100).toFixed(0) + '%'} {((arc.data.display.numeric / total) * 100).toFixed(0) + '%'}
</tspan> </tspan>
)} )}
</text> </text>
@ -277,7 +324,7 @@ const PieLabel: FC<{
); );
}; };
function getLabelPos(arc: PieArcDatum<DisplayValue>, outerRadius: number, innerRadius: number) { function getLabelPos(arc: PieArcDatum<FieldDisplay>, outerRadius: number, innerRadius: number) {
const r = (outerRadius + innerRadius) / 2; const r = (outerRadius + innerRadius) / 2;
const a = (+arc.startAngle + +arc.endAngle) / 2 - Math.PI / 2; const a = (+arc.startAngle + +arc.endAngle) / 2 - Math.PI / 2;
return [Math.cos(a) * r, Math.sin(a) * r]; return [Math.cos(a) * r, Math.sin(a) * r];
@ -337,5 +384,8 @@ const getStyles = (theme: GrafanaTheme) => {
transform: scale3d(1.03, 1.03, 1); transform: scale3d(1.03, 1.03, 1);
} }
`, `,
tooltipPortal: css`
z-index: 1050;
`,
}; };
}; };

View File

@ -29,7 +29,7 @@ export const VizLegendList: React.FunctionComponent<Props> = ({
); );
} }
const getItemKey = (item: VizLegendItem) => `${item.label}`; const getItemKey = (item: VizLegendItem) => `${item.getItemKey ? item.getItemKey() : item.label}`;
switch (placement) { switch (placement) {
case 'right': { case 'right': {

View File

@ -20,6 +20,7 @@ export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps {
} }
export interface VizLegendItem { export interface VizLegendItem {
getItemKey?: () => string;
label: string; label: string;
color: string; color: string;
yAxis: number; yAxis: number;

View File

@ -23,20 +23,20 @@ export const PieChartPanel: React.FC<Props> = ({
[fieldConfig, onFieldConfigChange] [fieldConfig, onFieldConfigChange]
); );
const values = getFieldDisplayValues({ const fieldDisplayValues = getFieldDisplayValues({
fieldConfig, fieldConfig,
reduceOptions: options.reduceOptions, reduceOptions: options.reduceOptions,
data: data.series, data: data.series,
theme: useTheme(), theme: useTheme(),
replaceVariables: replaceVariables, replaceVariables: replaceVariables,
timeZone, timeZone,
}).map((v) => v.display); });
return ( return (
<PieChart <PieChart
width={width} width={width}
height={height} height={height}
values={values} fieldDisplayValues={fieldDisplayValues}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
pieType={options.pieType} pieType={options.pieType}
displayLabels={options.displayLabels} displayLabels={options.displayLabels}