PieChart: Refactoring & adding unsubscribe (#33432)

* Tweaks to piechart and theme

* Adds unsubscribe to events and move out to separate hook

* reverted constrast change

* Minor refactor after review feedback

* chain the subs
This commit is contained in:
Torkel Ödegaard 2021-04-28 14:02:41 +02:00 committed by GitHub
parent df8d301d1e
commit bac8b967be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 179 additions and 160 deletions

View File

@ -1,4 +1,4 @@
import React, { FC, ReactNode, useState } from 'react'; import React, { FC, useEffect, useState } from 'react';
import { import {
DataHoverClearEvent, DataHoverClearEvent,
DataHoverEvent, DataHoverEvent,
@ -6,9 +6,9 @@ import {
FieldDisplay, FieldDisplay,
formattedValueToString, formattedValueToString,
getFieldDisplayValues, getFieldDisplayValues,
GrafanaTheme, GrafanaThemeV2,
} from '@grafana/data'; } from '@grafana/data';
import { useStyles, useTheme } from '../../themes/ThemeContext'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import Pie, { PieArcDatum, ProvidedProps } 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';
@ -33,6 +33,7 @@ import {
import { getTooltipContainerStyles } from '../../themes/mixins'; import { getTooltipContainerStyles } from '../../themes/mixins';
import { SeriesTable, SeriesTableRowProps, VizTooltipOptions } from '../VizTooltip'; import { SeriesTable, SeriesTableRowProps, VizTooltipOptions } from '../VizTooltip';
import { usePanelContext } from '../PanelChrome'; import { usePanelContext } from '../PanelChrome';
import { Subscription } from 'rxjs';
const defaultLegendOptions: PieChartLegendOptions = { const defaultLegendOptions: PieChartLegendOptions = {
displayMode: LegendDisplayMode.List, displayMode: LegendDisplayMode.List,
@ -44,99 +45,33 @@ const defaultLegendOptions: PieChartLegendOptions = {
/** /**
* @beta * @beta
*/ */
export const PieChart: FC<PieChartProps> = ({ export function PieChart(props: PieChartProps) {
data, const {
timeZone, data,
reduceOptions, timeZone,
fieldConfig, reduceOptions,
replaceVariables, fieldConfig,
legendOptions = defaultLegendOptions, replaceVariables,
tooltipOptions, tooltipOptions,
onSeriesColorChange, onSeriesColorChange,
width, width,
height, height,
...restProps ...restProps
}) => { } = props;
const theme = useTheme();
const [highlightedTitle, setHighlightedTitle] = useState<string>();
const { eventBus } = usePanelContext();
if (eventBus) {
const setHighlightedSlice = (event: DataHoverEvent) => {
if (eventBus.isOwnEvent(event)) {
setHighlightedTitle(event.payload.dataId);
}
};
const resetHighlightedSlice = (event: DataHoverClearEvent) => {
if (eventBus.isOwnEvent(event)) {
setHighlightedTitle(undefined);
}
};
eventBus.subscribe(DataHoverEvent, setHighlightedSlice);
eventBus.subscribe(DataHoverClearEvent, resetHighlightedSlice);
}
const getLegend = (fields: FieldDisplay[], legendOptions: PieChartLegendOptions) => {
if (legendOptions.displayMode === LegendDisplayMode.Hidden) {
return undefined;
}
const values = fields.map((v) => v.display);
const total = values.reduce((acc, item) => item.numeric + acc, 0);
const legendItems = values.map<VizLegendItem>((value, idx) => {
return {
label: value.title ?? '',
color: value.color ?? FALLBACK_COLOR,
yAxis: 1,
getItemKey: () => (value.title ?? '') + idx,
getDisplayValues: () => {
const valuesToShow = legendOptions.values ?? [];
let displayValues = [];
if (valuesToShow.includes(PieChartLegendValues.Value)) {
displayValues.push({ numeric: value.numeric, text: formattedValueToString(value), title: 'Value' });
}
if (valuesToShow.includes(PieChartLegendValues.Percent)) {
const fractionOfTotal = value.numeric / total;
const percentOfTotal = fractionOfTotal * 100;
displayValues.push({
numeric: fractionOfTotal,
percent: percentOfTotal,
text: percentOfTotal.toFixed(0) + '%',
title: valuesToShow.length > 1 ? 'Percent' : undefined,
});
}
return displayValues;
},
};
});
return (
<VizLegend
items={legendItems}
onSeriesColorChange={onSeriesColorChange}
placement={legendOptions.placement}
displayMode={legendOptions.displayMode}
/>
);
};
const theme = useTheme2();
const highlightedTitle = useSliceHighlightState();
const fieldDisplayValues = getFieldDisplayValues({ const fieldDisplayValues = getFieldDisplayValues({
fieldConfig, fieldConfig,
reduceOptions, reduceOptions,
data, data,
theme, theme: theme.v1,
replaceVariables, replaceVariables,
timeZone, timeZone,
}); });
return ( return (
<VizLayout width={width} height={height} legend={getLegend(fieldDisplayValues, legendOptions)}> <VizLayout width={width} height={height} legend={getLegend(props, fieldDisplayValues)}>
{(vizWidth: number, vizHeight: number) => { {(vizWidth: number, vizHeight: number) => {
return ( return (
<PieChartSvg <PieChartSvg
@ -151,7 +86,90 @@ export const PieChart: FC<PieChartProps> = ({
}} }}
</VizLayout> </VizLayout>
); );
}; }
function getLegend(props: PieChartProps, displayValues: FieldDisplay[]) {
const { legendOptions = defaultLegendOptions } = props;
if (legendOptions.displayMode === LegendDisplayMode.Hidden) {
return undefined;
}
const values = displayValues.map((v) => v.display);
const total = values.reduce((acc, item) => item.numeric + acc, 0);
const legendItems = values.map<VizLegendItem>((value, idx) => {
return {
label: value.title ?? '',
color: value.color ?? FALLBACK_COLOR,
yAxis: 1,
getItemKey: () => (value.title ?? '') + idx,
getDisplayValues: () => {
const valuesToShow = legendOptions.values ?? [];
let displayValues = [];
if (valuesToShow.includes(PieChartLegendValues.Value)) {
displayValues.push({ numeric: value.numeric, text: formattedValueToString(value), title: 'Value' });
}
if (valuesToShow.includes(PieChartLegendValues.Percent)) {
const fractionOfTotal = value.numeric / total;
const percentOfTotal = fractionOfTotal * 100;
displayValues.push({
numeric: fractionOfTotal,
percent: percentOfTotal,
text: percentOfTotal.toFixed(0) + '%',
title: valuesToShow.length > 1 ? 'Percent' : undefined,
});
}
return displayValues;
},
};
});
return (
<VizLegend
items={legendItems}
onSeriesColorChange={props.onSeriesColorChange}
placement={legendOptions.placement}
displayMode={legendOptions.displayMode}
/>
);
}
function useSliceHighlightState() {
const [highlightedTitle, setHighlightedTitle] = useState<string>();
const { eventBus } = usePanelContext();
useEffect(() => {
if (!eventBus) {
return;
}
const setHighlightedSlice = (event: DataHoverEvent) => {
if (eventBus.isOwnEvent(event)) {
setHighlightedTitle(event.payload.dataId);
}
};
const resetHighlightedSlice = (event: DataHoverClearEvent) => {
if (eventBus.isOwnEvent(event)) {
setHighlightedTitle(undefined);
}
};
const subs = new Subscription()
.add(eventBus.subscribe(DataHoverEvent, setHighlightedSlice))
.add(eventBus.subscribe(DataHoverClearEvent, resetHighlightedSlice));
return () => {
subs.unsubscribe();
};
}, [setHighlightedTitle, eventBus]);
return highlightedTitle;
}
export const PieChartSvg: FC<PieChartSvgProps> = ({ export const PieChartSvg: FC<PieChartSvgProps> = ({
fieldDisplayValues, fieldDisplayValues,
@ -159,13 +177,12 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
width, width,
height, height,
highlightedTitle, highlightedTitle,
useGradients = true,
displayLabels = [], displayLabels = [],
tooltipOptions, tooltipOptions,
}) => { }) => {
const theme = useTheme(); const theme = useTheme2();
const componentInstanceId = useComponentInstanceId('PieChart'); const componentInstanceId = useComponentInstanceId('PieChart');
const styles = useStyles(getStyles); const styles = useStyles2(getStyles);
const tooltip = useTooltip<SeriesTableRowProps[]>(); const tooltip = useTooltip<SeriesTableRowProps[]>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({ const { containerRef, TooltipInPortal } = useTooltipInPortal({
detectBounds: true, detectBounds: true,
@ -218,55 +235,55 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
cornerRadius={3} cornerRadius={3}
padAngle={0.005} padAngle={0.005}
> >
{(pie) => { {(pie) => (
return pie.arcs.map((arc) => { <>
const color = arc.data.display.color ?? FALLBACK_COLOR; {pie.arcs.map((arc) => {
const highlighted = highlightedTitle === arc.data.display.title; let color = arc.data.display.color ?? FALLBACK_COLOR;
const label = showLabel ? ( const highlighted = highlightedTitle === arc.data.display.title;
<PieLabel if (arc.data.hasLinks && arc.data.getLinks) {
arc={arc} return (
outerRadius={layout.outerRadius} <DataLinksContextMenu config={arc.data.field} key={arc.index} links={arc.data.getLinks}>
innerRadius={layout.innerRadius} {(api) => (
displayLabels={displayLabels} <PieSlice
total={total} tooltip={tooltip}
color={theme.colors.text} highlighted={highlighted}
/> arc={arc}
) : undefined; pie={pie}
if (arc.data.hasLinks && arc.data.getLinks) { fill={getGradientColor(color)}
return ( openMenu={api.openMenu}
<DataLinksContextMenu config={arc.data.field} key={arc.index} links={arc.data.getLinks}> tooltipOptions={tooltipOptions}
{(api) => ( />
<PieSlice )}
tooltip={tooltip} </DataLinksContextMenu>
highlighted={highlighted} );
arc={arc} } else {
pie={pie} return (
fill={getGradientColor(color)} <PieSlice
openMenu={api.openMenu} key={arc.index}
tooltipOptions={tooltipOptions} highlighted={highlighted}
> tooltip={tooltip}
{label} arc={arc}
</PieSlice> pie={pie}
)} fill={getGradientColor(color)}
</DataLinksContextMenu> tooltipOptions={tooltipOptions}
); />
} else { );
return ( }
<PieSlice })}
key={arc.index} {showLabel &&
highlighted={highlighted} pie.arcs.map((arc) => (
tooltip={tooltip} <PieLabel
arc={arc} arc={arc}
pie={pie} key={arc.index}
fill={getGradientColor(color)} outerRadius={layout.outerRadius}
tooltipOptions={tooltipOptions} innerRadius={layout.innerRadius}
> displayLabels={displayLabels}
{label} total={total}
</PieSlice> color={theme.colors.text.primary}
); />
} ))}
}); </>
}} )}
</Pie> </Pie>
</Group> </Group>
</svg> </svg>
@ -286,8 +303,7 @@ export const PieChartSvg: FC<PieChartSvgProps> = ({
); );
}; };
const PieSlice: FC<{ interface SliceProps {
children: ReactNode;
arc: PieArcDatum<FieldDisplay>; arc: PieArcDatum<FieldDisplay>;
pie: ProvidedProps<FieldDisplay>; pie: ProvidedProps<FieldDisplay>;
highlighted?: boolean; highlighted?: boolean;
@ -295,9 +311,11 @@ const PieSlice: FC<{
tooltip: UseTooltipParams<SeriesTableRowProps[]>; tooltip: UseTooltipParams<SeriesTableRowProps[]>;
tooltipOptions: VizTooltipOptions; tooltipOptions: VizTooltipOptions;
openMenu?: (event: React.MouseEvent<SVGElement>) => void; openMenu?: (event: React.MouseEvent<SVGElement>) => void;
}> = ({ arc, children, pie, highlighted, openMenu, fill, tooltip, tooltipOptions }) => { }
const theme = useTheme();
const styles = useStyles(getStyles); function PieSlice({ arc, pie, highlighted, openMenu, fill, tooltip, tooltipOptions }: SliceProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const onMouseMoveOverArc = (event: any) => { const onMouseMoveOverArc = (event: any) => {
const coords = localPoint(event.target.ownerSVGElement, event); const coords = localPoint(event.target.ownerSVGElement, event);
@ -316,20 +334,21 @@ const PieSlice: FC<{
onMouseOut={tooltip.hideTooltip} onMouseOut={tooltip.hideTooltip}
onClick={openMenu} onClick={openMenu}
> >
<path d={pie.path({ ...arc })!} fill={fill} stroke={theme.colors.panelBg} strokeWidth={1} /> <path d={pie.path({ ...arc })!} fill={fill} stroke={theme.colors.background.primary} strokeWidth={1} />
{children}
</g> </g>
); );
}; }
const PieLabel: FC<{ interface LabelProps {
arc: PieArcDatum<FieldDisplay>; arc: PieArcDatum<FieldDisplay>;
outerRadius: number; outerRadius: number;
innerRadius: number; innerRadius: number;
displayLabels: PieChartLabels[]; displayLabels: PieChartLabels[];
total: number; total: number;
color: string; color: string;
}> = ({ arc, outerRadius, innerRadius, displayLabels, total, color }) => { }
function PieLabel({ arc, outerRadius, innerRadius, displayLabels, total, color }: LabelProps) {
const labelRadius = innerRadius === 0 ? outerRadius / 6 : innerRadius; const labelRadius = innerRadius === 0 ? outerRadius / 6 : innerRadius;
const [labelX, labelY] = getLabelPos(arc, outerRadius, labelRadius); const [labelX, labelY] = getLabelPos(arc, outerRadius, labelRadius);
const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.3; const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.3;
@ -371,7 +390,7 @@ const PieLabel: FC<{
</text> </text>
</g> </g>
); );
}; }
function getTooltipData( function getTooltipData(
pie: ProvidedProps<FieldDisplay>, pie: ProvidedProps<FieldDisplay>,
@ -403,14 +422,14 @@ function getLabelPos(arc: PieArcDatum<FieldDisplay>, outerRadius: number, innerR
return [Math.cos(a) * r, Math.sin(a) * r]; return [Math.cos(a) * r, Math.sin(a) * r];
} }
function getGradientColorFrom(color: string, theme: GrafanaTheme) { function getGradientColorFrom(color: string, theme: GrafanaThemeV2) {
return tinycolor(color) return tinycolor(color)
.darken(20 * (theme.isDark ? 1 : -0.7)) .darken(20 * (theme.isDark ? 1 : -0.7))
.spin(8) .spin(8)
.toRgbString(); .toRgbString();
} }
function getGradientColorTo(color: string, theme: GrafanaTheme) { function getGradientColorTo(color: string, theme: GrafanaThemeV2) {
return tinycolor(color) return tinycolor(color)
.darken(10 * (theme.isDark ? 1 : -0.7)) .darken(10 * (theme.isDark ? 1 : -0.7))
.spin(-8) .spin(-8)
@ -442,7 +461,7 @@ function getPieLayout(height: number, width: number, pieType: PieChartType, marg
}; };
} }
const getStyles = (theme: GrafanaTheme) => { const getStyles = (theme: GrafanaThemeV2) => {
return { return {
container: css` container: css`
width: 100%; width: 100%;

View File

@ -1,9 +1,9 @@
import React, { useState, useLayoutEffect, useRef, HTMLAttributes, useMemo } from 'react'; import React, { useState, useLayoutEffect, useRef, HTMLAttributes, useMemo } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useStyles } from '../../themes'; import { useStyles2 } from '../../themes';
import { getTooltipContainerStyles } from '../../themes/mixins'; import { getTooltipContainerStyles } from '../../themes/mixins';
import useWindowSize from 'react-use/lib/useWindowSize'; import useWindowSize from 'react-use/lib/useWindowSize';
import { Dimensions2D, GrafanaTheme } from '@grafana/data'; import { Dimensions2D, GrafanaThemeV2 } from '@grafana/data';
/** /**
* @public * @public
@ -84,7 +84,7 @@ export const VizTooltipContainer: React.FC<VizTooltipContainerProps> = ({
}); });
}, [width, height, positionX, offsetX, positionY, offsetY, tooltipMeasurement.width, tooltipMeasurement.height]); }, [width, height, positionX, offsetX, positionY, offsetY, tooltipMeasurement.width, tooltipMeasurement.height]);
const styles = useStyles(getStyles); const styles = useStyles2(getStyles);
return ( return (
<div <div
@ -105,7 +105,7 @@ export const VizTooltipContainer: React.FC<VizTooltipContainerProps> = ({
VizTooltipContainer.displayName = 'VizTooltipContainer'; VizTooltipContainer.displayName = 'VizTooltipContainer';
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = (theme: GrafanaThemeV2) => ({
wrapper: css` wrapper: css`
${getTooltipContainerStyles(theme)} ${getTooltipContainerStyles(theme)}
`, `,

View File

@ -63,11 +63,11 @@ export function getFocusStyles(theme: GrafanaThemeV2): CSSObject {
} }
// max-width is set up based on .grafana-tooltip class that's used in dashboard // max-width is set up based on .grafana-tooltip class that's used in dashboard
export const getTooltipContainerStyles = (theme: GrafanaTheme) => ` export const getTooltipContainerStyles = (theme: GrafanaThemeV2) => `
overflow: hidden; overflow: hidden;
background: ${theme.colors.bg2}; background: ${theme.components.tooltip.background};
max-width: 800px; max-width: 800px;
padding: ${theme.spacing.sm}; padding: ${theme.spacing(1)};
border-radius: ${theme.border.radius.sm}; border-radius: ${theme.shape.borderRadius()};
z-index: ${theme.zIndex.tooltip}; z-index: ${theme.zIndex.tooltip};
`; `;