Commit 5b022a1c by Torkel Ödegaard Committed by GitHub

Legends: Refactoring and rewrites of legend components to simplify components & reuse (#30165)

* Legends: Refactoring and rewrites of legend components to simplify components & reuse

* Removed onSeriesAxisToggle

* More removal of onSeriesAxisToggle and storybook improvements

* Added story with legend values

* Move table legend styles from inline to defined in stylesFactory

* Update styles

* Change to circle

* Updated style to fat line

* Rename to VizLegend

* More renamed and fixes / polish

* Removed imports

* Minor change

* Updates

* Updates
parent 807b31fb
......@@ -13,7 +13,7 @@ import {
import { useTheme } from '../../themes';
import { HorizontalGroup } from '../Layout/Layout';
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
import { SeriesIcon } from '../Legend/SeriesIcon';
import { SeriesIcon } from '../VizLegend/SeriesIcon';
import { css } from 'emotion';
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
......
import React from 'react';
import { GraphLegend } from './GraphLegend';
import { action } from '@storybook/addon-actions';
import { select, number } from '@storybook/addon-knobs';
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
import { generateLegendItems } from '../Legend/Legend';
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
export default {
title: 'Visualizations/Graph/GraphLegend',
component: GraphLegend,
decorators: [withHorizontallyCenteredStory],
};
const getStoriesKnobs = (isList = false) => {
const statsToDisplay = select<any>(
'Stats to display',
{
none: [],
'single (min)': [{ text: '10ms', title: 'min', numeric: 10 }],
'multiple (min, max)': [
{ text: '10ms', title: 'min', numeric: 10 },
{ text: '100ms', title: 'max', numeric: 100 },
],
},
[]
);
const numberOfSeries = number('Number of series', 3);
const containerWidth = select(
'Container width',
{
Small: '200px',
Medium: '500px',
'Full width': '100%',
},
'100%'
);
const legendPlacement = select<LegendPlacement>(
'Legend placement',
{
bottom: 'bottom',
right: 'right',
},
'bottom'
);
return {
statsToDisplay,
numberOfSeries,
containerWidth,
legendPlacement,
};
};
export const list = () => {
const { statsToDisplay, numberOfSeries, containerWidth, legendPlacement } = getStoriesKnobs(true);
return (
<div style={{ width: containerWidth }}>
<GraphLegend
displayMode={LegendDisplayMode.List}
items={generateLegendItems(numberOfSeries, statsToDisplay)}
onLabelClick={(item, event) => {
action('Series label clicked')(item, event);
}}
onSeriesColorChange={(label, color) => {
action('Series color changed')(label, color);
}}
onSeriesAxisToggle={(label, useRightYAxis) => {
action('Series axis toggle')(label, useRightYAxis);
}}
onToggleSort={sortBy => {
action('Toggle legend sort')(sortBy);
}}
placement={legendPlacement}
/>
</div>
);
};
export const table = () => {
const { statsToDisplay, numberOfSeries, containerWidth, legendPlacement } = getStoriesKnobs();
return (
<div style={{ width: containerWidth }}>
<GraphLegend
displayMode={LegendDisplayMode.Table}
items={generateLegendItems(numberOfSeries, statsToDisplay)}
onLabelClick={item => {
action('Series label clicked')(item);
}}
onSeriesColorChange={(label, color) => {
action('Series color changed')(label, color);
}}
onSeriesAxisToggle={(label, useRightYAxis) => {
action('Series axis toggle')(label, useRightYAxis);
}}
onToggleSort={sortBy => {
action('Toggle legend sort')(sortBy);
}}
placement={legendPlacement}
/>
</div>
);
};
import React, { useContext } from 'react';
import { LegendProps, LegendItem, LegendDisplayMode } from '../Legend/Legend';
import { GraphLegendListItem, GraphLegendTableRow } from './GraphLegendItem';
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from './GraphWithLegend';
import { LegendTable } from '../Legend/LegendTable';
import { LegendList } from '../Legend/LegendList';
import union from 'lodash/union';
import sortBy from 'lodash/sortBy';
import { ThemeContext } from '../../themes/ThemeContext';
import { css } from 'emotion';
export interface GraphLegendProps extends LegendProps {
displayMode: LegendDisplayMode;
sortBy?: string;
sortDesc?: boolean;
onSeriesColorChange?: SeriesColorChangeHandler;
onSeriesAxisToggle?: SeriesAxisToggleHandler;
onToggleSort?: (sortBy: string) => void;
onLabelClick?: (item: LegendItem, event: React.MouseEvent<HTMLElement>) => void;
}
export const GraphLegend: React.FunctionComponent<GraphLegendProps> = ({
items,
displayMode,
sortBy: sortKey,
sortDesc,
onToggleSort,
onSeriesAxisToggle,
placement,
className,
...graphLegendItemProps
}) => {
const theme = useContext(ThemeContext);
if (displayMode === LegendDisplayMode.Table) {
const columns = items
.map(item => {
if (item.displayValues) {
return item.displayValues.map(i => i.title);
}
return [];
})
.reduce(
(acc, current) => {
return union(
acc,
current.filter(item => !!item)
);
},
['']
) as string[];
const sortedItems = sortKey
? sortBy(items, item => {
if (item.displayValues) {
const stat = item.displayValues.filter(stat => stat.title === sortKey)[0];
return stat && stat.numeric;
}
return undefined;
})
: items;
const legendTableEvenRowBackground = theme.isDark ? theme.palette.dark6 : theme.palette.gray5;
return (
<LegendTable
className={css`
font-size: ${theme.typography.size.sm};
th {
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
}
`}
items={sortDesc ? sortedItems.reverse() : sortedItems}
columns={columns}
placement={placement}
sortBy={sortKey}
sortDesc={sortDesc}
itemRenderer={(item, index) => (
<GraphLegendTableRow
key={`${item.label}-${index}`}
item={item}
onToggleAxis={() => {
if (onSeriesAxisToggle) {
onSeriesAxisToggle(item.label, item.yAxis === 1 ? 2 : 1);
}
}}
className={css`
background: ${index % 2 === 0 ? legendTableEvenRowBackground : 'none'};
`}
{...graphLegendItemProps}
/>
)}
onToggleSort={onToggleSort}
/>
);
}
return (
<LegendList
items={items}
placement={placement}
itemRenderer={item => (
<GraphLegendListItem
item={item}
onToggleAxis={() => {
if (onSeriesAxisToggle) {
onSeriesAxisToggle(item.label, item.yAxis === 1 ? 2 : 1);
}
}}
{...graphLegendItemProps}
/>
)}
/>
);
};
GraphLegend.displayName = 'GraphLegend';
......@@ -2,7 +2,7 @@ import React from 'react';
import { stylesFactory } from '../../../themes/stylesFactory';
import { GrafanaTheme, GraphSeriesValue } from '@grafana/data';
import { css, cx } from 'emotion';
import { SeriesIcon } from '../../Legend/SeriesIcon';
import { SeriesIcon } from '../../VizLegend/SeriesIcon';
import { useTheme } from '../../../themes';
export interface SeriesTableRowProps {
......
......@@ -3,8 +3,7 @@ import React from 'react';
import { select, text } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { GraphWithLegend, GraphWithLegendProps } from './GraphWithLegend';
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
import { LegendPlacement, LegendDisplayMode } from '../VizLegend/types';
import { GraphSeriesXY, FieldType, ArrayVector, dateTime, FieldColorModeId } from '@grafana/data';
export default {
......
......@@ -5,21 +5,19 @@ import { css } from 'emotion';
import { GraphSeriesValue } from '@grafana/data';
import { Graph, GraphProps } from './Graph';
import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend';
import { GraphLegend } from './GraphLegend';
import { VizLegendItem, LegendDisplayMode, SeriesColorChangeHandler, LegendPlacement } from '../VizLegend/types';
import { VizLegend } from '../VizLegend/VizLegend';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { stylesFactory } from '../../themes';
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;
export type SeriesColorChangeHandler = SeriesOptionChangeHandler<string>;
export type SeriesAxisToggleHandler = SeriesOptionChangeHandler<number>;
export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions {
export interface GraphWithLegendProps extends GraphProps {
legendDisplayMode: LegendDisplayMode;
placement: LegendPlacement;
hideEmpty?: boolean;
hideZero?: boolean;
sortLegendBy?: string;
sortLegendDesc?: boolean;
onSeriesColorChange?: SeriesColorChangeHandler;
onSeriesAxisToggle?: SeriesAxisToggleHandler;
onSeriesToggle?: (label: string, event: React.MouseEvent<HTMLElement>) => void;
onToggleSort: (sortBy: string) => void;
}
......@@ -60,7 +58,6 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
sortLegendDesc,
legendDisplayMode,
placement,
onSeriesAxisToggle,
onSeriesColorChange,
onSeriesToggle,
onToggleSort,
......@@ -75,7 +72,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
} = props;
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
const legendItems = series.reduce<LegendItem[]>((acc, s) => {
const legendItems = series.reduce<VizLegendItem[]>((acc, s) => {
return shouldHideLegendItem(s.data, hideEmpty, hideZero)
? acc
: acc.concat([
......@@ -112,7 +109,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
{legendDisplayMode !== LegendDisplayMode.Hidden && (
<div className={legendContainer}>
<CustomScrollbar hideHorizontalTrack>
<GraphLegend
<VizLegend
items={legendItems}
displayMode={legendDisplayMode}
placement={placement}
......@@ -124,7 +121,6 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
}
}}
onSeriesColorChange={onSeriesColorChange}
onSeriesAxisToggle={onSeriesAxisToggle}
onToggleSort={onToggleSort}
/>
</CustomScrollbar>
......
......@@ -3,7 +3,7 @@ import React from 'react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { GraphNG } from './GraphNG';
import { dateTime } from '@grafana/data';
import { LegendDisplayMode } from '../Legend/Legend';
import { LegendDisplayMode } from '../VizLegend/types';
import { prepDataForStorybook } from '../../utils/storybook/data';
import { useTheme } from '../../themes';
import { text, select } from '@storybook/addon-knobs';
......
......@@ -16,8 +16,8 @@ import { PlotProps } from '../uPlot/types';
import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config';
import { useTheme } from '../../themes';
import { VizLayout } from '../VizLayout/VizLayout';
import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend';
import { GraphLegend } from '../Graph/GraphLegend';
import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types';
import { VizLegend } from '../VizLegend/VizLegend';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { useRevision } from '../uPlot/hooks';
import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types';
......@@ -30,7 +30,7 @@ export interface XYFieldMatchers {
}
export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
data: DataFrame[];
legend?: LegendOptions;
legend?: VizLegendOptions;
fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void;
}
......@@ -55,7 +55,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
}) => {
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
const theme = useTheme();
const legendItemsRef = useRef<LegendItem[]>([]);
const legendItemsRef = useRef<VizLegendItem[]>([]);
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const alignedFrame = alignedFrameWithGapTest?.frame;
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
......@@ -68,7 +68,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
}, []);
const onLabelClick = useCallback(
(legend: LegendItem, event: React.MouseEvent) => {
(legend: VizLegendItem, event: React.MouseEvent) => {
const { fieldIndex } = legend;
if (!onLegendClick || !fieldIndex) {
......@@ -128,7 +128,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
});
}
const legendItems: LegendItem[] = [];
const legendItems: VizLegendItem[] = [];
for (let i = 0; i < alignedFrame.fields.length; i++) {
const field = alignedFrame.fields[i];
......@@ -217,7 +217,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
if (hasLegend && legendItemsRef.current.length > 0) {
legendElement = (
<VizLayout.Legend position={legend!.placement} maxHeight="35%" maxWidth="60%">
<GraphLegend
<VizLegend
onLabelClick={onLabelClick}
placement={legend!.placement}
items={legendItemsRef.current}
......
......@@ -3,7 +3,7 @@ import { DataFrameFieldIndex } from '@grafana/data';
/**
* Mode to describe if a legend is isolated/selected or being appended to an existing
* series selection.
* @public
* @alpha
*/
export enum GraphNGLegendEventMode {
ToggleSelection = 'select',
......@@ -12,7 +12,7 @@ export enum GraphNGLegendEventMode {
/**
* Event being triggered when the user interact with the Graph legend.
* @public
* @alpha
*/
export interface GraphNGLegendEvent {
fieldIndex: DataFrameFieldIndex;
......
import React from 'react';
import { generateLegendItems } from './Legend';
import { LegendList, LegendPlacement, LegendItem, LegendTable } from '@grafana/ui';
import { number, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { GraphLegendListItem, GraphLegendTableRow, GraphLegendItemProps } from '../Graph/GraphLegendItem';
const getStoriesKnobs = (table = false) => {
const numberOfSeries = number('Number of series', 3);
const containerWidth = select(
'Container width',
{
Small: '200px',
Medium: '500px',
'Full width': '100%',
},
'100%'
);
const rawRenderer = (item: LegendItem) => (
<>
Label: <strong>{item.label}</strong>, Color: <strong>{item.color}</strong>, disabled:{' '}
<strong>{item.disabled ? 'yes' : 'no'}</strong>
</>
);
// eslint-disable-next-line react/display-name
const customRenderer = (component: React.ComponentType<GraphLegendItemProps>) => (item: LegendItem) =>
React.createElement(component, {
item,
onLabelClick: action('GraphLegendItem label clicked'),
onSeriesColorChange: action('Series color changed'),
onToggleAxis: action('Y-axis toggle'),
});
const typeSpecificRenderer = table
? {
'Custom renderer(GraphLegendTablerow)': 'custom-table',
}
: {
'Custom renderer(GraphLegendListItem)': 'custom-list',
};
const legendItemRenderer = select(
'Item rendered',
{
'Raw renderer': 'raw',
...typeSpecificRenderer,
},
'raw'
);
const rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
const legendPlacement = select<LegendPlacement>(
'Legend placement',
{
bottom: 'bottom',
right: 'right',
},
'bottom'
);
return {
numberOfSeries,
containerWidth,
itemRenderer:
legendItemRenderer === 'raw'
? rawRenderer
: customRenderer(legendItemRenderer === 'custom-list' ? GraphLegendListItem : GraphLegendTableRow),
rightAxisSeries,
legendPlacement,
};
};
export default {
title: 'Visualizations/Legend',
component: LegendList,
subcomponents: { LegendTable },
};
export const list = () => {
const { numberOfSeries, itemRenderer, containerWidth, rightAxisSeries, legendPlacement } = getStoriesKnobs();
let items = generateLegendItems(numberOfSeries);
items = items.map(i => {
if (
rightAxisSeries
.split(',')
.map(s => s.trim())
.indexOf(i.label.split('-')[0]) > -1
) {
i.yAxis = 2;
}
return i;
});
return (
<div style={{ width: containerWidth }}>
<LegendList itemRenderer={itemRenderer} items={items} placement={legendPlacement} />
</div>
);
};
export const table = () => {
const { numberOfSeries, itemRenderer, containerWidth, rightAxisSeries, legendPlacement } = getStoriesKnobs(true);
let items = generateLegendItems(numberOfSeries);
items = items.map(i => {
if (
rightAxisSeries
.split(',')
.map(s => s.trim())
.indexOf(i.label.split('-')[0]) > -1
) {
i.yAxis = 2;
}
return {
...i,
info: [
{ title: 'min', text: '14.42', numeric: 14.427101844163694 },
{ title: 'max', text: '18.42', numeric: 18.427101844163694 },
],
};
});
return (
<div style={{ width: containerWidth }}>
<LegendTable itemRenderer={itemRenderer} items={items} columns={['', 'min', 'max']} placement={legendPlacement} />
</div>
);
};
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
import { LegendList } from './LegendList';
import { LegendTable } from './LegendTable';
import tinycolor from 'tinycolor2';
export const generateLegendItems = (numberOfSeries: number, statsToDisplay?: DisplayValue[]): LegendItem[] => {
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split('');
return [...new Array(numberOfSeries)].map((item, i) => {
return {
label: `${alphabet[i].toUpperCase()}-series`,
color: tinycolor.fromRatio({ h: i / alphabet.length, s: 1, v: 1 }).toHexString(),
yAxis: 1,
displayValues: statsToDisplay || [],
};
});
};
export enum LegendDisplayMode {
List = 'list',
Table = 'table',
Hidden = 'hidden',
}
export interface LegendBasicOptions {
displayMode: LegendDisplayMode;
}
export interface LegendRenderOptions {
placement: LegendPlacement;
hideEmpty?: boolean;
hideZero?: boolean;
}
export type LegendPlacement = 'bottom' | 'right';
export interface LegendOptions extends LegendBasicOptions, LegendRenderOptions {}
export interface LegendItem {
label: string;
color: string;
yAxis: number;
disabled?: boolean;
displayValues?: DisplayValue[];
fieldIndex?: DataFrameFieldIndex;
}
export interface LegendComponentProps {
className?: string;
items: LegendItem[];
placement: LegendPlacement;
// Function to render given item
itemRenderer?: (item: LegendItem, index: number) => JSX.Element;
}
export interface LegendProps extends LegendComponentProps {}
export { LegendList, LegendTable };
import React, { useContext } from 'react';
import { LegendComponentProps, LegendItem } from './Legend';
import { InlineList } from '../List/InlineList';
import { List } from '../List/List';
import { css, cx } from 'emotion';
import { ThemeContext } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
item: css`
padding-left: 10px;
display: flex;
font-size: ${theme.typography.size.sm};
white-space: nowrap;
`,
wrapper: css`
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
`,
section: css`
display: flex;
`,
sectionRight: css`
justify-content: flex-end;
flex-grow: 1;
`,
}));
export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
items,
itemRenderer,
placement,
className,
}) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const renderItem = (item: LegendItem, index: number) => {
return <span className={styles.item}>{itemRenderer ? itemRenderer(item, index) : item.label}</span>;
};
const getItemKey = (item: LegendItem) => `${item.label}`;
return placement === 'bottom' ? (
<div className={cx(styles.wrapper, className)}>
<div className={styles.section}>
<InlineList items={items.filter(item => item.yAxis === 1)} renderItem={renderItem} getItemKey={getItemKey} />
</div>
<div className={cx(styles.section, styles.sectionRight)}>
<InlineList items={items.filter(item => item.yAxis !== 1)} renderItem={renderItem} getItemKey={getItemKey} />
</div>
</div>
) : (
<List items={items} renderItem={renderItem} getItemKey={getItemKey} className={className} />
);
};
LegendList.displayName = 'LegendList';
import React, { useContext } from 'react';
import { css, cx } from 'emotion';
import { LegendComponentProps } from './Legend';
import { Icon } from '../Icon/Icon';
import { ThemeContext } from '../../themes/ThemeContext';
export interface LegendTableProps extends LegendComponentProps {
columns: string[];
sortBy?: string;
sortDesc?: boolean;
onToggleSort?: (sortBy: string) => void;
}
export const LegendTable: React.FunctionComponent<LegendTableProps> = ({
items,
columns,
sortBy,
sortDesc,
itemRenderer,
className,
onToggleSort,
}) => {
const theme = useContext(ThemeContext);
return (
<table
className={cx(
css`
width: 100%;
td {
padding: 2px 10px;
}
`,
className
)}
>
<thead>
<tr>
{columns.map(columnHeader => {
return (
<th
key={columnHeader}
className={css`
color: ${theme.colors.textBlue};
font-weight: bold;
text-align: right;
cursor: pointer;
`}
onClick={() => {
if (onToggleSort) {
onToggleSort(columnHeader);
}
}}
>
{columnHeader}
{sortBy === columnHeader && (
<Icon
className={css`
margin-left: ${theme.spacing.sm};
`}
name={sortDesc ? 'angle-down' : 'angle-up'}
/>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{items.map((item, index) => {
return itemRenderer ? (
itemRenderer(item, index)
) : (
<tr key={`${item.label}-${index}`}>
<td>{item.label}</td>
</tr>
);
})}
</tbody>
</table>
);
};
import React from 'react';
import { Icon } from '../Icon/Icon';
export interface SeriesIconProps {
color: string;
className?: string;
}
export const SeriesIcon: React.FunctionComponent<SeriesIconProps> = ({ color, className }) => {
return <Icon name="minus" className={className} style={{ color }} />;
};
SeriesIcon.displayName = 'SeriesIcon';
import React, { CSSProperties } from 'react';
export interface Props extends React.HTMLAttributes<HTMLDivElement> {
color: string;
}
export const SeriesIcon = React.forwardRef<HTMLDivElement, Props>(({ color, className, ...restProps }, ref) => {
const styles: CSSProperties = {
backgroundColor: color,
width: '14px',
height: '4px',
borderRadius: '1px',
display: 'inline-block',
marginRight: '8px',
};
return <div ref={ref} className={className} style={styles} {...restProps} />;
});
SeriesIcon.displayName = 'SeriesIcon';
import React, { FC, useState } from 'react';
import { useTheme, VizLegend } from '@grafana/ui';
import { number, select } from '@storybook/addon-knobs';
import {} from './VizLegendListItem';
import { DisplayValue, getColorForTheme, GrafanaTheme } from '@grafana/data';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { LegendDisplayMode, VizLegendItem, LegendPlacement } from './types';
const getStoriesKnobs = (table = false) => {
const seriesCount = number('Number of series', 5);
const containerWidth = select(
'Container width',
{
Small: '200px',
Medium: '500px',
'Full width': '100%',
},
'100%'
);
return {
seriesCount,
containerWidth,
};
};
export default {
title: 'Visualizations/VizLegend',
component: VizLegend,
decorators: [withCenteredStory],
};
interface LegendStoryDemoProps {
name: string;
displayMode: LegendDisplayMode;
placement: LegendPlacement;
seriesCount: number;
stats?: DisplayValue[];
}
const LegendStoryDemo: FC<LegendStoryDemoProps> = ({ displayMode, seriesCount, name, placement, stats }) => {
const theme = useTheme();
const [items, setItems] = useState<VizLegendItem[]>(generateLegendItems(seriesCount, theme, stats));
const onSeriesColorChange = (label: string, color: string) => {
setItems(
items.map(item => {
if (item.label === label) {
return {
...item,
color: color,
};
}
return item;
})
);
};
const onLabelClick = (clickItem: VizLegendItem) => {
setItems(
items.map(item => {
if (item !== clickItem) {
return {
...item,
disabled: true,
};
} else {
return {
...item,
disabled: false,
};
}
})
);
};
return (
<p style={{ marginBottom: '32px' }}>
<h3 style={{ marginBottom: '32px' }}>{name}</h3>
<VizLegend
displayMode={displayMode}
items={items}
placement={placement}
onSeriesColorChange={onSeriesColorChange}
onLabelClick={onLabelClick}
/>
</p>
);
};
export const WithNoValues = () => {
const { seriesCount, containerWidth } = getStoriesKnobs();
return (
<div style={{ width: containerWidth }}>
<LegendStoryDemo
name="List mode, placement bottom"
displayMode={LegendDisplayMode.List}
seriesCount={seriesCount}
placement="bottom"
/>
<LegendStoryDemo
name="List mode, placement right"
displayMode={LegendDisplayMode.List}
seriesCount={seriesCount}
placement="right"
/>
<LegendStoryDemo
name="Table mode"
displayMode={LegendDisplayMode.Table}
seriesCount={seriesCount}
placement="bottom"
/>
</div>
);
};
export const WithValues = () => {
const { seriesCount, containerWidth } = getStoriesKnobs();
const stats: DisplayValue[] = [
{
title: 'Min',
text: '5.00',
numeric: 5,
},
{
title: 'Max',
text: '10.00',
numeric: 10,
},
{
title: 'Last',
text: '2.00',
numeric: 2,
},
];
return (
<div style={{ width: containerWidth }}>
<LegendStoryDemo
name="List mode, placement bottom"
displayMode={LegendDisplayMode.List}
seriesCount={seriesCount}
placement="bottom"
stats={stats}
/>
<LegendStoryDemo
name="List mode, placement right"
displayMode={LegendDisplayMode.List}
seriesCount={seriesCount}
placement="right"
stats={stats}
/>
<LegendStoryDemo
name="Table mode"
displayMode={LegendDisplayMode.Table}
seriesCount={seriesCount}
placement="bottom"
stats={stats}
/>
</div>
);
};
function generateLegendItems(
numberOfSeries: number,
theme: GrafanaTheme,
statsToDisplay?: DisplayValue[]
): VizLegendItem[] {
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split('');
const colors = ['green', 'blue', 'red', 'purple', 'orange', 'dark-green', 'yellow', 'light-blue'].map(c =>
getColorForTheme(c, theme)
);
return [...new Array(numberOfSeries)].map((item, i) => {
return {
label: `${alphabet[i].toUpperCase()}-series`,
color: colors[i],
yAxis: 1,
displayValues: statsToDisplay || [],
};
});
}
import React from 'react';
import { LegendProps, LegendDisplayMode } from './types';
import { VizLegendTable } from './VizLegendTable';
import { VizLegendList } from './VizLegendList';
export const VizLegend: React.FunctionComponent<LegendProps> = ({
items,
displayMode,
sortBy: sortKey,
sortDesc,
onToggleSort,
onLabelClick,
onSeriesColorChange,
placement,
className,
}) => {
switch (displayMode) {
case LegendDisplayMode.Table:
return (
<VizLegendTable
className={className}
items={items}
placement={placement}
sortBy={sortKey}
sortDesc={sortDesc}
onLabelClick={onLabelClick}
onToggleSort={onToggleSort}
onSeriesColorChange={onSeriesColorChange}
/>
);
case LegendDisplayMode.List:
return (
<VizLegendList
className={className}
items={items}
placement={placement}
onLabelClick={onLabelClick}
onSeriesColorChange={onSeriesColorChange}
/>
);
default:
return null;
}
};
VizLegend.displayName = 'Legend';
import React from 'react';
import { VizLegendBaseProps, VizLegendItem } from './types';
import { InlineList } from '../List/InlineList';
import { List } from '../List/List';
import { css, cx } from 'emotion';
import { useStyles } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
import { VizLegendListItem } from './VizLegendListItem';
export interface Props extends VizLegendBaseProps {}
export const VizLegendList: React.FunctionComponent<Props> = ({
items,
itemRenderer,
onSeriesColorChange,
onLabelClick,
placement,
className,
}) => {
const styles = useStyles(getStyles);
if (!itemRenderer) {
/* eslint-disable-next-line react/display-name */
itemRenderer = item => (
<VizLegendListItem item={item} onLabelClick={onLabelClick} onSeriesColorChange={onSeriesColorChange} />
);
}
const renderItem = (item: VizLegendItem, index: number) => {
return <span className={styles.item}>{itemRenderer!(item, index)}</span>;
};
const getItemKey = (item: VizLegendItem) => `${item.label}`;
switch (placement) {
case 'right':
return (
<div className={cx(styles.rightWrapper, className)}>
<List items={items} renderItem={renderItem} getItemKey={getItemKey} className={className} />
</div>
);
case 'bottom':
default:
return (
<div className={cx(styles.bottomWrapper, className)}>
<div className={styles.section}>
<InlineList
items={items.filter(item => item.yAxis === 1)}
renderItem={renderItem}
getItemKey={getItemKey}
/>
</div>
<div className={cx(styles.section, styles.sectionRight)}>
<InlineList
items={items.filter(item => item.yAxis !== 1)}
renderItem={renderItem}
getItemKey={getItemKey}
/>
</div>
</div>
);
}
};
VizLegendList.displayName = 'VizLegendList';
const getStyles = (theme: GrafanaTheme) => ({
item: css`
padding-right: 10px;
display: flex;
font-size: ${theme.typography.size.sm};
white-space: nowrap;
margin-bottom: ${theme.spacing.xs};
`,
rightWrapper: css`
margin-left: ${theme.spacing.sm};
`,
bottomWrapper: css`
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
margin-left: ${theme.spacing.md};
`,
section: css`
display: flex;
`,
sectionRight: css`
justify-content: flex-end;
flex-grow: 1;
`,
});
import React from 'react';
import { css, cx } from 'emotion';
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
import { VizLegendItem } from './types';
import { SeriesColorChangeHandler } from './types';
import { VizLegendStatsList } from './VizLegendStatsList';
import { useStyles } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
export interface Props {
item: VizLegendItem;
className?: string;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onSeriesColorChange?: SeriesColorChangeHandler;
}
export const VizLegendListItem: React.FunctionComponent<Props> = ({ item, onSeriesColorChange, onLabelClick }) => {
const styles = useStyles(getStyles);
return (
<div className={styles.itemWrapper}>
<VizLegendSeriesIcon
disabled={!onSeriesColorChange}
color={item.color}
onColorChange={color => {
if (onSeriesColorChange) {
onSeriesColorChange(item.label, color);
}
}}
yAxis={item.yAxis}
/>
<div
onClick={event => {
if (onLabelClick) {
onLabelClick(item, event);
}
}}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}
</div>
{item.displayValues && <VizLegendStatsList stats={item.displayValues} />}
</div>
);
};
VizLegendListItem.displayName = 'VizLegendListItem';
const getStyles = (theme: GrafanaTheme) => ({
label: css`
label: LegendLabel;
cursor: pointer;
white-space: nowrap;
`,
labelDisabled: css`
label: LegendLabelDisabled;
color: ${theme.colors.linkDisabled};
`,
itemWrapper: css`
display: flex;
white-space: nowrap;
align-items: center;
`,
value: css`
text-align: right;
`,
yAxisLabel: css`
color: ${theme.palette.gray2};
`,
});
import React from 'react';
import { css, cx } from 'emotion';
import { SeriesColorPicker } from '../ColorPicker/ColorPicker';
import { SeriesIcon, SeriesIconProps } from './SeriesIcon';
import { SeriesIcon } from './SeriesIcon';
interface LegendSeriesIconProps {
interface Props {
disabled: boolean;
color: string;
yAxis: number;
......@@ -11,36 +10,15 @@ interface LegendSeriesIconProps {
onToggleAxis?: () => void;
}
export const LegendSeriesIcon: React.FunctionComponent<LegendSeriesIconProps> = ({
export const VizLegendSeriesIcon: React.FunctionComponent<Props> = ({
disabled,
yAxis,
color,
onColorChange,
onToggleAxis,
}) => {
let iconProps: SeriesIconProps = {
color,
};
if (!disabled) {
iconProps = {
...iconProps,
className: 'pointer',
};
}
return disabled ? (
<span
className={cx(
'graph-legend-icon',
disabled &&
css`
cursor: default;
`
)}
>
<SeriesIcon {...iconProps} />
</span>
<SeriesIcon color={color} />
) : (
<SeriesColorPicker
yaxis={yAxis}
......@@ -50,12 +28,16 @@ export const LegendSeriesIcon: React.FunctionComponent<LegendSeriesIconProps> =
enableNamedColors
>
{({ ref, showColorPicker, hideColorPicker }) => (
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
<SeriesIcon {...iconProps} />
</span>
<SeriesIcon
color={color}
className="pointer"
ref={ref}
onClick={showColorPicker}
onMouseLeave={hideColorPicker}
/>
)}
</SeriesColorPicker>
);
};
LegendSeriesIcon.displayName = 'LegendSeriesIcon';
VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon';
......@@ -4,25 +4,25 @@ import { css } from 'emotion';
import { DisplayValue, formattedValueToString } from '@grafana/data';
import capitalize from 'lodash/capitalize';
const LegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ stat }) => {
const VizLegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ stat }) => {
const styles = css`
margin-left: 8px;
`;
return (
<div
className={css`
margin-left: 6px;
`}
>
<div className={styles}>
{stat.title && `${capitalize(stat.title)}:`} {formattedValueToString(stat)}
</div>
);
};
LegendItemStat.displayName = 'LegendItemStat';
VizLegendItemStat.displayName = 'VizLegendItemStat';
export const LegendStatsList: React.FunctionComponent<{ stats: DisplayValue[] }> = ({ stats }) => {
export const VizLegendStatsList: React.FunctionComponent<{ stats: DisplayValue[] }> = ({ stats }) => {
if (stats.length === 0) {
return null;
}
return <InlineList items={stats} renderItem={stat => <LegendItemStat stat={stat} />} />;
return <InlineList items={stats} renderItem={stat => <VizLegendItemStat stat={stat} />} />;
};
LegendStatsList.displayName = 'LegendStatsList';
VizLegendStatsList.displayName = 'VizLegendStatsList';
import React, { FC } from 'react';
import { css, cx } from 'emotion';
import { VizLegendTableProps } from './types';
import { Icon } from '../Icon/Icon';
import { useStyles } from '../../themes/ThemeContext';
import union from 'lodash/union';
import sortBy from 'lodash/sortBy';
import { LegendTableItem } from './VizLegendTableItem';
import { GrafanaTheme } from '@grafana/data';
export const VizLegendTable: FC<VizLegendTableProps> = ({
items,
sortBy: sortKey,
sortDesc,
itemRenderer,
className,
onToggleSort,
onLabelClick,
onSeriesColorChange,
}) => {
const styles = useStyles(getStyles);
const columns = items
.map(item => {
if (item.displayValues) {
return item.displayValues.map(i => i.title);
}
return [];
})
.reduce(
(acc, current) => {
return union(
acc,
current.filter(item => !!item)
);
},
['']
) as string[];
const sortedItems = sortKey
? sortBy(items, item => {
if (item.displayValues) {
const stat = item.displayValues.filter(stat => stat.title === sortKey)[0];
return stat && stat.numeric;
}
return undefined;
})
: items;
if (!itemRenderer) {
/* eslint-disable-next-line react/display-name */
itemRenderer = (item, index) => (
<LegendTableItem
key={`${item.label}-${index}`}
item={item}
onSeriesColorChange={onSeriesColorChange}
onLabelClick={onLabelClick}
/>
);
}
return (
<table className={cx(styles.table, className)}>
<thead>
<tr>
{columns.map(columnHeader => {
return (
<th
key={columnHeader}
className={styles.header}
onClick={() => {
if (onToggleSort) {
onToggleSort(columnHeader);
}
}}
>
{columnHeader}
{sortKey === columnHeader && (
<Icon className={styles.sortIcon} name={sortDesc ? 'angle-down' : 'angle-up'} />
)}
</th>
);
})}
</tr>
</thead>
<tbody>{sortedItems.map(itemRenderer!)}</tbody>
</table>
);
};
const getStyles = (theme: GrafanaTheme) => ({
table: css`
width: 100%;
margin-left: ${theme.spacing.sm};
`,
header: css`
color: ${theme.colors.textBlue};
font-weight: ${theme.typography.weight.semibold};
border-bottom: 1px solid ${theme.colors.border1};
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
text-align: right;
cursor: pointer;
`,
sortIcon: css`
margin-left: ${theme.spacing.sm};
`,
});
import React, { useContext } from 'react';
import React from 'react';
import { css, cx } from 'emotion';
import { LegendSeriesIcon } from '../Legend/LegendSeriesIcon';
import { LegendItem } from '../Legend/Legend';
import { SeriesColorChangeHandler } from './GraphWithLegend';
import { LegendStatsList } from '../Legend/LegendStatsList';
import { ThemeContext } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes';
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
import { VizLegendItem } from './types';
import { SeriesColorChangeHandler } from './types';
import { useStyles } from '../../themes/ThemeContext';
import { styleMixins } from '../../themes';
import { GrafanaTheme, formattedValueToString } from '@grafana/data';
export interface GraphLegendItemProps {
export interface Props {
key?: React.Key;
item: LegendItem;
item: VizLegendItem;
className?: string;
onLabelClick?: (item: LegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
onSeriesColorChange?: SeriesColorChangeHandler;
onToggleAxis?: () => void;
}
export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps> = ({
export const LegendTableItem: React.FunctionComponent<Props> = ({
item,
onSeriesColorChange,
onToggleAxis,
onLabelClick,
className,
}) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const styles = useStyles(getStyles);
return (
<>
<LegendSeriesIcon
<tr className={cx(styles.row, className)}>
<td>
<span className={styles.itemWrapper}>
<VizLegendSeriesIcon
disabled={!onSeriesColorChange}
color={item.color}
onColorChange={color => {
......@@ -35,7 +35,6 @@ export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps>
onSeriesColorChange(item.label, color);
}
}}
onToggleAxis={onToggleAxis}
yAxis={item.yAxis}
/>
<div
......@@ -46,23 +45,40 @@ export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps>
}}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label}
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}
</div>
{item.displayValues && <LegendStatsList stats={item.displayValues} />}
</>
</span>
</td>
{item.displayValues &&
item.displayValues.map((stat, index) => {
return (
<td className={styles.value} key={`${stat.title}-${index}`}>
{formattedValueToString(stat)}
</td>
);
})}
</tr>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
LegendTableItem.displayName = 'LegendTableItem';
const getStyles = (theme: GrafanaTheme) => {
const rowHoverBg = styleMixins.hoverColor(theme.colors.bg1, theme);
return {
row: css`
label: LegendRow;
font-size: ${theme.typography.size.sm};
border-bottom: 1px solid ${theme.colors.border1};
td {
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
white-space: nowrap;
}
&:hover {
background: ${rowHoverBg};
}
`,
label: css`
label: LegendLabel;
......@@ -76,6 +92,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
itemWrapper: css`
display: flex;
white-space: nowrap;
align-items: center;
`,
value: css`
text-align: right;
......@@ -84,52 +101,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
color: ${theme.palette.gray2};
`,
};
});
export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps> = ({
item,
onSeriesColorChange,
onToggleAxis,
onLabelClick,
className,
}) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
return (
<tr className={cx(styles.row, className)}>
<td>
<span className={styles.itemWrapper}>
<LegendSeriesIcon
disabled={!!onSeriesColorChange}
color={item.color}
onColorChange={color => {
if (onSeriesColorChange) {
onSeriesColorChange(item.label, color);
}
}}
onToggleAxis={onToggleAxis}
yAxis={item.yAxis}
/>
<div
onClick={event => {
if (onLabelClick) {
onLabelClick(item, event);
}
}}
className={cx(styles.label, item.disabled && styles.labelDisabled)}
>
{item.label} {item.yAxis === 2 && <span className={styles.yAxisLabel}>(right y-axis)</span>}
</div>
</span>
</td>
{item.displayValues &&
item.displayValues.map((stat, index) => {
return (
<td className={styles.value} key={`${stat.title}-${index}`}>
{formattedValueToString(stat)}
</td>
);
})}
</tr>
);
};
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
export interface VizLegendBaseProps {
placement: LegendPlacement;
className?: string;
items: VizLegendItem[];
itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element;
onSeriesColorChange?: SeriesColorChangeHandler;
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
}
export interface VizLegendTableProps extends VizLegendBaseProps {
sortBy?: string;
sortDesc?: boolean;
onToggleSort?: (sortBy: string) => void;
}
export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps {
displayMode: LegendDisplayMode;
}
export interface VizLegendItem {
label: string;
color: string;
yAxis: number;
disabled?: boolean;
displayValues?: DisplayValue[];
fieldIndex?: DataFrameFieldIndex;
}
export enum LegendDisplayMode {
List = 'list',
Table = 'table',
Hidden = 'hidden',
}
export type LegendPlacement = 'bottom' | 'right';
export interface VizLegendOptions {
displayMode: LegendDisplayMode;
placement: LegendPlacement;
}
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;
export type SeriesColorChangeHandler = SeriesOptionChangeHandler<string>;
......@@ -69,7 +69,6 @@ export {
export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { GraphLegend } from './Graph/GraphLegend';
export { GraphWithLegend } from './Graph/GraphWithLegend';
export { GraphContextMenu, GraphContextMenuHeader } from './Graph/GraphContextMenu';
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
......@@ -77,17 +76,8 @@ export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
export { graphTimeFormat, graphTickFormatter } from './Graph/utils';
export { VizLayout, VizLayoutComponentType, VizLayoutLegendProps, VizLayoutProps } from './VizLayout/VizLayout';
export {
LegendOptions,
LegendBasicOptions,
LegendRenderOptions,
LegendList,
LegendTable,
LegendItem,
LegendPlacement,
LegendDisplayMode,
} from './Legend/Legend';
export { VizLegendItem, LegendPlacement, LegendDisplayMode, VizLegendOptions } from './VizLegend/types';
export { VizLegend } from './VizLegend/VizLegend';
export { Alert, AlertVariant } from './Alert/Alert';
export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
......@@ -109,7 +99,7 @@ export { WithContextMenu } from './ContextMenu/WithContextMenu';
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
export { DataLinkInput } from './DataLinks/DataLinkInput';
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
export { SeriesIcon } from './Legend/SeriesIcon';
export { SeriesIcon } from './VizLegend/SeriesIcon';
export { InfoBox } from './InfoBox/InfoBox';
export { FeatureInfoBox } from './InfoBox/FeatureInfoBox';
......
import { LegendOptions, GraphTooltipOptions, LegendDisplayMode } from '@grafana/ui';
import { GraphTooltipOptions, LegendDisplayMode, LegendPlacement } from '@grafana/ui';
import { YAxis } from '@grafana/data';
export interface SeriesOptions {
......@@ -14,7 +14,10 @@ export interface GraphOptions {
export interface Options {
graph: GraphOptions;
legend: LegendOptions & GraphLegendEditorLegendOptions;
legend: {
displayMode: LegendDisplayMode;
placement: LegendPlacement;
};
series: {
[alias: string]: SeriesOptions;
};
......@@ -35,7 +38,9 @@ export const defaults: Options = {
tooltipOptions: { mode: 'single' },
};
export interface GraphLegendEditorLegendOptions extends LegendOptions {
export interface GraphLegendEditorLegendOptions {
displayMode: LegendDisplayMode;
placement: LegendPlacement;
stats?: string[];
decimals?: number;
sortBy?: string;
......
......@@ -197,7 +197,7 @@ class LegendSeriesIcon extends PureComponent<LegendSeriesIconProps, LegendSeries
enableNamedColors
>
{({ ref, showColorPicker, hideColorPicker }) => (
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker}>
<SeriesIcon color={this.props.color} />
</span>
)}
......
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
import { VizLegendOptions, GraphTooltipOptions } from '@grafana/ui';
export interface GraphOptions {
// Redraw as time passes
......@@ -7,13 +7,6 @@ export interface GraphOptions {
export interface Options {
graph: GraphOptions;
legend: LegendOptions;
legend: VizLegendOptions;
tooltipOptions: GraphTooltipOptions;
}
export interface GraphLegendEditorLegendOptions extends LegendOptions {
stats?: string[];
decimals?: number;
sortBy?: string;
sortDesc?: boolean;
}
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
import { VizLegendOptions, GraphTooltipOptions } from '@grafana/ui';
export interface XYDimensionConfig {
frame: number;
......@@ -9,6 +9,6 @@ export interface XYDimensionConfig {
export interface Options {
dims: XYDimensionConfig;
legend: LegendOptions;
legend: VizLegendOptions;
tooltipOptions: GraphTooltipOptions;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment