mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 16:15:42 -06:00
440 lines
13 KiB
TypeScript
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)}
|
|
`,
|
|
};
|
|
};
|