XYChart further improvements (#55152)

* Tooltip shows all data facets. Renamed options

* Add per series line style

* Remove line style option from manual panel options

* Refactored tooltip view

* sets selected after switch to manual

* remove facet prefixes

* in manual mode pull series names from config options, not y facet

* unused import

* Point size

* x & y axes labels

* Fix manual series prep

* betterer

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Victor Marin 2022-09-27 18:18:42 +03:00 committed by GitHub
parent 11eb02a183
commit 3361f2c62d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 205 additions and 172 deletions

View File

@ -8940,6 +8940,9 @@ exports[`better eslint`] = {
[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/TooltipView.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/xychart/XYChartPanel2.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import React, { FC, useState } from 'react';
import React, { useState, useEffect } from 'react';
import { GrafanaTheme, StandardEditorProps } from '@grafana/data';
import { Button, Field, IconButton, useStyles } from '@grafana/ui';
@ -9,12 +9,12 @@ import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensi
import { XYChartOptions, ScatterSeriesConfig, defaultScatterConfig } from './models.gen';
export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XYChartOptions>> = ({
export const ManualEditor = ({
value,
onChange,
context,
}) => {
const [selected, setSelected] = useState<number>(-1);
}: StandardEditorProps<ScatterSeriesConfig[], any, XYChartOptions>) => {
const [selected, setSelected] = useState<number>(0);
const style = useStyles(getStyles);
const onFieldChange = (val: any | undefined, index: number, field: string) => {
@ -36,22 +36,27 @@ export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XY
pointSize: defaultScatterConfig.pointSize,
},
]);
setSelected(value.length);
};
// Component-did-mount callback to check if a new series should be created
useEffect(() => {
if (!value?.length) {
createNewSeries(); // adds a new series
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSeriesDelete = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
const { options } = context;
// 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}>
@ -59,7 +64,7 @@ export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XY
</Button>
<div className={style.marginBot}>
{options.series.map((series, index) => {
{value.map((series, index) => {
return (
<div key={`series/${index}`} className={getRowStyle(index)} onMouseDown={() => setSelected(index)}>
<LayerName
@ -78,12 +83,12 @@ export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XY
})}
</div>
{selected >= 0 && options.series[selected] && (
{selected >= 0 && value[selected] && (
<>
<div key={`series/${selected}`}>
<Field label={'X Field'}>
<FieldNamePicker
value={options.series[selected].x ?? ''}
value={value[selected].x ?? ''}
context={context}
onChange={(field) => onFieldChange(field, selected, 'x')}
item={{} as any}
@ -91,7 +96,7 @@ export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XY
</Field>
<Field label={'Y Field'}>
<FieldNamePicker
value={options.series[selected].y ?? ''}
value={value[selected].y ?? ''}
context={context}
onChange={(field) => onFieldChange(field, selected, 'y')}
item={{} as any}
@ -99,7 +104,7 @@ export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XY
</Field>
<Field label={'Point color'}>
<ColorDimensionEditor
value={options.series[selected].pointColor!}
value={value[selected].pointColor!}
context={context}
onChange={(field) => onFieldChange(field, selected, 'pointColor')}
item={{} as any}
@ -107,7 +112,7 @@ export const ManualEditor: FC<StandardEditorProps<ScatterSeriesConfig[], any, XY
</Field>
<Field label={'Point size'}>
<ScaleDimensionEditor
value={options.series[selected].pointSize!}
value={value[selected].pointSize!}
context={context}
onChange={(field) => onFieldChange(field, selected, 'pointSize')}
item={{ settings: { min: 1, max: 50 } } as any}

View File

@ -2,7 +2,6 @@ import { css } from '@emotion/css';
import React from 'react';
import {
arrayUtils,
DataFrame,
Field,
formattedValueToString,
@ -11,30 +10,47 @@ import {
LinkModel,
TimeRange,
} from '@grafana/data';
import { SortOrder } from '@grafana/schema';
import {
LinkButton,
SeriesIcon,
TooltipDisplayMode,
usePanelContext,
useStyles2,
VerticalGroup,
VizTooltipOptions,
} from '@grafana/ui';
import { LinkButton, usePanelContext, useStyles2, VerticalGroup, VizTooltipOptions } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { ScatterSeriesConfig, SeriesMapping } from './models.gen';
import { ScatterSeries } from './types';
interface YValue {
name: string;
val: number;
field: Field;
color: string;
}
interface ExtraFacets {
colorFacetFieldName: string;
sizeFacetFieldName: string;
colorFacetValue: number;
sizeFacetValue: number;
}
export interface Props {
allSeries: ScatterSeries[];
data: DataFrame[]; // source data
manualSeriesConfigs: ScatterSeriesConfig[] | undefined;
rowIndex?: number; // the hover row
seriesMapping: SeriesMapping;
hoveredPointIndex: number; // the hovered point
options: VizTooltipOptions;
range: TimeRange;
}
export const TooltipView = ({ allSeries, data, rowIndex, hoveredPointIndex, options, range }: Props) => {
export const TooltipView = ({
allSeries,
data,
manualSeriesConfigs,
seriesMapping,
rowIndex,
hoveredPointIndex,
options,
range,
}: Props) => {
const style = useStyles2(getStyles);
const { onSplitOpen } = usePanelContext();
@ -53,72 +69,57 @@ export const TooltipView = ({ allSeries, data, rowIndex, hoveredPointIndex, opti
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);
let yValue: YValue | null = null;
let extraFacets: ExtraFacets | null = null;
if (seriesMapping === SeriesMapping.Manual && manualSeriesConfigs) {
const colorFacetFieldName = manualSeriesConfigs[hoveredPointIndex].pointColor?.field ?? '';
const sizeFacetFieldName = manualSeriesConfigs[hoveredPointIndex].pointSize?.field ?? '';
if (seriesXField.name !== xField.name) {
return null;
}
const colorFacet = colorFacetFieldName ? frame.fields.find((f) => f.name === colorFacetFieldName) : undefined;
const sizeFacet = sizeFacetFieldName ? frame.fields.find((f) => f.name === sizeFacetFieldName) : undefined;
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);
extraFacets = {
colorFacetFieldName,
sizeFacetFieldName,
colorFacetValue: colorFacet?.values.get(rowIndex),
sizeFacetValue: sizeFacet?.values.get(rowIndex),
};
}
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);
yValue = {
name: getFieldDisplayName(yField, frame),
val: yField.values.get(rowIndex),
field: yField,
color: series.pointColor(frame) as string,
};
return (
<>
<div className={style.xVal} aria-label="x-val">
{fmt(frame.fields[0], xField.values.get(rowIndex))}
</div>
<table className={style.infoWrap}>
<tr>
<th colSpan={2} style={{ backgroundColor: yValue.color }}></th>
</tr>
<tbody>
{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>
);
})}
<tr>
<th>{xField.name}</th>
<td>{fmt(frame.fields[0], xField.values.get(rowIndex))}</td>
</tr>
<tr>
<th>{yValue.name}:</th>
<td>{fmt(yValue.field, yValue.val)}</td>
</tr>
{extraFacets !== null && extraFacets.colorFacetFieldName && (
<tr>
<th>{extraFacets.colorFacetFieldName}:</th>
<td>{extraFacets.colorFacetValue}</td>
</tr>
)}
{extraFacets !== null && extraFacets.sizeFacetFieldName && (
<tr>
<th>{extraFacets.sizeFacetFieldName}:</th>
<td>{extraFacets.sizeFacetValue}</td>
</tr>
)}
{links.length > 0 && (
<tr>
<td colSpan={2}>

View File

@ -26,7 +26,7 @@ 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 { SeriesMapping, XYChartOptions } from './models.gen';
import { prepData, prepScatter, ScatterPanelInfo } from './scatter';
import { ScatterHoverEvent, ScatterSeries } from './types';
@ -73,7 +73,9 @@ export const XYChartPanel2: React.FC<Props> = (props: Props) => {
isToolTipOpen
);
if (info.series.length && props.data.series) {
if (info.error) {
setError(info.error);
} else if (info.series.length && props.data.series) {
setBuilder(info.builder);
setSeries(info.series);
setFacets(() => prepData(info, props.data.series));
@ -99,10 +101,11 @@ export const XYChartPanel2: React.FC<Props> = (props: Props) => {
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const theme = config.theme2;
for (const s of series) {
for (let si = 0; si < series.length; si++) {
const s = series[si];
const frame = s.frame(props.data.series);
if (frame) {
for (const item of s.legend(frame)) {
for (const item of s.legend()) {
item.getDisplayValues = () => {
const calcs = props.options.legend.calcs;
@ -167,6 +170,10 @@ export const XYChartPanel2: React.FC<Props> = (props: Props) => {
item.disabled = !(s.show ?? true);
if (props.options.seriesMapping === SeriesMapping.Manual) {
item.label = props.options.series?.[si]?.name ?? `Series ${si + 1}`;
}
items.push(item);
}
}
@ -240,6 +247,8 @@ export const XYChartPanel2: React.FC<Props> = (props: Props) => {
<TooltipView
options={props.options.tooltip}
allSeries={series}
manualSeriesConfigs={props.options.series}
seriesMapping={props.options.seriesMapping!}
rowIndex={hover.xIndex}
hoveredPointIndex={hover.scatterIndex}
data={props.data.series}

View File

@ -5,12 +5,12 @@ import {
identityOverrideProcessor,
SetFieldConfigOptionsArgs,
} from '@grafana/data';
import { LineStyle, VisibilityMode } from '@grafana/schema';
import { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui';
import { LineStyle } from '@grafana/schema';
import { commonOptionsBuilder } from '@grafana/ui';
import { LineStyleEditor } from '../timeseries/LineStyleEditor';
import { ScatterFieldConfig, ScatterLineMode } from './models.gen';
import { ScatterFieldConfig, ScatterShow } from './models.gen';
export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOptionsArgs<ScatterFieldConfig> {
return {
@ -30,40 +30,33 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
useCustomConfig: (builder) => {
builder
.addRadio({
path: 'point',
name: 'Points',
defaultValue: cfg.point,
path: 'show',
name: 'Show',
defaultValue: cfg.show,
settings: {
options: graphFieldOptions.showPoints,
options: [
{ label: 'Points', value: ScatterShow.Points },
{ label: 'Lines', value: ScatterShow.Lines },
{ label: 'Both', value: ScatterShow.PointsAndLines },
],
},
})
.addSliderInput({
path: 'pointSize.fixed',
name: 'Size',
name: 'Point size',
defaultValue: cfg.pointSize?.fixed,
settings: {
min: 1,
max: 100,
step: 1,
},
showIf: (c) => c.point !== VisibilityMode.Never,
})
.addRadio({
path: 'line',
name: 'Lines',
defaultValue: cfg.line,
settings: {
options: [
{ label: 'None', value: ScatterLineMode.None },
{ label: 'Linear', value: ScatterLineMode.Linear },
],
},
showIf: (c) => c.show !== ScatterShow.Lines,
})
.addCustomEditor<void, LineStyle>({
id: 'lineStyle',
path: 'lineStyle',
name: 'Line style',
showIf: (c) => c.line !== ScatterLineMode.None,
showIf: (c) => c.show !== ScatterShow.Points,
editor: LineStyleEditor,
override: LineStyleEditor,
process: identityOverrideProcessor,
@ -78,7 +71,7 @@ export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOp
max: 10,
step: 1,
},
showIf: (c) => c.line !== ScatterLineMode.None,
showIf: (c) => c.show !== ScatterShow.Points,
});
commonOptionsBuilder.addAxisConfig(builder, cfg);

View File

@ -14,20 +14,31 @@ import {
TextDimensionConfig,
} from 'app/features/dimensions';
export enum ScatterLineMode {
None = 'none',
Linear = 'linear',
// Smooth
// r2, etc
// export enum ScatterLineMode {
// None = 'none',
// Linear = 'linear',
// Smooth
// r2, etc
// }
export enum ScatterShow {
Points = 'points',
Lines = 'lines',
PointsAndLines = 'points+lines',
}
export enum SeriesMapping {
Auto = 'auto',
Manual = 'manual',
}
export interface ScatterFieldConfig extends HideableFieldConfig, AxisConfig {
line?: ScatterLineMode;
show?: ScatterShow;
lineWidth?: number;
lineStyle?: LineStyle;
lineColor?: ColorDimensionConfig;
point?: VisibilityMode;
pointSize?: ScaleDimensionConfig; // only 'fixed' is exposed in the UI
pointColor?: ColorDimensionConfig;
pointSymbol?: DimensionSupplier<string>;
@ -44,12 +55,11 @@ export interface ScatterSeriesConfig extends ScatterFieldConfig {
}
export const defaultScatterConfig: ScatterFieldConfig = {
line: ScatterLineMode.None, // no line
show: ScatterShow.Points,
lineWidth: 1,
lineStyle: {
fill: 'solid',
},
point: VisibilityMode.Auto,
pointSize: {
fixed: 5,
min: 1,
@ -66,7 +76,7 @@ export interface XYDimensionConfig {
}
export interface XYChartOptions extends OptionsWithLegend, OptionsWithTooltip {
mode?: 'auto' | 'manual';
seriesMapping?: SeriesMapping;
dims: XYDimensionConfig;
series?: ScatterSeriesConfig[];

View File

@ -12,8 +12,8 @@ export const plugin = new PanelPlugin<XYChartOptions, ScatterFieldConfig>(XYChar
.setPanelOptions((builder) => {
builder
.addRadio({
path: 'mode',
name: 'Mode',
path: 'seriesMapping',
name: 'Series mapping',
defaultValue: 'auto',
settings: {
options: [
@ -27,7 +27,7 @@ export const plugin = new PanelPlugin<XYChartOptions, ScatterFieldConfig>(XYChar
path: 'dims',
name: '',
editor: AutoEditor,
showIf: (cfg) => cfg.mode === 'auto',
showIf: (cfg) => cfg.seriesMapping === 'auto',
})
.addCustomEditor({
id: 'series',
@ -35,7 +35,7 @@ export const plugin = new PanelPlugin<XYChartOptions, ScatterFieldConfig>(XYChar
name: '',
defaultValue: [],
editor: ManualEditor,
showIf: (cfg) => cfg.mode === 'manual',
showIf: (cfg) => cfg.seriesMapping === 'manual',
});
commonOptionsBuilder.addTooltipOptions(builder);

View File

@ -26,7 +26,7 @@ import {
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
import { isGraphable } from './dims';
import { defaultScatterConfig, ScatterFieldConfig, ScatterLineMode, XYChartOptions } from './models.gen';
import { defaultScatterConfig, ScatterFieldConfig, ScatterShow, XYChartOptions } from './models.gen';
import { DimensionValues, ScatterHoverCallback, ScatterSeries } from './types';
export interface ScatterPanelInfo {
@ -161,7 +161,7 @@ function getScatterSeries(
x: (frame) => frame.fields[xIndex],
y: (frame) => frame.fields[yIndex],
legend: (frame) => {
legend: () => {
return [
{
label: name,
@ -172,12 +172,12 @@ function getScatterSeries(
];
},
line: fieldConfig.line ?? ScatterLineMode.None,
showLine: fieldConfig.show !== ScatterShow.Points,
lineWidth: fieldConfig.lineWidth ?? 2,
lineStyle: fieldConfig.lineStyle!,
lineColor: () => seriesColor,
point: fieldConfig.point!,
showPoints: fieldConfig.show !== ScatterShow.Lines ? VisibilityMode.Always : VisibilityMode.Never,
pointSize,
pointColor,
pointSymbol: (frame: DataFrame, from?: number) => 'circle', // single field, multiple symbols.... kinda equals multiple series 🤔
@ -201,44 +201,46 @@ function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries
throw 'Missing data';
}
if (options.mode === 'manual') {
if (options.series?.length) {
const scatterSeries: ScatterSeries[] = [];
if (options.seriesMapping === 'manual') {
if (!options.series?.length) {
throw 'Missing series config';
}
for (const series of options.series) {
if (!series?.x) {
throw 'Select X dimension';
}
const scatterSeries: ScatterSeries[] = [];
if (!series?.y) {
throw 'Select Y dimension';
}
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const frame = frames[frameIndex];
const xIndex = findFieldIndex(frame, series.x);
if (xIndex != null) {
// TODO: this should find multiple y fields
const yIndex = findFieldIndex(frame, series.y);
if (yIndex == null) {
throw 'Y must be in the same frame as X';
}
const dims: Dims = {
pointColorFixed: series.pointColor?.fixed,
pointColorIndex: findFieldIndex(frame, series.pointColor?.field),
pointSizeConfig: series.pointSize,
pointSizeIndex: findFieldIndex(frame, series.pointSize?.field),
};
scatterSeries.push(getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims));
}
}
for (const series of options.series) {
if (!series?.x) {
throw 'Select X dimension';
}
return scatterSeries;
if (!series?.y) {
throw 'Select Y dimension';
}
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const frame = frames[frameIndex];
const xIndex = findFieldIndex(frame, series.x);
if (xIndex != null) {
// TODO: this should find multiple y fields
const yIndex = findFieldIndex(frame, series.y);
if (yIndex == null) {
throw 'Y must be in the same frame as X';
}
const dims: Dims = {
pointColorFixed: series.pointColor?.fixed,
pointColorIndex: findFieldIndex(frame, series.pointColor?.field),
pointSizeConfig: series.pointSize,
pointSizeIndex: findFieldIndex(frame, series.pointSize?.field),
};
scatterSeries.push(getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims));
}
}
}
return scatterSeries;
}
// Default behavior
@ -323,9 +325,9 @@ const prepConfig = (
const scatterInfo = scatterSeries[seriesIdx - 1];
let d = u.data[seriesIdx] as unknown as FacetSeries;
let showLine = scatterInfo.line !== ScatterLineMode.None;
let showPoints = scatterInfo.point === VisibilityMode.Always;
if (!showPoints && scatterInfo.point === VisibilityMode.Auto) {
let showLine = scatterInfo.showLine;
let showPoints = scatterInfo.showPoints === VisibilityMode.Always;
if (!showPoints && scatterInfo.showPoints === VisibilityMode.Auto) {
showPoints = d[0].length < 1000;
}
@ -589,16 +591,22 @@ const prepConfig = (
range: (u, min, max) => [min, max],
});
// why does this fall back to '' instead of null or undef?
let xAxisLabel = xField.config.custom.axisLabel;
builder.addAxis({
scaleKey: 'x',
placement:
xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden,
show: xField.config.custom?.axisPlacement !== AxisPlacement.Hidden,
theme,
label: xField.config.custom.axisLabel,
label:
xAxisLabel == null || xAxisLabel === ''
? getFieldDisplayName(xField, scatterSeries[0].frame(frames), frames)
: xAxisLabel,
});
scatterSeries.forEach((s) => {
scatterSeries.forEach((s, si) => {
let frame = s.frame(frames);
let field = s.y(frame);
@ -617,11 +625,17 @@ const prepConfig = (
});
if (field.config.custom?.axisPlacement !== AxisPlacement.Hidden) {
// why does this fall back to '' instead of null or undef?
let yAxisLabel = field.config.custom?.axisLabel;
builder.addAxis({
scaleKey,
theme,
placement: field.config.custom?.axisPlacement,
label: field.config.custom.axisLabel,
label:
yAxisLabel == null || yAxisLabel === ''
? getFieldDisplayName(field, scatterSeries[si].frame(frames), frames)
: yAxisLabel,
values: (u, splits) => splits.map((s) => field.display!(s).text),
});
}

View File

@ -3,8 +3,6 @@ import { LineStyle, VisibilityMode } from '@grafana/schema';
import { VizLegendItem } from '@grafana/ui';
import { ScaleDimensionConfig } from 'app/features/dimensions';
import { ScatterLineMode } from './models.gen';
/**
* @internal
*/
@ -37,14 +35,14 @@ export interface ScatterSeries {
x: (frame: DataFrame) => Field;
y: (frame: DataFrame) => Field;
legend: (frame: DataFrame) => VizLegendItem[]; // could be single if symbol is constant
legend: () => VizLegendItem[]; // could be single if symbol is constant
line: ScatterLineMode;
showLine: boolean;
lineWidth: number;
lineStyle: LineStyle;
lineColor: (frame: DataFrame) => CanvasRenderingContext2D['strokeStyle'];
point: VisibilityMode;
showPoints: VisibilityMode;
pointSize: DimensionValues<number>;
pointColor: DimensionValues<CanvasRenderingContext2D['strokeStyle']>;
pointSymbol: DimensionValues<string>; // single field, multiple symbols.... kinda equals multiple series