123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- import { css } from '@emotion/css';
- import { localPoint } from '@visx/event';
- import { RadialGradient } from '@visx/gradient';
- import { Group } from '@visx/group';
- import Pie, { PieArcDatum, ProvidedProps } from '@visx/shape/lib/shapes/Pie';
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
- import { UseTooltipParams } from '@visx/tooltip/lib/hooks/useTooltip';
- import React, { FC, useCallback } from 'react';
- import tinycolor from 'tinycolor2';
- import {
- FieldDisplay,
- FALLBACK_COLOR,
- formattedValueToString,
- GrafanaTheme2,
- DataHoverClearEvent,
- DataHoverEvent,
- } from '@grafana/data';
- import { selectors } from '@grafana/e2e-selectors';
- import { VizTooltipOptions } from '@grafana/schema';
- import {
- useTheme2,
- useStyles2,
- SeriesTableRowProps,
- DataLinksContextMenu,
- SeriesTable,
- usePanelContext,
- } from '@grafana/ui';
- import { getTooltipContainerStyles } from '@grafana/ui/src/themes/mixins';
- import { useComponentInstanceId } from '@grafana/ui/src/utils/useComponetInstanceId';
- import { PieChartType, PieChartLabels } from './types';
- 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)}
- `,
- };
- };
|