XYPanel: Improvements (#54220)

* Fix panel option bugs and make tooltip options work

* Support multiple series in explicit mode. Rename XY/Explicit to Auto/Manual

* Fixes

* Fix

* Legend improvements

* Rewrite Pure Component to Function Component

* Add datalinks support

* Legend fixes and CR modifications

* Fix bugs that crash panel
This commit is contained in:
Victor Marin 2022-09-05 11:03:17 +03:00 committed by GitHub
parent 4952b7f22d
commit 6d2352735d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 653 additions and 247 deletions

View File

@ -9253,10 +9253,24 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/plugins/panel/xychart/ExplicitEditor.tsx:5381": [
"public/app/plugins/panel/xychart/AutoEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/xychart/XYDimsEditor.tsx:5381": [
"public/app/plugins/panel/xychart/ManualEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Do not use any type assertions.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
],
"public/app/plugins/panel/xychart/XYChartPanel2.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/panel/xychart/dims.ts:5381": [

View File

@ -234,7 +234,6 @@ export class TestDataDataSource extends DataSourceWithBackend<TestDataQuery> {
options: DataQueryRequest<TestDataQuery>
): Observable<DataQueryResponse> | null {
const { errorType } = target;
console.log("we're here!", target);
if (errorType === 'server_panic') {
return null;

View File

@ -8,7 +8,7 @@ import {
getFieldDisplayName,
GrafanaTheme2,
} from '@grafana/data';
import { IconButton, Label, Select, useStyles2 } from '@grafana/ui';
import { Field, IconButton, Select, useStyles2 } from '@grafana/ui';
import { getXYDimensions, isGraphable } from './dims';
import { XYDimensionConfig, XYChartOptions } from './models.gen';
@ -19,7 +19,7 @@ interface XYInfo {
yFields: Array<SelectableValue<boolean>>;
}
export const XYDimsEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChartOptions>> = ({
export const AutoEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChartOptions>> = ({
value,
onChange,
context,
@ -89,54 +89,55 @@ export const XYDimsEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChar
return (
<div>
<Select
options={frameNames}
value={frameNames.find((v) => v.value === value?.frame) ?? frameNames[0]}
onChange={(v) => {
onChange({
...value,
frame: v.value!,
});
}}
/>
<br />
<Label>X Field</Label>
<Select
options={info.numberFields}
value={info.xAxis}
onChange={(v) => {
onChange({
...value,
x: v.value,
});
}}
/>
<br />
<Label>Y Fields</Label>
<div>
{info.yFields.map((v) => (
<div key={v.label} className={styles.row}>
<IconButton
name={v.value ? 'eye-slash' : 'eye'}
onClick={() => {
const exclude: string[] = value?.exclude ? [...value.exclude] : [];
let idx = exclude.indexOf(v.label!);
if (idx < 0) {
exclude.push(v.label!);
} else {
exclude.splice(idx, 1);
}
onChange({
...value,
exclude,
});
}}
/>
{v.label}
</div>
))}
</div>
<br /> <br />
<Field label={'Data'}>
<Select
options={frameNames}
value={frameNames.find((v) => v.value === value?.frame) ?? frameNames[0]}
onChange={(v) => {
onChange({
...value,
frame: v.value!,
});
}}
/>
</Field>
<Field label={'X Field'}>
<Select
options={info.numberFields}
value={info.xAxis}
onChange={(v) => {
onChange({
...value,
x: v.value,
});
}}
/>
</Field>
<Field label={'Y Fields'}>
<div>
{info.yFields.map((v) => (
<div key={v.label} className={styles.row}>
<IconButton
name={v.value ? 'eye-slash' : 'eye'}
onClick={() => {
const exclude: string[] = value?.exclude ? [...value.exclude] : [];
let idx = exclude.indexOf(v.label!);
if (idx < 0) {
exclude.push(v.label!);
} else {
exclude.splice(idx, 1);
}
onChange({
...value,
exclude,
});
}}
/>
{v.label}
</div>
))}
</div>
</Field>
</div>
);
};

View File

@ -1,13 +0,0 @@
import React, { FC } from 'react';
import { StandardEditorProps } from '@grafana/data';
import { XYChartOptions, ScatterFieldConfig } from './models.gen';
export const ExplicitEditor: FC<StandardEditorProps<ScatterFieldConfig[], any, XYChartOptions>> = ({
value,
onChange,
context,
}) => {
return <div>TODO: explicit scatter config</div>;
};

View File

@ -0,0 +1,155 @@
import { css, cx } from '@emotion/css';
import React, { FC, useState } from 'react';
import { GrafanaTheme, StandardEditorProps } from '@grafana/data';
import { Button, Field, IconButton, useStyles } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
import { LayerName } from 'app/core/components/Layers/LayerName';
import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
import { XYChartOptions, ScatterSeriesConfig, defaultScatterConfig } from './models.gen';
export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XYChartOptions>> = ({
value,
onChange,
context,
}) => {
const [selected, setSelected] = useState<number>(-1);
const style = useStyles(getStyles);
const onFieldChange = (val: any | undefined, index: number, field: string) => {
onChange(
value.map((obj, i) => {
if (i === index) {
return { ...obj, [field]: val };
}
return obj;
})
);
};
const createNewSeries = () => {
onChange([
...value,
{
pointColor: {} as any,
pointSize: defaultScatterConfig.pointSize,
},
]);
};
const onSeriesDelete = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
const { options } = context;
const getRowStyle = (index: number) => {
return index === selected ? `${style.row} ${style.sel}` : style.row;
};
if (options === undefined || !options.series) {
return null;
}
return (
<>
<Button icon="plus" size="sm" variant="secondary" onClick={createNewSeries} className={style.marginBot}>
Add series
</Button>
<div className={style.marginBot}>
{options.series.map((series, index) => {
return (
<div key={`series/${index}`} className={getRowStyle(index)} onMouseDown={() => setSelected(index)}>
<LayerName
name={series.name ?? `Series ${index + 1}`}
onChange={(v) => onFieldChange(v, index, 'name')}
/>
<IconButton
name="trash-alt"
title={'remove'}
className={cx(style.actionIcon)}
onClick={() => onSeriesDelete(index)}
/>
</div>
);
})}
</div>
{selected >= 0 && options.series[selected] && (
<>
<div key={`series/${selected}`}>
<Field label={'X Field'}>
<FieldNamePicker
value={options.series[selected].x ?? ''}
context={context}
onChange={(field) => onFieldChange(field, selected, 'x')}
item={{} as any}
/>
</Field>
<Field label={'Y Field'}>
<FieldNamePicker
value={options.series[selected].y ?? ''}
context={context}
onChange={(field) => onFieldChange(field, selected, 'y')}
item={{} as any}
/>
</Field>
<Field label={'Point color'}>
<ColorDimensionEditor
value={options.series[selected].pointColor!}
context={context}
onChange={(field) => onFieldChange(field, selected, 'pointColor')}
item={{} as any}
/>
</Field>
<Field label={'Point size'}>
<ScaleDimensionEditor
value={options.series[selected].pointSize!}
context={context}
onChange={(field) => onFieldChange(field, selected, 'pointSize')}
item={{ settings: { min: 1, max: 50 } } as any}
/>
</Field>
</div>
</>
)}
</>
);
};
const getStyles = (theme: GrafanaTheme) => ({
marginBot: css`
margin-bottom: 20px;
`,
row: css`
padding: ${theme.spacing.xs} ${theme.spacing.sm};
border-radius: ${theme.border.radius.sm};
background: ${theme.colors.bg2};
min-height: ${theme.spacing.formInputHeight}px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 3px;
cursor: pointer;
border: 1px solid ${theme.colors.formInputBorder};
&:hover {
border: 1px solid ${theme.colors.formInputBorderHover};
}
`,
sel: css`
border: 1px solid ${theme.colors.formInputBorderActive};
&:hover {
border: 1px solid ${theme.colors.formInputBorderActive};
}
`,
actionIcon: css`
color: ${theme.colors.textWeak};
&:hover {
color: ${theme.colors.text};
}
`,
});

View File

@ -1,55 +1,162 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import React from 'react';
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2 } from '@grafana/data';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
import {
arrayUtils,
DataFrame,
Field,
formattedValueToString,
getFieldDisplayName,
GrafanaTheme2,
LinkModel,
TimeRange,
} from '@grafana/data';
import { SortOrder } from '@grafana/schema';
import {
LinkButton,
SeriesIcon,
TooltipDisplayMode,
usePanelContext,
useStyles2,
VerticalGroup,
VizTooltipOptions,
} from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { ScatterSeries } from './types';
export interface Props {
series: ScatterSeries;
allSeries: ScatterSeries[];
data: DataFrame[]; // source data
rowIndex?: number; // the hover row
hoveredPointIndex: number; // the hovered point
options: VizTooltipOptions;
range: TimeRange;
}
export class TooltipView extends PureComponent<Props> {
style = getStyles(config.theme2);
export const TooltipView = ({ allSeries, data, rowIndex, hoveredPointIndex, options, range }: Props) => {
const style = useStyles2(getStyles);
const { onSplitOpen } = usePanelContext();
render() {
const { series, data, rowIndex } = this.props;
if (!series || rowIndex == null) {
return null;
}
const frame = series.frame(data);
const y = undefined; // series.y(frame);
if (!allSeries || rowIndex == null) {
return null;
}
return (
<table className={this.style.infoWrap}>
const series = allSeries[hoveredPointIndex];
const frame = series.frame(data);
const xField = series.x(frame);
const yField = series.y(frame);
const links: Array<LinkModel<Field>> = getFieldLinksForExplore({
field: yField,
splitOpenFn: onSplitOpen,
rowIndex,
range,
});
let yValues = [];
if (options.mode === TooltipDisplayMode.Single) {
yValues = [
{
name: getFieldDisplayName(yField, frame),
val: yField.values.get(rowIndex),
field: yField,
color: series.pointColor(frame),
},
];
} else {
yValues = allSeries
.map((series, i) => {
const frame = series.frame(data);
const seriesXField = series.x(frame);
if (seriesXField.name !== xField.name) {
return null;
}
const seriesYField = series.y(frame);
return {
name: getFieldDisplayName(seriesYField, frame),
val: seriesYField.values.get(rowIndex),
field: seriesYField,
color: allSeries[i].pointColor(frame),
};
})
.filter((v) => v != null);
}
if (options.sort !== SortOrder.None) {
const sortFn = arrayUtils.sortValues(options.sort);
yValues.sort((a, b) => {
return sortFn(a!.val, b!.val);
});
}
let activePointIndex = -1;
activePointIndex = yValues.findIndex((v) => v!.name === series.name);
return (
<>
<div className={style.xVal} aria-label="x-val">
{fmt(frame.fields[0], xField.values.get(rowIndex))}
</div>
<table className={style.infoWrap}>
<tbody>
{frame.fields.map((f, i) => (
<tr key={`${i}/${rowIndex}`} className={f === y ? this.style.highlight : ''}>
<th>{getFieldDisplayName(f, frame)}:</th>
<td>{fmt(f, rowIndex)}</td>
{yValues.map((el, index) => {
let color = null;
if (typeof el!.color === 'string') {
color = el!.color;
}
return (
<tr key={`${index}/${rowIndex}`} className={index === activePointIndex ? style.highlight : ''}>
<th>
{color && <SeriesIcon color={color} className={style.icon} />}
{el!.name}:
</th>
<td>{fmt(el!.field, el!.val)}</td>
</tr>
);
})}
{links.length > 0 && (
<tr>
<td colSpan={2}>
<VerticalGroup>
{links.map((link, i) => (
<LinkButton
key={i}
icon={'external-link-alt'}
target={link.target}
href={link.href}
onClick={link.onClick}
fill="text"
style={{ width: '100%' }}
>
{link.title}
</LinkButton>
))}
</VerticalGroup>
</td>
</tr>
))}
)}
</tbody>
</table>
);
}
}
</>
);
};
function fmt(field: Field, row: number): string {
const v = field.values.get(row);
function fmt(field: Field, val: number): string {
if (field.display) {
return formattedValueToString(field.display(v));
return formattedValueToString(field.display(val));
}
return `${v}`;
return `${val}`;
}
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
const getStyles = (theme: GrafanaTheme2) => ({
infoWrap: css`
padding: 8px;
width: 100%;
th {
font-weight: ${theme.typography.fontWeightMedium};
padding: ${theme.spacing(0.25, 2)};
@ -58,4 +165,11 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
highlight: css`
background: ${theme.colors.action.hover};
`,
}));
xVal: css`
font-weight: ${theme.typography.fontWeightBold};
`,
icon: css`
margin-right: ${theme.spacing(1)};
vertical-align: middle;
`,
});

View File

@ -1,10 +1,20 @@
import React, { PureComponent } from 'react';
import { css } from '@emotion/css';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { usePrevious } from 'react-use';
import { PanelProps } from '@grafana/data';
import {
DisplayProcessor,
DisplayValue,
fieldReducers,
PanelProps,
reduceField,
ReducerID,
getDisplayProcessor,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import {
LegendDisplayMode,
Portal,
TooltipDisplayMode,
UPlotChart,
UPlotConfigBuilder,
VizLayout,
@ -13,116 +23,231 @@ import {
VizTooltipContainer,
} from '@grafana/ui';
import { FacetedData } from '@grafana/ui/src/components/uPlot/types';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { TooltipView } from './TooltipView';
import { XYChartOptions } from './models.gen';
import { prepData, prepScatter } from './scatter';
import { prepData, prepScatter, ScatterPanelInfo } from './scatter';
import { ScatterHoverEvent, ScatterSeries } from './types';
type Props = PanelProps<XYChartOptions>;
type State = {
error?: string;
series: ScatterSeries[];
builder?: UPlotConfigBuilder;
facets?: FacetedData;
hover?: ScatterHoverEvent;
};
const TOOLTIP_OFFSET = 10;
export class XYChartPanel2 extends PureComponent<Props, State> {
state: State = {
series: [],
export const XYChartPanel2: React.FC<Props> = (props: Props) => {
const [error, setError] = useState<string | undefined>();
const [series, setSeries] = useState<ScatterSeries[]>([]);
const [builder, setBuilder] = useState<UPlotConfigBuilder | undefined>();
const [facets, setFacets] = useState<FacetedData | undefined>();
const [hover, setHover] = useState<ScatterHoverEvent | undefined>();
const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState<boolean>(false);
const isToolTipOpen = useRef<boolean>(false);
const oldOptions = usePrevious(props.options);
const oldData = usePrevious(props.data);
const onCloseToolTip = () => {
isToolTipOpen.current = false;
setShouldDisplayCloseButton(false);
scatterHoverCallback(undefined);
};
componentDidMount() {
this.initSeries(); // also data
}
const onUPlotClick = () => {
isToolTipOpen.current = !isToolTipOpen.current;
componentDidUpdate(oldProps: Props) {
const { options, data } = this.props;
const configsChanged = options !== oldProps.options || data.structureRev !== oldProps.data.structureRev;
// Linking into useState required to re-render tooltip
setShouldDisplayCloseButton(isToolTipOpen.current);
};
if (configsChanged) {
this.initSeries();
} else if (data !== oldProps.data) {
this.initFacets();
const scatterHoverCallback = (hover?: ScatterHoverEvent) => {
setHover(hover);
};
const initSeries = useCallback(() => {
const getData = () => props.data.series;
const info: ScatterPanelInfo = prepScatter(
props.options,
getData,
config.theme2,
scatterHoverCallback,
onUPlotClick,
isToolTipOpen
);
if (info.series.length && props.data.series) {
setBuilder(info.builder);
setSeries(info.series);
setFacets(() => prepData(info, props.data.series));
setError(undefined);
}
}
}, [props.data.series, props.options]);
scatterHoverCallback = (hover?: ScatterHoverEvent) => {
this.setState({ hover });
};
const initFacets = useCallback(() => {
setFacets(() => prepData({ error, series }, props.data.series));
}, [props.data.series, error, series]);
getData = () => {
return this.props.data.series;
};
initSeries = () => {
const { options, data } = this.props;
const info: State = prepScatter(options, this.getData, config.theme2, this.scatterHoverCallback);
if (info.series.length && data.series) {
info.facets = prepData(info, data.series);
info.error = undefined;
useEffect(() => {
if (oldOptions !== props.options || oldData?.structureRev !== props.data.structureRev) {
initSeries();
} else if (oldData !== props.data) {
initFacets();
}
this.setState(info);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
initFacets = () => {
this.setState({
facets: prepData(this.state, this.props.data.series),
});
};
renderLegend = () => {
const { data } = this.props;
const { series } = this.state;
const renderLegend = () => {
const items: VizLegendItem[] = [];
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const theme = config.theme2;
for (const s of series) {
const frame = s.frame(data.series);
const frame = s.frame(props.data.series);
if (frame) {
for (const item of s.legend(frame)) {
item.getDisplayValues = () => {
const calcs = props.options.legend.calcs;
if (!calcs?.length) {
return [];
}
const field = s.y(frame);
const fmt = field.display ?? defaultFormatter;
let countFormatter: DisplayProcessor | null = null;
const fieldCalcs = reduceField({
field,
reducers: calcs,
});
return calcs.map<DisplayValue>((reducerId) => {
const fieldReducer = fieldReducers.get(reducerId);
let formatter = fmt;
if (fieldReducer.id === ReducerID.diffperc) {
formatter = getDisplayProcessor({
field: {
...field,
config: {
...field.config,
unit: 'percent',
},
},
theme,
});
}
if (
fieldReducer.id === ReducerID.count ||
fieldReducer.id === ReducerID.changeCount ||
fieldReducer.id === ReducerID.distinctCount
) {
if (!countFormatter) {
countFormatter = getDisplayProcessor({
field: {
...field,
config: {
...field.config,
unit: 'none',
},
},
theme,
});
}
formatter = countFormatter;
}
return {
...formatter(fieldCalcs[reducerId]),
title: fieldReducer.name,
description: fieldReducer.description,
};
});
};
item.disabled = !(s.show ?? true);
items.push(item);
}
}
}
if (!props.options.legend.showLegend) {
return null;
}
const legendStyle = {
flexStart: css`
div {
justify-content: flex-start !important;
}
`,
};
return (
<VizLayout.Legend placement="bottom">
<VizLegend placement="bottom" items={items} displayMode={LegendDisplayMode.List} />
<VizLayout.Legend placement={props.options.legend.placement} width={props.options.legend.width}>
<VizLegend
className={legendStyle.flexStart}
placement={props.options.legend.placement}
items={items}
displayMode={props.options.legend.displayMode}
/>
</VizLayout.Legend>
);
};
render() {
const { width, height, timeRange, data } = this.props;
const { error, facets, builder, hover, series } = this.state;
if (error || !builder) {
return (
<div className="panel-empty">
<p>{error}</p>
</div>
);
}
if (error || !builder || !facets) {
return (
<>
<VizLayout width={width} height={height} legend={this.renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
// <pre style={{ width: vizWidth, height: vizHeight, border: '1px solid green', margin: '0px' }}>
// {JSON.stringify(scatterData, null, 2)}
// </pre>
<UPlotChart config={builder} data={facets!} width={vizWidth} height={vizHeight} timeRange={timeRange}>
{/*children ? children(config, alignedFrame) : null*/}
</UPlotChart>
)}
</VizLayout>
<Portal>
{hover && (
<VizTooltipContainer position={{ x: hover.pageX, y: hover.pageY }} offset={{ x: 10, y: 10 }}>
<TooltipView series={series[hover.scatterIndex]} rowIndex={hover.xIndex} data={data.series} />
</VizTooltipContainer>
)}
</Portal>
</>
<div className="panel-empty">
<p>{error}</p>
</div>
);
}
}
return (
<>
<VizLayout width={props.width} height={props.height} legend={renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight} timeRange={props.timeRange} />
)}
</VizLayout>
<Portal>
{hover && props.options.tooltip.mode !== TooltipDisplayMode.None && (
<VizTooltipContainer
position={{ x: hover.pageX, y: hover.pageY }}
offset={{ x: TOOLTIP_OFFSET, y: TOOLTIP_OFFSET }}
allowPointerEvents={isToolTipOpen.current}
>
{shouldDisplayCloseButton && (
<div
style={{
width: '100%',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<CloseButton
onClick={onCloseToolTip}
style={{
position: 'relative',
top: 'auto',
right: 'auto',
marginRight: 0,
}}
/>
</div>
)}
<TooltipView
options={props.options.tooltip}
allSeries={series}
rowIndex={hover.xIndex}
hoveredPointIndex={hover.scatterIndex}
data={props.data.series}
range={props.timeRange}
/>
</VizTooltipContainer>
)}
</Portal>
</>
);
};

View File

@ -12,8 +12,6 @@ import { LineStyleEditor } from '../timeseries/LineStyleEditor';
import { ScatterFieldConfig, ScatterLineMode } from './models.gen';
const categoryStyles = undefined; // ['Scatter styles'];
export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOptionsArgs<ScatterFieldConfig> {
return {
standardOptions: {
@ -34,7 +32,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
.addRadio({
path: 'point',
name: 'Points',
category: categoryStyles,
defaultValue: cfg.point,
settings: {
options: graphFieldOptions.showPoints,
@ -42,8 +39,7 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
})
.addSliderInput({
path: 'pointSize.fixed',
name: 'Point size',
category: categoryStyles,
name: 'Size',
defaultValue: cfg.pointSize?.fixed,
settings: {
min: 1,
@ -55,7 +51,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
.addRadio({
path: 'line',
name: 'Lines',
category: categoryStyles,
defaultValue: cfg.line,
settings: {
options: [
@ -68,7 +63,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
id: 'lineStyle',
path: 'lineStyle',
name: 'Line style',
category: categoryStyles,
showIf: (c) => c.line !== ScatterLineMode.None,
editor: LineStyleEditor,
override: LineStyleEditor,
@ -78,7 +72,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
.addSliderInput({
path: 'lineWidth',
name: 'Line width',
category: categoryStyles,
defaultValue: cfg.lineWidth,
settings: {
min: 0,

View File

@ -40,6 +40,7 @@ export interface ScatterFieldConfig extends HideableFieldConfig, AxisConfig {
export interface ScatterSeriesConfig extends ScatterFieldConfig {
x?: string;
y?: string;
name?: string;
}
export const defaultScatterConfig: ScatterFieldConfig = {
@ -65,7 +66,7 @@ export interface XYDimensionConfig {
}
export interface XYChartOptions extends OptionsWithLegend, OptionsWithTooltip {
mode?: 'xy' | 'explicit';
mode?: 'auto' | 'manual';
dims: XYDimensionConfig;
series?: ScatterSeriesConfig[];

View File

@ -1,9 +1,9 @@
import { PanelPlugin } from '@grafana/data';
import { commonOptionsBuilder } from '@grafana/ui';
import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
import { AutoEditor } from './AutoEditor';
import { ManualEditor } from './ManualEditor';
import { XYChartPanel2 } from './XYChartPanel2';
import { XYDimsEditor } from './XYDimsEditor';
import { getScatterFieldConfig } from './config';
import { defaultScatterConfig, XYChartOptions, ScatterFieldConfig } from './models.gen';
@ -14,55 +14,28 @@ export const plugin = new PanelPlugin<XYChartOptions, ScatterFieldConfig>(XYChar
.addRadio({
path: 'mode',
name: 'Mode',
defaultValue: 'single',
defaultValue: 'auto',
settings: {
options: [
{ value: 'xy', label: 'XY', description: 'No changes to saved model since 8.0' },
{ value: 'explicit', label: 'Explicit' },
{ value: 'auto', label: 'Auto', description: 'No changes to saved model since 8.0' },
{ value: 'manual', label: 'Manual' },
],
},
})
.addCustomEditor({
id: 'xyPlotConfig',
path: 'dims',
name: 'Data',
editor: XYDimsEditor,
showIf: (cfg) => cfg.mode === 'xy',
})
.addFieldNamePicker({
path: 'series[0].x',
name: 'X Field',
showIf: (cfg) => cfg.mode === 'explicit',
})
.addFieldNamePicker({
path: 'series[0].y',
name: 'Y Field',
showIf: (cfg) => cfg.mode === 'explicit',
name: '',
editor: AutoEditor,
showIf: (cfg) => cfg.mode === 'auto',
})
.addCustomEditor({
id: 'seriesZerox.pointColor',
path: 'series[0].pointColor',
name: 'Point color',
editor: ColorDimensionEditor,
settings: {},
defaultValue: {},
showIf: (cfg) => cfg.mode === 'explicit',
})
.addCustomEditor({
id: 'seriesZerox.pointSize',
path: 'series[0].pointSize',
name: 'Point size',
editor: ScaleDimensionEditor,
settings: {
min: 1,
max: 50,
},
defaultValue: {
fixed: 5,
min: 1,
max: 50,
},
showIf: (cfg) => cfg.mode === 'explicit',
id: 'series',
path: 'series',
name: '',
defaultValue: [],
editor: ManualEditor,
showIf: (cfg) => cfg.mode === 'manual',
});
commonOptionsBuilder.addTooltipOptions(builder);

View File

@ -1,3 +1,4 @@
import { MutableRefObject } from 'react';
import uPlot from 'uplot';
import {
@ -41,19 +42,26 @@ export function prepScatter(
options: XYChartOptions,
getData: () => DataFrame[],
theme: GrafanaTheme2,
ttip: ScatterHoverCallback
ttip: ScatterHoverCallback,
onUPlotClick: null | ((evt?: Object) => void),
isToolTipOpen: MutableRefObject<boolean>
): ScatterPanelInfo {
let series: ScatterSeries[];
let builder: UPlotConfigBuilder;
try {
series = prepSeries(options, getData());
builder = prepConfig(getData, series, theme, ttip);
builder = prepConfig(getData, series, theme, ttip, onUPlotClick, isToolTipOpen);
} catch (e) {
console.log('prepScatter ERROR', e);
const errorMessage = e instanceof Error ? e.message : 'Unknown error in prepScatter';
let errorMsg = 'Unknown error in prepScatter';
if (typeof e === 'string') {
errorMsg = e;
} else if (e instanceof Error) {
errorMsg = e.message;
}
return {
error: errorMessage,
error: errorMsg,
series: [],
};
}
@ -120,7 +128,7 @@ function getScatterSeries(
// Size configs
//----------------
let pointSizeHints = dims.pointSizeConfig;
let pointSizeFixed = dims.pointSizeConfig?.fixed ?? y.config.custom?.pointSizeConfig?.fixed ?? 5;
let pointSizeFixed = dims.pointSizeConfig?.fixed ?? y.config.custom?.pointSize?.fixed ?? 5;
let pointSize: DimensionValues<number> = () => pointSizeFixed;
if (dims.pointSizeIndex) {
pointSize = (frame) => {
@ -176,6 +184,7 @@ function getScatterSeries(
label: VisibilityMode.Never,
labelValue: () => '',
show: !frame.fields[yIndex].config.custom.hideFrom?.viz,
hints: {
pointSize: pointSizeHints!,
@ -189,11 +198,13 @@ function getScatterSeries(
function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries[] {
let seriesIndex = 0;
if (!frames.length) {
throw 'missing data';
throw 'Missing data';
}
if (options.mode === 'explicit') {
if (options.mode === 'manual') {
if (options.series?.length) {
const scatterSeries: ScatterSeries[] = [];
for (const series of options.series) {
if (!series?.x) {
throw 'Select X dimension';
@ -221,10 +232,12 @@ function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries
pointSizeConfig: series.pointSize,
pointSizeIndex: findFieldIndex(frame, series.pointSize?.field),
};
return [getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims)];
scatterSeries.push(getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims));
}
}
}
return scatterSeries;
}
}
@ -232,7 +245,7 @@ function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries
const dims = options.dims ?? {};
const frameIndex = dims.frame ?? 0;
const frame = frames[frameIndex];
const numericIndicies: number[] = [];
const numericIndices: number[] = [];
let xIndex = findFieldIndex(frame, dims.x);
for (let i = 0; i < frame.fields.length; i++) {
@ -245,7 +258,7 @@ function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries
continue; // skip
}
numericIndicies.push(i);
numericIndices.push(i);
}
}
@ -253,10 +266,10 @@ function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries
throw 'Missing X dimension';
}
if (!numericIndicies.length) {
if (!numericIndices.length) {
throw 'No Y values';
}
return numericIndicies.map((yIndex) => getScatterSeries(seriesIndex++, frames, frameIndex, xIndex!, yIndex, {}));
return numericIndices.map((yIndex) => getScatterSeries(seriesIndex++, frames, frameIndex, xIndex!, yIndex, {}));
}
interface DrawBubblesOpts {
@ -278,7 +291,9 @@ const prepConfig = (
getData: () => DataFrame[],
scatterSeries: ScatterSeries[],
theme: GrafanaTheme2,
ttip: ScatterHoverCallback
ttip: ScatterHoverCallback,
onUPlotClick: null | ((evt?: Object) => void),
isToolTipOpen: MutableRefObject<boolean>
) => {
let qt: Quadtree;
let hRect: Rect | null;
@ -489,9 +504,32 @@ const prepConfig = (
},
});
const clearPopupIfOpened = () => {
if (isToolTipOpen.current) {
ttip(undefined);
if (onUPlotClick) {
onUPlotClick();
}
}
};
let ref_parent: HTMLElement | null = null;
// clip hover points/bubbles to plotting area
builder.addHook('init', (u, r) => {
u.over.style.overflow = 'hidden';
ref_parent = u.root.parentElement;
if (onUPlotClick) {
ref_parent?.addEventListener('click', onUPlotClick);
}
});
builder.addHook('destroy', (u) => {
if (onUPlotClick) {
ref_parent?.removeEventListener('click', onUPlotClick);
clearPopupIfOpened();
}
});
let rect: DOMRect;
@ -502,11 +540,10 @@ const prepConfig = (
});
builder.addHook('setLegend', (u) => {
// console.log('TTIP???', u.cursor.idxs);
if (u.cursor.idxs != null) {
for (let i = 0; i < u.cursor.idxs.length; i++) {
const sel = u.cursor.idxs[i];
if (sel != null) {
if (sel != null && !isToolTipOpen.current) {
ttip({
scatterIndex: i - 1,
xIndex: sel,
@ -517,10 +554,15 @@ const prepConfig = (
}
}
}
ttip(undefined);
if (!isToolTipOpen.current) {
ttip(undefined);
}
});
builder.addHook('drawClear', (u) => {
clearPopupIfOpened();
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();
@ -600,6 +642,7 @@ const prepConfig = (
scaleKey: '', // facets' scales used (above)
lineColor: lineColor as string,
fillColor: alpha(pointColor, 0.5),
show: !field.config.custom.hideFrom?.viz,
});
});
@ -634,7 +677,7 @@ const prepConfig = (
* from? is this where we would support that? -- need the previous values
*/
export function prepData(info: ScatterPanelInfo, data: DataFrame[], from?: number): FacetedData {
if (info.error) {
if (info.error || !data.length) {
return [null];
}
return [

View File

@ -51,6 +51,7 @@ export interface ScatterSeries {
label: VisibilityMode;
labelValue: DimensionValues<string>;
show: boolean;
hints: {
pointSize: ScaleDimensionConfig;