diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 93ca2a954c0..76582b17eba 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -115,7 +115,7 @@ export class GraphNG extends React.Component { state = { alignedFrame, - alignedData: config!.prepData!(alignedFrame), + alignedData: config!.prepData!([alignedFrame]) as AlignedData, config, }; @@ -195,7 +195,7 @@ export class GraphNG extends React.Component { if (shouldReconfig) { newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); - newState.alignedData = newState.config.prepData!(newState.alignedFrame); + newState.alignedData = newState.config.prepData!([newState.alignedFrame]) as AlignedData; pluginLog('GraphNG', false, 'config recreated', newState.config); } } diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap index 1f9a6b89982..62c51603c9d 100644 --- a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -98,6 +98,7 @@ Object { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object { "__fixed": Object { @@ -127,6 +128,7 @@ Object { 1, 2, ], + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -149,6 +151,7 @@ Object { 1, 2, ], + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -171,6 +174,7 @@ Object { 1, 2, ], + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -193,6 +197,7 @@ Object { 1, 2, ], + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -215,6 +220,7 @@ Object { 1, 2, ], + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index f6c16aec35f..d089d949227 100755 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -50,7 +50,7 @@ export class Sparkline extends PureComponent { const alignedDataFrame = preparePlotFrame(props.sparkline, props.config); this.state = { - data: preparePlotData(alignedDataFrame), + data: preparePlotData([alignedDataFrame]), alignedDataFrame, configBuilder: this.prepareConfig(alignedDataFrame), }; @@ -64,7 +64,7 @@ export class Sparkline extends PureComponent { return { ...state, - data: preparePlotData(frame), + data: preparePlotData([frame]), alignedDataFrame: frame, }; } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 95cfc9ff834..9de67699ed4 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -246,12 +246,14 @@ export { LegacyForms, LegacyInputStatus }; // WIP, need renames and exports cleanup export * from './uPlot/config'; -export { UPlotConfigBuilder, UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder'; +export { ScaleDistribution } from '@grafana/schema'; +export { UPlotConfigBuilder } from './uPlot/config/UPlotConfigBuilder'; export { UPlotChart } from './uPlot/Plot'; export { PlotLegend } from './uPlot/PlotLegend'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; export { PlotTooltipInterpolator, PlotSelection } from './uPlot/types'; +export { UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder'; export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG'; export { TimeSeries } from './TimeSeries/TimeSeries'; export { useGraphNGContext } from './GraphNG/hooks'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index f82cc63f57b..26640d5b532 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -55,7 +55,7 @@ const mockData = () => { const config = new UPlotConfigBuilder(); config.addSeries({} as SeriesProps); - return { data, timeRange, config }; + return { data: [data], timeRange, config }; }; describe('UPlotChart', () => { @@ -104,7 +104,7 @@ describe('UPlotChart', () => { expect(uPlot).toBeCalledTimes(1); - data.fields[1].values.set(0, 1); + data[0].fields[1].values.set(0, 1); rerender( { }; pluginLog('UPlot', false, 'Reinitializing plot', config); - const plot = new uPlot(config, this.props.data, this.plotContainer!.current!); + const plot = new uPlot(config, this.props.data as AlignedData, this.plotContainer!.current!); if (plotRef) { plotRef(plot); @@ -100,12 +100,12 @@ export class UPlotChart extends React.Component { } else if (!sameConfig(prevProps, this.props)) { this.reinitPlot(); } else if (!sameData(prevProps, this.props)) { - plot?.setData(this.props.data); + plot?.setData(this.props.data as AlignedData); // this is a uPlot cache-busting hack for bar charts in case x axis labels changed // since the x scale's "range" doesnt change, the axis size doesnt get recomputed, which is where the tick labels are regenerated & cached // the more expensive, more proper/thorough way to do this is to force all axes to recalc: plot?.redraw(false, true); - if (plot && typeof this.props.data[0][0] === 'string') { + if (plot && typeof this.props.data[0]?.[0] === 'string') { //@ts-ignore plot.axes[0]._values = this.props.data[0]; } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 743a16f4056..3732c9eb8a9 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -18,7 +18,7 @@ export interface AxisProps { formatValue?: (v: any) => string; incrs?: Axis.Incrs; splits?: Axis.Splits; - values?: any; + values?: Axis.Values; isTime?: boolean; timeZone?: TimeZone; } diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 50b93c34527..57933c09c0f 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -37,6 +37,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object {}, "select": undefined, @@ -87,6 +88,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object { "scale-x": Object { @@ -166,6 +168,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object { "scale-y": Object { @@ -218,6 +221,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object { "scale-y": Object { @@ -271,6 +275,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object { "scale-y": Object { @@ -387,6 +392,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object {}, "select": undefined, @@ -505,6 +511,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object {}, "select": undefined, @@ -513,6 +520,7 @@ describe('UPlotConfigBuilder', () => { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -619,6 +627,7 @@ describe('UPlotConfigBuilder', () => { }, }, "hooks": Object {}, + "mode": 1, "padding": undefined, "scales": Object {}, "select": undefined, @@ -627,6 +636,7 @@ describe('UPlotConfigBuilder', () => { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -644,6 +654,7 @@ describe('UPlotConfigBuilder', () => { "width": 1, }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -661,6 +672,7 @@ describe('UPlotConfigBuilder', () => { "width": 1, }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 58fd1e8e2a8..a17eba2dc1e 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -1,4 +1,4 @@ -import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding } from 'uplot'; +import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding, Series } from 'uplot'; import { merge } from 'lodash'; import { DataFrame, @@ -9,7 +9,7 @@ import { TimeRange, TimeZone, } from '@grafana/data'; -import { PlotConfig, PlotTooltipInterpolator } from '../types'; +import { FacetedData, PlotConfig, PlotTooltipInterpolator } from '../types'; import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder'; import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder'; @@ -31,7 +31,7 @@ const cursorDefaults: Cursor = { }, }; -type PrepData = (frame: DataFrame) => AlignedData; +type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData; export class UPlotConfigBuilder { private series: UPlotSeriesBuilder[] = []; @@ -45,7 +45,8 @@ export class UPlotConfigBuilder { private hooks: Hooks.Arrays = {}; private tz: string | undefined = undefined; private sync = false; - private frame: DataFrame | undefined = undefined; + private mode: uPlot.Mode = 1; + private frames: DataFrame[] | undefined = undefined; // to prevent more than one threshold per scale private thresholds: Record = {}; // Custom handler for closest datapoint and series lookup @@ -112,6 +113,10 @@ export class UPlotConfigBuilder { this.cursor = merge({}, this.cursor, cursor); } + setMode(mode: uPlot.Mode) { + this.mode = mode; + } + setSelect(select: Select) { this.select = select; } @@ -151,9 +156,9 @@ export class UPlotConfigBuilder { } setPrepData(prepData: PrepData) { - this.prepData = (frame) => { - this.frame = frame; - return prepData(frame); + this.prepData = (frames) => { + this.frames = frames; + return prepData(frames); }; } @@ -171,10 +176,13 @@ export class UPlotConfigBuilder { getConfig() { const config: PlotConfig = { + mode: this.mode, series: [ - { - value: () => '', - }, + this.mode === 2 + ? ((null as unknown) as Series) + : { + value: () => '', + }, ], }; config.axes = this.ensureNonOverlappingAxes(Object.values(this.axes)).map((a) => a.getConfig()); @@ -193,19 +201,24 @@ export class UPlotConfigBuilder { // interpolate for gradients/thresholds if (typeof s !== 'string') { - let field = this.frame!.fields[seriesIdx]; + let field = this.frames![0].fields[seriesIdx]; s = field.display!(field.values.get(u.cursor.idxs![seriesIdx]!)).color!; } return s + alphaHex; }; - config.cursor = merge({}, cursorDefaults, this.cursor, { - points: { - stroke: pointColorFn('80'), - fill: pointColorFn(), + config.cursor = merge( + {}, + cursorDefaults, + { + points: { + stroke: pointColorFn('80'), + fill: pointColorFn(), + }, }, - }); + this.cursor + ); config.tzDate = this.tzDate; config.padding = this.padding; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts index c02e6c4cb84..e0f88e63854 100755 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotSeriesBuilder.ts @@ -27,6 +27,8 @@ export interface SeriesProps extends LineConfig, BarConfig, FillConfig, PointsCo pxAlign?: boolean; gradientMode?: GraphGradientMode; + facets?: uPlot.Series.Facet[]; + /** Used when gradientMode is set to Scheme */ thresholds?: ThresholdsConfig; colorMode?: FieldColorMode; @@ -48,6 +50,7 @@ export interface SeriesProps extends LineConfig, BarConfig, FillConfig, PointsCo export class UPlotSeriesBuilder extends PlotConfigBuilder { getConfig() { const { + facets, drawStyle, pathBuilder, pointsBuilder, @@ -132,6 +135,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder { return { scale: scaleKey, + facets, spanGaps: typeof spanNulls === 'number' ? false : spanNulls, value: () => '', pxAlign, diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 36390dcbab4..8095b9bd262 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -5,15 +5,19 @@ import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; export type PlotConfig = Pick< Options, - 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding' + 'mode' | 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding' >; export interface PlotPluginProps { id: string; } +export type FacetValues = any[]; +export type FacetSeries = FacetValues[]; +export type FacetedData = [_: null, ...series: FacetSeries]; + export interface PlotProps { - data: AlignedData; + data: AlignedData | FacetedData; width: number; height: number; config: UPlotConfigBuilder; diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index c3523457613..68f9ffecd8f 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -27,7 +27,7 @@ describe('preparePlotData', () => { }); it('creates array from DataFrame', () => { - expect(preparePlotData(df)).toMatchInlineSnapshot(` + expect(preparePlotData([df])).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -75,7 +75,7 @@ describe('preparePlotData', () => { }, ], }); - expect(preparePlotData(df)).toMatchInlineSnapshot(` + expect(preparePlotData([df])).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -122,7 +122,7 @@ describe('preparePlotData', () => { }, ], }); - expect(preparePlotData(df)).toMatchInlineSnapshot(` + expect(preparePlotData([df])).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -185,7 +185,7 @@ describe('preparePlotData', () => { ], }); - expect(preparePlotData(df)).toMatchInlineSnapshot(` + expect(preparePlotData([df])).toMatchInlineSnapshot(` Array [ Array [ 9997, @@ -260,7 +260,7 @@ describe('preparePlotData', () => { ], }); - expect(preparePlotData(df)).toMatchInlineSnapshot(` + expect(preparePlotData([df])).toMatchInlineSnapshot(` Array [ Array [ 9997, diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 59ac16919bb..aaa496f0654 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -39,7 +39,8 @@ interface StackMeta { } /** @internal */ -export function preparePlotData(frame: DataFrame, onStackMeta?: (meta: StackMeta) => void): AlignedData { +export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackMeta) => void): AlignedData { + const frame = frames[0]; const result: any[] = []; const stackingGroups: Map = new Map(); let seriesIndex = 0; diff --git a/public/app/features/dimensions/color.ts b/public/app/features/dimensions/color.ts index 45098808b57..ebe82ef586e 100644 --- a/public/app/features/dimensions/color.ts +++ b/public/app/features/dimensions/color.ts @@ -1,4 +1,4 @@ -import { DataFrame, getFieldColorModeForField, getScaleCalculator, GrafanaTheme2 } from '@grafana/data'; +import { DataFrame, Field, getFieldColorModeForField, getScaleCalculator, GrafanaTheme2 } from '@grafana/data'; import { ColorDimensionConfig, DimensionSupplier } from './types'; import { findField, getLastNotNullFieldValue } from './utils'; @@ -11,7 +11,14 @@ export function getColorDimension( config: ColorDimensionConfig, theme: GrafanaTheme2 ): DimensionSupplier { - const field = findField(frame, config.field); + return getColorDimensionForField(findField(frame, config.field), config, theme); +} + +export function getColorDimensionForField( + field: Field | undefined, + config: ColorDimensionConfig, + theme: GrafanaTheme2 +): DimensionSupplier { if (!field) { const v = theme.visualization.getColorByName(config.fixed) ?? 'grey'; return { diff --git a/public/app/features/dimensions/editors/ColorDimensionEditor.tsx b/public/app/features/dimensions/editors/ColorDimensionEditor.tsx index f0cc688ca05..699b9411aee 100644 --- a/public/app/features/dimensions/editors/ColorDimensionEditor.tsx +++ b/public/app/features/dimensions/editors/ColorDimensionEditor.tsx @@ -16,6 +16,8 @@ const fixedColorOption: SelectableValue = { export const ColorDimensionEditor: FC> = (props) => { const { value, context, onChange } = props; + const defaultColor = 'dark-green'; + const styles = useStyles2(getStyles); const fieldName = value?.field; const isFixed = Boolean(!fieldName); @@ -31,7 +33,7 @@ export const ColorDimensionEditor: FC { onChange({ field: undefined, - fixed: c ?? 'grey', + fixed: c ?? defaultColor, }); }, [onChange] @@ -65,7 +67,7 @@ export const ColorDimensionEditor: FC {isFixed && (
- +
)} diff --git a/public/app/features/dimensions/scale.ts b/public/app/features/dimensions/scale.ts index 3c434af10aa..45c82c027a1 100644 --- a/public/app/features/dimensions/scale.ts +++ b/public/app/features/dimensions/scale.ts @@ -1,4 +1,5 @@ -import { DataFrame } from '@grafana/data'; +import { DataFrame, Field } from '@grafana/data'; +import { ScaleDimensionMode } from '.'; import { getMinMaxAndDelta } from '../../../../packages/grafana-data/src/field/scale'; import { ScaleDimensionConfig, DimensionSupplier, ScaleDimensionOptions } from './types'; import { findField, getLastNotNullFieldValue } from './utils'; @@ -11,7 +12,14 @@ export function getScaledDimension( frame: DataFrame | undefined, config: ScaleDimensionConfig ): DimensionSupplier { - const field = findField(frame, config.field); + return getScaledDimensionForField(findField(frame, config?.field), config); +} + +export function getScaledDimensionForField( + field: Field | undefined, + config: ScaleDimensionConfig, + mode?: ScaleDimensionMode +): DimensionSupplier { if (!field) { const v = config.fixed ?? 0; return { @@ -32,6 +40,19 @@ export function getScaledDimension( }; } + let scaled = (percent: number) => config.min + percent * delta; + if (mode === ScaleDimensionMode.Quadratic) { + const maxArea = Math.PI * (config.max / 2) ** 2; + const minArea = Math.PI * (config.min / 2) ** 2; + const deltaArea = maxArea - minArea; + + // quadratic scaling (px area) + scaled = (percent: number) => { + let area = minArea + deltaArea * percent; + return Math.sqrt(area / Math.PI) * 2; + }; + } + const get = (i: number) => { const value = field.values.get(i); let percent = 0; @@ -43,7 +64,7 @@ export function getScaledDimension( } else if (percent < 0) { percent = 0; } - return config.min + percent * delta; + return scaled(percent); }; return { diff --git a/public/app/features/dimensions/text.ts b/public/app/features/dimensions/text.ts index 689cfaf96d8..4933dcd6137 100644 --- a/public/app/features/dimensions/text.ts +++ b/public/app/features/dimensions/text.ts @@ -1,4 +1,4 @@ -import { DataFrame, formattedValueToString } from '@grafana/data'; +import { DataFrame, Field, formattedValueToString } from '@grafana/data'; import { DimensionSupplier, TextDimensionConfig, TextDimensionMode } from './types'; import { findField, getLastNotNullFieldValue } from './utils'; @@ -7,6 +7,13 @@ import { findField, getLastNotNullFieldValue } from './utils'; //--------------------------------------------------------- export function getTextDimension(frame: DataFrame | undefined, config: TextDimensionConfig): DimensionSupplier { + return getTextDimensionForField(findField(frame, config.field), config); +} + +export function getTextDimensionForField( + field: Field | undefined, + config: TextDimensionConfig +): DimensionSupplier { let v = config.fixed; const mode = config.mode ?? TextDimensionMode.Fixed; if (mode === TextDimensionMode.Fixed) { @@ -18,7 +25,6 @@ export function getTextDimension(frame: DataFrame | undefined, config: TextDimen }; } - const field = findField(frame, config.field); if (mode === TextDimensionMode.Template) { const disp = (v: any) => { return `TEMPLATE[${config.fixed} // ${v}]`; diff --git a/public/app/features/dimensions/types.ts b/public/app/features/dimensions/types.ts index 5a71591584a..bb028304dcb 100644 --- a/public/app/features/dimensions/types.ts +++ b/public/app/features/dimensions/types.ts @@ -32,6 +32,11 @@ export interface DimensionSupplier { get: (index: number) => T; } +export enum ScaleDimensionMode { + Linear = 'linear', + Quadratic = 'quad', +} + /** This will map the field value% to a scaled value within the range */ export interface ScaleDimensionConfig extends BaseDimensionConfig { min: number; diff --git a/public/app/features/dimensions/utils.ts b/public/app/features/dimensions/utils.ts index 01144b47035..6859781be1d 100644 --- a/public/app/features/dimensions/utils.ts +++ b/public/app/features/dimensions/utils.ts @@ -89,6 +89,24 @@ export function findField(frame?: DataFrame, name?: string): Field | undefined { return undefined; } +export function findFieldIndex(frame?: DataFrame, name?: string): number | undefined { + if (!frame || !name?.length) { + return undefined; + } + + for (let i = 0; i < frame.fields.length; i++) { + const field = frame.fields[i]; + if (name === field.name) { + return i; + } + const disp = getFieldDisplayName(field, frame); + if (name === disp) { + return i; + } + } + return undefined; +} + export function getLastNotNullFieldValue(field: Field): T { const calcs = field.state?.calcs; if (calcs) { diff --git a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap index ee0ea325e06..9c1dfd74b93 100644 --- a/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap +++ b/public/app/plugins/panel/barchart/__snapshots__/utils.test.ts.snap @@ -82,6 +82,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -109,6 +110,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -212,6 +214,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -239,6 +242,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -342,6 +346,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -369,6 +374,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -472,6 +478,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -499,6 +506,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -602,6 +610,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -629,6 +638,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -732,6 +742,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -759,6 +770,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -862,6 +874,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -889,6 +902,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { @@ -992,6 +1006,7 @@ Object { [Function], ], }, + "mode": 1, "padding": undefined, "scales": Object { "m/s": Object { @@ -1019,6 +1034,7 @@ Object { "value": [Function], }, Object { + "facets": undefined, "fill": [Function], "paths": [Function], "points": Object { diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index ffe1f40f933..51c7c907f68 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -308,10 +308,10 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { let alignedTotals: AlignedData | null = null; - function prepData(alignedFrame: DataFrame) { + function prepData(frames: DataFrame[]) { alignedTotals = null; - return preparePlotData(alignedFrame, ({ totals }) => { + return preparePlotData(frames, ({ totals }) => { alignedTotals = totals; }); } diff --git a/public/app/plugins/panel/xychart/ExplicitEditor.tsx b/public/app/plugins/panel/xychart/ExplicitEditor.tsx new file mode 100644 index 00000000000..a5af738005f --- /dev/null +++ b/public/app/plugins/panel/xychart/ExplicitEditor.tsx @@ -0,0 +1,12 @@ +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/TooltipView.tsx b/public/app/plugins/panel/xychart/TooltipView.tsx new file mode 100644 index 00000000000..db62263a678 --- /dev/null +++ b/public/app/plugins/panel/xychart/TooltipView.tsx @@ -0,0 +1,59 @@ +import React, { PureComponent } from 'react'; +import { stylesFactory } from '@grafana/ui'; +import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; +import { config } from 'app/core/config'; +import { ScatterSeries } from './types'; + +export interface Props { + series: ScatterSeries; + data: DataFrame[]; // source data + rowIndex?: number; // the hover row +} + +export class TooltipView extends PureComponent { + style = getStyles(config.theme2); + + render() { + const { series, data, rowIndex } = this.props; + if (!series || rowIndex == null) { + return null; + } + const frame = series.frame(data); + const y = undefined; // series.y(frame); + + return ( + + + {frame.fields.map((f, i) => ( + + + + + ))} + +
{getFieldDisplayName(f, frame)}:{fmt(f, rowIndex)}
+ ); + } +} + +function fmt(field: Field, row: number): string { + const v = field.values.get(row); + if (field.display) { + return formattedValueToString(field.display(v)); + } + return `${v}`; +} + +const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ + infoWrap: css` + padding: 8px; + th { + font-weight: ${theme.typography.fontWeightMedium}; + padding: ${theme.spacing(0.25, 2)}; + } + `, + highlight: css` + background: ${theme.colors.action.hover}; + `, +})); diff --git a/public/app/plugins/panel/xychart/XYChartPanel.old b/public/app/plugins/panel/xychart/XYChartPanel.old new file mode 100644 index 00000000000..a446d74d7b2 --- /dev/null +++ b/public/app/plugins/panel/xychart/XYChartPanel.old @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react'; +import { LegendDisplayMode, UPlotChart, useTheme2, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui'; +import { PanelProps } from '@grafana/data'; +import { XYChartOptions } from './models.gen'; +import { prepData, prepScatter } from './scatter'; + +interface XYChartPanelProps extends PanelProps {} + +export const XYChartPanel: React.FC = ({ + data, + width, + height, + options, + fieldConfig, + timeRange, + //onFieldConfigChange, +}) => { + const theme = useTheme2(); + + const info = useMemo(() => { + console.log('prepScatter!'); + return prepScatter(options, data, theme, () => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.structureRev, options]); + + // preps data in various shapes...aligned, stacked, merged, interpolated, etc.. + const scatterData = useMemo(() => { + console.log('prepData!'); + return prepData(info, data.series); + }, [info, data.series]); + + const legend = useMemo(() => { + const items: VizLegendItem[] = []; + for (const s of info.series) { + const frame = s.frame(data.series); + if (frame) { + for (const item of s.legend(frame)) { + items.push(item); + } + } + } + + return ( + + + + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [info]); + + if (info.error) { + return ( +
+

{info.error}

+
+ ); + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + //
+        //   {JSON.stringify(scatterData, null, 2)}
+        // 
+ + {/*children ? children(config, alignedFrame) : null*/} + + )} +
+ ); +}; diff --git a/public/app/plugins/panel/xychart/XYChartPanel.tsx b/public/app/plugins/panel/xychart/XYChartPanel.tsx deleted file mode 100644 index 560efac724d..00000000000 --- a/public/app/plugins/panel/xychart/XYChartPanel.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { Button, GraphNGLegendEvent, TimeSeries, TooltipPlugin } from '@grafana/ui'; -import { PanelProps } from '@grafana/data'; -import { Options } from './types'; -import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory'; -import { getXYDimensions } from './dims'; - -interface XYChartPanelProps extends PanelProps {} - -export const XYChartPanel: React.FC = ({ - data, - timeRange, - timeZone, - width, - height, - options, - fieldConfig, - onFieldConfigChange, -}) => { - const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]); - - const frames = useMemo(() => [dims.frame], [dims]); - - const onLegendClick = useCallback( - (event: GraphNGLegendEvent) => { - onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, frames)); - }, - [fieldConfig, onFieldConfigChange, frames] - ); - - if (dims.error) { - return ( -
-
ERROR: {dims.error}
- {dims.hasData && ( -
- - {dims.hasTime && } -
- )} -
- ); - } - - return ( - - {(config, alignedDataFrame) => { - return ( - - ); - }} - - ); -}; diff --git a/public/app/plugins/panel/xychart/XYChartPanel2.tsx b/public/app/plugins/panel/xychart/XYChartPanel2.tsx new file mode 100644 index 00000000000..dd1051fa648 --- /dev/null +++ b/public/app/plugins/panel/xychart/XYChartPanel2.tsx @@ -0,0 +1,126 @@ +import React, { PureComponent } from 'react'; +import { PanelProps } from '@grafana/data'; +import { XYChartOptions } from './models.gen'; +import { ScatterHoverEvent, ScatterSeries } from './types'; +import { + LegendDisplayMode, + Portal, + UPlotChart, + UPlotConfigBuilder, + VizLayout, + VizLegend, + VizLegendItem, + VizTooltipContainer, +} from '@grafana/ui'; +import { FacetedData } from '@grafana/ui/src/components/uPlot/types'; +import { prepData, prepScatter } from './scatter'; +import { config } from '@grafana/runtime'; +import { TooltipView } from './TooltipView'; + +type Props = PanelProps; +type State = { + error?: string; + series: ScatterSeries[]; + builder?: UPlotConfigBuilder; + facets?: FacetedData; + hover?: ScatterHoverEvent; +}; + +export class XYChartPanel2 extends PureComponent { + state: State = { + series: [], + }; + + componentDidMount() { + this.initSeries(); // also data + } + + componentDidUpdate(oldProps: Props) { + const { options, data } = this.props; + const configsChanged = options !== oldProps.options || data.structureRev !== oldProps.data.structureRev; + + if (configsChanged) { + this.initSeries(); + } else if (data !== oldProps.data) { + this.initFacets(); + } + } + + scatterHoverCallback = (hover?: ScatterHoverEvent) => { + this.setState({ hover }); + }; + + 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; + } + this.setState(info); + }; + + initFacets = () => { + this.setState({ + facets: prepData(this.state, this.props.data.series), + }); + }; + + renderLegend = () => { + const { data } = this.props; + const { series } = this.state; + const items: VizLegendItem[] = []; + for (const s of series) { + const frame = s.frame(data.series); + if (frame) { + for (const item of s.legend(frame)) { + items.push(item); + } + } + } + + return ( + + + + ); + }; + + render() { + const { width, height, timeRange, data } = this.props; + const { error, facets, builder, hover, series } = this.state; + if (error || !builder) { + return ( +
+

{error}

+
+ ); + } + + return ( + <> + + {(vizWidth: number, vizHeight: number) => ( + //
+            //   {JSON.stringify(scatterData, null, 2)}
+            // 
+ + {/*children ? children(config, alignedFrame) : null*/} + + )} +
+ + {hover && ( + + + + )} + + + ); + } +} diff --git a/public/app/plugins/panel/xychart/XYDimsEditor.tsx b/public/app/plugins/panel/xychart/XYDimsEditor.tsx index a002fe57685..50019f6354e 100644 --- a/public/app/plugins/panel/xychart/XYDimsEditor.tsx +++ b/public/app/plugins/panel/xychart/XYDimsEditor.tsx @@ -9,7 +9,7 @@ import { getFieldDisplayName, } from '@grafana/data'; -import { XYDimensionConfig, Options } from './types'; +import { XYDimensionConfig, XYChartOptions } from './models.gen'; import { getXYDimensions, isGraphable } from './dims'; interface XYInfo { @@ -18,7 +18,7 @@ interface XYInfo { yFields: Array>; } -export const XYDimsEditor: FC> = ({ +export const XYDimsEditor: FC> = ({ value, onChange, context, diff --git a/public/app/plugins/panel/xychart/config.ts b/public/app/plugins/panel/xychart/config.ts new file mode 100644 index 00000000000..5d5347a2344 --- /dev/null +++ b/public/app/plugins/panel/xychart/config.ts @@ -0,0 +1,94 @@ +import { + FieldColorModeId, + FieldConfigProperty, + FieldType, + identityOverrideProcessor, + SetFieldConfigOptionsArgs, +} from '@grafana/data'; +import { LineStyle, VisibilityMode } from '@grafana/schema'; + +import { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui'; +import { LineStyleEditor } from '../timeseries/LineStyleEditor'; +import { ScatterFieldConfig, ScatterLineMode } from './models.gen'; + +const categoryStyles = undefined; // ['Scatter styles']; + +export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOptionsArgs { + return { + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + bySeriesSupport: true, + preferThresholdsMode: false, + }, + defaultValue: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + }, + + useCustomConfig: (builder) => { + builder + .addRadio({ + path: 'point', + name: 'Points', + category: categoryStyles, + defaultValue: cfg.point, + settings: { + options: graphFieldOptions.showPoints, + }, + }) + .addSliderInput({ + path: 'pointSize.fixed', + name: 'Point size', + category: categoryStyles, + defaultValue: cfg.pointSize?.fixed, + settings: { + min: 1, + max: 100, + step: 1, + }, + showIf: (c) => c.point !== VisibilityMode.Never, + }) + .addRadio({ + path: 'line', + name: 'Lines', + category: categoryStyles, + defaultValue: cfg.line, + settings: { + options: [ + { label: 'None', value: ScatterLineMode.None }, + { label: 'Linear', value: ScatterLineMode.Linear }, + ], + }, + }) + .addCustomEditor({ + id: 'lineStyle', + path: 'lineStyle', + name: 'Line style', + category: categoryStyles, + showIf: (c) => c.line !== ScatterLineMode.None, + editor: LineStyleEditor, + override: LineStyleEditor, + process: identityOverrideProcessor, + shouldApply: (f) => f.type === FieldType.number, + }) + .addSliderInput({ + path: 'lineWidth', + name: 'Line width', + category: categoryStyles, + defaultValue: cfg.lineWidth, + settings: { + min: 0, + max: 10, + step: 1, + }, + showIf: (c) => c.line !== ScatterLineMode.None, + }); + + commonOptionsBuilder.addAxisConfig(builder, cfg); + commonOptionsBuilder.addHideFrom(builder); + }, + }; +} diff --git a/public/app/plugins/panel/xychart/dims.ts b/public/app/plugins/panel/xychart/dims.ts index b82fbb11a6a..7f11ee1757d 100644 --- a/public/app/plugins/panel/xychart/dims.ts +++ b/public/app/plugins/panel/xychart/dims.ts @@ -1,5 +1,5 @@ import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data'; -import { XYDimensionConfig } from './types'; +import { XYDimensionConfig } from './models.gen'; // TODO: fix import import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types'; diff --git a/public/app/plugins/panel/xychart/models.gen.ts b/public/app/plugins/panel/xychart/models.gen.ts new file mode 100644 index 00000000000..fa9ddc2083b --- /dev/null +++ b/public/app/plugins/panel/xychart/models.gen.ts @@ -0,0 +1,72 @@ +import { + OptionsWithTooltip, + OptionsWithLegend, + LineStyle, + VisibilityMode, + HideableFieldConfig, + AxisConfig, + AxisPlacement, +} from '@grafana/schema'; +import { + ColorDimensionConfig, + DimensionSupplier, + ScaleDimensionConfig, + TextDimensionConfig, +} from 'app/features/dimensions'; + +export enum ScatterLineMode { + None = 'none', + Linear = 'linear', + // Smooth + // r2, etc +} + +export interface ScatterFieldConfig extends HideableFieldConfig, AxisConfig { + line?: ScatterLineMode; + lineWidth?: number; + lineStyle?: LineStyle; + lineColor?: ColorDimensionConfig; + + point?: VisibilityMode; + pointSize?: ScaleDimensionConfig; // only 'fixed' is exposed in the UI + pointColor?: ColorDimensionConfig; + pointSymbol?: DimensionSupplier; + + label?: VisibilityMode; + labelValue?: TextDimensionConfig; +} + +/** Configured in the panel level */ +export interface ScatterSeriesConfig extends ScatterFieldConfig { + x?: string; + y?: string; +} + +export const defaultScatterConfig: ScatterFieldConfig = { + line: ScatterLineMode.None, // no line + lineWidth: 1, + lineStyle: { + fill: 'solid', + }, + point: VisibilityMode.Auto, + pointSize: { + fixed: 5, + min: 1, + max: 20, + }, + axisPlacement: AxisPlacement.Auto, +}; + +/** Old config saved with 8.0+ */ +export interface XYDimensionConfig { + frame: number; + x?: string; // name | first + exclude?: string[]; // all other numbers except +} + +export interface XYChartOptions extends OptionsWithLegend, OptionsWithTooltip { + mode?: 'xy' | 'explicit'; + dims: XYDimensionConfig; + + series?: ScatterSeriesConfig[]; +} diff --git a/public/app/plugins/panel/xychart/module.tsx b/public/app/plugins/panel/xychart/module.tsx index 4182594b724..32e917025ba 100644 --- a/public/app/plugins/panel/xychart/module.tsx +++ b/public/app/plugins/panel/xychart/module.tsx @@ -1,25 +1,69 @@ -import { GraphFieldConfig, GraphDrawStyle } from '@grafana/schema'; import { PanelPlugin } from '@grafana/data'; import { commonOptionsBuilder } from '@grafana/ui'; -import { XYChartPanel } from './XYChartPanel'; -import { Options } from './types'; +import { defaultScatterConfig, XYChartOptions, ScatterFieldConfig } from './models.gen'; +import { getScatterFieldConfig } from './config'; import { XYDimsEditor } from './XYDimsEditor'; -import { getGraphFieldConfig, defaultGraphConfig } from '../timeseries/config'; +import { XYChartPanel2 } from './XYChartPanel2'; +import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors'; -export const plugin = new PanelPlugin(XYChartPanel) - .useFieldConfig( - getGraphFieldConfig({ - ...defaultGraphConfig, - drawStyle: GraphDrawStyle.Points, - }) - ) +export const plugin = new PanelPlugin(XYChartPanel2) + .useFieldConfig(getScatterFieldConfig(defaultScatterConfig)) .setPanelOptions((builder) => { - builder.addCustomEditor({ - id: 'xyPlotConfig', - path: 'dims', - name: 'Data', - editor: XYDimsEditor, - }); + builder + .addRadio({ + path: 'mode', + name: 'Mode', + defaultValue: 'single', + settings: { + options: [ + { value: 'xy', label: 'XY', description: 'No changes to saved model since 8.0' }, + { value: 'explicit', label: 'Explicit' }, + ], + }, + }) + .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', + }) + .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', + }); + commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addLegendOptions(builder); }); diff --git a/public/app/plugins/panel/xychart/scatter.ts b/public/app/plugins/panel/xychart/scatter.ts new file mode 100644 index 00000000000..28a2a114c53 --- /dev/null +++ b/public/app/plugins/panel/xychart/scatter.ts @@ -0,0 +1,674 @@ +import { + DataFrame, + FieldColorModeId, + fieldColorModeRegistry, + getDisplayProcessor, + getFieldColorModeForField, + getFieldDisplayName, + getFieldSeriesColor, + GrafanaTheme2, +} from '@grafana/data'; +import { AxisPlacement, ScaleDirection, ScaleOrientation, VisibilityMode } from '@grafana/schema'; +import { UPlotConfigBuilder } from '@grafana/ui'; +import { FacetedData, FacetSeries } from '@grafana/ui/src/components/uPlot/types'; +import { + findFieldIndex, + getScaledDimensionForField, + ScaleDimensionConfig, + ScaleDimensionMode, +} from 'app/features/dimensions'; +import { config } from '@grafana/runtime'; +import { defaultScatterConfig, ScatterFieldConfig, ScatterLineMode, XYChartOptions } from './models.gen'; +import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; +import { alpha } from '@grafana/data/src/themes/colorManipulator'; +import uPlot from 'uplot'; +import { DimensionValues, ScatterHoverCallback, ScatterSeries } from './types'; +import { isGraphable } from './dims'; + +export interface ScatterPanelInfo { + error?: string; + series: ScatterSeries[]; + builder?: UPlotConfigBuilder; +} + +/** + * This is called when options or structure rev changes + */ +export function prepScatter( + options: XYChartOptions, + getData: () => DataFrame[], + theme: GrafanaTheme2, + ttip: ScatterHoverCallback +): ScatterPanelInfo { + let series: ScatterSeries[]; + let builder: UPlotConfigBuilder; + + try { + series = prepSeries(options, getData()); + builder = prepConfig(getData, series, theme, ttip); + } catch (e) { + console.log('prepScatter ERROR', e); + return { + error: e.message, + series: [], + }; + } + + return { + series, + builder, + }; +} + +interface Dims { + pointColorIndex?: number; + pointColorFixed?: string; + + pointSizeIndex?: number; + pointSizeConfig?: ScaleDimensionConfig; +} + +function getScatterSeries( + seriesIndex: number, + frames: DataFrame[], + frameIndex: number, + xIndex: number, + yIndex: number, + dims: Dims +): ScatterSeries { + const frame = frames[frameIndex]; + const y = frame.fields[yIndex]; + let state = y.state ?? {}; + state.seriesIndex = seriesIndex; + y.state = state; + + // Color configs + //---------------- + let seriesColor = dims.pointColorFixed + ? config.theme2.visualization.getColorByName(dims.pointColorFixed) + : getFieldSeriesColor(y, config.theme2).color; + let pointColor: DimensionValues = () => seriesColor; + const fieldConfig: ScatterFieldConfig = { ...defaultScatterConfig, ...y.config.custom }; + let pointColorMode = fieldColorModeRegistry.get(FieldColorModeId.PaletteClassic); + if (dims.pointColorIndex) { + const f = frames[frameIndex].fields[dims.pointColorIndex]; + if (f) { + const disp = + f.display ?? + getDisplayProcessor({ + field: f, + theme: config.theme2, + }); + pointColorMode = getFieldColorModeForField(y); + if (pointColorMode.isByValue) { + const index = dims.pointColorIndex; + pointColor = (frame: DataFrame) => { + // Yes we can improve this later + return frame.fields[index].values.toArray().map((v) => disp(v).color!); + }; + } else { + seriesColor = pointColorMode.getCalculator(f, config.theme2)(f.values.get(0), 1); + pointColor = () => seriesColor; + } + } + } + + // Size configs + //---------------- + let pointSizeHints = dims.pointSizeConfig; + let pointSizeFixed = dims.pointSizeConfig?.fixed ?? y.config.custom?.pointSizeConfig?.fixed ?? 5; + let pointSize: DimensionValues = () => pointSizeFixed; + if (dims.pointSizeIndex) { + pointSize = (frame) => { + const s = getScaledDimensionForField( + frame.fields[dims.pointSizeIndex!], + dims.pointSizeConfig!, + ScaleDimensionMode.Quadratic + ); + const vals = Array(frame.length); + for (let i = 0; i < frame.length; i++) { + vals[i] = s.get(i); + } + return vals; + }; + } else { + pointSizeHints = { + fixed: pointSizeFixed, + min: pointSizeFixed, + max: pointSizeFixed, + }; + } + + // Series config + //---------------- + const name = getFieldDisplayName(y, frame, frames); + return { + name, + + frame: (frames) => frames[frameIndex], + + x: (frame) => frame.fields[xIndex], + y: (frame) => frame.fields[yIndex], + legend: (frame) => { + return [ + { + label: name, + color: seriesColor, // single color for series? + getItemKey: () => name, + yAxis: yIndex, // << but not used + }, + ]; + }, + + line: fieldConfig.line ?? ScatterLineMode.None, + lineWidth: fieldConfig.lineWidth ?? 2, + lineStyle: fieldConfig.lineStyle!, + lineColor: () => seriesColor, + + point: fieldConfig.point!, + pointSize, + pointColor, + pointSymbol: (frame: DataFrame, from?: number) => 'circle', // single field, multiple symbols.... kinda equals multiple series 🤔 + + label: VisibilityMode.Never, + labelValue: () => '', + + hints: { + pointSize: pointSizeHints!, + pointColor: { + mode: pointColorMode, + }, + }, + }; +} + +function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries[] { + let seriesIndex = 0; + if (!frames.length) { + throw 'missing data'; + } + + if (options.mode === 'explicit') { + if (options.series?.length) { + for (const series of options.series) { + if (!series?.x) { + throw 'Select X dimension'; + } + + 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), + }; + return [getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims)]; + } + } + } + } + } + + // Default behavior + const dims = options.dims ?? {}; + const frameIndex = dims.frame ?? 0; + const frame = frames[frameIndex]; + const numericIndicies: number[] = []; + + let xIndex = findFieldIndex(frame, dims.x); + for (let i = 0; i < frame.fields.length; i++) { + if (isGraphable(frame.fields[i])) { + if (xIndex == null || i === xIndex) { + xIndex = i; + continue; + } + if (dims.exclude && dims.exclude.includes(getFieldDisplayName(frame.fields[i], frame, frames))) { + continue; // skip + } + + numericIndicies.push(i); + } + } + + if (xIndex == null) { + throw 'Missing X dimension'; + } + + if (!numericIndicies.length) { + throw 'No Y values'; + } + return numericIndicies.map((yIndex) => getScatterSeries(seriesIndex++, frames, frameIndex, xIndex!, yIndex, {})); +} + +interface DrawBubblesOpts { + each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; + disp: { + //unit: 3, + size: { + values: (u: uPlot, seriesIdx: number) => number[]; + }; + color: { + values: (u: uPlot, seriesIdx: number) => string[]; + alpha: (u: uPlot, seriesIdx: number) => string[]; + }; + }; +} + +//const prepConfig: UPlotConfigPrepFnXY = ({ frames, series, theme }) => { +const prepConfig = ( + getData: () => DataFrame[], + scatterSeries: ScatterSeries[], + theme: GrafanaTheme2, + ttip: ScatterHoverCallback +) => { + let qt: Quadtree; + let hRect: Rect | null; + + function drawBubblesFactory(opts: DrawBubblesOpts) { + const drawBubbles: uPlot.Series.PathBuilder = (u, seriesIdx, idx0, idx1) => { + uPlot.orient( + u, + seriesIdx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + arc + ) => { + 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) { + showPoints = d[0].length < 1000; + } + + // always show something + if (!showPoints && !showLine) { + showLine = true; + } + + let strokeWidth = 1; + + u.ctx.save(); + + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + u.ctx.fillStyle = (series.fill as any)(); // assumes constant + u.ctx.strokeStyle = (series.stroke as any)(); + u.ctx.lineWidth = strokeWidth; + + let deg360 = 2 * Math.PI; + + // leon forgot to add these to the uPlot's Scale interface, but they exist! + //let xKey = scaleX.key as string; + //let yKey = scaleY.key as string; + let xKey = series.facets![0].scale; + let yKey = series.facets![1].scale; + + let pointHints = scatterInfo.hints.pointSize; + const colorByValue = scatterInfo.hints.pointColor.mode.isByValue; + + let maxSize = (pointHints.max ?? pointHints.fixed) * devicePixelRatio; + + // todo: this depends on direction & orientation + // todo: calc once per redraw, not per path + let filtLft = u.posToVal(-maxSize / 2, xKey); + let filtRgt = u.posToVal(u.bbox.width / devicePixelRatio + maxSize / 2, xKey); + let filtBtm = u.posToVal(u.bbox.height / devicePixelRatio + maxSize / 2, yKey); + let filtTop = u.posToVal(-maxSize / 2, yKey); + + let sizes = opts.disp.size.values(u, seriesIdx); + let pointColors = opts.disp.color.values(u, seriesIdx); + let pointAlpha = opts.disp.color.alpha(u, seriesIdx); + + let linePath: Path2D | null = showLine ? new Path2D() : null; + + for (let i = 0; i < d[0].length; i++) { + let xVal = d[0][i]; + let yVal = d[1][i]; + let size = sizes[i] * devicePixelRatio; + + if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { + let cx = valToPosX(xVal, scaleX, xDim, xOff); + let cy = valToPosY(yVal, scaleY, yDim, yOff); + + if (showLine) { + linePath!.lineTo(cx, cy); + } + + if (showPoints) { + u.ctx.moveTo(cx + size / 2, cy); + u.ctx.beginPath(); + u.ctx.arc(cx, cy, size / 2, 0, deg360); + + if (colorByValue) { + u.ctx.fillStyle = pointAlpha[i]; + u.ctx.strokeStyle = pointColors[i]; + } + + u.ctx.fill(); + u.ctx.stroke(); + opts.each( + u, + seriesIdx, + i, + cx - size / 2 - strokeWidth / 2, + cy - size / 2 - strokeWidth / 2, + size + strokeWidth, + size + strokeWidth + ); + } + } + } + + if (showLine) { + let frame = scatterInfo.frame(getData()); + u.ctx.strokeStyle = scatterInfo.lineColor(frame); + u.ctx.lineWidth = scatterInfo.lineWidth * devicePixelRatio; + + const { lineStyle } = scatterInfo; + if (lineStyle && lineStyle.fill !== 'solid') { + if (lineStyle.fill === 'dot') { + u.ctx.lineCap = 'round'; + } + u.ctx.setLineDash(lineStyle.dash ?? [10, 10]); + } + + u.ctx.stroke(linePath!); + } + + u.ctx.restore(); + } + ); + + return null; + }; + + return drawBubbles; + } + + let drawBubbles = drawBubblesFactory({ + disp: { + size: { + //unit: 3, // raw CSS pixels + values: (u, seriesIdx) => { + return u.data[seriesIdx][2] as any; // already contains final pixel geometry + //let [minValue, maxValue] = getSizeMinMax(u); + //return u.data[seriesIdx][2].map(v => getSize(v, minValue, maxValue)); + }, + }, + color: { + // string values + values: (u, seriesIdx) => { + return u.data[seriesIdx][3] as any; + }, + alpha: (u, seriesIdx) => { + return u.data[seriesIdx][4] as any; + }, + }, + }, + each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { + // we get back raw canvas coords (included axes & padding). translate to the plotting area origin + lft -= u.bbox.left; + top -= u.bbox.top; + qt.add({ x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx }); + }, + }); + + const builder = new UPlotConfigBuilder(); + + builder.setCursor({ + drag: { setScale: true }, + dataIdx: (u, seriesIdx) => { + if (seriesIdx === 1) { + hRect = null; + + let dist = Infinity; + let cx = u.cursor.left! * devicePixelRatio; + let cy = u.cursor.top! * devicePixelRatio; + + qt.get(cx, cy, 1, 1, (o) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) { + let ocx = o.x + o.w / 2; + let ocy = o.y + o.h / 2; + + let dx = ocx - cx; + let dy = ocy - cy; + + let d = Math.sqrt(dx ** 2 + dy ** 2); + + // test against radius for actual hover + if (d <= o.w / 2) { + // only hover bbox with closest distance + if (d <= dist) { + dist = d; + hRect = o; + } + } + } + }); + } + + return hRect && seriesIdx === hRect.sidx ? hRect.didx : null; + }, + points: { + size: (u, seriesIdx) => { + return hRect && seriesIdx === hRect.sidx ? hRect.w / devicePixelRatio : 0; + }, + fill: (u, seriesIdx) => 'rgba(255,255,255,0.4)', + }, + }); + + // clip hover points/bubbles to plotting area + builder.addHook('init', (u, r) => { + u.over.style.overflow = 'hidden'; + }); + + let rect: DOMRect; + + // rect of .u-over (grid area) + builder.addHook('syncRect', (u, r) => { + rect = r; + }); + + 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) { + ttip({ + scatterIndex: i - 1, + xIndex: sel, + pageX: rect.left + u.cursor.left!, + pageY: rect.top + u.cursor.top!, + }); + return; // only show the first one + } + } + } + ttip(undefined); + }); + + builder.addHook('drawClear', (u) => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s, i) => { + if (i > 0) { + // @ts-ignore + s._paths = null; + } + }); + }); + + builder.setMode(2); + + const frames = getData(); + let xField = scatterSeries[0].x(scatterSeries[0].frame(frames)); + + builder.addScale({ + scaleKey: 'x', + isTime: false, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + range: (u, min, max) => [min, max], + }); + + builder.addAxis({ + scaleKey: 'x', + placement: AxisPlacement.Bottom, + theme, + label: xField.config.custom.axisLabel, + }); + + scatterSeries.forEach((s) => { + let frame = s.frame(frames); + let field = s.y(frame); + + const lineColor = s.lineColor(frame); + const pointColor = asSingleValue(frame, s.pointColor) as string; + //const lineColor = s.lineColor(frame); + //const lineWidth = s.lineWidth; + + let scaleKey = field.config.unit ?? 'y'; + + builder.addScale({ + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + range: (u, min, max) => [min, max], + }); + + builder.addAxis({ + scaleKey, + theme, + label: field.config.custom.axisLabel, + values: (u, splits) => splits.map((s) => field.display!(s).text), + }); + + builder.addSeries({ + facets: [ + { + scale: 'x', + auto: true, + }, + { + scale: scaleKey, + auto: true, + }, + ], + pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}}) + theme, + scaleKey: '', // facets' scales used (above) + lineColor: lineColor as string, + fillColor: alpha(pointColor, 0.5), + }); + }); + + /* + builder.setPrepData((frames) => { + let seriesData = lookup.fieldMaps.flatMap((f, i) => { + let { fields } = frames[i]; + + return f.y.map((yIndex, frameSeriesIndex) => { + let xValues = fields[f.x[frameSeriesIndex]].values.toArray(); + let yValues = fields[f.y[frameSeriesIndex]].values.toArray(); + let sizeValues = f.size![frameSeriesIndex](frames[i]); + + if (!Array.isArray(sizeValues)) { + sizeValues = Array(xValues.length).fill(sizeValues); + } + + return [xValues, yValues, sizeValues]; + }); + }); + + return [null, ...seriesData]; + }); + */ + + return builder; +}; + +/** + * This is called everytime the data changes + * + * 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) { + return [null]; + } + return [ + null, + ...info.series.map((s, idx) => { + const frame = s.frame(data); + + let colorValues; + let colorAlphaValues; + const r = s.pointColor(frame); + if (Array.isArray(r)) { + colorValues = r; + colorAlphaValues = r.map((c) => alpha(c as string, 0.5)); + } else { + colorValues = Array(frame.length).fill(r); + colorAlphaValues = Array(frame.length).fill(alpha(r as string, 0.5)); + } + return [ + s.x(frame).values.toArray(), // X + s.y(frame).values.toArray(), // Y + asArray(frame, s.pointSize), + colorValues, + colorAlphaValues, + ]; + }), + ]; +} + +function asArray(frame: DataFrame, lookup: DimensionValues): T[] { + const r = lookup(frame); + if (Array.isArray(r)) { + return r; + } + return Array(frame.length).fill(r); +} + +function asSingleValue(frame: DataFrame, lookup: DimensionValues): T { + const r = lookup(frame); + if (Array.isArray(r)) { + return r[0]; + } + return r; +} diff --git a/public/app/plugins/panel/xychart/types.ts b/public/app/plugins/panel/xychart/types.ts index d5ae63c076b..10e6e64171a 100644 --- a/public/app/plugins/panel/xychart/types.ts +++ b/public/app/plugins/panel/xychart/types.ts @@ -1,10 +1,60 @@ -import { OptionsWithTooltip, OptionsWithLegend } from '@grafana/schema'; -export interface XYDimensionConfig { - frame: number; - x?: string; // name | first - exclude?: string[]; // all other numbers except +import { DataFrame, Field, FieldColorMode } from '@grafana/data'; +import { LineStyle, VisibilityMode } from '@grafana/schema'; +import { VizLegendItem } from '@grafana/ui'; +import { ScaleDimensionConfig } from 'app/features/dimensions'; +import { ScatterLineMode } from './models.gen'; + +/** + * @internal + */ +export type DimensionValues = (frame: DataFrame, from?: number) => T | T[]; + +export interface ScatterHoverEvent { + scatterIndex: number; + xIndex: number; + pageX: number; + pageY: number; } -export interface Options extends OptionsWithLegend, OptionsWithTooltip { - dims: XYDimensionConfig; +export type ScatterHoverCallback = (evt?: ScatterHoverEvent) => void; + +export interface LegendInfo { + color: CanvasRenderingContext2D['strokeStyle']; + text: string; + symbol: string; + openEditor?: (evt: any) => void; +} + +// Using field where we will need formatting/scale/axis info +// Use raw or DimensionValues when the values can be used directly +export interface ScatterSeries { + name: string; + + /** Finds the relevant frame from the raw panel data */ + frame: (frames: DataFrame[]) => DataFrame; + + x: (frame: DataFrame) => Field; + y: (frame: DataFrame) => Field; + + legend: (frame: DataFrame) => VizLegendItem[]; // could be single if symbol is constant + + line: ScatterLineMode; + lineWidth: number; + lineStyle: LineStyle; + lineColor: (frame: DataFrame) => CanvasRenderingContext2D['strokeStyle']; + + point: VisibilityMode; + pointSize: DimensionValues; + pointColor: DimensionValues; + pointSymbol: DimensionValues; // single field, multiple symbols.... kinda equals multiple series + + label: VisibilityMode; + labelValue: DimensionValues; + + hints: { + pointSize: ScaleDimensionConfig; + pointColor: { + mode: FieldColorMode; + }; + }; }