grafana/public/app/plugins/panel/piechart/PieChart.tsx

440 lines
13 KiB
TypeScript

import React, { FC, useCallback } from 'react';
import { VizTooltipOptions } from '@grafana/schema';
import {
FieldDisplay,
FALLBACK_COLOR,
formattedValueToString,
GrafanaTheme2,
DataHoverClearEvent,
DataHoverEvent,
} from '@grafana/data';
import {
useTheme2,
useStyles2,
SeriesTableRowProps,
DataLinksContextMenu,
SeriesTable,
usePanelContext,
} from '@grafana/ui';
import { PieChartType, PieChartLabels } from './types';
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
import Pie, { PieArcDatum, ProvidedProps } from '@visx/shape/lib/shapes/Pie';
import { UseTooltipParams } from '@visx/tooltip/lib/hooks/useTooltip';
import { RadialGradient } from '@visx/gradient';
import { localPoint } from '@visx/event';
import { Group } from '@visx/group';
import tinycolor from 'tinycolor2';
import { css } from '@emotion/css';
import { useComponentInstanceId } from '@grafana/ui/src/utils/useComponetInstanceId';
import { getTooltipContainerStyles } from '@grafana/ui/src/themes/mixins';
import { selectors } from '@grafana/e2e-selectors';
import { filterDisplayItems, sumDisplayItemsReducer } from './utils';
/**
* @beta
*/
interface PieChartProps {
height: number;
width: number;
fieldDisplayValues: FieldDisplay[];
pieType: PieChartType;
highlightedTitle?: string;
displayLabels?: PieChartLabels[];
useGradients?: boolean; // not used?
tooltipOptions: VizTooltipOptions;
}
export const PieChart: FC<PieChartProps> = ({
fieldDisplayValues,
pieType,
width,
height,
highlightedTitle,
displayLabels = [],
tooltipOptions,
}) => {
const theme = useTheme2();
const componentInstanceId = useComponentInstanceId('PieChart');
const styles = useStyles2(getStyles);
const tooltip = useTooltip<SeriesTableRowProps[]>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({
detectBounds: true,
scroll: true,
});
const filteredFieldDisplayValues = fieldDisplayValues.filter(filterDisplayItems);
const getValue = (d: FieldDisplay) => d.display.numeric;
const getGradientId = (color: string) => `${componentInstanceId}-${tinycolor(color).toHex()}`;
const getGradientColor = (color: string) => {
return `url(#${getGradientId(color)})`;
};
const showLabel = displayLabels.length > 0;
const showTooltip = tooltipOptions.mode !== 'none' && tooltip.tooltipOpen;
const total = filteredFieldDisplayValues.reduce(sumDisplayItemsReducer, 0);
const layout = getPieLayout(width, height, pieType);
const colors = [
...new Set(
filteredFieldDisplayValues.map((fieldDisplayValue) => fieldDisplayValue.display.color ?? FALLBACK_COLOR)
),
];
return (
<div className={styles.container}>
<svg width={layout.size} height={layout.size} ref={containerRef}>
<Group top={layout.position} left={layout.position}>
{colors.map((color) => {
return (
<RadialGradient
key={color}
id={getGradientId(color)}
from={getGradientColorFrom(color, theme)}
to={getGradientColorTo(color, theme)}
fromOffset={layout.gradientFromOffset}
toOffset="1"
gradientUnits="userSpaceOnUse"
cx={0}
cy={0}
radius={layout.outerRadius}
/>
);
})}
<Pie
data={filteredFieldDisplayValues}
pieValue={getValue}
outerRadius={layout.outerRadius}
innerRadius={layout.innerRadius}
cornerRadius={3}
padAngle={0.005}
>
{(pie) => (
<>
{pie.arcs.map((arc) => {
const color = arc.data.display.color ?? FALLBACK_COLOR;
const highlightState = getHighlightState(highlightedTitle, arc);
if (arc.data.hasLinks && arc.data.getLinks) {
return (
<DataLinksContextMenu config={arc.data.field} key={arc.index} links={arc.data.getLinks}>
{(api) => (
<PieSlice
tooltip={tooltip}
highlightState={highlightState}
arc={arc}
pie={pie}
fill={getGradientColor(color)}
openMenu={api.openMenu}
tooltipOptions={tooltipOptions}
/>
)}
</DataLinksContextMenu>
);
} else {
return (
<PieSlice
key={arc.index}
highlightState={highlightState}
tooltip={tooltip}
arc={arc}
pie={pie}
fill={getGradientColor(color)}
tooltipOptions={tooltipOptions}
/>
);
}
})}
{showLabel &&
pie.arcs.map((arc) => {
const highlightState = getHighlightState(highlightedTitle, arc);
return (
<PieLabel
arc={arc}
key={arc.index}
highlightState={highlightState}
outerRadius={layout.outerRadius}
innerRadius={layout.innerRadius}
displayLabels={displayLabels}
total={total}
color={theme.colors.text.primary}
/>
);
})}
</>
)}
</Pie>
</Group>
</svg>
{showTooltip ? (
<TooltipInPortal
key={Math.random()}
top={tooltip.tooltipTop}
className={styles.tooltipPortal}
left={tooltip.tooltipLeft}
unstyled={true}
applyPositionStyle={true}
>
<SeriesTable series={tooltip.tooltipData!} />
</TooltipInPortal>
) : null}
</div>
);
};
interface SliceProps {
arc: PieArcDatum<FieldDisplay>;
pie: ProvidedProps<FieldDisplay>;
highlightState: HighLightState;
fill: string;
tooltip: UseTooltipParams<SeriesTableRowProps[]>;
tooltipOptions: VizTooltipOptions;
openMenu?: (event: React.MouseEvent<SVGElement>) => void;
}
function PieSlice({ arc, pie, highlightState, openMenu, fill, tooltip, tooltipOptions }: SliceProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const { eventBus } = usePanelContext();
const onMouseOut = useCallback(
(event: any) => {
eventBus?.publish({
type: DataHoverClearEvent.type,
payload: {
raw: event,
x: 0,
y: 0,
dataId: arc.data.display.title,
},
});
tooltip.hideTooltip();
},
[eventBus, arc, tooltip]
);
const onMouseMoveOverArc = useCallback(
(event: any) => {
eventBus?.publish({
type: DataHoverEvent.type,
payload: {
raw: event,
x: 0,
y: 0,
dataId: arc.data.display.title,
},
});
const coords = localPoint(event.target.ownerSVGElement, event);
tooltip.showTooltip({
tooltipLeft: coords!.x,
tooltipTop: coords!.y,
tooltipData: getTooltipData(pie, arc, tooltipOptions),
});
},
[eventBus, arc, tooltip, pie, tooltipOptions]
);
const pieStyle = getSvgStyle(highlightState, styles);
return (
<g
key={arc.data.display.title}
className={pieStyle}
onMouseMove={tooltipOptions.mode !== 'none' ? onMouseMoveOverArc : undefined}
onMouseOut={onMouseOut}
onClick={openMenu}
aria-label={selectors.components.Panels.Visualization.PieChart.svgSlice}
>
<path d={pie.path({ ...arc })!} fill={fill} stroke={theme.colors.background.primary} strokeWidth={1} />
</g>
);
}
interface LabelProps {
arc: PieArcDatum<FieldDisplay>;
outerRadius: number;
innerRadius: number;
displayLabels: PieChartLabels[];
highlightState: HighLightState;
total: number;
color: string;
}
function PieLabel({ arc, outerRadius, innerRadius, displayLabels, total, color, highlightState }: LabelProps) {
const styles = useStyles2(getStyles);
const labelRadius = innerRadius === 0 ? outerRadius / 6 : innerRadius;
const [labelX, labelY] = getLabelPos(arc, outerRadius, labelRadius);
const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.3;
if (!hasSpaceForLabel) {
return null;
}
let labelFontSize = displayLabels.includes(PieChartLabels.Name)
? Math.min(Math.max((outerRadius / 150) * 14, 12), 30)
: Math.min(Math.max((outerRadius / 100) * 14, 12), 36);
return (
<g className={getSvgStyle(highlightState, styles)}>
<text
fill={color}
x={labelX}
y={labelY}
dy=".33em"
fontSize={labelFontSize}
fontWeight={500}
textAnchor="middle"
pointerEvents="none"
>
{displayLabels.includes(PieChartLabels.Name) && (
<tspan x={labelX} dy="1.2em">
{arc.data.display.title}
</tspan>
)}
{displayLabels.includes(PieChartLabels.Value) && (
<tspan x={labelX} dy="1.2em">
{formattedValueToString(arc.data.display)}
</tspan>
)}
{displayLabels.includes(PieChartLabels.Percent) && (
<tspan x={labelX} dy="1.2em">
{((arc.data.display.numeric / total) * 100).toFixed(arc.data.field.decimals ?? 0) + '%'}
</tspan>
)}
</text>
</g>
);
}
function getTooltipData(
pie: ProvidedProps<FieldDisplay>,
arc: PieArcDatum<FieldDisplay>,
tooltipOptions: VizTooltipOptions
) {
if (tooltipOptions.mode === 'multi') {
return pie.arcs.map((pieArc) => {
return {
color: pieArc.data.display.color ?? FALLBACK_COLOR,
label: pieArc.data.display.title,
value: formattedValueToString(pieArc.data.display),
isActive: pieArc.index === arc.index,
};
});
}
return [
{
color: arc.data.display.color ?? FALLBACK_COLOR,
label: arc.data.display.title,
value: formattedValueToString(arc.data.display),
},
];
}
function getLabelPos(arc: PieArcDatum<FieldDisplay>, outerRadius: number, innerRadius: number) {
const r = (outerRadius + innerRadius) / 2;
const a = (+arc.startAngle + +arc.endAngle) / 2 - Math.PI / 2;
return [Math.cos(a) * r, Math.sin(a) * r];
}
function getGradientColorFrom(color: string, theme: GrafanaTheme2) {
return tinycolor(color)
.darken(20 * (theme.isDark ? 1 : -0.7))
.spin(4)
.toRgbString();
}
function getGradientColorTo(color: string, theme: GrafanaTheme2) {
return tinycolor(color)
.darken(10 * (theme.isDark ? 1 : -0.7))
.spin(-4)
.toRgbString();
}
interface PieLayout {
position: number;
size: number;
outerRadius: number;
innerRadius: number;
gradientFromOffset: number;
}
function getPieLayout(height: number, width: number, pieType: PieChartType, margin = 16): PieLayout {
const size = Math.min(width, height);
const outerRadius = (size - margin * 2) / 2;
const donutThickness = pieType === PieChartType.Pie ? outerRadius : Math.max(outerRadius / 3, 20);
const innerRadius = outerRadius - donutThickness;
const centerOffset = (size - margin * 2) / 2;
// for non donut pie charts shift gradient out a bit
const gradientFromOffset = 1 - (outerRadius - innerRadius) / outerRadius;
return {
position: centerOffset + margin,
size: size,
outerRadius: outerRadius,
innerRadius: innerRadius,
gradientFromOffset: gradientFromOffset,
};
}
enum HighLightState {
Highlighted,
Deemphasized,
Normal,
}
function getHighlightState(highlightedTitle: string | undefined, arc: PieArcDatum<FieldDisplay>) {
if (highlightedTitle) {
if (highlightedTitle === arc.data.display.title) {
return HighLightState.Highlighted;
} else {
return HighLightState.Deemphasized;
}
}
return HighLightState.Normal;
}
function getSvgStyle(
highlightState: HighLightState,
styles: {
svgArg: { normal: string; highlighted: string; deemphasized: string };
}
) {
switch (highlightState) {
case HighLightState.Highlighted:
return styles.svgArg.highlighted;
case HighLightState.Deemphasized:
return styles.svgArg.deemphasized;
case HighLightState.Normal:
default:
return styles.svgArg.normal;
}
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
`,
svgArg: {
normal: css`
transition: all 200ms ease-in-out;
`,
highlighted: css`
transition: all 200ms ease-in-out;
transform: scale3d(1.03, 1.03, 1);
`,
deemphasized: css`
transition: all 200ms ease-in-out;
fill-opacity: 0.5;
`,
},
tooltipPortal: css`
${getTooltipContainerStyles(theme)}
`,
};
};