NodeGraph: Show gradient fields in legend (#34078)

* Add gradient fields to legend

* Fix test

* Remove unnecessary mapping

* Add tests
This commit is contained in:
Andrej Ocenas 2021-05-18 16:30:27 +02:00 committed by GitHub
parent bbaa7a9b62
commit 2e7ccf0e42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 161 additions and 46 deletions

View File

@ -1,7 +1,7 @@
import { GrafanaTheme, GrafanaThemeType } from '../types/theme'; import { GrafanaTheme, GrafanaThemeType } from '../types/theme';
/** /**
* @deprecated use theme.vizColors.getByName * @deprecated use theme.visualization.getColorByName
*/ */
export function getColorForTheme(color: string, theme: GrafanaTheme): string { export function getColorForTheme(color: string, theme: GrafanaTheme): string {
return theme.visualization.getColorByName(color); return theme.visualization.getColorByName(color);

View File

@ -0,0 +1,20 @@
import React from 'react';
import { render } from '@testing-library/react';
import { SeriesIcon } from './SeriesIcon';
describe('SeriesIcon', () => {
it('renders gradient correctly', () => {
const { container } = render(<SeriesIcon gradient={'continuous-GrYlRd'} />);
const div = container.firstChild! as HTMLDivElement;
// There is issue in JSDOM which means we cannot actually get the gradient value. I guess if it's empty at least
// we know it is setting some gradient instead of a single color.
// https://github.com/jsdom/jsdom/issues/2166
expect(div.style.getPropertyValue('background')).toBe('');
});
it('renders color correctly', () => {
const { container } = render(<SeriesIcon color={'red'} />);
const div = container.firstChild! as HTMLDivElement;
expect(div.style.getPropertyValue('background')).toBe('red');
});
});

View File

@ -1,20 +1,40 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useTheme2 } from '../../themes';
import { fieldColorModeRegistry } from '@grafana/data';
export interface Props extends React.HTMLAttributes<HTMLDivElement> { export interface Props extends React.HTMLAttributes<HTMLDivElement> {
color: string; color?: string;
gradient?: string;
} }
export const SeriesIcon = React.forwardRef<HTMLDivElement, Props>(({ color, className, ...restProps }, ref) => { export const SeriesIcon = React.forwardRef<HTMLDivElement, Props>(
const styles: CSSProperties = { ({ color, className, gradient, ...restProps }, ref) => {
backgroundColor: color, const theme = useTheme2();
width: '14px', let cssColor: string;
height: '4px',
borderRadius: '1px',
display: 'inline-block',
marginRight: '8px',
};
return <div ref={ref} className={className} style={styles} {...restProps} />; if (gradient) {
}); const colors = fieldColorModeRegistry.get(gradient).getColors?.(theme);
if (colors?.length) {
cssColor = `linear-gradient(90deg, ${colors.join(', ')})`;
} else {
// Not sure what to default to, this will return gray, this should not happen though.
cssColor = theme.visualization.getColorByName('');
}
} else {
cssColor = color!;
}
const styles: CSSProperties = {
background: cssColor,
width: '14px',
height: '4px',
borderRadius: '1px',
display: 'inline-block',
marginRight: '8px',
};
return <div ref={ref} className={className} style={styles} {...restProps} />;
}
);
SeriesIcon.displayName = 'SeriesIcon'; SeriesIcon.displayName = 'SeriesIcon';

View File

@ -59,7 +59,7 @@ export const VizLegendListItem = <T extends unknown = any>({
className={cx(styles.itemWrapper, className)} className={cx(styles.itemWrapper, className)}
aria-label={selectors.components.VizLegend.seriesName(item.label)} aria-label={selectors.components.VizLegend.seriesName(item.label)}
> >
<VizLegendSeriesIcon seriesName={item.label} color={item.color} /> <VizLegendSeriesIcon seriesName={item.label} color={item.color} gradient={item.gradient} />
<div <div
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseOut={onMouseOut} onMouseOut={onMouseOut}

View File

@ -5,13 +5,14 @@ import { SeriesIcon } from './SeriesIcon';
interface Props { interface Props {
seriesName: string; seriesName: string;
color: string; color?: string;
gradient?: string;
} }
/** /**
* @internal * @internal
*/ */
export const VizLegendSeriesIcon: React.FunctionComponent<Props> = ({ seriesName, color }) => { export const VizLegendSeriesIcon: React.FunctionComponent<Props> = ({ seriesName, color, gradient }) => {
const { onSeriesColorChange } = usePanelContext(); const { onSeriesColorChange } = usePanelContext();
const onChange = useCallback( const onChange = useCallback(
(color: string) => { (color: string) => {
@ -20,7 +21,7 @@ export const VizLegendSeriesIcon: React.FunctionComponent<Props> = ({ seriesName
[seriesName, onSeriesColorChange] [seriesName, onSeriesColorChange]
); );
if (seriesName && onSeriesColorChange) { if (seriesName && onSeriesColorChange && color) {
return ( return (
<SeriesColorPicker color={color} onChange={onChange} enableNamedColors> <SeriesColorPicker color={color} onChange={onChange} enableNamedColors>
{({ ref, showColorPicker, hideColorPicker }) => ( {({ ref, showColorPicker, hideColorPicker }) => (
@ -35,7 +36,7 @@ export const VizLegendSeriesIcon: React.FunctionComponent<Props> = ({ seriesName
</SeriesColorPicker> </SeriesColorPicker>
); );
} }
return <SeriesIcon color={color} />; return <SeriesIcon color={color} gradient={gradient} />;
}; };
VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon'; VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon';

View File

@ -31,7 +31,8 @@ export interface LegendProps<T = any> extends VizLegendBaseProps<T>, VizLegendTa
export interface VizLegendItem<T = any> { export interface VizLegendItem<T = any> {
getItemKey?: () => string; getItemKey?: () => string;
label: string; label: string;
color: string; color?: string;
gradient?: string;
yAxis: number; yAxis: number;
disabled?: boolean; disabled?: boolean;
// displayValues?: DisplayValue[]; // displayValues?: DisplayValue[];

View File

@ -26,7 +26,11 @@ export function createGraphFrames(data: TraceResponse): DataFrame[] {
{ name: Fields.subTitle, type: FieldType.string }, { name: Fields.subTitle, type: FieldType.string },
{ name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Total time (% of trace)' } }, { name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Total time (% of trace)' } },
{ name: Fields.secondaryStat, type: FieldType.string, config: { displayName: 'Self time (% of total)' } }, { name: Fields.secondaryStat, type: FieldType.string, config: { displayName: 'Self time (% of total)' } },
{ name: Fields.color, type: FieldType.number, config: { color: { mode: 'continuous-GrYlRd' } } }, {
name: Fields.color,
type: FieldType.number,
config: { color: { mode: 'continuous-GrYlRd' }, displayName: 'Self time / Trace duration' },
},
], ],
meta: { meta: {
preferredVisualisationType: 'nodeGraph', preferredVisualisationType: 'nodeGraph',

View File

@ -44,7 +44,11 @@ export function createGraphFrames(data: DataFrame): DataFrame[] {
{ name: Fields.subTitle, type: FieldType.string }, { name: Fields.subTitle, type: FieldType.string },
{ name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Total time (% of trace)' } }, { name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Total time (% of trace)' } },
{ name: Fields.secondaryStat, type: FieldType.string, config: { displayName: 'Self time (% of total)' } }, { name: Fields.secondaryStat, type: FieldType.string, config: { displayName: 'Self time (% of total)' } },
{ name: Fields.color, type: FieldType.number, config: { color: { mode: 'continuous-GrYlRd' } } }, {
name: Fields.color,
type: FieldType.number,
config: { color: { mode: 'continuous-GrYlRd' }, displayName: 'Self time / Trace duration' },
},
], ],
meta: { meta: {
preferredVisualisationType: 'nodeGraph', preferredVisualisationType: 'nodeGraph',

View File

@ -0,0 +1,30 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { FieldColorModeId } from '@grafana/data';
import { Legend } from './Legend';
import { NodeDatum } from './types';
describe('Legend', () => {
it('renders ok without nodes', () => {
render(<Legend nodes={[]} onSort={(sort) => {}} sortable={false} />);
});
it('renders ok with color fields', () => {
const nodes: NodeDatum[] = [
{
id: 'nodeId',
mainStat: { config: { displayName: 'stat1' } } as any,
secondaryStat: { config: { displayName: 'stat2' } } as any,
arcSections: [
{ config: { displayName: 'error', color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } } } as any,
],
} as any,
];
render(<Legend nodes={nodes} onSort={(sort) => {}} sortable={false} />);
const items = screen.getAllByLabelText(/VizLegend series/);
expect(items.length).toBe(3);
const item = screen.getByLabelText(/VizLegend series error/);
expect((item.firstChild as HTMLDivElement).style.getPropertyValue('background')).toBe('rgb(242, 73, 92)');
});
});

View File

@ -62,6 +62,9 @@ interface ItemData {
} }
function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array<VizLegendItem<ItemData>> { function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array<VizLegendItem<ItemData>> {
if (!nodes.length) {
return [];
}
const fields = [nodes[0].mainStat, nodes[0].secondaryStat].filter(identity) as Field[]; const fields = [nodes[0].mainStat, nodes[0].secondaryStat].filter(identity) as Field[];
const node = nodes.find((n) => n.arcSections.length > 0); const node = nodes.find((n) => n.arcSections.length > 0);
@ -71,18 +74,30 @@ function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array<Viz
// Lets collect and deduplicate as there isn't a requirement for 0 size arc section to be defined // Lets collect and deduplicate as there isn't a requirement for 0 size arc section to be defined
fields.push(...new Set(nodes.map((n) => n.arcSections).flat())); fields.push(...new Set(nodes.map((n) => n.arcSections).flat()));
} else {
// TODO: probably some sort of gradient which we will have to deal with later
return [];
} }
} }
if (nodes[0].color) {
fields.push(nodes[0].color);
}
return fields.map((f) => { return fields.map((f) => {
return { const item: VizLegendItem = {
label: f.config.displayName || f.name, label: f.config.displayName || f.name,
color: getColorForTheme(f.config.color?.fixedColor || '', theme),
yAxis: 0, yAxis: 0,
data: { field: f }, data: { field: f },
}; };
if (f.config.color?.mode === FieldColorModeId.Fixed && f.config.color?.fixedColor) {
item.color = getColorForTheme(f.config.color?.fixedColor || '', theme);
} else if (f.config.color?.mode) {
item.gradient = f.config.color?.mode;
}
if (!(item.color || item.gradient)) {
// Defaults to gray color
item.color = getColorForTheme('', theme);
}
return item;
}); });
} }

View File

@ -1,7 +1,7 @@
import React, { MouseEvent, memo } from 'react'; import React, { MouseEvent, memo } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { getColorForTheme, GrafanaTheme2 } from '@grafana/data'; import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme } from '@grafana/ui'; import { useStyles2, useTheme2 } from '@grafana/ui';
import { NodeDatum } from './types'; import { NodeDatum } from './types';
import { css } from 'emotion'; import { css } from 'emotion';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
@ -117,14 +117,14 @@ export const Node = memo(function Node(props: {
function ColorCircle(props: { node: NodeDatum }) { function ColorCircle(props: { node: NodeDatum }) {
const { node } = props; const { node } = props;
const fullStat = node.arcSections.find((s) => s.values.get(node.dataFrameRowIndex) === 1); const fullStat = node.arcSections.find((s) => s.values.get(node.dataFrameRowIndex) === 1);
const theme = useTheme(); const theme = useTheme2();
if (fullStat) { if (fullStat) {
// Doing arc with path does not work well so it's better to just do a circle in that case // Doing arc with path does not work well so it's better to just do a circle in that case
return ( return (
<circle <circle
fill="none" fill="none"
stroke={getColorForTheme(fullStat.config.color?.fixedColor || '', theme)} stroke={theme.visualization.getColorByName(fullStat.config.color?.fixedColor || '')}
strokeWidth={2} strokeWidth={2}
r={nodeR} r={nodeR}
cx={node.x} cx={node.x}
@ -136,7 +136,16 @@ function ColorCircle(props: { node: NodeDatum }) {
const nonZero = node.arcSections.filter((s) => s.values.get(node.dataFrameRowIndex) !== 0); const nonZero = node.arcSections.filter((s) => s.values.get(node.dataFrameRowIndex) !== 0);
if (nonZero.length === 0) { if (nonZero.length === 0) {
// Fallback if no arc is defined // Fallback if no arc is defined
return <circle fill="none" stroke={node.color} strokeWidth={2} r={nodeR} cx={node.x} cy={node.y} />; return (
<circle
fill="none"
stroke={node.color ? getColor(node.color, node.dataFrameRowIndex, theme) : 'gray'}
strokeWidth={2}
r={nodeR}
cx={node.x}
cy={node.y}
/>
);
} }
const { elements } = nonZero.reduce( const { elements } = nonZero.reduce(
@ -151,7 +160,7 @@ function ColorCircle(props: { node: NodeDatum }) {
y={node.y!} y={node.y!}
startPercent={acc.percent} startPercent={acc.percent}
percent={value} percent={value}
color={getColorForTheme(color, theme)} color={theme.visualization.getColorByName(color)}
strokeWidth={2} strokeWidth={2}
/> />
); );
@ -197,3 +206,11 @@ function ArcSection({
/> />
); );
} }
function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
if (!field.config.color) {
return field.values.get(index);
}
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
}

View File

@ -12,7 +12,7 @@ export type NodeDatum = SimulationNodeDatum & {
mainStat?: Field; mainStat?: Field;
secondaryStat?: Field; secondaryStat?: Field;
arcSections: Field[]; arcSections: Field[];
color: string; color?: Field;
}; };
// This is the data we have before the graph is laid out with source and target being string IDs. // This is the data we have before the graph is laid out with source and target being string IDs.

View File

@ -19,6 +19,18 @@ describe('processNodes', () => {
theme theme
); );
const colorField = {
config: {
color: {
mode: 'continuous-GrYlRd',
},
},
index: 7,
name: 'color',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
};
expect(nodes).toEqual([ expect(nodes).toEqual([
{ {
arcSections: [ arcSections: [
@ -43,7 +55,7 @@ describe('processNodes', () => {
values: new ArrayVector([0.5, 0.5, 0.5]), values: new ArrayVector([0.5, 0.5, 0.5]),
}, },
], ],
color: 'rgb(226, 192, 61)', color: colorField,
dataFrameRowIndex: 0, dataFrameRowIndex: 0,
id: '0', id: '0',
incoming: 0, incoming: 0,
@ -87,7 +99,7 @@ describe('processNodes', () => {
values: new ArrayVector([0.5, 0.5, 0.5]), values: new ArrayVector([0.5, 0.5, 0.5]),
}, },
], ],
color: 'rgb(226, 192, 61)', color: colorField,
dataFrameRowIndex: 1, dataFrameRowIndex: 1,
id: '1', id: '1',
incoming: 1, incoming: 1,
@ -131,7 +143,7 @@ describe('processNodes', () => {
values: new ArrayVector([0.5, 0.5, 0.5]), values: new ArrayVector([0.5, 0.5, 0.5]),
}, },
], ],
color: 'rgb(226, 192, 61)', color: colorField,
dataFrameRowIndex: 2, dataFrameRowIndex: 2,
id: '2', id: '2',
incoming: 2, incoming: 2,

View File

@ -4,7 +4,6 @@ import {
Field, Field,
FieldCache, FieldCache,
FieldType, FieldType,
getFieldColorModeForField,
GrafanaTheme2, GrafanaTheme2,
MutableDataFrame, MutableDataFrame,
NodeGraphDataFrameFieldNames, NodeGraphDataFrameFieldNames,
@ -100,7 +99,7 @@ export function processNodes(
mainStat: nodeFields.mainStat, mainStat: nodeFields.mainStat,
secondaryStat: nodeFields.secondaryStat, secondaryStat: nodeFields.secondaryStat,
arcSections: nodeFields.arc, arcSections: nodeFields.arc,
color: nodeFields.color ? getColor(nodeFields.color, index, theme) : '', color: nodeFields.color,
}; };
return acc; return acc;
}, {}) || {}; }, {}) || {};
@ -271,14 +270,6 @@ function edgesFrame() {
}); });
} }
function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
if (!field.config.color) {
return field.values.get(index);
}
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
}
export interface Bounds { export interface Bounds {
top: number; top: number;
right: number; right: number;