feat(barchart): render legend threshold colors

pull/92241/head
Ihor Yeromin 11 months ago
parent 134467fc4a
commit 5654a52249
  1. 7
      .betterer.results
  2. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  3. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 84
      packages/grafana-ui/src/components/VizLegend2/SeriesIcon.tsx
  5. 139
      packages/grafana-ui/src/components/VizLegend2/VizLegend2.tsx
  6. 121
      packages/grafana-ui/src/components/VizLegend2/VizLegendList.tsx
  7. 128
      packages/grafana-ui/src/components/VizLegend2/VizLegendListItem.tsx
  8. 49
      packages/grafana-ui/src/components/VizLegend2/VizLegendSeriesIcon.tsx
  9. 46
      packages/grafana-ui/src/components/VizLegend2/VizLegendStatsList.tsx
  10. 145
      packages/grafana-ui/src/components/VizLegend2/VizLegendTable.tsx
  11. 153
      packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx
  12. 54
      packages/grafana-ui/src/components/VizLegend2/types.ts
  13. 8
      packages/grafana-ui/src/components/VizLegend2/utils.ts
  14. 1
      packages/grafana-ui/src/components/index.ts
  15. 7
      pkg/services/featuremgmt/registry.go
  16. 1
      pkg/services/featuremgmt/toggles_gen.csv
  17. 4
      pkg/services/featuremgmt/toggles_gen.go
  18. 13
      pkg/services/featuremgmt/toggles_gen.json
  19. 56
      public/app/plugins/panel/barchart/BarChartLegend.tsx

@ -913,6 +913,13 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"packages/grafana-ui/src/components/VizLegend2/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]

@ -195,6 +195,7 @@ Experimental features might be changed or removed without prior notice.
| `backgroundPluginInstaller` | Enable background plugin installer |
| `dataplaneAggregator` | Enable grafana dataplane aggregator |
| `adhocFilterOneOf` | Exposes a new 'one of' operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter. |
| `newVizLegend` | Enhanced legend for visualizations |
## Development feature toggles

@ -204,4 +204,5 @@ export interface FeatureToggles {
backgroundPluginInstaller?: boolean;
dataplaneAggregator?: boolean;
adhocFilterOneOf?: boolean;
newVizLegend?: boolean;
}

@ -0,0 +1,84 @@
import { css, cx } from '@emotion/css';
import { CSSProperties } from 'react';
import * as React from 'react';
import { GrafanaTheme2, fieldColorModeRegistry } from '@grafana/data';
import { LineStyle } from '@grafana/schema';
import { useTheme2, useStyles2 } from '../../themes';
export interface Props extends React.HTMLAttributes<HTMLDivElement> {
color?: string;
gradient?: string;
lineStyle?: LineStyle;
}
export const SeriesIcon = React.memo(
React.forwardRef<HTMLDivElement, Props>(({ color, className, gradient, lineStyle, ...restProps }, ref) => {
const theme = useTheme2();
const styles = useStyles2(getStyles);
let cssColor: string;
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!;
}
let customStyle: CSSProperties;
if (lineStyle?.fill === 'dot' && !gradient) {
// make a circle bg image and repeat it
customStyle = {
backgroundImage: `radial-gradient(circle at 2px 2px, ${color} 2px, transparent 0)`,
backgroundSize: '4px 4px',
backgroundRepeat: 'space',
};
} else if (lineStyle?.fill === 'dash' && !gradient) {
// make a rectangle bg image and repeat it
customStyle = {
backgroundImage: `linear-gradient(to right, ${color} 100%, transparent 0%)`,
backgroundSize: '6px 4px',
backgroundRepeat: 'space',
};
} else {
customStyle = {
background: cssColor,
borderRadius: theme.shape.radius.pill,
};
}
return (
<div
data-testid="series-icon"
ref={ref}
className={cx(className, styles.forcedColors, styles.container)}
style={customStyle}
{...restProps}
/>
);
})
);
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
marginRight: '8px',
display: 'inline-block',
width: '14px',
height: '4px',
}),
forcedColors: css({
'@media (forced-colors: active)': {
forcedColorAdjust: 'none',
},
}),
});
SeriesIcon.displayName = 'SeriesIcon';

@ -0,0 +1,139 @@
import { useCallback } from 'react';
import * as React from 'react';
import { DataHoverClearEvent, DataHoverEvent } from '@grafana/data';
import { LegendDisplayMode } from '@grafana/schema';
import { SeriesVisibilityChangeMode, usePanelContext } from '../PanelChrome';
import { VizLegendList } from './VizLegendList';
import { VizLegendTable } from './VizLegendTable';
import { LegendProps, SeriesVisibilityChangeBehavior, VizLegendItem } from './types';
import { mapMouseEventToMode } from './utils';
/**
* @public
*/
export function VizLegend2<T>({
items,
thresholdItems,
displayMode,
sortBy: sortKey,
seriesVisibilityChangeBehavior = SeriesVisibilityChangeBehavior.Isolate,
sortDesc,
onLabelClick,
onToggleSort,
placement,
className,
itemRenderer,
readonly,
isSortable,
}: LegendProps<T>) {
const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext();
const onMouseOver = useCallback(
(
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>
) => {
eventBus?.publish({
type: DataHoverEvent.type,
payload: {
raw: event,
x: 0,
y: 0,
dataId: item.label,
},
});
},
[eventBus]
);
const onMouseOut = useCallback(
(
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>
) => {
eventBus?.publish({
type: DataHoverClearEvent.type,
payload: {
raw: event,
x: 0,
y: 0,
dataId: item.label,
},
});
},
[eventBus]
);
const onLegendLabelClick = useCallback(
(item: VizLegendItem, event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (onLabelClick) {
onLabelClick(item, event);
}
if (onToggleSeriesVisibility) {
onToggleSeriesVisibility(
item.fieldName ?? item.label,
seriesVisibilityChangeBehavior === SeriesVisibilityChangeBehavior.Hide
? SeriesVisibilityChangeMode.AppendToSelection
: mapMouseEventToMode(event)
);
}
},
[onToggleSeriesVisibility, onLabelClick, seriesVisibilityChangeBehavior]
);
switch (displayMode) {
case LegendDisplayMode.Table:
return (
<VizLegendTable<T>
className={className}
items={items}
placement={placement}
sortBy={sortKey}
sortDesc={sortDesc}
onLabelClick={onLegendLabelClick}
onToggleSort={onToggleSort || onToggleLegendSort}
onLabelMouseOver={onMouseOver}
onLabelMouseOut={onMouseOut}
itemRenderer={itemRenderer}
readonly={readonly}
isSortable={isSortable}
/>
);
case LegendDisplayMode.List:
return (
<>
{/* render series colors if more than one series or no threshold colors */}
{(items.length > 1 || thresholdItems?.length === 0) && (
<VizLegendList<T>
className={className}
items={items}
placement={placement}
onLabelMouseOver={onMouseOver}
onLabelMouseOut={onMouseOut}
onLabelClick={onLegendLabelClick}
itemRenderer={itemRenderer}
readonly={readonly}
/>
)}
{/* render threshold colors if exists */}
{thresholdItems && (
<VizLegendList<T>
className={className}
items={thresholdItems}
placement={placement}
onLabelMouseOver={onMouseOver}
onLabelMouseOut={onMouseOut}
onLabelClick={onLegendLabelClick}
itemRenderer={itemRenderer}
readonly={readonly}
/>
)}
</>
);
default:
return null;
}
}

@ -0,0 +1,121 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { InlineList } from '../List/InlineList';
import { List } from '../List/List';
import { VizLegendListItem } from './VizLegendListItem';
import { VizLegendBaseProps, VizLegendItem } from './types';
export interface Props<T> extends VizLegendBaseProps<T> {}
/**
* @internal
*/
export const VizLegendList = <T extends unknown>({
items,
itemRenderer,
onLabelMouseOver,
onLabelMouseOut,
onLabelClick,
placement,
className,
readonly,
}: Props<T>) => {
const styles = useStyles2(getStyles);
if (!itemRenderer) {
/* eslint-disable-next-line react/display-name */
itemRenderer = (item) => (
<VizLegendListItem
item={item}
onLabelClick={onLabelClick}
onLabelMouseOver={onLabelMouseOver}
onLabelMouseOut={onLabelMouseOut}
readonly={readonly}
/>
);
}
const getItemKey = (item: VizLegendItem<T>) => `${item.getItemKey ? item.getItemKey() : item.label}`;
switch (placement) {
case 'right': {
const renderItem = (item: VizLegendItem<T>, index: number) => {
return <span className={styles.itemRight}>{itemRenderer!(item, index)}</span>;
};
return (
<div className={cx(styles.rightWrapper, className)}>
<List items={items} renderItem={renderItem} getItemKey={getItemKey} />
</div>
);
}
case 'bottom':
default: {
const leftItems = items.filter((item) => item.yAxis === 1);
const rightItems = items.filter((item) => item.yAxis !== 1);
const renderItem = (item: VizLegendItem<T>, index: number) => {
return <span className={styles.itemBottom}>{itemRenderer!(item, index)}</span>;
};
return (
<div className={cx(styles.bottomWrapper, className)}>
{leftItems.length > 0 && (
<div className={styles.section}>
<InlineList items={leftItems} renderItem={renderItem} getItemKey={getItemKey} />
</div>
)}
{rightItems.length > 0 && (
<div className={cx(styles.section, styles.sectionRight)}>
<InlineList items={rightItems} renderItem={renderItem} getItemKey={getItemKey} />
</div>
)}
</div>
);
}
}
};
VizLegendList.displayName = 'VizLegendList';
const getStyles = (theme: GrafanaTheme2) => {
const itemStyles = css({
paddingRight: '10px',
display: 'flex',
fontSize: theme.typography.bodySmall.fontSize,
whiteSpace: 'nowrap',
});
return {
itemBottom: itemStyles,
itemRight: cx(
itemStyles,
css({
marginBottom: theme.spacing(0.5),
})
),
rightWrapper: css({
paddingLeft: theme.spacing(0.5),
}),
bottomWrapper: css({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
width: '100%',
paddingLeft: theme.spacing(0.5),
gap: '15px 25px',
}),
section: css({
display: 'flex',
}),
sectionRight: css({
justifyContent: 'flex-end',
flexGrow: 1,
flexBasis: '50%',
}),
};
};

@ -0,0 +1,128 @@
import { css, cx } from '@emotion/css';
import { useCallback } from 'react';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '../../themes';
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
import { VizLegendStatsList } from './VizLegendStatsList';
import { VizLegendItem } from './types';
export interface Props<T> {
item: VizLegendItem<T>;
className?: string;
onLabelClick?: (item: VizLegendItem<T>, event: React.MouseEvent<HTMLButtonElement>) => void;
onLabelMouseOver?: (
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>
) => void;
onLabelMouseOut?: (
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>
) => void;
readonly?: boolean;
}
/**
* @internal
*/
export const VizLegendListItem = <T = unknown,>({
item,
onLabelClick,
onLabelMouseOver,
onLabelMouseOut,
className,
readonly,
}: Props<T>) => {
const styles = useStyles2(getStyles);
const onMouseOver = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => {
if (onLabelMouseOver) {
onLabelMouseOver(item, event);
}
},
[item, onLabelMouseOver]
);
const onMouseOut = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => {
if (onLabelMouseOut) {
onLabelMouseOut(item, event);
}
},
[item, onLabelMouseOut]
);
const onClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (onLabelClick) {
onLabelClick(item, event);
}
},
[item, onLabelClick]
);
return (
<div
className={cx(styles.itemWrapper, item.disabled && styles.itemDisabled, className)}
data-testid={selectors.components.VizLegend.seriesName(item.label)}
>
<VizLegendSeriesIcon
seriesName={item.fieldName ?? item.label}
color={item.color}
gradient={item.gradient}
readonly={readonly}
lineStyle={item.lineStyle}
/>
<button
disabled={readonly}
type="button"
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={onClick}
className={styles.label}
>
{item.label}
</button>
{item.getDisplayValues && <VizLegendStatsList stats={item.getDisplayValues()} />}
</div>
);
};
VizLegendListItem.displayName = 'VizLegendListItem';
const getStyles = (theme: GrafanaTheme2) => ({
label: css({
label: 'LegendLabel',
whiteSpace: 'nowrap',
background: 'none',
border: 'none',
fontSize: 'inherit',
padding: 0,
userSelect: 'text',
}),
itemDisabled: css({
label: 'LegendLabelDisabled',
color: theme.colors.text.disabled,
}),
itemWrapper: css({
label: 'LegendItemWrapper',
display: 'flex',
whiteSpace: 'nowrap',
alignItems: 'center',
flexGrow: 1,
}),
value: css({
textAlign: 'right',
}),
yAxisLabel: css({
color: theme.v1.palette.gray2,
}),
});

@ -0,0 +1,49 @@
import { memo, useCallback } from 'react';
import { LineStyle } from '@grafana/schema';
import { SeriesColorPicker } from '../ColorPicker/ColorPicker';
import { usePanelContext } from '../PanelChrome';
import { SeriesIcon } from './SeriesIcon';
interface Props {
seriesName: string;
color?: string;
gradient?: string;
readonly?: boolean;
lineStyle?: LineStyle;
}
/**
* @internal
*/
export const VizLegendSeriesIcon = memo(({ seriesName, color, gradient, readonly, lineStyle }: Props) => {
const { onSeriesColorChange } = usePanelContext();
const onChange = useCallback(
(color: string) => {
return onSeriesColorChange!(seriesName, color);
},
[seriesName, onSeriesColorChange]
);
if (seriesName && onSeriesColorChange && color && !readonly) {
return (
<SeriesColorPicker color={color} onChange={onChange} enableNamedColors>
{({ ref, showColorPicker, hideColorPicker }) => (
<SeriesIcon
color={color}
className="pointer"
ref={ref}
onClick={showColorPicker}
onMouseLeave={hideColorPicker}
lineStyle={lineStyle}
/>
)}
</SeriesColorPicker>
);
}
return <SeriesIcon color={color} gradient={gradient} lineStyle={lineStyle} />;
});
VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon';

@ -0,0 +1,46 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import { DisplayValue, formattedValueToString } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { InlineList } from '../List/InlineList';
interface Props {
stats: DisplayValue[];
}
/**
* @internal
*/
export const VizLegendStatsList = ({ stats }: Props) => {
const styles = useStyles2(getStyles);
if (stats.length === 0) {
return null;
}
return (
<InlineList
className={styles.list}
items={stats}
renderItem={(stat) => (
<div className={styles.item} title={stat.description}>
{stat.title && `${capitalize(stat.title)}:`} {formattedValueToString(stat)}
</div>
)}
/>
);
};
const getStyles = () => ({
list: css({
flexGrow: 1,
textAlign: 'right',
}),
item: css({
marginLeft: '8px',
}),
});
VizLegendStatsList.displayName = 'VizLegendStatsList';

@ -0,0 +1,145 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { Icon } from '../Icon/Icon';
import { LegendTableItem } from './VizLegendTableItem';
import { VizLegendItem, VizLegendTableProps } from './types';
const nameSortKey = 'Name';
const naturalCompare = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare;
/**
* @internal
*/
export const VizLegendTable = <T extends unknown>({
items,
sortBy: sortKey,
sortDesc,
itemRenderer,
className,
onToggleSort,
onLabelClick,
onLabelMouseOver,
onLabelMouseOut,
readonly,
isSortable,
}: VizLegendTableProps<T>): JSX.Element => {
const styles = useStyles2(getStyles);
const header: Record<string, string> = {};
if (isSortable) {
header[nameSortKey] = '';
}
for (const item of items) {
if (item.getDisplayValues) {
for (const displayValue of item.getDisplayValues()) {
header[displayValue.title ?? '?'] = displayValue.description ?? '';
}
}
}
if (sortKey != null) {
let itemVals = new Map<VizLegendItem, number>();
items.forEach((item) => {
if (sortKey !== nameSortKey && item.getDisplayValues) {
const stat = item.getDisplayValues().find((stat) => stat.title === sortKey);
const val = stat == null || Number.isNaN(stat.numeric) ? -Infinity : stat.numeric;
itemVals.set(item, val);
}
});
let sortMult = sortDesc ? -1 : 1;
if (sortKey === nameSortKey) {
// string sort
items.sort((a, b) => {
return sortMult * naturalCompare(a.label, b.label);
});
} else {
// numeric sort
items.sort((a, b) => {
const aVal = itemVals.get(a) ?? 0;
const bVal = itemVals.get(b) ?? 0;
return sortMult * (aVal - bVal);
});
}
}
if (!itemRenderer) {
/* eslint-disable-next-line react/display-name */
itemRenderer = (item, index) => (
<LegendTableItem
key={`${item.label}-${index}`}
item={item}
onLabelClick={onLabelClick}
onLabelMouseOver={onLabelMouseOver}
onLabelMouseOut={onLabelMouseOut}
readonly={readonly}
/>
);
}
return (
<table className={cx(styles.table, className)}>
<thead>
<tr>
{!isSortable && <th></th>}
{Object.keys(header).map((columnTitle) => (
<th
title={header[columnTitle]}
key={columnTitle}
className={cx(styles.header, onToggleSort && styles.headerSortable, isSortable && styles.nameHeader, {
[styles.withIcon]: sortKey === columnTitle,
})}
onClick={() => {
if (onToggleSort) {
onToggleSort(columnTitle);
}
}}
>
{columnTitle}
{sortKey === columnTitle && <Icon size="xs" name={sortDesc ? 'angle-down' : 'angle-up'} />}
</th>
))}
</tr>
</thead>
<tbody>{items.map(itemRenderer!)}</tbody>
</table>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
table: css({
width: '100%',
'th:first-child': {
width: '100%',
borderBottom: `1px solid ${theme.colors.border.weak}`,
},
}),
header: css({
color: theme.colors.primary.text,
fontWeight: theme.typography.fontWeightMedium,
borderBottom: `1px solid ${theme.colors.border.weak}`,
padding: theme.spacing(0.25, 1, 0.25, 1),
fontSize: theme.typography.bodySmall.fontSize,
textAlign: 'right',
whiteSpace: 'nowrap',
}),
nameHeader: css({
textAlign: 'left',
paddingLeft: '30px',
}),
// This needs to be padding-right - icon size(xs==12) to avoid jumping
withIcon: css({
paddingRight: '4px',
}),
headerSortable: css({
cursor: 'pointer',
}),
});

@ -0,0 +1,153 @@
import { css, cx } from '@emotion/css';
import { useCallback } from 'react';
import * as React from 'react';
import { formattedValueToString, GrafanaTheme2 } from '@grafana/data';
import { styleMixins } from '../../themes';
import { useStyles2 } from '../../themes/ThemeContext';
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
import { VizLegendItem } from './types';
export interface Props {
key?: React.Key;
item: VizLegendItem;
className?: string;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLButtonElement>) => void;
onLabelMouseOver?: (
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>
) => void;
onLabelMouseOut?: (
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>
) => void;
readonly?: boolean;
}
/**
* @internal
*/
export const LegendTableItem = ({
item,
onLabelClick,
onLabelMouseOver,
onLabelMouseOut,
className,
readonly,
}: Props) => {
const styles = useStyles2(getStyles);
const onMouseOver = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => {
if (onLabelMouseOver) {
onLabelMouseOver(item, event);
}
},
[item, onLabelMouseOver]
);
const onMouseOut = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.FocusEvent<HTMLButtonElement>) => {
if (onLabelMouseOut) {
onLabelMouseOut(item, event);
}
},
[item, onLabelMouseOut]
);
const onClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (onLabelClick) {
onLabelClick(item, event);
}
},
[item, onLabelClick]
);
return (
<tr className={cx(styles.row, className)}>
<td>
<span className={styles.itemWrapper}>
<VizLegendSeriesIcon
color={item.color}
seriesName={item.fieldName ?? item.label}
readonly={readonly}
lineStyle={item.lineStyle}
/>
<button
disabled={readonly}
type="button"
title={item.label}
onBlur={onMouseOut}
onFocus={onMouseOver}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onClick={!readonly ? onClick : undefined}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}
</button>
</span>
</td>
{item.getDisplayValues &&
item.getDisplayValues().map((stat, index) => {
return (
<td className={styles.value} key={`${stat.title}-${index}`}>
{formattedValueToString(stat)}
</td>
);
})}
</tr>
);
};
LegendTableItem.displayName = 'LegendTableItem';
const getStyles = (theme: GrafanaTheme2) => {
const rowHoverBg = styleMixins.hoverColor(theme.colors.background.primary, theme);
return {
row: css({
label: 'LegendRow',
fontSize: theme.v1.typography.size.sm,
borderBottom: `1px solid ${theme.colors.border.weak}`,
td: {
padding: theme.spacing(0.25, 1),
whiteSpace: 'nowrap',
},
'&:hover': {
background: rowHoverBg,
},
}),
label: css({
label: 'LegendLabel',
whiteSpace: 'nowrap',
background: 'none',
border: 'none',
fontSize: 'inherit',
padding: 0,
maxWidth: '600px',
textOverflow: 'ellipsis',
overflow: 'hidden',
userSelect: 'text',
}),
labelDisabled: css({
label: 'LegendLabelDisabled',
color: theme.colors.text.disabled,
}),
itemWrapper: css({
display: 'flex',
whiteSpace: 'nowrap',
alignItems: 'center',
}),
value: css({
textAlign: 'right',
}),
yAxisLabel: css({
color: theme.colors.text.secondary,
}),
};
};

@ -0,0 +1,54 @@
import * as React from 'react';
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
import { LegendDisplayMode, LegendPlacement, LineStyle } from '@grafana/schema';
export enum SeriesVisibilityChangeBehavior {
Isolate,
Hide,
}
export interface VizLegendBaseProps<T> {
placement: LegendPlacement;
className?: string;
items: Array<VizLegendItem<T>>;
thresholdItems?: Array<VizLegendItem<T>>;
seriesVisibilityChangeBehavior?: SeriesVisibilityChangeBehavior;
onLabelClick?: (item: VizLegendItem<T>, event: React.MouseEvent<HTMLButtonElement>) => void;
itemRenderer?: (item: VizLegendItem<T>, index: number) => JSX.Element;
onLabelMouseOver?: (
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>
) => void;
onLabelMouseOut?: (
item: VizLegendItem,
event: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>
) => void;
readonly?: boolean;
}
export interface VizLegendTableProps<T> extends VizLegendBaseProps<T> {
sortBy?: string;
sortDesc?: boolean;
onToggleSort?: (sortBy: string) => void;
isSortable?: boolean;
}
export interface LegendProps<T = any> extends VizLegendBaseProps<T>, VizLegendTableProps<T> {
displayMode: LegendDisplayMode;
}
export interface VizLegendItem<T = any> {
getItemKey?: () => string;
label: string;
color?: string;
gradient?: string;
yAxis: number;
disabled?: boolean;
// displayValues?: DisplayValue[];
getDisplayValues?: () => DisplayValue[];
fieldIndex?: DataFrameFieldIndex;
fieldName?: string;
data?: T;
lineStyle?: LineStyle;
}

@ -0,0 +1,8 @@
import { SeriesVisibilityChangeMode } from '../PanelChrome/types';
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return SeriesVisibilityChangeMode.AppendToSelection;
}
return SeriesVisibilityChangeMode.ToggleSelection;
}

@ -148,6 +148,7 @@ export {
} from './VizLayout/VizLayout';
export { type VizLegendItem, SeriesVisibilityChangeBehavior } from './VizLegend/types';
export { VizLegend } from './VizLegend/VizLegend';
export { VizLegend2 } from './VizLegend2/VizLegend2';
export { VizLegendListItem } from './VizLegend/VizLegendListItem';
export { Alert, type AlertVariant } from './Alert/Alert';

@ -1406,6 +1406,13 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaDashboardsSquad,
},
{
Name: "newVizLegend",
Description: "Enhanced legend for visualizations",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
}
)

@ -185,3 +185,4 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa
backgroundPluginInstaller,experimental,@grafana/plugins-platform-backend,false,true,false
dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
adhocFilterOneOf,experimental,@grafana/dashboards-squad,false,false,false
newVizLegend,experimental,@grafana/dataviz-squad,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
185 backgroundPluginInstaller experimental @grafana/plugins-platform-backend false true false
186 dataplaneAggregator experimental @grafana/grafana-app-platform-squad false true false
187 adhocFilterOneOf experimental @grafana/dashboards-squad false false false
188 newVizLegend experimental @grafana/dataviz-squad false false true

@ -750,4 +750,8 @@ const (
// FlagAdhocFilterOneOf
// Exposes a new &#39;one of&#39; operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter.
FlagAdhocFilterOneOf = "adhocFilterOneOf"
// FlagNewVizLegend
// Enhanced legend for visualizations
FlagNewVizLegend = "newVizLegend"
)

@ -1837,6 +1837,19 @@
"codeowner": "@grafana/sharing-squad"
}
},
{
"metadata": {
"name": "newVizLegend",
"resourceVersion": "1724158935069",
"creationTimestamp": "2024-08-20T13:02:15Z"
},
"spec": {
"description": "Enhanced legend for visualizations",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true
}
},
{
"metadata": {
"name": "nodeGraphDotLayout",

@ -2,9 +2,18 @@ import { memo } from 'react';
import { DataFrame, Field, getFieldSeriesColor } from '@grafana/data';
import { VizLegendOptions, AxisPlacement } from '@grafana/schema';
import { UPlotConfigBuilder, VizLayout, VizLayoutLegendProps, VizLegend, VizLegendItem, useTheme2 } from '@grafana/ui';
import {
UPlotConfigBuilder,
VizLayout,
VizLayoutLegendProps,
VizLegend,
VizLegend2,
VizLegendItem,
useTheme2,
} from '@grafana/ui';
import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils';
import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils';
import { getThresholdItems } from 'app/core/components/TimelineChart/utils';
import { config } from 'app/core/config';
interface BarChartLegend2Props extends VizLegendOptions, Omit<VizLayoutLegendProps, 'children'> {
data: DataFrame[];
@ -32,17 +41,26 @@ export const BarChartLegend = memo(
({ data, placement, calcs, displayMode, colorField, ...vizLayoutLegendProps }: BarChartLegend2Props) => {
const theme = useTheme2();
if (colorField != null) {
const items = getFieldLegendItem([colorField], theme);
// if (colorField != null) {
// const items = getFieldLegendItem([colorField], theme);
if (items?.length) {
return (
<VizLayout.Legend placement={placement}>
<VizLegend placement={placement} items={items} displayMode={displayMode} />
</VizLayout.Legend>
);
}
}
// if (items?.length) {
// if (config.featureToggles.newVizLegend) {
// return (
// <VizLegend2 placement={placement} items={items} displayMode={displayMode} />
// );
// } else {
// return (
// <VizLayout.Legend placement={placement}>
// <VizLegend placement={placement} items={items} displayMode={displayMode} />
// </VizLayout.Legend>
// );
// }
// }
// }
const fieldConfig = data[0].fields[0].config;
const legendThresholdItems: VizLegendItem[] | undefined = getThresholdItems(fieldConfig, theme);
const legendItems = data[0].fields
.slice(1)
@ -75,6 +93,19 @@ export const BarChartLegend = memo(
})
.filter((i): i is VizLegendItem => i !== undefined);
if (config.featureToggles.newVizLegend) {
return (
<VizLegend2
placement={placement}
items={legendItems}
thresholdItems={legendThresholdItems}
displayMode={displayMode}
sortBy={vizLayoutLegendProps.sortBy}
sortDesc={vizLayoutLegendProps.sortDesc}
isSortable={true}
/>
);
} else {
return (
<VizLayout.Legend placement={placement} {...vizLayoutLegendProps}>
<VizLegend
@ -88,6 +119,7 @@ export const BarChartLegend = memo(
</VizLayout.Legend>
);
}
}
);
BarChartLegend.displayName = 'BarChartLegend';

Loading…
Cancel
Save