From 6d2352735d9529300978857f0f741ea6fbf2e48c Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon, 5 Sep 2022 11:03:17 +0300 Subject: [PATCH] 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 --- .betterer.results | 18 +- .../plugins/datasource/testdata/datasource.ts | 1 - .../{XYDimsEditor.tsx => AutoEditor.tsx} | 101 +++--- .../plugins/panel/xychart/ExplicitEditor.tsx | 13 - .../plugins/panel/xychart/ManualEditor.tsx | 155 ++++++++++ .../app/plugins/panel/xychart/TooltipView.tsx | 174 +++++++++-- .../plugins/panel/xychart/XYChartPanel2.tsx | 291 +++++++++++++----- public/app/plugins/panel/xychart/config.ts | 9 +- .../app/plugins/panel/xychart/models.gen.ts | 3 +- public/app/plugins/panel/xychart/module.tsx | 55 +--- public/app/plugins/panel/xychart/scatter.ts | 79 +++-- public/app/plugins/panel/xychart/types.ts | 1 + 12 files changed, 653 insertions(+), 247 deletions(-) rename public/app/plugins/panel/xychart/{XYDimsEditor.tsx => AutoEditor.tsx} (64%) delete mode 100644 public/app/plugins/panel/xychart/ExplicitEditor.tsx create mode 100644 public/app/plugins/panel/xychart/ManualEditor.tsx diff --git a/.betterer.results b/.betterer.results index 0f0253e4715..bc8d05d499a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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": [ diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index a79e258bea5..38fe2ec34f1 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -234,7 +234,6 @@ export class TestDataDataSource extends DataSourceWithBackend { options: DataQueryRequest ): Observable | null { const { errorType } = target; - console.log("we're here!", target); if (errorType === 'server_panic') { return null; diff --git a/public/app/plugins/panel/xychart/XYDimsEditor.tsx b/public/app/plugins/panel/xychart/AutoEditor.tsx similarity index 64% rename from public/app/plugins/panel/xychart/XYDimsEditor.tsx rename to public/app/plugins/panel/xychart/AutoEditor.tsx index 095b1df7b74..534eebf8c83 100644 --- a/public/app/plugins/panel/xychart/XYDimsEditor.tsx +++ b/public/app/plugins/panel/xychart/AutoEditor.tsx @@ -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>; } -export const XYDimsEditor: FC> = ({ +export const AutoEditor: FC> = ({ value, onChange, context, @@ -89,54 +89,55 @@ export const XYDimsEditor: FC - { - onChange({ - ...value, - x: v.value, - }); - }} - /> -
- -
- {info.yFields.map((v) => ( -
- { - 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} -
- ))} -
-

+ + { + onChange({ + ...value, + x: v.value, + }); + }} + /> + + +
+ {info.yFields.map((v) => ( +
+ { + 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} +
+ ))} +
+
); }; diff --git a/public/app/plugins/panel/xychart/ExplicitEditor.tsx b/public/app/plugins/panel/xychart/ExplicitEditor.tsx deleted file mode 100644 index d2ddac4d6da..00000000000 --- a/public/app/plugins/panel/xychart/ExplicitEditor.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React, { FC } from 'react'; - -import { StandardEditorProps } from '@grafana/data'; - -import { XYChartOptions, ScatterFieldConfig } from './models.gen'; - -export const ExplicitEditor: FC> = ({ - value, - onChange, - context, -}) => { - return
TODO: explicit scatter config
; -}; diff --git a/public/app/plugins/panel/xychart/ManualEditor.tsx b/public/app/plugins/panel/xychart/ManualEditor.tsx new file mode 100644 index 00000000000..99ae51ca050 --- /dev/null +++ b/public/app/plugins/panel/xychart/ManualEditor.tsx @@ -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> = ({ + value, + onChange, + context, +}) => { + const [selected, setSelected] = useState(-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 ( + <> + + +
+ {options.series.map((series, index) => { + return ( +
setSelected(index)}> + onFieldChange(v, index, 'name')} + /> + + onSeriesDelete(index)} + /> +
+ ); + })} +
+ + {selected >= 0 && options.series[selected] && ( + <> +
+ + onFieldChange(field, selected, 'x')} + item={{} as any} + /> + + + onFieldChange(field, selected, 'y')} + item={{} as any} + /> + + + onFieldChange(field, selected, 'pointColor')} + item={{} as any} + /> + + + onFieldChange(field, selected, 'pointSize')} + item={{ settings: { min: 1, max: 50 } } as any} + /> + +
+ + )} + + ); +}; + +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}; + } + `, +}); diff --git a/public/app/plugins/panel/xychart/TooltipView.tsx b/public/app/plugins/panel/xychart/TooltipView.tsx index d5fa55c0084..818803fa1f6 100644 --- a/public/app/plugins/panel/xychart/TooltipView.tsx +++ b/public/app/plugins/panel/xychart/TooltipView.tsx @@ -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 { - 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 ( - + const series = allSeries[hoveredPointIndex]; + const frame = series.frame(data); + const xField = series.x(frame); + const yField = series.y(frame); + const links: Array> = 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 ( + <> +
+ {fmt(frame.fields[0], xField.values.get(rowIndex))} +
+
- {frame.fields.map((f, i) => ( - - - + {yValues.map((el, index) => { + let color = null; + if (typeof el!.color === 'string') { + color = el!.color; + } + + return ( + + + + + ); + })} + {links.length > 0 && ( + + - ))} + )}
{getFieldDisplayName(f, frame)}:{fmt(f, rowIndex)}
+ {color && } + {el!.name}: + {fmt(el!.field, el!.val)}
+ + {links.map((link, i) => ( + + {link.title} + + ))} + +
- ); - } -} + + ); +}; -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; + `, +}); diff --git a/public/app/plugins/panel/xychart/XYChartPanel2.tsx b/public/app/plugins/panel/xychart/XYChartPanel2.tsx index 262c9aa74a0..ef9bbde5b67 100644 --- a/public/app/plugins/panel/xychart/XYChartPanel2.tsx +++ b/public/app/plugins/panel/xychart/XYChartPanel2.tsx @@ -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; -type State = { - error?: string; - series: ScatterSeries[]; - builder?: UPlotConfigBuilder; - facets?: FacetedData; - hover?: ScatterHoverEvent; -}; +const TOOLTIP_OFFSET = 10; -export class XYChartPanel2 extends PureComponent { - state: State = { - series: [], +export const XYChartPanel2: React.FC = (props: Props) => { + const [error, setError] = useState(); + const [series, setSeries] = useState([]); + const [builder, setBuilder] = useState(); + const [facets, setFacets] = useState(); + const [hover, setHover] = useState(); + const [shouldDisplayCloseButton, setShouldDisplayCloseButton] = useState(false); + + const isToolTipOpen = useRef(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((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 ( - - + + ); }; - render() { - const { width, height, timeRange, data } = this.props; - const { error, facets, builder, hover, series } = this.state; - if (error || !builder) { - return ( -
-

{error}

-
- ); - } - + if (error || !builder || !facets) { return ( - <> - - {(vizWidth: number, vizHeight: number) => ( - //
-            //   {JSON.stringify(scatterData, null, 2)}
-            // 
- - {/*children ? children(config, alignedFrame) : null*/} - - )} -
- - {hover && ( - - - - )} - - +
+

{error}

+
); } -} + + return ( + <> + + {(vizWidth: number, vizHeight: number) => ( + + )} + + + {hover && props.options.tooltip.mode !== TooltipDisplayMode.None && ( + + {shouldDisplayCloseButton && ( +
+ +
+ )} + +
+ )} +
+ + ); +}; diff --git a/public/app/plugins/panel/xychart/config.ts b/public/app/plugins/panel/xychart/config.ts index ac05945f02f..c18b39e911b 100644 --- a/public/app/plugins/panel/xychart/config.ts +++ b/public/app/plugins/panel/xychart/config.ts @@ -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 { 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, diff --git a/public/app/plugins/panel/xychart/models.gen.ts b/public/app/plugins/panel/xychart/models.gen.ts index fa9ddc2083b..59ce80bb385 100644 --- a/public/app/plugins/panel/xychart/models.gen.ts +++ b/public/app/plugins/panel/xychart/models.gen.ts @@ -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[]; diff --git a/public/app/plugins/panel/xychart/module.tsx b/public/app/plugins/panel/xychart/module.tsx index d9a46da727f..dd0ab11b7bc 100644 --- a/public/app/plugins/panel/xychart/module.tsx +++ b/public/app/plugins/panel/xychart/module.tsx @@ -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(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); diff --git a/public/app/plugins/panel/xychart/scatter.ts b/public/app/plugins/panel/xychart/scatter.ts index 646d7b42f32..d65db201cea 100644 --- a/public/app/plugins/panel/xychart/scatter.ts +++ b/public/app/plugins/panel/xychart/scatter.ts @@ -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 ): 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 = () => 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 ) => { 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 [ diff --git a/public/app/plugins/panel/xychart/types.ts b/public/app/plugins/panel/xychart/types.ts index 0ef28147778..fcb37fb2c5c 100644 --- a/public/app/plugins/panel/xychart/types.ts +++ b/public/app/plugins/panel/xychart/types.ts @@ -51,6 +51,7 @@ export interface ScatterSeries { label: VisibilityMode; labelValue: DimensionValues; + show: boolean; hints: { pointSize: ScaleDimensionConfig;