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, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"] [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"] [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"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
"public/app/plugins/panel/xychart/dims.ts:5381": [ "public/app/plugins/panel/xychart/dims.ts:5381": [

View File

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

View File

@ -8,7 +8,7 @@ import {
getFieldDisplayName, getFieldDisplayName,
GrafanaTheme2, GrafanaTheme2,
} from '@grafana/data'; } 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 { getXYDimensions, isGraphable } from './dims';
import { XYDimensionConfig, XYChartOptions } from './models.gen'; import { XYDimensionConfig, XYChartOptions } from './models.gen';
@ -19,7 +19,7 @@ interface XYInfo {
yFields: Array<SelectableValue<boolean>>; yFields: Array<SelectableValue<boolean>>;
} }
export const XYDimsEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChartOptions>> = ({ export const AutoEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChartOptions>> = ({
value, value,
onChange, onChange,
context, context,
@ -89,54 +89,55 @@ export const XYDimsEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChar
return ( return (
<div> <div>
<Select <Field label={'Data'}>
options={frameNames} <Select
value={frameNames.find((v) => v.value === value?.frame) ?? frameNames[0]} options={frameNames}
onChange={(v) => { value={frameNames.find((v) => v.value === value?.frame) ?? frameNames[0]}
onChange({ onChange={(v) => {
...value, onChange({
frame: v.value!, ...value,
}); frame: v.value!,
}} });
/> }}
<br /> />
<Label>X Field</Label> </Field>
<Select <Field label={'X Field'}>
options={info.numberFields} <Select
value={info.xAxis} options={info.numberFields}
onChange={(v) => { value={info.xAxis}
onChange({ onChange={(v) => {
...value, onChange({
x: v.value, ...value,
}); x: v.value,
}} });
/> }}
<br /> />
<Label>Y Fields</Label> </Field>
<div> <Field label={'Y Fields'}>
{info.yFields.map((v) => ( <div>
<div key={v.label} className={styles.row}> {info.yFields.map((v) => (
<IconButton <div key={v.label} className={styles.row}>
name={v.value ? 'eye-slash' : 'eye'} <IconButton
onClick={() => { name={v.value ? 'eye-slash' : 'eye'}
const exclude: string[] = value?.exclude ? [...value.exclude] : []; onClick={() => {
let idx = exclude.indexOf(v.label!); const exclude: string[] = value?.exclude ? [...value.exclude] : [];
if (idx < 0) { let idx = exclude.indexOf(v.label!);
exclude.push(v.label!); if (idx < 0) {
} else { exclude.push(v.label!);
exclude.splice(idx, 1); } else {
} exclude.splice(idx, 1);
onChange({ }
...value, onChange({
exclude, ...value,
}); exclude,
}} });
/> }}
{v.label} />
</div> {v.label}
))} </div>
</div> ))}
<br /> <br /> </div>
</Field>
</div> </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 { css } from '@emotion/css';
import React, { PureComponent } from 'react'; import React from 'react';
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2 } from '@grafana/data'; import {
import { stylesFactory } from '@grafana/ui'; arrayUtils,
import { config } from 'app/core/config'; 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'; import { ScatterSeries } from './types';
export interface Props { export interface Props {
series: ScatterSeries; allSeries: ScatterSeries[];
data: DataFrame[]; // source data data: DataFrame[]; // source data
rowIndex?: number; // the hover row rowIndex?: number; // the hover row
hoveredPointIndex: number; // the hovered point
options: VizTooltipOptions;
range: TimeRange;
} }
export class TooltipView extends PureComponent<Props> { export const TooltipView = ({ allSeries, data, rowIndex, hoveredPointIndex, options, range }: Props) => {
style = getStyles(config.theme2); const style = useStyles2(getStyles);
const { onSplitOpen } = usePanelContext();
render() { if (!allSeries || rowIndex == null) {
const { series, data, rowIndex } = this.props; return null;
if (!series || rowIndex == null) { }
return null;
}
const frame = series.frame(data);
const y = undefined; // series.y(frame);
return ( const series = allSeries[hoveredPointIndex];
<table className={this.style.infoWrap}> 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> <tbody>
{frame.fields.map((f, i) => ( {yValues.map((el, index) => {
<tr key={`${i}/${rowIndex}`} className={f === y ? this.style.highlight : ''}> let color = null;
<th>{getFieldDisplayName(f, frame)}:</th> if (typeof el!.color === 'string') {
<td>{fmt(f, rowIndex)}</td> 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> </tr>
))} )}
</tbody> </tbody>
</table> </table>
); </>
} );
} };
function fmt(field: Field, row: number): string { function fmt(field: Field, val: number): string {
const v = field.values.get(row);
if (field.display) { 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` infoWrap: css`
padding: 8px; padding: 8px;
width: 100%;
th { th {
font-weight: ${theme.typography.fontWeightMedium}; font-weight: ${theme.typography.fontWeightMedium};
padding: ${theme.spacing(0.25, 2)}; padding: ${theme.spacing(0.25, 2)};
@ -58,4 +165,11 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
highlight: css` highlight: css`
background: ${theme.colors.action.hover}; 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 { config } from '@grafana/runtime';
import { import {
LegendDisplayMode,
Portal, Portal,
TooltipDisplayMode,
UPlotChart, UPlotChart,
UPlotConfigBuilder, UPlotConfigBuilder,
VizLayout, VizLayout,
@ -13,116 +23,231 @@ import {
VizTooltipContainer, VizTooltipContainer,
} from '@grafana/ui'; } from '@grafana/ui';
import { FacetedData } from '@grafana/ui/src/components/uPlot/types'; import { FacetedData } from '@grafana/ui/src/components/uPlot/types';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { TooltipView } from './TooltipView'; import { TooltipView } from './TooltipView';
import { XYChartOptions } from './models.gen'; import { XYChartOptions } from './models.gen';
import { prepData, prepScatter } from './scatter'; import { prepData, prepScatter, ScatterPanelInfo } from './scatter';
import { ScatterHoverEvent, ScatterSeries } from './types'; import { ScatterHoverEvent, ScatterSeries } from './types';
type Props = PanelProps<XYChartOptions>; type Props = PanelProps<XYChartOptions>;
type State = { const TOOLTIP_OFFSET = 10;
error?: string;
series: ScatterSeries[];
builder?: UPlotConfigBuilder;
facets?: FacetedData;
hover?: ScatterHoverEvent;
};
export class XYChartPanel2 extends PureComponent<Props, State> { export const XYChartPanel2: React.FC<Props> = (props: Props) => {
state: State = { const [error, setError] = useState<string | undefined>();
series: [], 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() { const onUPlotClick = () => {
this.initSeries(); // also data isToolTipOpen.current = !isToolTipOpen.current;
}
componentDidUpdate(oldProps: Props) { // Linking into useState required to re-render tooltip
const { options, data } = this.props; setShouldDisplayCloseButton(isToolTipOpen.current);
const configsChanged = options !== oldProps.options || data.structureRev !== oldProps.data.structureRev; };
if (configsChanged) { const scatterHoverCallback = (hover?: ScatterHoverEvent) => {
this.initSeries(); setHover(hover);
} else if (data !== oldProps.data) { };
this.initFacets();
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) => { const initFacets = useCallback(() => {
this.setState({ hover }); setFacets(() => prepData({ error, series }, props.data.series));
}; }, [props.data.series, error, series]);
getData = () => { useEffect(() => {
return this.props.data.series; if (oldOptions !== props.options || oldData?.structureRev !== props.data.structureRev) {
}; initSeries();
} else if (oldData !== props.data) {
initSeries = () => { initFacets();
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;
} }
this.setState(info); // eslint-disable-next-line react-hooks/exhaustive-deps
}; }, [props]);
initFacets = () => { const renderLegend = () => {
this.setState({
facets: prepData(this.state, this.props.data.series),
});
};
renderLegend = () => {
const { data } = this.props;
const { series } = this.state;
const items: VizLegendItem[] = []; const items: VizLegendItem[] = [];
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const theme = config.theme2;
for (const s of series) { for (const s of series) {
const frame = s.frame(data.series); const frame = s.frame(props.data.series);
if (frame) { if (frame) {
for (const item of s.legend(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); items.push(item);
} }
} }
} }
if (!props.options.legend.showLegend) {
return null;
}
const legendStyle = {
flexStart: css`
div {
justify-content: flex-start !important;
}
`,
};
return ( return (
<VizLayout.Legend placement="bottom"> <VizLayout.Legend placement={props.options.legend.placement} width={props.options.legend.width}>
<VizLegend placement="bottom" items={items} displayMode={LegendDisplayMode.List} /> <VizLegend
className={legendStyle.flexStart}
placement={props.options.legend.placement}
items={items}
displayMode={props.options.legend.displayMode}
/>
</VizLayout.Legend> </VizLayout.Legend>
); );
}; };
render() { if (error || !builder || !facets) {
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>
);
}
return ( return (
<> <div className="panel-empty">
<VizLayout width={width} height={height} legend={this.renderLegend()}> <p>{error}</p>
{(vizWidth: number, vizHeight: number) => ( </div>
// <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>
</>
); );
} }
}
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'; import { ScatterFieldConfig, ScatterLineMode } from './models.gen';
const categoryStyles = undefined; // ['Scatter styles'];
export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOptionsArgs<ScatterFieldConfig> { export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOptionsArgs<ScatterFieldConfig> {
return { return {
standardOptions: { standardOptions: {
@ -34,7 +32,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
.addRadio({ .addRadio({
path: 'point', path: 'point',
name: 'Points', name: 'Points',
category: categoryStyles,
defaultValue: cfg.point, defaultValue: cfg.point,
settings: { settings: {
options: graphFieldOptions.showPoints, options: graphFieldOptions.showPoints,
@ -42,8 +39,7 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
}) })
.addSliderInput({ .addSliderInput({
path: 'pointSize.fixed', path: 'pointSize.fixed',
name: 'Point size', name: 'Size',
category: categoryStyles,
defaultValue: cfg.pointSize?.fixed, defaultValue: cfg.pointSize?.fixed,
settings: { settings: {
min: 1, min: 1,
@ -55,7 +51,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
.addRadio({ .addRadio({
path: 'line', path: 'line',
name: 'Lines', name: 'Lines',
category: categoryStyles,
defaultValue: cfg.line, defaultValue: cfg.line,
settings: { settings: {
options: [ options: [
@ -68,7 +63,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
id: 'lineStyle', id: 'lineStyle',
path: 'lineStyle', path: 'lineStyle',
name: 'Line style', name: 'Line style',
category: categoryStyles,
showIf: (c) => c.line !== ScatterLineMode.None, showIf: (c) => c.line !== ScatterLineMode.None,
editor: LineStyleEditor, editor: LineStyleEditor,
override: LineStyleEditor, override: LineStyleEditor,
@ -78,7 +72,6 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
.addSliderInput({ .addSliderInput({
path: 'lineWidth', path: 'lineWidth',
name: 'Line width', name: 'Line width',
category: categoryStyles,
defaultValue: cfg.lineWidth, defaultValue: cfg.lineWidth,
settings: { settings: {
min: 0, min: 0,

View File

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

View File

@ -1,9 +1,9 @@
import { PanelPlugin } from '@grafana/data'; import { PanelPlugin } from '@grafana/data';
import { commonOptionsBuilder } from '@grafana/ui'; 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 { XYChartPanel2 } from './XYChartPanel2';
import { XYDimsEditor } from './XYDimsEditor';
import { getScatterFieldConfig } from './config'; import { getScatterFieldConfig } from './config';
import { defaultScatterConfig, XYChartOptions, ScatterFieldConfig } from './models.gen'; import { defaultScatterConfig, XYChartOptions, ScatterFieldConfig } from './models.gen';
@ -14,55 +14,28 @@ export const plugin = new PanelPlugin<XYChartOptions, ScatterFieldConfig>(XYChar
.addRadio({ .addRadio({
path: 'mode', path: 'mode',
name: 'Mode', name: 'Mode',
defaultValue: 'single', defaultValue: 'auto',
settings: { settings: {
options: [ options: [
{ value: 'xy', label: 'XY', description: 'No changes to saved model since 8.0' }, { value: 'auto', label: 'Auto', description: 'No changes to saved model since 8.0' },
{ value: 'explicit', label: 'Explicit' }, { value: 'manual', label: 'Manual' },
], ],
}, },
}) })
.addCustomEditor({ .addCustomEditor({
id: 'xyPlotConfig', id: 'xyPlotConfig',
path: 'dims', path: 'dims',
name: 'Data', name: '',
editor: XYDimsEditor, editor: AutoEditor,
showIf: (cfg) => cfg.mode === 'xy', showIf: (cfg) => cfg.mode === 'auto',
})
.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',
}) })
.addCustomEditor({ .addCustomEditor({
id: 'seriesZerox.pointColor', id: 'series',
path: 'series[0].pointColor', path: 'series',
name: 'Point color', name: '',
editor: ColorDimensionEditor, defaultValue: [],
settings: {}, editor: ManualEditor,
defaultValue: {}, showIf: (cfg) => cfg.mode === 'manual',
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',
}); });
commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addTooltipOptions(builder);

View File

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

View File

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