mirror of https://github.com/grafana/grafana.git
				
				
				
			XYChart: Remove old implementation (#96416)
This commit is contained in:
		
							parent
							
								
									c6f85579db
								
							
						
					
					
						commit
						39fe0b29ff
					
				|  | @ -5536,35 +5536,16 @@ exports[`better eslint`] = { | |||
|       [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], | ||||
|       [0, 0, 0, "Unexpected any. Specify a different type.", "1"] | ||||
|     ], | ||||
|     "public/app/plugins/panel/xychart/ManualEditor.tsx:5381": [ | ||||
|       [0, 0, 0, "Do not use any type assertions.", "0"] | ||||
|     ], | ||||
|     "public/app/plugins/panel/xychart/XYChartPanel.tsx:5381": [ | ||||
|       [0, 0, 0, "Do not use any type assertions.", "0"] | ||||
|     ], | ||||
|     "public/app/plugins/panel/xychart/scatter.ts:5381": [ | ||||
|       [0, 0, 0, "Do not use any type assertions.", "0"], | ||||
|       [0, 0, 0, "Do not use any type assertions.", "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"] | ||||
|     ], | ||||
|     "public/app/plugins/panel/xychart/v2/SeriesEditor.tsx:5381": [ | ||||
|     "public/app/plugins/panel/xychart/SeriesEditor.tsx:5381": [ | ||||
|       [0, 0, 0, "Do not use any type assertions.", "0"], | ||||
|       [0, 0, 0, "Do not use any type assertions.", "1"], | ||||
|       [0, 0, 0, "Do not use any type assertions.", "2"], | ||||
|       [0, 0, 0, "Do not use any type assertions.", "3"] | ||||
|     ], | ||||
|     "public/app/plugins/panel/xychart/v2/migrations.ts:5381": [ | ||||
|     "public/app/plugins/panel/xychart/migrations.ts:5381": [ | ||||
|       [0, 0, 0, "Do not use any type assertions.", "0"] | ||||
|     ], | ||||
|     "public/app/plugins/panel/xychart/v2/scatter.ts:5381": [ | ||||
|     "public/app/plugins/panel/xychart/scatter.ts:5381": [ | ||||
|       [0, 0, 0, "Do not use any type assertions.", "0"], | ||||
|       [0, 0, 0, "Do not use any type assertions.", "1"], | ||||
|       [0, 0, 0, "Do not use any type assertions.", "2"], | ||||
|  |  | |||
|  | @ -28,7 +28,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | |||
| | `publicDashboardsScene`                | Enables public dashboard rendering using scenes                                                                                                                           | Yes                | | ||||
| | `featureHighlights`                    | Highlight Grafana Enterprise features                                                                                                                                     |                    | | ||||
| | `correlations`                         | Correlations page                                                                                                                                                         | Yes                | | ||||
| | `autoMigrateXYChartPanel`              | Migrate old XYChart panel to new XYChart2 model                                                                                                                           | Yes                | | ||||
| | `cloudWatchCrossAccountQuerying`       | Enables cross-account querying in CloudWatch datasources                                                                                                                  | Yes                | | ||||
| | `accessControlOnCall`                  | Access control primitives for OnCall                                                                                                                                      | Yes                | | ||||
| | `nestedFolders`                        | Enable folder nesting                                                                                                                                                     | Yes                | | ||||
|  |  | |||
|  | @ -35,7 +35,6 @@ export interface FeatureToggles { | |||
|   autoMigratePiechartPanel?: boolean; | ||||
|   autoMigrateWorldmapPanel?: boolean; | ||||
|   autoMigrateStatPanel?: boolean; | ||||
|   autoMigrateXYChartPanel?: boolean; | ||||
|   disableAngular?: boolean; | ||||
|   canvasPanelNesting?: boolean; | ||||
|   vizActions?: boolean; | ||||
|  |  | |||
|  | @ -12,66 +12,85 @@ import * as common from '@grafana/schema'; | |||
| 
 | ||||
| export const pluginVersion = "11.4.0-pre"; | ||||
| 
 | ||||
| /** | ||||
|  * Auto is "table" in the UI | ||||
|  */ | ||||
| export enum PointShape { | ||||
|   Circle = 'circle', | ||||
|   Square = 'square', | ||||
| } | ||||
| 
 | ||||
| export enum SeriesMapping { | ||||
|   Auto = 'auto', | ||||
|   Manual = 'manual', | ||||
| } | ||||
| 
 | ||||
| export enum ScatterShow { | ||||
| export enum XYShowMode { | ||||
|   Lines = 'lines', | ||||
|   Points = 'points', | ||||
|   PointsAndLines = 'points+lines', | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Configuration for the Table/Auto mode | ||||
|  * NOTE: (copied from dashboard_kind.cue, since not exported) | ||||
|  * Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. | ||||
|  * It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | ||||
|  */ | ||||
| export interface XYDimensionConfig { | ||||
|   exclude?: Array<string>; | ||||
|   frame: number; | ||||
|   x?: string; | ||||
| export interface MatcherConfig { | ||||
|   /** | ||||
|    * The matcher id. This is used to find the matcher implementation from registry. | ||||
|    */ | ||||
|   id: string; | ||||
|   /** | ||||
|    * The matcher options. This is specific to the matcher implementation. | ||||
|    */ | ||||
|   options?: unknown; | ||||
| } | ||||
| 
 | ||||
| export const defaultXYDimensionConfig: Partial<XYDimensionConfig> = { | ||||
|   exclude: [], | ||||
| export const defaultMatcherConfig: Partial<MatcherConfig> = { | ||||
|   id: '', | ||||
| }; | ||||
| 
 | ||||
| export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | ||||
|   label?: common.VisibilityMode; | ||||
|   labelValue?: common.TextDimensionConfig; | ||||
|   lineColor?: common.ColorDimensionConfig; | ||||
|   fillOpacity?: number; | ||||
|   lineStyle?: common.LineStyle; | ||||
|   lineWidth?: number; | ||||
|   pointColor?: common.ColorDimensionConfig; | ||||
|   pointSize?: common.ScaleDimensionConfig; | ||||
|   show?: ScatterShow; | ||||
|   pointShape?: PointShape; | ||||
|   pointSize?: { | ||||
|     fixed?: number; | ||||
|     min?: number; | ||||
|     max?: number; | ||||
|   }; | ||||
|   pointStrokeWidth?: number; | ||||
|   show?: XYShowMode; | ||||
| } | ||||
| 
 | ||||
| export const defaultFieldConfig: Partial<FieldConfig> = { | ||||
|   label: common.VisibilityMode.Auto, | ||||
|   show: ScatterShow.Points, | ||||
|   fillOpacity: 50, | ||||
|   show: XYShowMode.Points, | ||||
| }; | ||||
| 
 | ||||
| export interface ScatterSeriesConfig extends FieldConfig { | ||||
|   frame?: number; | ||||
|   name?: string; | ||||
|   x?: string; | ||||
|   y?: string; | ||||
| export interface XYSeriesConfig { | ||||
|   color?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   frame?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   name?: { | ||||
|     fixed?: string; | ||||
|   }; | ||||
|   size?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   x?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   y?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | ||||
|   /** | ||||
|    * Table Mode (auto) | ||||
|    */ | ||||
|   dims: XYDimensionConfig; | ||||
|   /** | ||||
|    * Manual Mode | ||||
|    */ | ||||
|   series: Array<ScatterSeriesConfig>; | ||||
|   seriesMapping?: SeriesMapping; | ||||
|   mapping: SeriesMapping; | ||||
|   series: Array<XYSeriesConfig>; | ||||
| } | ||||
| 
 | ||||
| export const defaultOptions: Partial<Options> = { | ||||
|  |  | |||
|  | @ -142,14 +142,6 @@ var ( | |||
| 			FrontendOnly: true, | ||||
| 			Owner:        grafanaDatavizSquad, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Name:         "autoMigrateXYChartPanel", | ||||
| 			Description:  "Migrate old XYChart panel to new XYChart2 model", | ||||
| 			Stage:        FeatureStageGeneralAvailability, | ||||
| 			FrontendOnly: true, | ||||
| 			Expression:   "true", // enabled by default
 | ||||
| 			Owner:        grafanaDatavizSquad, | ||||
| 		}, | ||||
| 		{ | ||||
| 			Name:              "disableAngular", | ||||
| 			Description:       "Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime.", | ||||
|  |  | |||
|  | @ -16,7 +16,6 @@ autoMigrateTablePanel,preview,@grafana/dataviz-squad,false,false,true | |||
| autoMigratePiechartPanel,preview,@grafana/dataviz-squad,false,false,true | ||||
| autoMigrateWorldmapPanel,preview,@grafana/dataviz-squad,false,false,true | ||||
| autoMigrateStatPanel,preview,@grafana/dataviz-squad,false,false,true | ||||
| autoMigrateXYChartPanel,GA,@grafana/dataviz-squad,false,false,true | ||||
| disableAngular,preview,@grafana/dataviz-squad,false,false,true | ||||
| canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true | ||||
| vizActions,experimental,@grafana/dataviz-squad,false,false,true | ||||
|  |  | |||
| 
 | 
|  | @ -75,10 +75,6 @@ const ( | |||
| 	// Migrate old stat panel to supported stat panel - broken out from autoMigrateOldPanels to enable granular tracking
 | ||||
| 	FlagAutoMigrateStatPanel = "autoMigrateStatPanel" | ||||
| 
 | ||||
| 	// FlagAutoMigrateXYChartPanel
 | ||||
| 	// Migrate old XYChart panel to new XYChart2 model
 | ||||
| 	FlagAutoMigrateXYChartPanel = "autoMigrateXYChartPanel" | ||||
| 
 | ||||
| 	// FlagDisableAngular
 | ||||
| 	// Dynamic flag to disable angular at runtime. The preferred method is to set `angular_support_enabled` to `false` in the [security] settings, which allows you to change the state at runtime.
 | ||||
| 	FlagDisableAngular = "disableAngular" | ||||
|  |  | |||
|  | @ -535,6 +535,7 @@ | |||
|         "name": "autoMigrateXYChartPanel", | ||||
|         "resourceVersion": "1722537244598", | ||||
|         "creationTimestamp": "2024-03-22T15:44:37Z", | ||||
|         "deletionTimestamp": "2024-11-14T01:17:06Z", | ||||
|         "annotations": { | ||||
|           "grafana.app/updatedTimestamp": "2024-08-01 18:34:04.598082 +0000 UTC" | ||||
|         } | ||||
|  |  | |||
|  | @ -2089,7 +2089,7 @@ | |||
|     "hasUpdate": false, | ||||
|     "defaultNavUrl": "/plugins/xychart/", | ||||
|     "category": "", | ||||
|     "state": "beta", | ||||
|     "state": "", | ||||
|     "signature": "internal", | ||||
|     "signatureType": "", | ||||
|     "signatureOrg": "", | ||||
|  |  | |||
|  | @ -20,8 +20,6 @@ const prometheusPlugin = async () => | |||
| const alertmanagerPlugin = async () => | ||||
|   await import(/* webpackChunkName: "alertmanagerPlugin" */ 'app/plugins/datasource/alertmanager/module'); | ||||
| 
 | ||||
| import { config } from '@grafana/runtime'; | ||||
| 
 | ||||
| // Async loaded panels
 | ||||
| const alertListPanel = async () => | ||||
|   await import(/* webpackChunkName: "alertListPanel" */ 'app/plugins/panel/alertlist/module'); | ||||
|  | @ -67,13 +65,7 @@ const welcomeBanner = async () => | |||
| const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module'); | ||||
| const canvasPanel = async () => await import(/* webpackChunkName: "canvasPanel" */ 'app/plugins/panel/canvas/module'); | ||||
| const graphPanel = async () => await import(/* webpackChunkName: "graphPlugin" */ 'app/plugins/panel/graph/module'); | ||||
| const xychartPanel = async () => { | ||||
|   if (config.featureToggles.autoMigrateXYChartPanel) { | ||||
|     return await import(/* webpackChunkName: "xychart2" */ 'app/plugins/panel/xychart/v2/module'); | ||||
|   } else { | ||||
|     return await import(/* webpackChunkName: "xychart" */ 'app/plugins/panel/xychart/module'); | ||||
|   } | ||||
| }; | ||||
| const xychartPanel = async () => await import(/* webpackChunkName: "xychart" */ 'app/plugins/panel/xychart/module'); | ||||
| const heatmapPanel = async () => | ||||
|   await import(/* webpackChunkName: "heatmapPanel" */ 'app/plugins/panel/heatmap/module'); | ||||
| const tableOldPanel = async () => | ||||
|  |  | |||
|  | @ -1,165 +0,0 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { useMemo } from 'react'; | ||||
| 
 | ||||
| import { | ||||
|   SelectableValue, | ||||
|   getFrameDisplayName, | ||||
|   StandardEditorProps, | ||||
|   getFieldDisplayName, | ||||
|   GrafanaTheme2, | ||||
| } from '@grafana/data'; | ||||
| import { Field, IconButton, Select, useStyles2 } from '@grafana/ui'; | ||||
| 
 | ||||
| import { getXYDimensions, isGraphable } from './dims'; | ||||
| import { XYDimensionConfig, Options } from './panelcfg.gen'; | ||||
| 
 | ||||
| interface XYInfo { | ||||
|   numberFields: Array<SelectableValue<string>>; | ||||
|   xAxis?: SelectableValue<string>; | ||||
|   yFields: Array<SelectableValue<boolean>>; | ||||
| } | ||||
| 
 | ||||
| export const AutoEditor = ({ value, onChange, context }: StandardEditorProps<XYDimensionConfig, {}, Options>) => { | ||||
|   const frameNames = useMemo(() => { | ||||
|     if (context?.data?.length) { | ||||
|       return context.data.map((f, idx) => ({ | ||||
|         value: idx, | ||||
|         label: `${getFrameDisplayName(f, idx)} (index: ${idx}, rows: ${f.length})`, | ||||
|       })); | ||||
|     } | ||||
|     return [{ value: 0, label: 'First result' }]; | ||||
|   }, [context.data]); | ||||
| 
 | ||||
|   const dims = useMemo(() => getXYDimensions(value, context.data), [context.data, value]); | ||||
| 
 | ||||
|   const info = useMemo(() => { | ||||
|     const v: XYInfo = { | ||||
|       numberFields: [], | ||||
|       yFields: [], | ||||
|       xAxis: value?.x | ||||
|         ? { | ||||
|             label: `${value.x} (Not found)`, | ||||
|             value: value.x, // empty
 | ||||
|           } | ||||
|         : undefined, | ||||
|     }; | ||||
|     const frame = context.data ? context.data[value?.frame ?? 0] : undefined; | ||||
|     if (frame) { | ||||
|       const xName = 'x' in dims ? getFieldDisplayName(dims.x, dims.frame, context.data) : undefined; | ||||
|       for (let field of frame.fields) { | ||||
|         if (isGraphable(field)) { | ||||
|           const name = getFieldDisplayName(field, frame, context.data); | ||||
|           const sel = { | ||||
|             label: name, | ||||
|             value: name, | ||||
|           }; | ||||
|           v.numberFields.push(sel); | ||||
|           if (value?.x && name === value.x) { | ||||
|             v.xAxis = sel; | ||||
|           } | ||||
|           if (xName !== name) { | ||||
|             v.yFields.push({ | ||||
|               label: name, | ||||
|               value: value?.exclude?.includes(name), | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       if (!v.xAxis) { | ||||
|         v.xAxis = { label: xName, value: xName }; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return v; | ||||
|   }, [dims, context.data, value]); | ||||
| 
 | ||||
|   const styles = useStyles2(getStyles); | ||||
| 
 | ||||
|   if (!context.data?.length) { | ||||
|     return <div>No data...</div>; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <Field label={'Data'}> | ||||
|         <Select | ||||
|           isClearable={true} | ||||
|           options={frameNames} | ||||
|           placeholder={'Change filter'} | ||||
|           value={frameNames.find((v) => v.value === value?.frame)} | ||||
|           onChange={(v) => { | ||||
|             onChange({ | ||||
|               ...value, | ||||
|               frame: v?.value!, | ||||
|               x: undefined, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|       <Field label={'X Field'}> | ||||
|         <Select | ||||
|           isClearable={true} | ||||
|           options={info.numberFields} | ||||
|           value={info.xAxis} | ||||
|           placeholder={`${info.numberFields?.[0].label} (First numeric)`} | ||||
|           onChange={(v) => { | ||||
|             onChange({ | ||||
|               ...value, | ||||
|               x: v?.value, | ||||
|             }); | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|       <Field label={'Y Fields'}> | ||||
|         <div> | ||||
|           {info.yFields.map((v) => ( | ||||
|             <div key={v.label} className={styles.row}> | ||||
|               <IconButton | ||||
|                 name={v.value ? 'eye-slash' : 'eye'} | ||||
|                 onClick={() => { | ||||
|                   const exclude: string[] = value?.exclude ? [...value.exclude] : []; | ||||
|                   let idx = exclude.indexOf(v.label!); | ||||
|                   if (idx < 0) { | ||||
|                     exclude.push(v.label!); | ||||
|                   } else { | ||||
|                     exclude.splice(idx, 1); | ||||
|                   } | ||||
|                   onChange({ | ||||
|                     ...value, | ||||
|                     exclude, | ||||
|                   }); | ||||
|                 }} | ||||
|                 tooltip={v.value ? 'Disable' : 'Enable'} | ||||
|               /> | ||||
|               {v.label} | ||||
|             </div> | ||||
|           ))} | ||||
|         </div> | ||||
|       </Field> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   sorter: css({ | ||||
|     marginTop: '10px', | ||||
|     display: 'flex', | ||||
|     flexDirection: 'row', | ||||
|     flexWrap: 'nowrap', | ||||
|     alignItems: 'center', | ||||
|     cursor: 'pointer', | ||||
|   }), | ||||
| 
 | ||||
|   row: css({ | ||||
|     padding: theme.spacing(0.5, 1), | ||||
|     borderRadius: theme.shape.radius.default, | ||||
|     background: theme.colors.background.secondary, | ||||
|     minHeight: theme.spacing(4), | ||||
|     display: 'flex', | ||||
|     flexDirection: 'row', | ||||
|     flexWrap: 'nowrap', | ||||
|     alignItems: 'center', | ||||
|     marginBottom: '3px', | ||||
|     border: `1px solid ${theme.components.input.borderColor}`, | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,199 +0,0 @@ | |||
| import { css, cx } from '@emotion/css'; | ||||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| 
 | ||||
| import { | ||||
|   GrafanaTheme2, | ||||
|   StandardEditorProps, | ||||
|   FieldNamePickerBaseNameMode, | ||||
|   StandardEditorsRegistryItem, | ||||
|   getFrameDisplayName, | ||||
| } from '@grafana/data'; | ||||
| import { Button, Field, IconButton, Select, useStyles2 } from '@grafana/ui'; | ||||
| import { LayerName } from 'app/core/components/Layers/LayerName'; | ||||
| 
 | ||||
| import { ScatterSeriesEditor } from './ScatterSeriesEditor'; | ||||
| import { Options, ScatterSeriesConfig, defaultFieldConfig } from './panelcfg.gen'; | ||||
| 
 | ||||
| export const ManualEditor = ({ | ||||
|   value, | ||||
|   onChange, | ||||
|   context, | ||||
| }: StandardEditorProps<ScatterSeriesConfig[], unknown, Options>) => { | ||||
|   const frameNames = useMemo(() => { | ||||
|     if (context?.data?.length) { | ||||
|       return context.data.map((frame, index) => ({ | ||||
|         value: index, | ||||
|         label: `${getFrameDisplayName(frame, index)} (index: ${index}, rows: ${frame.length})`, | ||||
|       })); | ||||
|     } | ||||
|     return [{ value: 0, label: 'First result' }]; | ||||
|   }, [context.data]); | ||||
| 
 | ||||
|   const [selected, setSelected] = useState(0); | ||||
|   const style = useStyles2(getStyles); | ||||
| 
 | ||||
|   const onFieldChange = (val: unknown | undefined, index: number, field: string) => { | ||||
|     onChange( | ||||
|       value.map((obj, i) => { | ||||
|         if (i === index) { | ||||
|           return { ...obj, [field]: val }; | ||||
|         } | ||||
|         return obj; | ||||
|       }) | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   const createNewSeries = () => { | ||||
|     onChange([ | ||||
|       ...value, | ||||
|       { | ||||
|         pointColor: undefined, | ||||
|         pointSize: defaultFieldConfig.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 getRowStyle = (index: number) => { | ||||
|     return index === selected ? `${style.row} ${style.sel}` : style.row; | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Button icon="plus" size="sm" variant="secondary" onClick={createNewSeries} className={style.marginBot}> | ||||
|         Add series | ||||
|       </Button> | ||||
| 
 | ||||
|       <div className={style.marginBot}> | ||||
|         {value.map((series, index) => { | ||||
|           return ( | ||||
|             <div | ||||
|               key={`series/${index}`} | ||||
|               className={getRowStyle(index)} | ||||
|               onClick={() => setSelected(index)} | ||||
|               role="button" | ||||
|               aria-label={`Select series ${index + 1}`} | ||||
|               tabIndex={0} | ||||
|               onKeyPress={(e) => { | ||||
|                 if (e.key === 'Enter') { | ||||
|                   setSelected(index); | ||||
|                 } | ||||
|               }} | ||||
|             > | ||||
|               <LayerName | ||||
|                 name={series.name ?? `Series ${index + 1}`} | ||||
|                 onChange={(v) => onFieldChange(v, index, 'name')} | ||||
|               /> | ||||
| 
 | ||||
|               <IconButton | ||||
|                 name="trash-alt" | ||||
|                 title={'remove'} | ||||
|                 className={cx(style.actionIcon)} | ||||
|                 onClick={() => onSeriesDelete(index)} | ||||
|                 tooltip="Delete series" | ||||
|               /> | ||||
|             </div> | ||||
|           ); | ||||
|         })} | ||||
|       </div> | ||||
| 
 | ||||
|       {selected >= 0 && value[selected] && ( | ||||
|         <> | ||||
|           {frameNames.length > 1 && ( | ||||
|             <Field label={'Data'}> | ||||
|               <Select | ||||
|                 isClearable={false} | ||||
|                 options={frameNames} | ||||
|                 placeholder={'Change filter'} | ||||
|                 value={ | ||||
|                   frameNames.find((v) => { | ||||
|                     return v.value === value[selected].frame; | ||||
|                   }) ?? 0 | ||||
|                 } | ||||
|                 onChange={(val) => { | ||||
|                   onChange( | ||||
|                     value.map((obj, i) => { | ||||
|                       if (i === selected) { | ||||
|                         if (val === null) { | ||||
|                           return { ...value[i], frame: undefined }; | ||||
|                         } | ||||
|                         return { ...value[i], frame: val?.value!, x: undefined, y: undefined }; | ||||
|                       } | ||||
|                       return obj; | ||||
|                     }) | ||||
|                   ); | ||||
|                 }} | ||||
|               /> | ||||
|             </Field> | ||||
|           )} | ||||
|           <ScatterSeriesEditor | ||||
|             key={`series/${selected}`} | ||||
|             baseNameMode={FieldNamePickerBaseNameMode.ExcludeBaseNames} | ||||
|             item={{} as StandardEditorsRegistryItem} | ||||
|             context={context} | ||||
|             value={value[selected]} | ||||
|             onChange={(val) => { | ||||
|               onChange( | ||||
|                 value.map((obj, i) => { | ||||
|                   if (i === selected) { | ||||
|                     return val!; | ||||
|                   } | ||||
|                   return obj; | ||||
|                 }) | ||||
|               ); | ||||
|             }} | ||||
|             frameFilter={value[selected].frame ?? undefined} | ||||
|           /> | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = (theme: GrafanaTheme2) => ({ | ||||
|   marginBot: css({ | ||||
|     marginBottom: '20px', | ||||
|   }), | ||||
|   row: css({ | ||||
|     padding: `${theme.spacing(0.5, 1)}`, | ||||
|     borderRadius: `${theme.shape.radius.default}`, | ||||
|     background: `${theme.colors.background.secondary}`, | ||||
|     minHeight: `${theme.spacing(4)}`, | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'space-between', | ||||
|     marginBottom: '3px', | ||||
|     cursor: 'pointer', | ||||
| 
 | ||||
|     border: `1px solid ${theme.components.input.borderColor}`, | ||||
|     '&:hover': { | ||||
|       border: `1px solid ${theme.components.input.borderHover}`, | ||||
|     }, | ||||
|   }), | ||||
|   sel: css({ | ||||
|     border: `1px solid ${theme.colors.primary.border}`, | ||||
|     '&:hover': { | ||||
|       border: `1px solid ${theme.colors.primary.border}`, | ||||
|     }, | ||||
|   }), | ||||
|   actionIcon: css({ | ||||
|     color: `${theme.colors.text.secondary}`, | ||||
|     '&:hover': { | ||||
|       color: `${theme.colors.text}`, | ||||
|     }, | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,89 +0,0 @@ | |||
| import { StandardEditorProps, FieldNamePickerBaseNameMode } from '@grafana/data'; | ||||
| import { Field } from '@grafana/ui'; | ||||
| import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; | ||||
| import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors'; | ||||
| 
 | ||||
| import { Options, ScatterSeriesConfig } from './panelcfg.gen'; | ||||
| 
 | ||||
| export interface Props extends StandardEditorProps<ScatterSeriesConfig, unknown, Options> { | ||||
|   baseNameMode: FieldNamePickerBaseNameMode; | ||||
|   frameFilter?: number; | ||||
| } | ||||
| 
 | ||||
| export const ScatterSeriesEditor = ({ value, onChange, context, baseNameMode, frameFilter = -1 }: Props) => { | ||||
|   const onFieldChange = (val: unknown | undefined, field: string) => { | ||||
|     onChange({ ...value, [field]: val }); | ||||
|   }; | ||||
| 
 | ||||
|   const frame = context.data && frameFilter > -1 ? context.data[frameFilter] : undefined; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <Field label={'X Field'}> | ||||
|         <FieldNamePicker | ||||
|           value={value.x ?? ''} | ||||
|           context={context} | ||||
|           onChange={(field) => onFieldChange(field, 'x')} | ||||
|           item={{ | ||||
|             id: 'x', | ||||
|             name: 'x', | ||||
|             settings: { | ||||
|               filter: (field) => | ||||
|                 frame?.fields.some((obj) => obj.state?.displayName === field.state?.displayName) ?? true, | ||||
|               baseNameMode, | ||||
|               placeholderText: 'select X field', | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|       <Field label={'Y Field'}> | ||||
|         <FieldNamePicker | ||||
|           value={value.y ?? ''} | ||||
|           context={context} | ||||
|           onChange={(field) => onFieldChange(field, 'y')} | ||||
|           item={{ | ||||
|             id: 'y', | ||||
|             name: 'y', | ||||
|             settings: { | ||||
|               filter: (field) => | ||||
|                 frame?.fields.some((obj) => obj.state?.displayName === field.state?.displayName) ?? true, | ||||
|               baseNameMode, | ||||
|               placeholderText: 'select Y field', | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|       <Field label={'Point color'}> | ||||
|         <ColorDimensionEditor | ||||
|           value={value.pointColor!} | ||||
|           context={context} | ||||
|           onChange={(field) => onFieldChange(field, 'pointColor')} | ||||
|           item={{ | ||||
|             id: 'x', | ||||
|             name: 'x', | ||||
|             settings: { | ||||
|               baseNameMode, | ||||
|               isClearable: true, | ||||
|               placeholder: 'Use standard color scheme', | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|       <Field label={'Point size'}> | ||||
|         <ScaleDimensionEditor | ||||
|           value={value.pointSize!} | ||||
|           context={context} | ||||
|           onChange={(field) => onFieldChange(field, 'pointSize')} | ||||
|           item={{ | ||||
|             id: 'x', | ||||
|             name: 'x', | ||||
|             settings: { | ||||
|               min: 1, | ||||
|               max: 100, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Field> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,29 +0,0 @@ | |||
| import { StandardEditorProps } from '@grafana/data'; | ||||
| import { ResourceDimensionConfig, ResourceDimensionMode } from '@grafana/schema'; | ||||
| import { RadioButtonGroup } from '@grafana/ui'; | ||||
| import { ResourceDimensionOptions } from 'app/features/dimensions'; | ||||
| 
 | ||||
| export const SymbolEditor = ( | ||||
|   props: StandardEditorProps<ResourceDimensionConfig, ResourceDimensionOptions, unknown> | ||||
| ) => { | ||||
|   const { value } = props; | ||||
| 
 | ||||
|   const basicSymbols = [ | ||||
|     { value: 'img/icons/marker/circle.svg', label: 'Circle' }, | ||||
|     { value: 'img/icons/marker/square.svg', label: 'Square' }, | ||||
|   ]; | ||||
| 
 | ||||
|   const onSymbolChange = (v: string) => { | ||||
|     props.onChange({ | ||||
|       fixed: v, | ||||
|       mode: ResourceDimensionMode.Fixed, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div> | ||||
|       <RadioButtonGroup options={basicSymbols} value={value.fixed} onChange={onSymbolChange} /> | ||||
|       {!basicSymbols.find((v) => v.value === value.fixed) && <div>{value.fixed}</div>} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,117 +1,103 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { useState, useEffect, useCallback } from 'react'; | ||||
| import { usePrevious } from 'react-use'; | ||||
| import { useMemo } from 'react'; | ||||
| 
 | ||||
| import { PanelProps } from '@grafana/data'; | ||||
| import { FALLBACK_COLOR, PanelProps } from '@grafana/data'; | ||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { | ||||
|   TooltipDisplayMode, | ||||
|   TooltipPlugin2, | ||||
|   UPlotChart, | ||||
|   UPlotConfigBuilder, | ||||
|   useTheme2, | ||||
|   VizLayout, | ||||
|   VizLegend, | ||||
|   VizLegendItem, | ||||
|   useStyles2, | ||||
|   useTheme2, | ||||
| } from '@grafana/ui'; | ||||
| import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; | ||||
| import { FacetedData } from '@grafana/ui/src/components/uPlot/types'; | ||||
| import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils'; | ||||
| 
 | ||||
| import { XYChartTooltip } from './XYChartTooltip'; | ||||
| import { Options, SeriesMapping } from './panelcfg.gen'; | ||||
| import { prepData, prepScatter, ScatterPanelInfo } from './scatter'; | ||||
| import { ScatterSeries } from './types'; | ||||
| import { Options } from './panelcfg.gen'; | ||||
| import { prepConfig } from './scatter'; | ||||
| import { prepSeries } from './utils'; | ||||
| 
 | ||||
| type Props = PanelProps<Options>; | ||||
| type Props2 = PanelProps<Options>; | ||||
| 
 | ||||
| export const XYChartPanel = (props: Props) => { | ||||
| export const XYChartPanel2 = (props: Props2) => { | ||||
|   const styles = useStyles2(getStyles); | ||||
|   const theme = useTheme2(); | ||||
| 
 | ||||
|   const [error, setError] = useState<string | undefined>(); | ||||
|   const [series, setSeries] = useState<ScatterSeries[]>([]); | ||||
|   const [builder, setBuilder] = useState<UPlotConfigBuilder | undefined>(); | ||||
|   const [facets, setFacets] = useState<FacetedData | undefined>(); | ||||
|   let { mapping, series: mappedSeries } = props.options; | ||||
| 
 | ||||
|   const oldOptions = usePrevious(props.options); | ||||
|   const oldData = usePrevious(props.data); | ||||
| 
 | ||||
|   const initSeries = useCallback(() => { | ||||
|     const getData = () => props.data.series; | ||||
|     const info: ScatterPanelInfo = prepScatter(props.options, getData, config.theme2); | ||||
| 
 | ||||
|     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)); | ||||
|       setError(undefined); | ||||
|     } | ||||
|   }, [props.data.series, props.options]); | ||||
| 
 | ||||
|   const initFacets = useCallback(() => { | ||||
|     setFacets(() => prepData({ error, series }, props.data.series)); | ||||
|   }, [props.data.series, error, series]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (oldOptions !== props.options || oldData?.structureRev !== props.data.structureRev) { | ||||
|       initSeries(); | ||||
|     } else if (oldData?.series !== props.data.series) { | ||||
|       initFacets(); | ||||
|     } | ||||
|   // regenerate series schema when mappings or data changes
 | ||||
|   let series = useMemo( | ||||
|     () => prepSeries(mapping, mappedSeries, props.data.series, props.fieldConfig), | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|   }, [props]); | ||||
|     [mapping, mappedSeries, props.data.series, props.fieldConfig] | ||||
|   ); | ||||
| 
 | ||||
|   // if series changed due to mappings or data structure, re-init config & renderers
 | ||||
|   let { builder, prepData } = useMemo( | ||||
|     () => prepConfig(series, config.theme2), | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [mapping, mappedSeries, props.data.structureRev, props.fieldConfig, props.options.tooltip] | ||||
|   ); | ||||
| 
 | ||||
|   // generate data struct for uPlot mode: 2
 | ||||
|   let data = useMemo( | ||||
|     () => prepData(series), | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [series] | ||||
|   ); | ||||
| 
 | ||||
|   // todo: handle errors
 | ||||
|   let error = builder == null || data.length === 0 ? 'Err' : ''; | ||||
| 
 | ||||
|   // TODO: React.memo()
 | ||||
|   const renderLegend = () => { | ||||
|     const items: VizLegendItem[] = []; | ||||
| 
 | ||||
|     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()) { | ||||
|           const field = s.y(frame); | ||||
|           item.getDisplayValues = () => getDisplayValuesForCalcs(props.options.legend.calcs, field, theme); | ||||
|           item.disabled = !(s.show ?? true); | ||||
| 
 | ||||
|           if (props.options.seriesMapping === SeriesMapping.Manual) { | ||||
|             item.label = props.options.series?.[si]?.name ?? `Series ${si + 1}`; | ||||
|           } | ||||
| 
 | ||||
|           item.color = alpha(s.lineColor(frame) as string, 1); | ||||
| 
 | ||||
|           items.push(item); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (!props.options.legend.showLegend) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const legendStyle = { | ||||
|       flexStart: css({ | ||||
|         div: { | ||||
|           justifyContent: 'flex-start', | ||||
|         }, | ||||
|       }), | ||||
|     }; | ||||
|     const items: VizLegendItem[] = []; | ||||
| 
 | ||||
|     series.forEach((s, idx) => { | ||||
|       let yField = s.y.field; | ||||
|       let config = yField.config; | ||||
|       let custom = config.custom; | ||||
| 
 | ||||
|       if (!custom.hideFrom?.legend) { | ||||
|         items.push({ | ||||
|           yAxis: 1, // TODO: pull from y field
 | ||||
|           label: s.name.value, | ||||
|           color: alpha(s.color.fixed ?? FALLBACK_COLOR, 1), | ||||
|           getItemKey: () => `${idx}-${s.name.value}`, | ||||
|           fieldName: yField.state?.displayName ?? yField.name, | ||||
|           disabled: yField.state?.hideFrom?.viz ?? false, | ||||
|           getDisplayValues: () => getDisplayValuesForCalcs(props.options.legend.calcs, yField, theme), | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     const { placement, displayMode, width, sortBy, sortDesc } = props.options.legend; | ||||
| 
 | ||||
|     return ( | ||||
|       <VizLayout.Legend placement={props.options.legend.placement} width={props.options.legend.width}> | ||||
|       <VizLayout.Legend placement={placement} width={width}> | ||||
|         <VizLegend | ||||
|           className={legendStyle.flexStart} | ||||
|           placement={props.options.legend.placement} | ||||
|           className={styles.legend} | ||||
|           placement={placement} | ||||
|           items={items} | ||||
|           displayMode={props.options.legend.displayMode} | ||||
|           displayMode={displayMode} | ||||
|           sortBy={sortBy} | ||||
|           sortDesc={sortDesc} | ||||
|           isSortable={true} | ||||
|         /> | ||||
|       </VizLayout.Legend> | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   if (error || !builder || !facets) { | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div className="panel-empty"> | ||||
|         <p>{error}</p> | ||||
|  | @ -120,33 +106,39 @@ export const XYChartPanel = (props: Props) => { | |||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <VizLayout width={props.width} height={props.height} legend={renderLegend()}> | ||||
|         {(vizWidth: number, vizHeight: number) => ( | ||||
|           <UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight}> | ||||
|             {props.options.tooltip.mode !== TooltipDisplayMode.None && ( | ||||
|               <TooltipPlugin2 | ||||
|                 config={builder} | ||||
|                 hoverMode={TooltipHoverMode.xyOne} | ||||
|                 render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { | ||||
|                   return ( | ||||
|                     <XYChartTooltip | ||||
|                       data={props.data.series} | ||||
|                       dataIdxs={dataIdxs} | ||||
|                       allSeries={series} | ||||
|                       dismiss={dismiss} | ||||
|                       isPinned={isPinned} | ||||
|                       options={props.options} | ||||
|                       seriesIdx={seriesIdx} | ||||
|                     /> | ||||
|                   ); | ||||
|                 }} | ||||
|                 maxWidth={props.options.tooltip.maxWidth} | ||||
|               /> | ||||
|             )} | ||||
|           </UPlotChart> | ||||
|         )} | ||||
|       </VizLayout> | ||||
|     </> | ||||
|     <VizLayout width={props.width} height={props.height} legend={renderLegend()}> | ||||
|       {(vizWidth: number, vizHeight: number) => ( | ||||
|         <UPlotChart config={builder!} data={data} width={vizWidth} height={vizHeight}> | ||||
|           {props.options.tooltip.mode !== TooltipDisplayMode.None && ( | ||||
|             <TooltipPlugin2 | ||||
|               config={builder!} | ||||
|               hoverMode={TooltipHoverMode.xyOne} | ||||
|               render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { | ||||
|                 return ( | ||||
|                   <XYChartTooltip | ||||
|                     data={props.data.series} | ||||
|                     dataIdxs={dataIdxs} | ||||
|                     xySeries={series} | ||||
|                     dismiss={dismiss} | ||||
|                     isPinned={isPinned} | ||||
|                     seriesIdx={seriesIdx!} | ||||
|                     replaceVariables={props.replaceVariables} | ||||
|                   /> | ||||
|                 ); | ||||
|               }} | ||||
|               maxWidth={props.options.tooltip.maxWidth} | ||||
|             /> | ||||
|           )} | ||||
|         </UPlotChart> | ||||
|       )} | ||||
|     </VizLayout> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = () => ({ | ||||
|   legend: css({ | ||||
|     div: { | ||||
|       justifyContent: 'flex-start', | ||||
|     }, | ||||
|   }), | ||||
| }); | ||||
|  |  | |||
|  | @ -1,179 +0,0 @@ | |||
| import { render } from '@testing-library/react'; | ||||
| 
 | ||||
| import { DataFrame, FieldType, ValueLinkConfig, LinkTarget } from '@grafana/data'; | ||||
| import { SortOrder, VisibilityMode } from '@grafana/schema'; | ||||
| import { LegendDisplayMode, TooltipDisplayMode } from '@grafana/ui'; | ||||
| 
 | ||||
| import { XYChartTooltip, Props } from './XYChartTooltip'; | ||||
| import { ScatterSeries } from './types'; | ||||
| 
 | ||||
| describe('XYChartTooltip', () => { | ||||
|   it('should render null when `allSeries` is empty', () => { | ||||
|     const { container } = render(<XYChartTooltip {...getProps()} />); | ||||
| 
 | ||||
|     expect(container.firstChild).toBeNull(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render null when `dataIdxs` is null', () => { | ||||
|     const { container } = render(<XYChartTooltip {...getProps({ dataIdxs: [null] })} />); | ||||
| 
 | ||||
|     expect(container.firstChild).toBeNull(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render the tooltip header label with series name', () => { | ||||
|     const seriesName = 'seriesName_1'; | ||||
|     const { getByText } = render( | ||||
|       <XYChartTooltip | ||||
|         {...getProps({ allSeries: buildAllSeries(seriesName), data: buildData(), dataIdxs: [1], seriesIdx: 1 })} | ||||
|       /> | ||||
|     ); | ||||
| 
 | ||||
|     expect(getByText(seriesName)).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render the tooltip content with x and y field names and values', () => { | ||||
|     const field1Name = 'test_field_1'; | ||||
|     const field2Name = 'test_field_2'; | ||||
|     const { getByText } = render( | ||||
|       <XYChartTooltip | ||||
|         {...getProps({ | ||||
|           allSeries: buildAllSeries(), | ||||
|           data: buildData({ field1Name, field2Name }), | ||||
|           dataIdxs: [1], | ||||
|           seriesIdx: 1, | ||||
|         })} | ||||
|       /> | ||||
|     ); | ||||
| 
 | ||||
|     expect(getByText(field1Name)).toBeInTheDocument(); | ||||
|     expect(getByText('32.799')).toBeInTheDocument(); | ||||
|     expect(getByText(field2Name)).toBeInTheDocument(); | ||||
|     expect(getByText(300)).toBeInTheDocument(); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render the tooltip footer with data links', () => { | ||||
|     const dataLinkTitle = 'Google'; | ||||
|     const { getByText } = render( | ||||
|       <XYChartTooltip | ||||
|         {...getProps({ | ||||
|           allSeries: buildAllSeries(), | ||||
|           data: buildData({ dataLinkTitle }), | ||||
|           dataIdxs: [1], | ||||
|           seriesIdx: 1, | ||||
|           isPinned: true, | ||||
|         })} | ||||
|       /> | ||||
|     ); | ||||
| 
 | ||||
|     expect(getByText(dataLinkTitle)).toBeInTheDocument(); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| function getProps(additionalProps: Partial<Props> | null = null): Props { | ||||
|   if (!additionalProps) { | ||||
|     return getDefaultProps(); | ||||
|   } | ||||
| 
 | ||||
|   return { ...getDefaultProps(), ...additionalProps }; | ||||
| } | ||||
| 
 | ||||
| function getDefaultProps(): Props { | ||||
|   return { | ||||
|     data: [], | ||||
|     allSeries: [], | ||||
|     dataIdxs: [], | ||||
|     seriesIdx: null, | ||||
|     isPinned: false, | ||||
|     dismiss: jest.fn(), | ||||
|     options: { | ||||
|       dims: { | ||||
|         frame: 0, | ||||
|       }, | ||||
|       series: [], | ||||
|       legend: { | ||||
|         calcs: [], | ||||
|         displayMode: LegendDisplayMode.List, | ||||
|         placement: 'bottom', | ||||
|         showLegend: true, | ||||
|       }, | ||||
|       tooltip: { | ||||
|         mode: TooltipDisplayMode.Single, | ||||
|         sort: SortOrder.Ascending, | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function buildAllSeries(testSeriesName = 'test'): ScatterSeries[] { | ||||
|   return [ | ||||
|     { | ||||
|       name: testSeriesName, | ||||
|       legend: jest.fn(), | ||||
|       frame: (frames: DataFrame[]) => frames[0], | ||||
|       x: (frame: DataFrame) => frame.fields[0], | ||||
|       y: (frame: DataFrame) => frame.fields[1], | ||||
|       pointColor: (_frame: DataFrame) => '#111', | ||||
|       showLine: false, | ||||
|       lineWidth: 1, | ||||
|       lineStyle: {}, | ||||
|       lineColor: jest.fn(), | ||||
|       showPoints: VisibilityMode.Always, | ||||
|       pointSize: jest.fn(), | ||||
|       pointSymbol: jest.fn(), | ||||
|       label: VisibilityMode.Always, | ||||
|       labelValue: jest.fn(), | ||||
|       show: true, | ||||
|       hints: { | ||||
|         pointSize: { fixed: 10, max: 10, min: 1 }, | ||||
|         pointColor: { | ||||
|           mode: { | ||||
|             id: 'threshold', | ||||
|             name: 'Threshold', | ||||
|             getCalculator: jest.fn(), | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| function buildData({ dataLinkTitle = 'Grafana', field1Name = 'field_1', field2Name = 'field_2' } = {}): DataFrame[] { | ||||
|   return [ | ||||
|     { | ||||
|       fields: [ | ||||
|         { | ||||
|           name: field1Name, | ||||
|           type: FieldType.number, | ||||
|           config: {}, | ||||
|           values: [ | ||||
|             61.385, 32.799, 33.7712, 36.17, 39.0646, 27.8333, 42.0046, 40.3363, 39.8647, 37.669, 42.2373, 43.3504, | ||||
|             35.6411, 40.314, 34.8375, 40.3736, 44.5672, | ||||
|           ], | ||||
|         }, | ||||
|         { | ||||
|           name: field2Name, | ||||
|           type: FieldType.number, | ||||
|           config: { | ||||
|             links: [ | ||||
|               { | ||||
|                 title: dataLinkTitle, | ||||
|                 targetBlank: true, | ||||
|                 url: 'http://www.someWebsite.com', | ||||
|               }, | ||||
|             ], | ||||
|           }, | ||||
|           values: [500, 300, 150, 250, 600, 500, 700, 400, 540, 630, 460, 250, 500, 400, 800, 930, 360], | ||||
|           getLinks: (_config: ValueLinkConfig) => [ | ||||
|             { | ||||
|               href: 'http://www.someWebsite.com', | ||||
|               title: dataLinkTitle, | ||||
|               target: '_blank' as LinkTarget, | ||||
|               origin: { name: '', type: FieldType.boolean, config: {}, values: [] }, | ||||
|             }, | ||||
|           ], | ||||
|         }, | ||||
|       ], | ||||
|       length: 17, | ||||
|     }, | ||||
|   ]; | ||||
| } | ||||
|  | @ -1,6 +1,6 @@ | |||
| import { ReactNode } from 'react'; | ||||
| 
 | ||||
| import { DataFrame, Field, getFieldDisplayName } from '@grafana/data'; | ||||
| import { DataFrame, InterpolateFunction } from '@grafana/data'; | ||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||
| import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; | ||||
| import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; | ||||
|  | @ -8,10 +8,9 @@ import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizToolt | |||
| import { VizTooltipWrapper } from '@grafana/ui/src/components/VizTooltip/VizTooltipWrapper'; | ||||
| import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; | ||||
| 
 | ||||
| import { getDataLinks } from '../status-history/utils'; | ||||
| import { getDataLinks, getFieldActions } from '../status-history/utils'; | ||||
| 
 | ||||
| import { Options } from './panelcfg.gen'; | ||||
| import { ScatterSeries } from './types'; | ||||
| import { XYSeries } from './types2'; | ||||
| import { fmt } from './utils'; | ||||
| 
 | ||||
| export interface Props { | ||||
|  | @ -19,73 +18,87 @@ export interface Props { | |||
|   seriesIdx: number | null | undefined; | ||||
|   isPinned: boolean; | ||||
|   dismiss: () => void; | ||||
|   options: Options; | ||||
|   data: DataFrame[]; // source data
 | ||||
|   allSeries: ScatterSeries[]; | ||||
|   data: DataFrame[]; | ||||
|   xySeries: XYSeries[]; | ||||
|   replaceVariables: InterpolateFunction; | ||||
| } | ||||
| 
 | ||||
| export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss, options, isPinned }: Props) => { | ||||
|   const rowIndex = dataIdxs.find((idx) => idx !== null); | ||||
|   // @todo: remove -1 when uPlot v2 arrive
 | ||||
|   // context: first value in dataIdxs always null and represent X series
 | ||||
|   const hoveredPointIndex = seriesIdx! - 1; | ||||
| 
 | ||||
|   if (!allSeries || rowIndex == null) { | ||||
|     return null; | ||||
| function stripSeriesName(fieldName: string, seriesName: string) { | ||||
|   if (fieldName !== seriesName && fieldName.includes(' ')) { | ||||
|     fieldName = fieldName.replace(seriesName, '').trim(); | ||||
|   } | ||||
| 
 | ||||
|   const series = allSeries[hoveredPointIndex]; | ||||
|   const frame = series.frame(data); | ||||
|   const xField = series.x(frame); | ||||
|   const yField = series.y(frame); | ||||
|   return fieldName; | ||||
| } | ||||
| 
 | ||||
|   let label = series.name; | ||||
|   if (options.seriesMapping === 'manual') { | ||||
|     label = options.series?.[hoveredPointIndex]?.name ?? `Series ${hoveredPointIndex + 1}`; | ||||
|   } | ||||
| export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, isPinned, replaceVariables }: Props) => { | ||||
|   const rowIndex = dataIdxs.find((idx) => idx !== null)!; | ||||
| 
 | ||||
|   let colorThing = series.pointColor(frame); | ||||
|   const series = xySeries[seriesIdx! - 1]; | ||||
|   const xField = series.x.field; | ||||
|   const yField = series.y.field; | ||||
| 
 | ||||
|   if (Array.isArray(colorThing)) { | ||||
|     colorThing = colorThing[rowIndex]; | ||||
|   } | ||||
|   const sizeField = series.size.field; | ||||
|   const colorField = series.color.field; | ||||
| 
 | ||||
|   let label = series.name.value; | ||||
| 
 | ||||
|   let seriesColor = series.color.fixed; | ||||
|   // let colorField = series.color.field;
 | ||||
|   // let pointColor: string;
 | ||||
| 
 | ||||
|   // if (colorField != null) {
 | ||||
|   //   pointColor = colorField.display?.(colorField.values[rowIndex]).color!;
 | ||||
|   // }
 | ||||
| 
 | ||||
|   const headerItem: VizTooltipItem = { | ||||
|     label, | ||||
|     value: '', | ||||
|     // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
 | ||||
|     color: alpha(colorThing as string, 0.5), | ||||
|     color: alpha(seriesColor ?? '#fff', 0.5), | ||||
|     colorIndicator: ColorIndicator.marker_md, | ||||
|   }; | ||||
| 
 | ||||
|   const contentItems: VizTooltipItem[] = [ | ||||
|     { | ||||
|       label: getFieldDisplayName(xField, frame), | ||||
|       label: stripSeriesName(xField.state?.displayName ?? xField.name, label), | ||||
|       value: fmt(xField, xField.values[rowIndex]), | ||||
|     }, | ||||
|     { | ||||
|       label: getFieldDisplayName(yField, frame), | ||||
|       label: stripSeriesName(yField.state?.displayName ?? yField.name, label), | ||||
|       value: fmt(yField, yField.values[rowIndex]), | ||||
|     }, | ||||
|   ]; | ||||
| 
 | ||||
|   // add extra fields
 | ||||
|   const extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField); | ||||
|   if (extraFields) { | ||||
|     extraFields.forEach((field) => { | ||||
|       contentItems.push({ | ||||
|         label: field.name, | ||||
|         value: fmt(field, field.values[rowIndex]), | ||||
|       }); | ||||
|   // mapped fields for size/color
 | ||||
|   if (sizeField != null && sizeField !== yField) { | ||||
|     contentItems.push({ | ||||
|       label: stripSeriesName(sizeField.state?.displayName ?? sizeField.name, label), | ||||
|       value: fmt(sizeField, sizeField.values[rowIndex]), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   if (colorField != null && colorField !== yField) { | ||||
|     contentItems.push({ | ||||
|       label: stripSeriesName(colorField.state?.displayName ?? colorField.name, label), | ||||
|       value: fmt(colorField, colorField.values[rowIndex]), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   series._rest.forEach((field) => { | ||||
|     contentItems.push({ | ||||
|       label: stripSeriesName(field.state?.displayName ?? field.name, label), | ||||
|       value: fmt(field, field.values[rowIndex]), | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   let footer: ReactNode; | ||||
| 
 | ||||
|   if (isPinned && seriesIdx != null) { | ||||
|     const links = getDataLinks(yField, rowIndex); | ||||
|     const yFieldFrame = data.find((frame) => frame.fields.includes(yField))!; | ||||
|     const actions = getFieldActions(yFieldFrame, yField, replaceVariables, rowIndex); | ||||
| 
 | ||||
|     footer = <VizTooltipFooter dataLinks={links} />; | ||||
|     footer = <VizTooltipFooter dataLinks={links} actions={actions} />; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ import { commonOptionsBuilder } from '@grafana/ui'; | |||
| 
 | ||||
| import { LineStyleEditor } from '../timeseries/LineStyleEditor'; | ||||
| 
 | ||||
| import { FieldConfig, ScatterShow } from './panelcfg.gen'; | ||||
| import { FieldConfig, XYShowMode, PointShape } from './panelcfg.gen'; | ||||
| 
 | ||||
| export const DEFAULT_POINT_SIZE = 5; | ||||
| 
 | ||||
|  | @ -58,9 +58,9 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr | |||
|           defaultValue: cfg.show, | ||||
|           settings: { | ||||
|             options: [ | ||||
|               { label: 'Points', value: ScatterShow.Points }, | ||||
|               { label: 'Lines', value: ScatterShow.Lines }, | ||||
|               { label: 'Both', value: ScatterShow.PointsAndLines }, | ||||
|               { label: 'Points', value: XYShowMode.Points }, | ||||
|               { label: 'Lines', value: XYShowMode.Lines }, | ||||
|               { label: 'Both', value: XYShowMode.PointsAndLines }, | ||||
|             ], | ||||
|           }, | ||||
|         }) | ||||
|  | @ -92,24 +92,56 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr | |||
|             max: 100, | ||||
|             step: 1, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== ScatterShow.Lines, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addNumberInput({ | ||||
|           path: 'pointSize.min', | ||||
|           name: 'Min point size', | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addNumberInput({ | ||||
|           path: 'pointSize.max', | ||||
|           name: 'Max point size', | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addRadio({ | ||||
|           path: 'pointShape', | ||||
|           name: 'Point shape', | ||||
|           defaultValue: PointShape.Circle, | ||||
|           settings: { | ||||
|             options: [ | ||||
|               { value: PointShape.Circle, label: 'Circle' }, | ||||
|               { value: PointShape.Square, label: 'Square' }, | ||||
|             ], | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addSliderInput({ | ||||
|           path: 'pointStrokeWidth', | ||||
|           name: 'Point stroke width', | ||||
|           defaultValue: 1, | ||||
|           settings: { | ||||
|             min: 0, | ||||
|             max: 10, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addSliderInput({ | ||||
|           path: 'fillOpacity', | ||||
|           name: 'Fill opacity', | ||||
|           defaultValue: 50, | ||||
|           settings: { | ||||
|             min: 0, | ||||
|             max: 100, | ||||
|             step: 1, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         // .addSliderInput({
 | ||||
|         //   path: 'fillOpacity',
 | ||||
|         //   name: 'Fill opacity',
 | ||||
|         //   defaultValue: 0.4, // defaultFieldConfig.fillOpacity,
 | ||||
|         //   settings: {
 | ||||
|         //     min: 0, // hidden?  or just outlines?
 | ||||
|         //     max: 1,
 | ||||
|         //     step: 0.05,
 | ||||
|         //   },
 | ||||
|         //   showIf: (c) => c.show !== ScatterShow.Lines,
 | ||||
|         // })
 | ||||
|         .addCustomEditor<void, LineStyle>({ | ||||
|           id: 'lineStyle', | ||||
|           path: 'lineStyle', | ||||
|           name: 'Line style', | ||||
|           showIf: (c) => c.show !== ScatterShow.Points, | ||||
|           showIf: (c) => c.show !== XYShowMode.Points, | ||||
|           editor: LineStyleEditor, | ||||
|           override: LineStyleEditor, | ||||
|           process: identityOverrideProcessor, | ||||
|  | @ -124,7 +156,7 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr | |||
|             max: 10, | ||||
|             step: 1, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== ScatterShow.Points, | ||||
|           showIf: (c) => c.show !== XYShowMode.Points, | ||||
|         }); | ||||
| 
 | ||||
|       commonOptionsBuilder.addAxisConfig(builder, cfg); | ||||
|  |  | |||
|  | @ -1,106 +0,0 @@ | |||
| import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data'; | ||||
| import { XYFieldMatchers } from 'app/core/components/GraphNG/types'; | ||||
| 
 | ||||
| import { XYDimensionConfig } from './panelcfg.gen'; | ||||
| 
 | ||||
| // TODO: fix import
 | ||||
| 
 | ||||
| export enum DimensionError { | ||||
|   NoData, | ||||
|   BadFrameSelection, | ||||
|   XNotFound, | ||||
| } | ||||
| 
 | ||||
| export interface XYDimensions { | ||||
|   frame: DataFrame; // matches order from configs, excluds non-graphable values
 | ||||
|   x: Field; | ||||
|   fields: XYFieldMatchers; | ||||
|   hasData?: boolean; | ||||
|   hasTime?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface XYDimensionsError { | ||||
|   error: DimensionError; | ||||
| } | ||||
| 
 | ||||
| export function isGraphable(field: Field) { | ||||
|   return field.type === FieldType.number; | ||||
| } | ||||
| 
 | ||||
| export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions | XYDimensionsError { | ||||
|   if (!data || !data.length) { | ||||
|     return { error: DimensionError.NoData }; | ||||
|   } | ||||
|   if (!cfg) { | ||||
|     cfg = { | ||||
|       frame: 0, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   let frame = data[cfg.frame ?? 0]; | ||||
|   if (!frame) { | ||||
|     return { error: DimensionError.BadFrameSelection }; | ||||
|   } | ||||
| 
 | ||||
|   let xIndex = -1; | ||||
|   for (let i = 0; i < frame.fields.length; i++) { | ||||
|     const f = frame.fields[i]; | ||||
|     if (cfg.x && cfg.x === getFieldDisplayName(f, frame, data)) { | ||||
|       xIndex = i; | ||||
|       break; | ||||
|     } | ||||
|     if (isGraphable(f) && !cfg.x) { | ||||
|       xIndex = i; | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   let hasTime = false; | ||||
|   const x = frame.fields[xIndex]; | ||||
|   const fields: Field[] = [x]; | ||||
|   for (const f of frame.fields) { | ||||
|     if (f.type === FieldType.time) { | ||||
|       hasTime = true; | ||||
|     } | ||||
|     if (f === x || !isGraphable(f)) { | ||||
|       continue; | ||||
|     } | ||||
|     if (cfg.exclude) { | ||||
|       const name = getFieldDisplayName(f, frame, data); | ||||
|       if (cfg.exclude.includes(name)) { | ||||
|         continue; | ||||
|       } | ||||
|     } | ||||
|     fields.push(f); | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     x, | ||||
|     fields: { | ||||
|       x: getSimpleFieldMatcher(x), | ||||
|       y: getSimpleFieldNotMatcher(x), // Not x
 | ||||
|     }, | ||||
|     frame: { | ||||
|       ...frame, | ||||
|       fields, | ||||
|     }, | ||||
|     hasData: frame.fields.length > 0, | ||||
|     hasTime, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function getSimpleFieldMatcher(f: Field): FieldMatcher { | ||||
|   if (!f) { | ||||
|     return () => false; | ||||
|   } | ||||
|   // the field may change if sorted
 | ||||
|   return (field) => f === field || !!(f.state && f.state === field.state); | ||||
| } | ||||
| 
 | ||||
| function getSimpleFieldNotMatcher(f: Field): FieldMatcher { | ||||
|   if (!f) { | ||||
|     return () => false; | ||||
|   } | ||||
|   const m = getSimpleFieldMatcher(f); | ||||
|   return (field) => !m(field, { fields: [], length: 0 }, []); | ||||
| } | ||||
|  | @ -1,8 +1,7 @@ | |||
| import { FieldMatcherID, FrameMatcherID, MatcherConfig, PanelModel } from '@grafana/data'; | ||||
| 
 | ||||
| import { ScatterSeriesConfig, SeriesMapping, XYDimensionConfig, Options as PrevOptions } from '../panelcfg.gen'; | ||||
| 
 | ||||
| import { XYSeriesConfig, Options } from './panelcfg.gen'; | ||||
| import { ScatterSeriesConfig, SeriesMapping, XYDimensionConfig, Options as PrevOptions } from './panelcfgold.gen'; | ||||
| 
 | ||||
| export const xyChartMigrationHandler = (panel: PanelModel): Options => { | ||||
|   const pluginVersion = panel?.pluginVersion ?? ''; | ||||
|  | @ -1,41 +1,35 @@ | |||
| import { PanelPlugin } from '@grafana/data'; | ||||
| import { commonOptionsBuilder } from '@grafana/ui'; | ||||
| 
 | ||||
| import { AutoEditor } from './AutoEditor'; | ||||
| import { ManualEditor } from './ManualEditor'; | ||||
| import { XYChartPanel } from './XYChartPanel'; | ||||
| import { SeriesEditor } from './SeriesEditor'; | ||||
| import { XYChartPanel2 } from './XYChartPanel'; | ||||
| import { getScatterFieldConfig } from './config'; | ||||
| import { Options, FieldConfig, defaultFieldConfig } from './panelcfg.gen'; | ||||
| import { xyChartMigrationHandler } from './migrations'; | ||||
| import { FieldConfig, defaultFieldConfig, Options } from './panelcfg.gen'; | ||||
| 
 | ||||
| export const plugin = new PanelPlugin<Options, FieldConfig>(XYChartPanel) | ||||
| export const plugin = new PanelPlugin<Options, FieldConfig>(XYChartPanel2) | ||||
|   // .setPanelChangeHandler(xyChartChangeHandler)
 | ||||
|   .setMigrationHandler(xyChartMigrationHandler) | ||||
|   .useFieldConfig(getScatterFieldConfig(defaultFieldConfig)) | ||||
|   .setPanelOptions((builder) => { | ||||
|     builder | ||||
|       .addRadio({ | ||||
|         path: 'seriesMapping', | ||||
|         path: 'mapping', | ||||
|         name: 'Series mapping', | ||||
|         defaultValue: 'auto', | ||||
|         settings: { | ||||
|           options: [ | ||||
|             { value: 'auto', label: 'Table', description: 'Plot values within a single table result' }, | ||||
|             { value: 'manual', label: 'Manual', description: 'Construct values from any result' }, | ||||
|             { value: 'auto', label: 'Auto' }, | ||||
|             { value: 'manual', label: 'Manual' }, | ||||
|           ], | ||||
|         }, | ||||
|       }) | ||||
|       .addCustomEditor({ | ||||
|         id: 'xyPlotConfig', | ||||
|         path: 'dims', | ||||
|         name: '', | ||||
|         editor: AutoEditor, | ||||
|         showIf: (cfg) => cfg.seriesMapping === 'auto', | ||||
|       }) | ||||
|       .addCustomEditor({ | ||||
|         id: 'series', | ||||
|         path: 'series', | ||||
|         name: '', | ||||
|         defaultValue: [], | ||||
|         editor: ManualEditor, | ||||
|         showIf: (cfg) => cfg.seriesMapping === 'manual', | ||||
|         editor: SeriesEditor, | ||||
|         defaultValue: [{}], | ||||
|       }); | ||||
| 
 | ||||
|     commonOptionsBuilder.addTooltipOptions(builder, true); | ||||
|  |  | |||
|  | @ -25,55 +25,58 @@ composableKinds: PanelCfg: { | |||
| 		schemas: [{ | ||||
| 			version: [0, 0] | ||||
| 			schema: { | ||||
| 				// Auto is "table" in the UI | ||||
| 				PointShape:    "circle" | "square"                 @cuetsy(kind="enum") | ||||
| 				SeriesMapping: "auto" | "manual"                   @cuetsy(kind="enum") | ||||
| 				ScatterShow:   "points" | "lines" | "points+lines" @cuetsy(kind="enum", memberNames="Points|Lines|PointsAndLines") | ||||
| 				XYShowMode:    "points" | "lines" | "points+lines" @cuetsy(kind="enum", memberNames="Points|Lines|PointsAndLines") | ||||
| 
 | ||||
| 				// Configuration for the Table/Auto mode | ||||
| 				XYDimensionConfig: { | ||||
| 					frame: int32 & >=0 | ||||
| 					x?:    string | ||||
| 					exclude?: [...string] | ||||
| 				} @cuetsy(kind="interface") | ||||
| 				// NOTE: (copied from dashboard_kind.cue, since not exported) | ||||
| 				// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. | ||||
| 				// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | ||||
| 				#MatcherConfig: { | ||||
| 					// The matcher id. This is used to find the matcher implementation from registry. | ||||
| 					id: string | *"" @grafanamaturity(NeedsExpertReview)
 | ||||
| 					// The matcher options. This is specific to the matcher implementation. | ||||
| 					options?: _ @grafanamaturity(NeedsExpertReview) | ||||
| 				} @cuetsy(kind="interface") @grafana(TSVeneer="type") | ||||
| 
 | ||||
| 				FieldConfig: { | ||||
| 					common.HideableFieldConfig | ||||
| 					common.AxisConfig | ||||
| 
 | ||||
| 					show?: ScatterShow & (*"points" | _) | ||||
| 					show?: XYShowMode & (*"points" | _) | ||||
| 
 | ||||
| 					pointSize?:  common.ScaleDimensionConfig | ||||
| 					pointColor?: common.ColorDimensionConfig | ||||
| 					// pointSymbol?: common.ResourceDimensionConfig | ||||
| 					// fillOpacity?: number & >=0 & <=1 | *0.5 | ||||
| 					pointSize?:  { | ||||
| 						fixed?: int32 & >=0 | ||||
| 						min?:   int32 & >=0 | ||||
| 						max?:   int32 & >=0 | ||||
| 					} | ||||
| 
 | ||||
| 					pointShape?: PointShape | ||||
| 
 | ||||
| 					pointStrokeWidth?: int32 & >=0 | ||||
| 
 | ||||
| 					fillOpacity?: uint32 & <=100 | *50 | ||||
| 
 | ||||
| 					lineColor?: common.ColorDimensionConfig | ||||
| 					lineWidth?: int32 & >=0 | ||||
| 					lineStyle?: common.LineStyle | ||||
| 
 | ||||
| 					label?:      common.VisibilityMode & (*"auto" | _) | ||||
| 					labelValue?: common.TextDimensionConfig | ||||
| 				} @cuetsy(kind="interface",TSVeneer="type") | ||||
| 
 | ||||
| 				ScatterSeriesConfig: { | ||||
| 					FieldConfig | ||||
| 					x?:     string | ||||
| 					y?:     string | ||||
| 					name?:  string | ||||
| 					frame?: number | ||||
| 				XYSeriesConfig: { | ||||
| 					name?:   { fixed?: string } | ||||
| 					frame?:  { matcher: #MatcherConfig } | ||||
| 					x?:      { matcher: #MatcherConfig } | ||||
| 					y?:      { matcher: #MatcherConfig } | ||||
| 					color?:  { matcher: #MatcherConfig } | ||||
| 					size?:   { matcher: #MatcherConfig } | ||||
| 				} @cuetsy(kind="interface") | ||||
| 
 | ||||
| 				Options: { | ||||
| 					common.OptionsWithLegend | ||||
| 					common.OptionsWithTooltip | ||||
| 
 | ||||
| 					seriesMapping?: SeriesMapping | ||||
| 					mapping: SeriesMapping | ||||
| 
 | ||||
| 					// Table Mode (auto) | ||||
| 					dims: XYDimensionConfig | ||||
| 
 | ||||
| 					// Manual Mode | ||||
| 					series: [...ScatterSeriesConfig] | ||||
| 					series: [...XYSeriesConfig] | ||||
| 				} @cuetsy(kind="interface") | ||||
| 			} | ||||
| 		}] | ||||
|  |  | |||
|  | @ -10,66 +10,85 @@ | |||
| 
 | ||||
| import * as common from '@grafana/schema'; | ||||
| 
 | ||||
| /** | ||||
|  * Auto is "table" in the UI | ||||
|  */ | ||||
| export enum PointShape { | ||||
|   Circle = 'circle', | ||||
|   Square = 'square', | ||||
| } | ||||
| 
 | ||||
| export enum SeriesMapping { | ||||
|   Auto = 'auto', | ||||
|   Manual = 'manual', | ||||
| } | ||||
| 
 | ||||
| export enum ScatterShow { | ||||
| export enum XYShowMode { | ||||
|   Lines = 'lines', | ||||
|   Points = 'points', | ||||
|   PointsAndLines = 'points+lines', | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Configuration for the Table/Auto mode | ||||
|  * NOTE: (copied from dashboard_kind.cue, since not exported) | ||||
|  * Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. | ||||
|  * It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | ||||
|  */ | ||||
| export interface XYDimensionConfig { | ||||
|   exclude?: Array<string>; | ||||
|   frame: number; | ||||
|   x?: string; | ||||
| export interface MatcherConfig { | ||||
|   /** | ||||
|    * The matcher id. This is used to find the matcher implementation from registry. | ||||
|    */ | ||||
|   id: string; | ||||
|   /** | ||||
|    * The matcher options. This is specific to the matcher implementation. | ||||
|    */ | ||||
|   options?: unknown; | ||||
| } | ||||
| 
 | ||||
| export const defaultXYDimensionConfig: Partial<XYDimensionConfig> = { | ||||
|   exclude: [], | ||||
| export const defaultMatcherConfig: Partial<MatcherConfig> = { | ||||
|   id: '', | ||||
| }; | ||||
| 
 | ||||
| export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | ||||
|   label?: common.VisibilityMode; | ||||
|   labelValue?: common.TextDimensionConfig; | ||||
|   lineColor?: common.ColorDimensionConfig; | ||||
|   fillOpacity?: number; | ||||
|   lineStyle?: common.LineStyle; | ||||
|   lineWidth?: number; | ||||
|   pointColor?: common.ColorDimensionConfig; | ||||
|   pointSize?: common.ScaleDimensionConfig; | ||||
|   show?: ScatterShow; | ||||
|   pointShape?: PointShape; | ||||
|   pointSize?: { | ||||
|     fixed?: number; | ||||
|     min?: number; | ||||
|     max?: number; | ||||
|   }; | ||||
|   pointStrokeWidth?: number; | ||||
|   show?: XYShowMode; | ||||
| } | ||||
| 
 | ||||
| export const defaultFieldConfig: Partial<FieldConfig> = { | ||||
|   label: common.VisibilityMode.Auto, | ||||
|   show: ScatterShow.Points, | ||||
|   fillOpacity: 50, | ||||
|   show: XYShowMode.Points, | ||||
| }; | ||||
| 
 | ||||
| export interface ScatterSeriesConfig extends FieldConfig { | ||||
|   frame?: number; | ||||
|   name?: string; | ||||
|   x?: string; | ||||
|   y?: string; | ||||
| export interface XYSeriesConfig { | ||||
|   color?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   frame?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   name?: { | ||||
|     fixed?: string; | ||||
|   }; | ||||
|   size?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   x?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   y?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | ||||
|   /** | ||||
|    * Table Mode (auto) | ||||
|    */ | ||||
|   dims: XYDimensionConfig; | ||||
|   /** | ||||
|    * Manual Mode | ||||
|    */ | ||||
|   series: Array<ScatterSeriesConfig>; | ||||
|   seriesMapping?: SeriesMapping; | ||||
|   mapping: SeriesMapping; | ||||
|   series: Array<XYSeriesConfig>; | ||||
| } | ||||
| 
 | ||||
| export const defaultOptions: Partial<Options> = { | ||||
|  |  | |||
|  | @ -0,0 +1,77 @@ | |||
| // Code generated - EDITING IS FUTILE. DO NOT EDIT.
 | ||||
| //
 | ||||
| // Generated by:
 | ||||
| //     public/app/plugins/gen.go
 | ||||
| // Using jennies:
 | ||||
| //     TSTypesJenny
 | ||||
| //     PluginTsTypesJenny
 | ||||
| //
 | ||||
| // Run 'make gen-cue' from repository root to regenerate.
 | ||||
| 
 | ||||
| import * as common from '@grafana/schema'; | ||||
| 
 | ||||
| /** | ||||
|  * Auto is "table" in the UI | ||||
|  */ | ||||
| export enum SeriesMapping { | ||||
|   Auto = 'auto', | ||||
|   Manual = 'manual', | ||||
| } | ||||
| 
 | ||||
| export enum ScatterShow { | ||||
|   Lines = 'lines', | ||||
|   Points = 'points', | ||||
|   PointsAndLines = 'points+lines', | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Configuration for the Table/Auto mode | ||||
|  */ | ||||
| export interface XYDimensionConfig { | ||||
|   exclude?: Array<string>; | ||||
|   frame: number; | ||||
|   x?: string; | ||||
| } | ||||
| 
 | ||||
| export const defaultXYDimensionConfig: Partial<XYDimensionConfig> = { | ||||
|   exclude: [], | ||||
| }; | ||||
| 
 | ||||
| export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | ||||
|   label?: common.VisibilityMode; | ||||
|   labelValue?: common.TextDimensionConfig; | ||||
|   lineColor?: common.ColorDimensionConfig; | ||||
|   lineStyle?: common.LineStyle; | ||||
|   lineWidth?: number; | ||||
|   pointColor?: common.ColorDimensionConfig; | ||||
|   pointSize?: common.ScaleDimensionConfig; | ||||
|   show?: ScatterShow; | ||||
| } | ||||
| 
 | ||||
| export const defaultFieldConfig: Partial<FieldConfig> = { | ||||
|   label: common.VisibilityMode.Auto, | ||||
|   show: ScatterShow.Points, | ||||
| }; | ||||
| 
 | ||||
| export interface ScatterSeriesConfig extends FieldConfig { | ||||
|   frame?: number; | ||||
|   name?: string; | ||||
|   x?: string; | ||||
|   y?: string; | ||||
| } | ||||
| 
 | ||||
| export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | ||||
|   /** | ||||
|    * Table Mode (auto) | ||||
|    */ | ||||
|   dims: XYDimensionConfig; | ||||
|   /** | ||||
|    * Manual Mode | ||||
|    */ | ||||
|   series: Array<ScatterSeriesConfig>; | ||||
|   seriesMapping?: SeriesMapping; | ||||
| } | ||||
| 
 | ||||
| export const defaultOptions: Partial<Options> = { | ||||
|   series: [], | ||||
| }; | ||||
|  | @ -2,7 +2,6 @@ | |||
|   "type": "panel", | ||||
|   "name": "XY Chart", | ||||
|   "id": "xychart", | ||||
|   "state": "beta", | ||||
| 
 | ||||
|   "info": { | ||||
|     "description": "Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.", | ||||
|  |  | |||
|  | @ -1,280 +1,28 @@ | |||
| import tinycolor from 'tinycolor2'; | ||||
| import uPlot from 'uplot'; | ||||
| 
 | ||||
| import { | ||||
|   DataFrame, | ||||
|   FieldColorModeId, | ||||
|   fieldColorModeRegistry, | ||||
|   FALLBACK_COLOR, | ||||
|   Field, | ||||
|   FieldType, | ||||
|   formattedValueToString, | ||||
|   getDisplayProcessor, | ||||
|   getFieldColorModeForField, | ||||
|   getFieldDisplayName, | ||||
|   getFieldSeriesColor, | ||||
|   GrafanaTheme2, | ||||
|   MappingType, | ||||
|   SpecialValueMatch, | ||||
|   ThresholdsMode, | ||||
| } from '@grafana/data'; | ||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { | ||||
|   AxisPlacement, | ||||
|   ScaleDirection, | ||||
|   ScaleOrientation, | ||||
|   VisibilityMode, | ||||
|   ScaleDimensionConfig, | ||||
|   ScaleDimensionMode, | ||||
| } from '@grafana/schema'; | ||||
| import { AxisPlacement, FieldColorModeId, ScaleDirection, ScaleOrientation, VisibilityMode } from '@grafana/schema'; | ||||
| import { UPlotConfigBuilder } from '@grafana/ui'; | ||||
| import { FacetedData, FacetSeries } from '@grafana/ui/src/components/uPlot/types'; | ||||
| import { findFieldIndex, getScaledDimensionForField } from 'app/features/dimensions'; | ||||
| 
 | ||||
| import { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; | ||||
| import { valuesToFills } from '../heatmap/utils'; | ||||
| 
 | ||||
| import { DEFAULT_POINT_SIZE } from './config'; | ||||
| import { isGraphable } from './dims'; | ||||
| import { FieldConfig, defaultFieldConfig, Options, ScatterShow } from './panelcfg.gen'; | ||||
| import { DimensionValues, ScatterSeries } from './types'; | ||||
| 
 | ||||
| export interface ScatterPanelInfo { | ||||
|   error?: string; | ||||
|   series: ScatterSeries[]; | ||||
|   builder?: UPlotConfigBuilder; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * This is called when options or structure rev changes | ||||
|  */ | ||||
| export function prepScatter(options: Options, getData: () => DataFrame[], theme: GrafanaTheme2): ScatterPanelInfo { | ||||
|   let series: ScatterSeries[]; | ||||
|   let builder: UPlotConfigBuilder; | ||||
| 
 | ||||
|   try { | ||||
|     series = prepSeries(options, getData()); | ||||
|     builder = prepConfig(getData, series, theme); | ||||
|   } catch (e) { | ||||
|     let errorMsg = 'Unknown error in prepScatter'; | ||||
|     if (typeof e === 'string') { | ||||
|       errorMsg = e; | ||||
|     } else if (e instanceof Error) { | ||||
|       errorMsg = e.message; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       error: errorMsg, | ||||
|       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<string> = () => seriesColor; | ||||
|   const fieldConfig: FieldConfig = { ...defaultFieldConfig, ...y.config.custom }; | ||||
|   let pointColorMode = fieldColorModeRegistry.get(FieldColorModeId.PaletteClassic); | ||||
|   if (dims.pointColorIndex) { | ||||
|     const f = frames[frameIndex].fields[dims.pointColorIndex]; | ||||
|     if (f) { | ||||
|       pointColorMode = getFieldColorModeForField(y); | ||||
|       if (pointColorMode.isByValue) { | ||||
|         const index = dims.pointColorIndex; | ||||
|         pointColor = (frame: DataFrame) => { | ||||
|           const field = frame.fields[index]; | ||||
| 
 | ||||
|           if (field.state?.range) { | ||||
|             // this forces local min/max recalc, rather than using global min/max from field.state
 | ||||
|             field.state.range = undefined; | ||||
|           } | ||||
| 
 | ||||
|           field.display = getDisplayProcessor({ field, theme: config.theme2 }); | ||||
| 
 | ||||
|           return field.values.map((v) => field.display!(v).color!); // slow!
 | ||||
|         }; | ||||
|       } else { | ||||
|         seriesColor = pointColorMode.getCalculator(f, config.theme2)(f.values[0], 1); | ||||
|         pointColor = () => seriesColor; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Size configs
 | ||||
|   //----------------
 | ||||
|   let pointSizeHints = dims.pointSizeConfig; | ||||
|   let pointSizeFixed = dims.pointSizeConfig?.fixed ?? y.config.custom?.pointSize?.fixed ?? DEFAULT_POINT_SIZE; | ||||
|   let pointSize: DimensionValues<number> = () => pointSizeFixed; | ||||
|   if (dims.pointSizeIndex) { | ||||
|     pointSize = (frame) => { | ||||
|       const s = getScaledDimensionForField( | ||||
|         frame.fields[dims.pointSizeIndex!], | ||||
|         dims.pointSizeConfig!, | ||||
|         ScaleDimensionMode.Quad | ||||
|       ); | ||||
|       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: () => { | ||||
|       return [ | ||||
|         { | ||||
|           label: name, | ||||
|           color: seriesColor, // single color for series?
 | ||||
|           getItemKey: () => name, | ||||
|           yAxis: yIndex, // << but not used
 | ||||
|         }, | ||||
|       ]; | ||||
|     }, | ||||
| 
 | ||||
|     showLine: fieldConfig.show !== ScatterShow.Points, | ||||
|     lineWidth: fieldConfig.lineWidth ?? 2, | ||||
|     lineStyle: fieldConfig.lineStyle!, | ||||
|     lineColor: () => seriesColor, | ||||
| 
 | ||||
|     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 🤔
 | ||||
| 
 | ||||
|     label: VisibilityMode.Never, | ||||
|     labelValue: () => '', | ||||
|     show: !frame.fields[yIndex].config.custom.hideFrom?.viz, | ||||
| 
 | ||||
|     hints: { | ||||
|       pointSize: pointSizeHints!, | ||||
|       pointColor: { | ||||
|         mode: pointColorMode, | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function prepSeries(options: Options, frames: DataFrame[]): ScatterSeries[] { | ||||
|   let seriesIndex = 0; | ||||
|   if (!frames.length) { | ||||
|     throw 'Missing data'; | ||||
|   } | ||||
| 
 | ||||
|   if (options.seriesMapping === 'manual') { | ||||
|     if (!options.series?.length) { | ||||
|       throw 'Missing series config'; | ||||
|     } | ||||
| 
 | ||||
|     const scatterSeries: ScatterSeries[] = []; | ||||
| 
 | ||||
|     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++) { | ||||
|         // When a frame filter is applied, only include matching frame index
 | ||||
|         if (series.frame !== undefined && series.frame !== frameIndex) { | ||||
|           continue; | ||||
|         } | ||||
|         const frame = frames[frameIndex]; | ||||
|         const xIndex = findFieldIndex(series.x, frame, frames); | ||||
| 
 | ||||
|         if (xIndex != null) { | ||||
|           // TODO: this should find multiple y fields
 | ||||
|           const yIndex = findFieldIndex(series.y, frame, frames); | ||||
| 
 | ||||
|           if (yIndex == null) { | ||||
|             throw 'Y must be in the same frame as X'; | ||||
|           } | ||||
| 
 | ||||
|           const dims: Dims = { | ||||
|             pointColorFixed: series.pointColor?.fixed, | ||||
|             pointColorIndex: findFieldIndex(series.pointColor?.field, frame, frames), | ||||
|             pointSizeConfig: series.pointSize, | ||||
|             pointSizeIndex: findFieldIndex(series.pointSize?.field, frame, frames), | ||||
|           }; | ||||
|           scatterSeries.push(getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims)); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return scatterSeries; | ||||
|   } | ||||
| 
 | ||||
|   // Default behavior
 | ||||
|   const dims = options.dims ?? {}; | ||||
|   const frameIndex = dims.frame ?? 0; | ||||
|   const frame = frames[frameIndex]; | ||||
|   const numericIndices: number[] = []; | ||||
| 
 | ||||
|   let xIndex = findFieldIndex(dims.x, frame, frames); | ||||
|   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
 | ||||
|       } | ||||
| 
 | ||||
|       numericIndices.push(i); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (xIndex == null) { | ||||
|     throw 'Missing X dimension'; | ||||
|   } | ||||
| 
 | ||||
|   if (!numericIndices.length) { | ||||
|     throw 'No Y values'; | ||||
|   } | ||||
|   return numericIndices.map((yIndex) => getScatterSeries(seriesIndex++, frames, frameIndex, xIndex!, yIndex, {})); | ||||
| } | ||||
| import { PointShape } from './panelcfg.gen'; | ||||
| import { XYSeries } from './types2'; | ||||
| import { getCommonPrefixSuffix } from './utils'; | ||||
| 
 | ||||
| interface DrawBubblesOpts { | ||||
|   each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; | ||||
|  | @ -285,12 +33,15 @@ interface DrawBubblesOpts { | |||
|     }; | ||||
|     color: { | ||||
|       values: (u: uPlot, seriesIdx: number) => string[]; | ||||
|       alpha: number; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], theme: GrafanaTheme2) => { | ||||
| export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => { | ||||
|   if (xySeries.length === 0) { | ||||
|     return { builder: null, prepData: () => [] }; | ||||
|   } | ||||
| 
 | ||||
|   let qt: Quadtree; | ||||
|   let hRect: Rect | null; | ||||
| 
 | ||||
|  | @ -317,29 +68,26 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|           arc | ||||
|         ) => { | ||||
|           const pxRatio = uPlot.pxRatio; | ||||
|           const scatterInfo = scatterSeries[seriesIdx - 1]; | ||||
|           const scatterInfo = xySeries[seriesIdx - 1]; | ||||
|           let d = u.data[seriesIdx] as unknown as FacetSeries; | ||||
| 
 | ||||
|           // showLine: boolean;
 | ||||
|           // lineStyle: common.LineStyle;
 | ||||
|           // showPoints: common.VisibilityMode;
 | ||||
| 
 | ||||
|           let showLine = scatterInfo.showLine; | ||||
|           let showPoints = scatterInfo.showPoints === VisibilityMode.Always; | ||||
|           if (!showPoints && scatterInfo.showPoints === VisibilityMode.Auto) { | ||||
|             showPoints = d[0].length < 1000; | ||||
|           } | ||||
| 
 | ||||
|           // always show something
 | ||||
|           if (!showPoints && !showLine) { | ||||
|             showLine = true; | ||||
|           } | ||||
| 
 | ||||
|           let strokeWidth = 1; | ||||
|           let strokeWidth = scatterInfo.pointStrokeWidth ?? 0; | ||||
| 
 | ||||
|           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)(); | ||||
|           let pointAlpha = scatterInfo.fillOpacity / 100; | ||||
| 
 | ||||
|           u.ctx.fillStyle = alpha((series.fill as any)(), pointAlpha); | ||||
|           u.ctx.strokeStyle = alpha((series.stroke as any)(), 1); | ||||
|           u.ctx.lineWidth = strokeWidth; | ||||
| 
 | ||||
|           let deg360 = 2 * Math.PI; | ||||
|  | @ -347,10 +95,11 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|           let xKey = scaleX.key!; | ||||
|           let yKey = scaleY.key!; | ||||
| 
 | ||||
|           let pointHints = scatterInfo.hints.pointSize; | ||||
|           const colorByValue = scatterInfo.hints.pointColor.mode.isByValue; | ||||
|           //const colorMode = getFieldColorModeForField(field); // isByValue
 | ||||
|           const pointSize = scatterInfo.y.field.config.custom.pointSize; | ||||
|           const colorByValue = scatterInfo.color.field != null; // && colorMode.isByValue;
 | ||||
| 
 | ||||
|           let maxSize = (pointHints.max ?? pointHints.fixed) * pxRatio; | ||||
|           let maxSize = (pointSize.max ?? pointSize.fixed) * pxRatio; | ||||
| 
 | ||||
|           // todo: this depends on direction & orientation
 | ||||
|           // todo: calc once per redraw, not per path
 | ||||
|  | @ -360,19 +109,23 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|           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; | ||||
|           // let pointColors = opts.disp.color.values(u, seriesIdx);
 | ||||
|           let pointColors = dispColors[seriesIdx - 1].values; // idxs
 | ||||
|           let pointPalette = dispColors[seriesIdx - 1].index as Array<CanvasRenderingContext2D['fillStyle']>; | ||||
|           let paletteHasAlpha = dispColors[seriesIdx - 1].hasAlpha; | ||||
| 
 | ||||
|           let isSquare = scatterInfo.pointShape === PointShape.Square; | ||||
| 
 | ||||
|           let linePath: Path2D | null = showLine ? new Path2D() : null; | ||||
| 
 | ||||
|           let curColor: CanvasRenderingContext2D['fillStyle'] | null = null; | ||||
|           let curColorIdx = -1; | ||||
| 
 | ||||
|           for (let i = 0; i < d[0].length; i++) { | ||||
|             let xVal = d[0][i]; | ||||
|             let yVal = d[1][i]; | ||||
|             let size = sizes[i] * pxRatio; | ||||
| 
 | ||||
|             if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { | ||||
|               let size = Math.round(sizes[i] * pxRatio); | ||||
|               let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||
|               let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||
| 
 | ||||
|  | @ -381,22 +134,39 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|               } | ||||
| 
 | ||||
|               if (showPoints) { | ||||
|                 // if pointHints.fixed? don't recalc size
 | ||||
|                 // if pointColor has 0 opacity, draw as single path (assuming all strokes are alpha 1)
 | ||||
| 
 | ||||
|                 u.ctx.beginPath(); | ||||
|                 u.ctx.arc(cx, cy, size / 2, 0, deg360); | ||||
| 
 | ||||
|                 if (colorByValue) { | ||||
|                   if (pointColors[i] !== curColor) { | ||||
|                     curColor = pointColors[i]; | ||||
|                     u.ctx.fillStyle = alpha(curColor, pointAlpha); | ||||
|                     u.ctx.strokeStyle = curColor; | ||||
|                   if (pointColors[i] !== curColorIdx) { | ||||
|                     curColorIdx = pointColors[i]; | ||||
|                     let c = curColorIdx === -1 ? FALLBACK_COLOR : pointPalette[curColorIdx]; | ||||
|                     u.ctx.fillStyle = paletteHasAlpha ? c : alpha(c as string, pointAlpha); | ||||
|                     u.ctx.strokeStyle = alpha(c as string, 1); | ||||
|                   } | ||||
|                 } | ||||
| 
 | ||||
|                 if (isSquare) { | ||||
|                   let x = Math.round(cx - size / 2); | ||||
|                   let y = Math.round(cy - size / 2); | ||||
| 
 | ||||
|                   if (colorByValue || pointAlpha > 0) { | ||||
|                     u.ctx.fillRect(x, y, size, size); | ||||
|                   } | ||||
| 
 | ||||
|                   if (strokeWidth > 0) { | ||||
|                     u.ctx.strokeRect(x, y, size, size); | ||||
|                   } | ||||
|                 } else { | ||||
|                   u.ctx.beginPath(); | ||||
|                   u.ctx.arc(cx, cy, size / 2, 0, deg360); | ||||
| 
 | ||||
|                   if (colorByValue || pointAlpha > 0) { | ||||
|                     u.ctx.fill(); | ||||
|                   } | ||||
| 
 | ||||
|                   if (strokeWidth > 0) { | ||||
|                     u.ctx.stroke(); | ||||
|                   } | ||||
|                 } | ||||
| 
 | ||||
|                 u.ctx.fill(); | ||||
|                 u.ctx.stroke(); | ||||
|                 opts.each( | ||||
|                   u, | ||||
|                   seriesIdx, | ||||
|  | @ -411,8 +181,7 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|           } | ||||
| 
 | ||||
|           if (showLine) { | ||||
|             let frame = scatterInfo.frame(getData()); | ||||
|             u.ctx.strokeStyle = scatterInfo.lineColor(frame); | ||||
|             u.ctx.strokeStyle = scatterInfo.color.fixed!; | ||||
|             u.ctx.lineWidth = scatterInfo.lineWidth * pxRatio; | ||||
| 
 | ||||
|             const { lineStyle } = scatterInfo; | ||||
|  | @ -451,7 +220,6 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|         values: (u, seriesIdx) => { | ||||
|           return u.data[seriesIdx][3] as any; | ||||
|         }, | ||||
|         alpha: 0.5, | ||||
|       }, | ||||
|     }, | ||||
|     each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { | ||||
|  | @ -508,6 +276,11 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   // clip hover points/bubbles to plotting area
 | ||||
|   builder.addHook('init', (u, r) => { | ||||
|     u.over.style.overflow = 'hidden'; | ||||
|   }); | ||||
| 
 | ||||
|   builder.addHook('drawClear', (u) => { | ||||
|     qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); | ||||
| 
 | ||||
|  | @ -524,8 +297,7 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
| 
 | ||||
|   builder.setMode(2); | ||||
| 
 | ||||
|   const frames = getData(); | ||||
|   let xField = scatterSeries[0].x(scatterSeries[0].frame(frames)); | ||||
|   let xField = xySeries[0].x.field; | ||||
| 
 | ||||
|   let fieldConfig = xField.config; | ||||
|   let customConfig = fieldConfig.custom; | ||||
|  | @ -550,6 +322,21 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|   // why does this fall back to '' instead of null or undef?
 | ||||
|   let xAxisLabel = customConfig.axisLabel; | ||||
| 
 | ||||
|   if (xAxisLabel == null || xAxisLabel === '') { | ||||
|     let dispNames = xySeries.map((s) => s.x.field.state?.displayName ?? ''); | ||||
| 
 | ||||
|     let xAxisAutoLabel = | ||||
|       xySeries.length === 1 | ||||
|         ? (xField.state?.displayName ?? xField.name) | ||||
|         : new Set(dispNames).size === 1 | ||||
|           ? dispNames[0] | ||||
|           : getCommonPrefixSuffix(dispNames); | ||||
| 
 | ||||
|     if (xAxisAutoLabel !== '') { | ||||
|       xAxisLabel = xAxisAutoLabel; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   builder.addAxis({ | ||||
|     scaleKey: 'x', | ||||
|     placement: customConfig?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden, | ||||
|  | @ -557,19 +344,15 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|     grid: { show: customConfig?.axisGridShow }, | ||||
|     border: { show: customConfig?.axisBorderShow }, | ||||
|     theme, | ||||
|     label: | ||||
|       xAxisLabel == null || xAxisLabel === '' | ||||
|         ? getFieldDisplayName(xField, scatterSeries[0].frame(frames), frames) | ||||
|         : xAxisLabel, | ||||
|     label: xAxisLabel, | ||||
|     formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), | ||||
|   }); | ||||
| 
 | ||||
|   scatterSeries.forEach((s, si) => { | ||||
|     let frame = s.frame(frames); | ||||
|     let field = s.y(frame); | ||||
|   xySeries.forEach((s, si) => { | ||||
|     let field = s.y.field; | ||||
| 
 | ||||
|     const lineColor = s.lineColor(frame); | ||||
|     const pointColor = asSingleValue(frame, s.pointColor) as string; | ||||
|     const lineColor = s.color.fixed; | ||||
|     const pointColor = s.color.fixed; | ||||
|     //const lineColor = s.lineColor(frame);
 | ||||
|     //const lineWidth = s.lineWidth;
 | ||||
| 
 | ||||
|  | @ -594,7 +377,22 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|     }); | ||||
| 
 | ||||
|     // why does this fall back to '' instead of null or undef?
 | ||||
|     let yAxisLabel = customConfig?.axisLabel; | ||||
|     let yAxisLabel = customConfig.axisLabel; | ||||
| 
 | ||||
|     if (yAxisLabel == null || yAxisLabel === '') { | ||||
|       let dispNames = xySeries.map((s) => s.y.field.state?.displayName ?? ''); | ||||
| 
 | ||||
|       let yAxisAutoLabel = | ||||
|         xySeries.length === 1 | ||||
|           ? (field.state?.displayName ?? field.name) | ||||
|           : new Set(dispNames).size === 1 | ||||
|             ? dispNames[0] | ||||
|             : getCommonPrefixSuffix(dispNames); | ||||
| 
 | ||||
|       if (yAxisAutoLabel !== '') { | ||||
|         yAxisLabel = yAxisAutoLabel; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     builder.addAxis({ | ||||
|       scaleKey, | ||||
|  | @ -604,10 +402,8 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|       grid: { show: customConfig?.axisGridShow }, | ||||
|       border: { show: customConfig?.axisBorderShow }, | ||||
|       size: customConfig?.axisWidth, | ||||
|       label: | ||||
|         yAxisLabel == null || yAxisLabel === '' | ||||
|           ? getFieldDisplayName(field, scatterSeries[si].frame(frames), frames) | ||||
|           : yAxisLabel, | ||||
|       // label: yAxisLabel == null || yAxisLabel === '' ? fieldDisplayName : yAxisLabel,
 | ||||
|       label: yAxisLabel, | ||||
|       formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)), | ||||
|     }); | ||||
| 
 | ||||
|  | @ -625,80 +421,269 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | |||
|       pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}})
 | ||||
|       theme, | ||||
|       scaleKey: '', // facets' scales used (above)
 | ||||
|       lineColor: alpha('' + lineColor, 1), | ||||
|       fillColor: alpha(pointColor, 0.5), | ||||
|       show: !customConfig.hideFrom?.viz, | ||||
|       lineColor: alpha(lineColor ?? '#ffff', 1), | ||||
|       fillColor: alpha(pointColor ?? '#ffff', 0.5), | ||||
|       show: !field.state?.hideFrom?.viz, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   /* | ||||
|   builder.setPrepData((frames) => { | ||||
|     let seriesData = lookup.fieldMaps.flatMap((f, i) => { | ||||
|       let { fields } = frames[i]; | ||||
|   const dispColors = xySeries.map((s): FieldColorValuesWithCache => { | ||||
|     const cfg: FieldColorValuesWithCache = { | ||||
|       index: [], | ||||
|       getAll: () => [], | ||||
|       getOne: () => -1, | ||||
|       // cache for renderer, refreshed in prepData()
 | ||||
|       values: [], | ||||
|       hasAlpha: false, | ||||
|     }; | ||||
| 
 | ||||
|       return f.y.map((yIndex, frameSeriesIndex) => { | ||||
|         let xValues = fields[f.x[frameSeriesIndex]].values; | ||||
|         let yValues = fields[f.y[frameSeriesIndex]].values; | ||||
|         let sizeValues = f.size; | ||||
|     const f = s.color.field; | ||||
| 
 | ||||
|         if (!Array.isArray(sizeValues)) { | ||||
|           sizeValues = Array(xValues.length).fill(sizeValues); | ||||
|     if (f != null) { | ||||
|       Object.assign(cfg, fieldValueColors(f, theme)); | ||||
|       cfg.hasAlpha = cfg.index.some((v) => !(v as string).endsWith('ff')); | ||||
|     } | ||||
| 
 | ||||
|     return cfg; | ||||
|   }); | ||||
| 
 | ||||
|   function prepData(xySeries: XYSeries[]): FacetedData { | ||||
|     // if (info.error || !data.length) {
 | ||||
|     //   return [null];
 | ||||
|     // }
 | ||||
| 
 | ||||
|     const { size: sizeRange, color: colorRange } = getGlobalRanges(xySeries); | ||||
| 
 | ||||
|     xySeries.forEach((s, i) => { | ||||
|       dispColors[i].values = dispColors[i].getAll(s.color.field?.values ?? [], colorRange.min, colorRange.max); | ||||
|     }); | ||||
| 
 | ||||
|     return [ | ||||
|       null, | ||||
|       ...xySeries.map((s, idx) => { | ||||
|         let len = s.x.field.values.length; | ||||
| 
 | ||||
|         let diams: number[]; | ||||
| 
 | ||||
|         if (s.size.field != null) { | ||||
|           let { min, max } = s.size; | ||||
| 
 | ||||
|           // todo: this scaling should be in renderer from raw values (not by passing css pixel diams via data)
 | ||||
|           let minPx = min! ** 2; | ||||
|           let maxPx = max! ** 2; | ||||
|           // use quadratic size scaling in byValue modes
 | ||||
|           let pxRange = maxPx - minPx; | ||||
| 
 | ||||
|           let vals = s.size.field.values; | ||||
|           let minVal = sizeRange.min; | ||||
|           let maxVal = sizeRange.max; | ||||
|           let valRange = maxVal - minVal; | ||||
| 
 | ||||
|           diams = Array(len); | ||||
| 
 | ||||
|           for (let i = 0; i < vals.length; i++) { | ||||
|             let val = vals[i]; | ||||
| 
 | ||||
|             let valPct = (val - minVal) / valRange; | ||||
|             let pxArea = minPx + valPct * pxRange; | ||||
|             diams[i] = pxArea ** 0.5; | ||||
|           } | ||||
|         } else { | ||||
|           diams = Array(len).fill(s.size.fixed!); | ||||
|         } | ||||
| 
 | ||||
|         return [xValues, yValues, sizeValues]; | ||||
|       }); | ||||
|     }); | ||||
|         return [ | ||||
|           s.x.field.values, // X
 | ||||
|           s.y.field.values, // Y
 | ||||
|           diams, | ||||
|           Array(len).fill(s.color.fixed!), // TODO: fails for by value
 | ||||
|         ]; | ||||
|       }), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|     return [null, ...seriesData]; | ||||
|   }); | ||||
|   */ | ||||
| 
 | ||||
|   return builder; | ||||
|   return { builder, prepData }; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * 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 || !data.length) { | ||||
|     return [null]; | ||||
|   } | ||||
|   return [ | ||||
|     null, | ||||
|     ...info.series.map((s, idx) => { | ||||
|       const frame = s.frame(data); | ||||
| export type PrepData = (xySeries: XYSeries[]) => FacetedData; | ||||
| 
 | ||||
|       let colorValues; | ||||
|       const r = s.pointColor(frame); | ||||
|       if (Array.isArray(r)) { | ||||
|         colorValues = r; | ||||
|       } else { | ||||
|         colorValues = Array(frame.length).fill(r); | ||||
| const getGlobalRanges = (xySeries: XYSeries[]) => { | ||||
|   const ranges = { | ||||
|     size: { | ||||
|       min: Infinity, | ||||
|       max: -Infinity, | ||||
|     }, | ||||
|     color: { | ||||
|       min: Infinity, | ||||
|       max: -Infinity, | ||||
|     }, | ||||
|   }; | ||||
| 
 | ||||
|   xySeries.forEach((series) => { | ||||
|     [series.size, series.color].forEach((facet, fi) => { | ||||
|       if (facet.field != null) { | ||||
|         let range = fi === 0 ? ranges.size : ranges.color; | ||||
| 
 | ||||
|         const vals = facet.field.values; | ||||
| 
 | ||||
|         for (let i = 0; i < vals.length; i++) { | ||||
|           const v = vals[i]; | ||||
| 
 | ||||
|           if (v != null) { | ||||
|             if (v < range.min) { | ||||
|               range.min = v; | ||||
|             } | ||||
| 
 | ||||
|             if (v > range.max) { | ||||
|               range.max = v; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       return [ | ||||
|         s.x(frame).values, // X
 | ||||
|         s.y(frame).values, // Y
 | ||||
|         asArray(frame, s.pointSize), | ||||
|         colorValues, | ||||
|       ]; | ||||
|     }), | ||||
|   ]; | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   return ranges; | ||||
| }; | ||||
| 
 | ||||
| function getHex8Color(color: string, theme: GrafanaTheme2) { | ||||
|   return tinycolor(theme.visualization.getColorByName(color)).toHex8String(); | ||||
| } | ||||
| 
 | ||||
| function asArray<T>(frame: DataFrame, lookup: DimensionValues<T>): T[] { | ||||
|   const r = lookup(frame); | ||||
|   if (Array.isArray(r)) { | ||||
|     return r; | ||||
|   } | ||||
|   return Array(frame.length).fill(r); | ||||
| interface FieldColorValues { | ||||
|   index: unknown[]; | ||||
|   getOne: GetOneValue; | ||||
|   getAll: GetAllValues; | ||||
| } | ||||
| interface FieldColorValuesWithCache extends FieldColorValues { | ||||
|   values: number[]; | ||||
|   hasAlpha: boolean; | ||||
| } | ||||
| type GetAllValues = (values: unknown[], min?: number, max?: number) => number[]; | ||||
| type GetOneValue = (value: unknown, min?: number, max?: number) => number; | ||||
| 
 | ||||
| function asSingleValue<T>(frame: DataFrame, lookup: DimensionValues<T>): T { | ||||
|   const r = lookup(frame); | ||||
|   if (Array.isArray(r)) { | ||||
|     return r[0]; | ||||
| /** compiler for values to palette color idxs (from thresholds, mappings, by-value gradients) */ | ||||
| function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { | ||||
|   let index: unknown[] = []; | ||||
|   let getAll: GetAllValues = () => []; | ||||
|   let getOne: GetOneValue = () => -1; | ||||
| 
 | ||||
|   let conds = ''; | ||||
| 
 | ||||
|   // if any mappings exist, use them regardless of other settings
 | ||||
|   if (f.config.mappings?.length ?? 0 > 0) { | ||||
|     let mappings = f.config.mappings!; | ||||
| 
 | ||||
|     for (let i = 0; i < mappings.length; i++) { | ||||
|       let m = mappings[i]; | ||||
| 
 | ||||
|       if (m.type === MappingType.ValueToText) { | ||||
|         for (let k in m.options) { | ||||
|           let { color } = m.options[k]; | ||||
| 
 | ||||
|           if (color != null) { | ||||
|             let rhs = f.type === FieldType.string ? JSON.stringify(k) : Number(k); | ||||
|             conds += `v === ${rhs} ? ${index.length} : `; | ||||
|             index.push(getHex8Color(color, theme)); | ||||
|           } | ||||
|         } | ||||
|       } else if (m.options.result.color != null) { | ||||
|         let { color } = m.options.result; | ||||
| 
 | ||||
|         if (m.type === MappingType.RangeToText) { | ||||
|           let range = []; | ||||
| 
 | ||||
|           if (m.options.from != null) { | ||||
|             range.push(`v >= ${Number(m.options.from)}`); | ||||
|           } | ||||
| 
 | ||||
|           if (m.options.to != null) { | ||||
|             range.push(`v <= ${Number(m.options.to)}`); | ||||
|           } | ||||
| 
 | ||||
|           if (range.length > 0) { | ||||
|             conds += `${range.join(' && ')} ? ${index.length} : `; | ||||
|             index.push(getHex8Color(color, theme)); | ||||
|           } | ||||
|         } else if (m.type === MappingType.SpecialValue) { | ||||
|           let spl = m.options.match; | ||||
| 
 | ||||
|           if (spl === SpecialValueMatch.NaN) { | ||||
|             conds += `isNaN(v)`; | ||||
|           } else if (spl === SpecialValueMatch.NullAndNaN) { | ||||
|             conds += `v == null || isNaN(v)`; | ||||
|           } else { | ||||
|             conds += `v ${ | ||||
|               spl === SpecialValueMatch.True | ||||
|                 ? '=== true' | ||||
|                 : spl === SpecialValueMatch.False | ||||
|                   ? '=== false' | ||||
|                   : spl === SpecialValueMatch.Null | ||||
|                     ? '== null' | ||||
|                     : spl === SpecialValueMatch.Empty | ||||
|                       ? '=== ""' | ||||
|                       : '== null' | ||||
|             }`;
 | ||||
|           } | ||||
| 
 | ||||
|           conds += ` ? ${index.length} : `; | ||||
|           index.push(getHex8Color(color, theme)); | ||||
|         } else if (m.type === MappingType.RegexToText) { | ||||
|           // TODO
 | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     conds += '-1'; // ?? what default here? null? FALLBACK_COLOR?
 | ||||
|   } else if (f.config.color?.mode === FieldColorModeId.Thresholds) { | ||||
|     if (f.config.thresholds?.mode === ThresholdsMode.Absolute) { | ||||
|       let steps = f.config.thresholds.steps; | ||||
|       let lasti = steps.length - 1; | ||||
| 
 | ||||
|       for (let i = lasti; i > 0; i--) { | ||||
|         conds += `v >= ${steps[i].value} ? ${i} : `; | ||||
|       } | ||||
| 
 | ||||
|       conds += '0'; | ||||
| 
 | ||||
|       index = steps.map((s) => getHex8Color(s.color, theme)); | ||||
|     } else { | ||||
|       // TODO: percent thresholds?
 | ||||
|     } | ||||
|   } else if (f.config.color?.mode?.startsWith('continuous')) { | ||||
|     let calc = getFieldColorModeForField(f).getCalculator(f, theme); | ||||
| 
 | ||||
|     index = Array(32); | ||||
| 
 | ||||
|     for (let i = 0; i < index.length; i++) { | ||||
|       let pct = i / (index.length - 1); | ||||
|       index[i] = getHex8Color(calc(pct, pct), theme); | ||||
|     } | ||||
| 
 | ||||
|     getAll = (vals, min, max) => valuesToFills(vals as number[], index as string[], min!, max!); | ||||
|   } | ||||
|   return r; | ||||
| 
 | ||||
|   if (conds !== '') { | ||||
|     getOne = new Function('v', `return ${conds};`) as GetOneValue; | ||||
| 
 | ||||
|     getAll = new Function( | ||||
|       'vals', | ||||
|       ` | ||||
|       let idxs = Array(vals.length); | ||||
| 
 | ||||
|       for (let i = 0; i < vals.length; i++) { | ||||
|         let v = vals[i]; | ||||
|         idxs[i] = ${conds}; | ||||
|       } | ||||
| 
 | ||||
|       return idxs; | ||||
|     ` | ||||
|     ) as GetAllValues; | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     index, | ||||
|     getOne, | ||||
|     getAll, | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -1,43 +0,0 @@ | |||
| import { DataFrame, Field, FieldColorMode } from '@grafana/data'; | ||||
| import { LineStyle, ScaleDimensionConfig, VisibilityMode } from '@grafana/schema'; | ||||
| import { VizLegendItem } from '@grafana/ui'; | ||||
| 
 | ||||
| /** | ||||
|  * @internal | ||||
|  */ | ||||
| export type DimensionValues<T> = (frame: DataFrame, from?: number) => T | T[]; | ||||
| 
 | ||||
| // 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: () => VizLegendItem[]; // could be single if symbol is constant
 | ||||
| 
 | ||||
|   showLine: boolean; | ||||
|   lineWidth: number; | ||||
|   lineStyle: LineStyle; | ||||
|   lineColor: (frame: DataFrame) => CanvasRenderingContext2D['strokeStyle']; | ||||
| 
 | ||||
|   showPoints: VisibilityMode; | ||||
|   pointSize: DimensionValues<number>; | ||||
|   pointColor: DimensionValues<CanvasRenderingContext2D['strokeStyle']>; | ||||
|   pointSymbol: DimensionValues<string>; // single field, multiple symbols.... kinda equals multiple series
 | ||||
| 
 | ||||
|   label: VisibilityMode; | ||||
|   labelValue: DimensionValues<string>; | ||||
|   show: boolean; | ||||
| 
 | ||||
|   hints: { | ||||
|     pointSize: ScaleDimensionConfig; | ||||
|     pointColor: { | ||||
|       mode: FieldColorMode; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
|  | @ -1,4 +1,23 @@ | |||
| import { Field, formattedValueToString } from '@grafana/data'; | ||||
| import { | ||||
|   Field, | ||||
|   formattedValueToString, | ||||
|   getFieldMatcher, | ||||
|   FieldType, | ||||
|   getFieldDisplayName, | ||||
|   DataFrame, | ||||
|   FrameMatcherID, | ||||
|   MatcherConfig, | ||||
|   FieldColorModeId, | ||||
|   cacheFieldDisplayNames, | ||||
|   FieldMatcherID, | ||||
|   FieldConfigSource, | ||||
| } from '@grafana/data'; | ||||
| import { decoupleHideFromState } from '@grafana/data/src/field/fieldState'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { VisibilityMode } from '@grafana/schema'; | ||||
| 
 | ||||
| import { XYShowMode, SeriesMapping, XYSeriesConfig } from './panelcfg.gen'; | ||||
| import { XYSeries } from './types2'; | ||||
| 
 | ||||
| export function fmt(field: Field, val: number): string { | ||||
|   if (field.display) { | ||||
|  | @ -7,3 +26,301 @@ export function fmt(field: Field, val: number): string { | |||
| 
 | ||||
|   return `${val}`; | ||||
| } | ||||
| 
 | ||||
| // cause we dont have a proper matcher for this currently
 | ||||
| function getFrameMatcher2(config: MatcherConfig) { | ||||
|   if (config.id === FrameMatcherID.byIndex) { | ||||
|     return (frame: DataFrame, index: number) => index === config.options; | ||||
|   } | ||||
| 
 | ||||
|   return () => false; | ||||
| } | ||||
| 
 | ||||
| export function prepSeries( | ||||
|   mapping: SeriesMapping, | ||||
|   mappedSeries: XYSeriesConfig[], | ||||
|   frames: DataFrame[], | ||||
|   fieldConfig: FieldConfigSource | ||||
| ) { | ||||
|   cacheFieldDisplayNames(frames); | ||||
|   decoupleHideFromState(frames, fieldConfig); | ||||
| 
 | ||||
|   let series: XYSeries[] = []; | ||||
| 
 | ||||
|   if (mappedSeries.length === 0) { | ||||
|     mappedSeries = [{}]; | ||||
|   } | ||||
| 
 | ||||
|   const { palette, getColorByName } = config.theme2.visualization; | ||||
| 
 | ||||
|   mappedSeries.forEach((seriesCfg, seriesIdx) => { | ||||
|     if (mapping === SeriesMapping.Manual) { | ||||
|       if (seriesCfg.frame?.matcher == null || seriesCfg.x?.matcher == null || seriesCfg.y?.matcher == null) { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     let xMatcher = getFieldMatcher( | ||||
|       seriesCfg.x?.matcher ?? { | ||||
|         id: FieldMatcherID.byType, | ||||
|         options: 'number', | ||||
|       } | ||||
|     ); | ||||
|     let yMatcher = getFieldMatcher( | ||||
|       seriesCfg.y?.matcher ?? { | ||||
|         id: FieldMatcherID.byType, | ||||
|         options: 'number', | ||||
|       } | ||||
|     ); | ||||
|     let colorMatcher = seriesCfg.color ? getFieldMatcher(seriesCfg.color.matcher) : null; | ||||
|     let sizeMatcher = seriesCfg.size ? getFieldMatcher(seriesCfg.size.matcher) : null; | ||||
|     // let frameMatcher = seriesCfg.frame ? getFrameMatchers(seriesCfg.frame) : null;
 | ||||
|     let frameMatcher = seriesCfg.frame ? getFrameMatcher2(seriesCfg.frame.matcher) : null; | ||||
| 
 | ||||
|     // loop over all frames and fields, adding a new series for each y dim
 | ||||
|     frames.forEach((frame, frameIdx) => { | ||||
|       // must match frame in manual mode
 | ||||
|       if (frameMatcher != null && !frameMatcher(frame, frameIdx)) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // shared across each series in this frame
 | ||||
|       let restFields: Field[] = []; | ||||
| 
 | ||||
|       let frameSeries: XYSeries[] = []; | ||||
| 
 | ||||
|       // only grabbing number fields (exclude time, string, enum, other)
 | ||||
|       let onlyNumFields = frame.fields.filter((field) => field.type === FieldType.number); | ||||
| 
 | ||||
|       // only one of these per frame
 | ||||
|       let x = onlyNumFields.find((field) => xMatcher(field, frame, frames)); | ||||
|       let color = | ||||
|         colorMatcher != null | ||||
|           ? onlyNumFields.find((field) => field !== x && colorMatcher!(field, frame, frames)) | ||||
|           : undefined; | ||||
|       let size = | ||||
|         sizeMatcher != null | ||||
|           ? onlyNumFields.find((field) => field !== x && field !== color && sizeMatcher!(field, frame, frames)) | ||||
|           : undefined; | ||||
| 
 | ||||
|       // x field is required
 | ||||
|       if (x != null) { | ||||
|         // match y fields and create series
 | ||||
|         onlyNumFields.forEach((field) => { | ||||
|           if (field === x) { | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // in auto mode don't reuse already-mapped fields
 | ||||
|           if (mapping === SeriesMapping.Auto && (field === color || field === size)) { | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // in manual mode only add single series for this config
 | ||||
|           if (mapping === SeriesMapping.Manual && frameSeries.length > 0) { | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // if we match non-excluded y, create series
 | ||||
|           if (yMatcher(field, frame, frames) && !field.config.custom?.hideFrom?.viz) { | ||||
|             let y = field; | ||||
|             let name = seriesCfg.name?.fixed ?? getFieldDisplayName(y, frame, frames); | ||||
| 
 | ||||
|             let ser: XYSeries = { | ||||
|               // these typically come from y field
 | ||||
|               name: { | ||||
|                 value: name, | ||||
|               }, | ||||
| 
 | ||||
|               showPoints: y.config.custom.show === XYShowMode.Lines ? VisibilityMode.Never : VisibilityMode.Always, | ||||
|               pointShape: y.config.custom.pointShape, | ||||
|               pointStrokeWidth: y.config.custom.pointStrokeWidth, | ||||
|               fillOpacity: y.config.custom.fillOpacity, | ||||
| 
 | ||||
|               showLine: y.config.custom.show !== XYShowMode.Points, | ||||
|               lineWidth: y.config.custom.lineWidth ?? 2, | ||||
|               lineStyle: y.config.custom.lineStyle, | ||||
| 
 | ||||
|               x: { | ||||
|                 field: x!, | ||||
|               }, | ||||
|               y: { | ||||
|                 field: y, | ||||
|               }, | ||||
|               color: {}, | ||||
|               size: {}, | ||||
|               _rest: restFields, | ||||
|             }; | ||||
| 
 | ||||
|             if (color != null) { | ||||
|               ser.color.field = color; | ||||
|             } | ||||
| 
 | ||||
|             if (size != null) { | ||||
|               ser.size.field = size; | ||||
|               ser.size.min = size.config.custom.pointSize?.min ?? 5; | ||||
|               ser.size.max = size.config.custom.pointSize?.max ?? 100; | ||||
|               // ser.size.mode =
 | ||||
|             } | ||||
| 
 | ||||
|             frameSeries.push(ser); | ||||
|           } | ||||
|         }); | ||||
| 
 | ||||
|         if (frameSeries.length === 0) { | ||||
|           // TODO: could not create series, skip & show error?
 | ||||
|         } | ||||
| 
 | ||||
|         // populate rest fields
 | ||||
|         frame.fields.forEach((field) => { | ||||
|           let isUsedField = frameSeries.some( | ||||
|             ({ x, y, color, size }) => | ||||
|               x.field === field || y.field === field || color.field === field || size.field === field | ||||
|           ); | ||||
| 
 | ||||
|           if (!isUsedField) { | ||||
|             restFields.push(field); | ||||
|           } | ||||
|         }); | ||||
| 
 | ||||
|         series.push(...frameSeries); | ||||
|       } else { | ||||
|         // x is missing in this frame!
 | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   if (series.length === 0) { | ||||
|     // TODO: could not create series, skip & show error?
 | ||||
|   } else { | ||||
|     // assign classic palette colors by index, as fallbacks for all series
 | ||||
| 
 | ||||
|     let paletteIdx = 0; | ||||
| 
 | ||||
|     // todo: populate min, max, mode from field + hints
 | ||||
|     series.forEach((s, i) => { | ||||
|       if (s.color.field == null) { | ||||
|         // derive fixed color from y field config
 | ||||
|         let colorCfg = s.y.field.config.color ?? { mode: FieldColorModeId.PaletteClassic }; | ||||
| 
 | ||||
|         let value = ''; | ||||
| 
 | ||||
|         if (colorCfg.mode === FieldColorModeId.PaletteClassic) { | ||||
|           value = getColorByName(palette[paletteIdx++ % palette.length]); // todo: do this via state.seriesIdx and re-init displayProcessor
 | ||||
|         } else if (colorCfg.mode === FieldColorModeId.Fixed) { | ||||
|           value = getColorByName(colorCfg.fixedColor!); | ||||
|         } | ||||
| 
 | ||||
|         s.color.fixed = value; | ||||
|       } | ||||
| 
 | ||||
|       if (s.size.field == null) { | ||||
|         // derive fixed size from y field config
 | ||||
|         s.size.fixed = s.y.field.config.custom.pointSize?.fixed ?? 5; | ||||
|         // ser.size.mode =
 | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     autoNameSeries(series); | ||||
| 
 | ||||
|     // TODO: re-assign y display names?
 | ||||
|     // y.state = {
 | ||||
|     //   ...y.state,
 | ||||
|     //   seriesIndex: series.length + ,
 | ||||
|     // };
 | ||||
|     // y.display = getDisplayProcessor({ field, theme });
 | ||||
|   } | ||||
| 
 | ||||
|   return series; | ||||
| } | ||||
| 
 | ||||
| // strip common prefixes and suffixes from y field names
 | ||||
| function autoNameSeries(series: XYSeries[]) { | ||||
|   let names = series.map((s) => s.name.value.split(/\s+/g)); | ||||
| 
 | ||||
|   const { prefix, suffix } = findCommonPrefixSuffixLengths(names); | ||||
| 
 | ||||
|   if (prefix < Infinity || suffix < Infinity) { | ||||
|     series.forEach((s, i) => { | ||||
|       s.name.value = names[i].slice(prefix, names[i].length - suffix).join(' '); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function getCommonPrefixSuffix(strs: string[]) { | ||||
|   let names = strs.map((s) => s.split(/\s+/g)); | ||||
| 
 | ||||
|   let { prefix, suffix } = findCommonPrefixSuffixLengths(names); | ||||
| 
 | ||||
|   let n = names[0]; | ||||
| 
 | ||||
|   if (n.length === 1 && prefix === 1 && suffix === 1) { | ||||
|     return ''; | ||||
|   } | ||||
| 
 | ||||
|   let parts = []; | ||||
| 
 | ||||
|   if (prefix > 0) { | ||||
|     parts.push(...n.slice(0, prefix)); | ||||
|   } | ||||
| 
 | ||||
|   if (suffix > 0) { | ||||
|     parts.push(...n.slice(-suffix)); | ||||
|   } | ||||
| 
 | ||||
|   return parts.join(' '); | ||||
| } | ||||
| 
 | ||||
| // lengths are in number of tokens (segments) in a phrase
 | ||||
| function findCommonPrefixSuffixLengths(names: string[][]) { | ||||
|   let commonPrefixLen = Infinity; | ||||
|   let commonSuffixLen = Infinity; | ||||
| 
 | ||||
|   // if auto naming strategy, rename fields by stripping common prefixes and suffixes
 | ||||
|   let segs0: string[] = names[0]; | ||||
| 
 | ||||
|   for (let i = 1; i < names.length; i++) { | ||||
|     if (names[i].length < segs0.length) { | ||||
|       segs0 = names[i]; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   for (let i = 1; i < names.length; i++) { | ||||
|     let segs = names[i]; | ||||
| 
 | ||||
|     if (segs !== segs0) { | ||||
|       // prefixes
 | ||||
|       let preLen = 0; | ||||
|       for (let j = 0; j < segs0.length; j++) { | ||||
|         if (segs[j] === segs0[j]) { | ||||
|           preLen++; | ||||
|         } else { | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (preLen < commonPrefixLen) { | ||||
|         commonPrefixLen = preLen; | ||||
|       } | ||||
| 
 | ||||
|       // suffixes
 | ||||
|       let sufLen = 0; | ||||
|       for (let j = segs0.length - 1; j >= 0; j--) { | ||||
|         if (segs[j] === segs0[j]) { | ||||
|           sufLen++; | ||||
|         } else { | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (sufLen < commonSuffixLen) { | ||||
|         commonSuffixLen = sufLen; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     prefix: commonPrefixLen, | ||||
|     suffix: commonSuffixLen, | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -1,3 +0,0 @@ | |||
| # XY Chart - Native Plugin | ||||
| 
 | ||||
| Support arbitrary X vs Y in graph | ||||
|  | @ -1,144 +0,0 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { useMemo } from 'react'; | ||||
| 
 | ||||
| import { FALLBACK_COLOR, PanelProps } from '@grafana/data'; | ||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { | ||||
|   TooltipDisplayMode, | ||||
|   TooltipPlugin2, | ||||
|   UPlotChart, | ||||
|   VizLayout, | ||||
|   VizLegend, | ||||
|   VizLegendItem, | ||||
|   useStyles2, | ||||
|   useTheme2, | ||||
| } from '@grafana/ui'; | ||||
| import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; | ||||
| import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils'; | ||||
| 
 | ||||
| import { XYChartTooltip } from './XYChartTooltip'; | ||||
| import { Options } from './panelcfg.gen'; | ||||
| import { prepConfig } from './scatter'; | ||||
| import { prepSeries } from './utils'; | ||||
| 
 | ||||
| type Props2 = PanelProps<Options>; | ||||
| 
 | ||||
| export const XYChartPanel2 = (props: Props2) => { | ||||
|   const styles = useStyles2(getStyles); | ||||
|   const theme = useTheme2(); | ||||
| 
 | ||||
|   let { mapping, series: mappedSeries } = props.options; | ||||
| 
 | ||||
|   // regenerate series schema when mappings or data changes
 | ||||
|   let series = useMemo( | ||||
|     () => prepSeries(mapping, mappedSeries, props.data.series, props.fieldConfig), | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [mapping, mappedSeries, props.data.series, props.fieldConfig] | ||||
|   ); | ||||
| 
 | ||||
|   // if series changed due to mappings or data structure, re-init config & renderers
 | ||||
|   let { builder, prepData } = useMemo( | ||||
|     () => prepConfig(series, config.theme2), | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [mapping, mappedSeries, props.data.structureRev, props.fieldConfig, props.options.tooltip] | ||||
|   ); | ||||
| 
 | ||||
|   // generate data struct for uPlot mode: 2
 | ||||
|   let data = useMemo( | ||||
|     () => prepData(series), | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     [series] | ||||
|   ); | ||||
| 
 | ||||
|   // todo: handle errors
 | ||||
|   let error = builder == null || data.length === 0 ? 'Err' : ''; | ||||
| 
 | ||||
|   // TODO: React.memo()
 | ||||
|   const renderLegend = () => { | ||||
|     if (!props.options.legend.showLegend) { | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const items: VizLegendItem[] = []; | ||||
| 
 | ||||
|     series.forEach((s, idx) => { | ||||
|       let yField = s.y.field; | ||||
|       let config = yField.config; | ||||
|       let custom = config.custom; | ||||
| 
 | ||||
|       if (!custom.hideFrom?.legend) { | ||||
|         items.push({ | ||||
|           yAxis: 1, // TODO: pull from y field
 | ||||
|           label: s.name.value, | ||||
|           color: alpha(s.color.fixed ?? FALLBACK_COLOR, 1), | ||||
|           getItemKey: () => `${idx}-${s.name.value}`, | ||||
|           fieldName: yField.state?.displayName ?? yField.name, | ||||
|           disabled: yField.state?.hideFrom?.viz ?? false, | ||||
|           getDisplayValues: () => getDisplayValuesForCalcs(props.options.legend.calcs, yField, theme), | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     const { placement, displayMode, width, sortBy, sortDesc } = props.options.legend; | ||||
| 
 | ||||
|     return ( | ||||
|       <VizLayout.Legend placement={placement} width={width}> | ||||
|         <VizLegend | ||||
|           className={styles.legend} | ||||
|           placement={placement} | ||||
|           items={items} | ||||
|           displayMode={displayMode} | ||||
|           sortBy={sortBy} | ||||
|           sortDesc={sortDesc} | ||||
|           isSortable={true} | ||||
|         /> | ||||
|       </VizLayout.Legend> | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div className="panel-empty"> | ||||
|         <p>{error}</p> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <VizLayout width={props.width} height={props.height} legend={renderLegend()}> | ||||
|       {(vizWidth: number, vizHeight: number) => ( | ||||
|         <UPlotChart config={builder!} data={data} width={vizWidth} height={vizHeight}> | ||||
|           {props.options.tooltip.mode !== TooltipDisplayMode.None && ( | ||||
|             <TooltipPlugin2 | ||||
|               config={builder!} | ||||
|               hoverMode={TooltipHoverMode.xyOne} | ||||
|               render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { | ||||
|                 return ( | ||||
|                   <XYChartTooltip | ||||
|                     data={props.data.series} | ||||
|                     dataIdxs={dataIdxs} | ||||
|                     xySeries={series} | ||||
|                     dismiss={dismiss} | ||||
|                     isPinned={isPinned} | ||||
|                     seriesIdx={seriesIdx!} | ||||
|                     replaceVariables={props.replaceVariables} | ||||
|                   /> | ||||
|                 ); | ||||
|               }} | ||||
|               maxWidth={props.options.tooltip.maxWidth} | ||||
|             /> | ||||
|           )} | ||||
|         </UPlotChart> | ||||
|       )} | ||||
|     </VizLayout> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = () => ({ | ||||
|   legend: css({ | ||||
|     div: { | ||||
|       justifyContent: 'flex-start', | ||||
|     }, | ||||
|   }), | ||||
| }); | ||||
|  | @ -1,111 +0,0 @@ | |||
| import { ReactNode } from 'react'; | ||||
| 
 | ||||
| import { DataFrame, InterpolateFunction } from '@grafana/data'; | ||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||
| import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; | ||||
| import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; | ||||
| import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; | ||||
| import { VizTooltipWrapper } from '@grafana/ui/src/components/VizTooltip/VizTooltipWrapper'; | ||||
| import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; | ||||
| 
 | ||||
| import { getDataLinks, getFieldActions } from '../../status-history/utils'; | ||||
| 
 | ||||
| import { XYSeries } from './types2'; | ||||
| import { fmt } from './utils'; | ||||
| 
 | ||||
| export interface Props { | ||||
|   dataIdxs: Array<number | null>; | ||||
|   seriesIdx: number | null | undefined; | ||||
|   isPinned: boolean; | ||||
|   dismiss: () => void; | ||||
|   data: DataFrame[]; | ||||
|   xySeries: XYSeries[]; | ||||
|   replaceVariables: InterpolateFunction; | ||||
| } | ||||
| 
 | ||||
| function stripSeriesName(fieldName: string, seriesName: string) { | ||||
|   if (fieldName !== seriesName && fieldName.includes(' ')) { | ||||
|     fieldName = fieldName.replace(seriesName, '').trim(); | ||||
|   } | ||||
| 
 | ||||
|   return fieldName; | ||||
| } | ||||
| 
 | ||||
| export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, isPinned, replaceVariables }: Props) => { | ||||
|   const rowIndex = dataIdxs.find((idx) => idx !== null)!; | ||||
| 
 | ||||
|   const series = xySeries[seriesIdx! - 1]; | ||||
|   const xField = series.x.field; | ||||
|   const yField = series.y.field; | ||||
| 
 | ||||
|   const sizeField = series.size.field; | ||||
|   const colorField = series.color.field; | ||||
| 
 | ||||
|   let label = series.name.value; | ||||
| 
 | ||||
|   let seriesColor = series.color.fixed; | ||||
|   // let colorField = series.color.field;
 | ||||
|   // let pointColor: string;
 | ||||
| 
 | ||||
|   // if (colorField != null) {
 | ||||
|   //   pointColor = colorField.display?.(colorField.values[rowIndex]).color!;
 | ||||
|   // }
 | ||||
| 
 | ||||
|   const headerItem: VizTooltipItem = { | ||||
|     label, | ||||
|     value: '', | ||||
|     color: alpha(seriesColor ?? '#fff', 0.5), | ||||
|     colorIndicator: ColorIndicator.marker_md, | ||||
|   }; | ||||
| 
 | ||||
|   const contentItems: VizTooltipItem[] = [ | ||||
|     { | ||||
|       label: stripSeriesName(xField.state?.displayName ?? xField.name, label), | ||||
|       value: fmt(xField, xField.values[rowIndex]), | ||||
|     }, | ||||
|     { | ||||
|       label: stripSeriesName(yField.state?.displayName ?? yField.name, label), | ||||
|       value: fmt(yField, yField.values[rowIndex]), | ||||
|     }, | ||||
|   ]; | ||||
| 
 | ||||
|   // mapped fields for size/color
 | ||||
|   if (sizeField != null && sizeField !== yField) { | ||||
|     contentItems.push({ | ||||
|       label: stripSeriesName(sizeField.state?.displayName ?? sizeField.name, label), | ||||
|       value: fmt(sizeField, sizeField.values[rowIndex]), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   if (colorField != null && colorField !== yField) { | ||||
|     contentItems.push({ | ||||
|       label: stripSeriesName(colorField.state?.displayName ?? colorField.name, label), | ||||
|       value: fmt(colorField, colorField.values[rowIndex]), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   series._rest.forEach((field) => { | ||||
|     contentItems.push({ | ||||
|       label: stripSeriesName(field.state?.displayName ?? field.name, label), | ||||
|       value: fmt(field, field.values[rowIndex]), | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   let footer: ReactNode; | ||||
| 
 | ||||
|   if (isPinned && seriesIdx != null) { | ||||
|     const links = getDataLinks(yField, rowIndex); | ||||
|     const yFieldFrame = data.find((frame) => frame.fields.includes(yField))!; | ||||
|     const actions = getFieldActions(yFieldFrame, yField, replaceVariables, rowIndex); | ||||
| 
 | ||||
|     footer = <VizTooltipFooter dataLinks={links} actions={actions} />; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <VizTooltipWrapper> | ||||
|       <VizTooltipHeader item={headerItem} isPinned={isPinned} /> | ||||
|       <VizTooltipContent items={contentItems} isPinned={isPinned} /> | ||||
|       {footer} | ||||
|     </VizTooltipWrapper> | ||||
|   ); | ||||
| }; | ||||
|  | @ -1,166 +0,0 @@ | |||
| import { | ||||
|   FieldColorModeId, | ||||
|   FieldConfigProperty, | ||||
|   FieldType, | ||||
|   identityOverrideProcessor, | ||||
|   SetFieldConfigOptionsArgs, | ||||
| } from '@grafana/data'; | ||||
| import { LineStyle } from '@grafana/schema'; | ||||
| import { commonOptionsBuilder } from '@grafana/ui'; | ||||
| 
 | ||||
| import { LineStyleEditor } from '../../timeseries/LineStyleEditor'; | ||||
| 
 | ||||
| import { FieldConfig, XYShowMode, PointShape } from './panelcfg.gen'; | ||||
| 
 | ||||
| export const DEFAULT_POINT_SIZE = 5; | ||||
| 
 | ||||
| export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsArgs<FieldConfig> { | ||||
|   return { | ||||
|     standardOptions: { | ||||
|       [FieldConfigProperty.Min]: { | ||||
|         hideFromDefaults: true, | ||||
|       }, | ||||
|       [FieldConfigProperty.Max]: { | ||||
|         hideFromDefaults: true, | ||||
|       }, | ||||
|       [FieldConfigProperty.Unit]: { | ||||
|         hideFromDefaults: true, | ||||
|       }, | ||||
|       [FieldConfigProperty.Decimals]: { | ||||
|         hideFromDefaults: true, | ||||
|       }, | ||||
|       [FieldConfigProperty.NoValue]: { | ||||
|         hideFromDefaults: true, | ||||
|       }, | ||||
|       [FieldConfigProperty.DisplayName]: { | ||||
|         hideFromDefaults: true, | ||||
|       }, | ||||
| 
 | ||||
|       // TODO: this still leaves Color series by: [ Last | Min | Max ]
 | ||||
|       // because item.settings?.bySeriesSupport && colorMode.isByValue
 | ||||
|       [FieldConfigProperty.Color]: { | ||||
|         settings: { | ||||
|           byValueSupport: true, | ||||
|           bySeriesSupport: true, | ||||
|           preferThresholdsMode: false, | ||||
|         }, | ||||
|         defaultValue: { | ||||
|           mode: FieldColorModeId.PaletteClassic, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
| 
 | ||||
|     useCustomConfig: (builder) => { | ||||
|       builder | ||||
|         .addRadio({ | ||||
|           path: 'show', | ||||
|           name: 'Show', | ||||
|           defaultValue: cfg.show, | ||||
|           settings: { | ||||
|             options: [ | ||||
|               { label: 'Points', value: XYShowMode.Points }, | ||||
|               { label: 'Lines', value: XYShowMode.Lines }, | ||||
|               { label: 'Both', value: XYShowMode.PointsAndLines }, | ||||
|             ], | ||||
|           }, | ||||
|         }) | ||||
|         // .addGenericEditor(
 | ||||
|         //   {
 | ||||
|         //     path: 'pointSymbol',
 | ||||
|         //     name: 'Point symbol',
 | ||||
|         //     defaultValue: defaultFieldConfig.pointSymbol ?? {
 | ||||
|         //       mode: 'fixed',
 | ||||
|         //       fixed: 'img/icons/marker/circle.svg',
 | ||||
|         //     },
 | ||||
|         //     settings: {
 | ||||
|         //       resourceType: MediaType.Icon,
 | ||||
|         //       folderName: ResourceFolderName.Marker,
 | ||||
|         //       placeholderText: 'Select a symbol',
 | ||||
|         //       placeholderValue: 'img/icons/marker/circle.svg',
 | ||||
|         //       showSourceRadio: false,
 | ||||
|         //     },
 | ||||
|         //     showIf: (c) => c.show !== ScatterShow.Lines,
 | ||||
|         //   },
 | ||||
|         //   SymbolEditor // ResourceDimensionEditor
 | ||||
|         // )
 | ||||
|         .addSliderInput({ | ||||
|           path: 'pointSize.fixed', | ||||
|           name: 'Point size', | ||||
|           defaultValue: cfg.pointSize?.fixed ?? DEFAULT_POINT_SIZE, | ||||
|           settings: { | ||||
|             min: 1, | ||||
|             max: 100, | ||||
|             step: 1, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addNumberInput({ | ||||
|           path: 'pointSize.min', | ||||
|           name: 'Min point size', | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addNumberInput({ | ||||
|           path: 'pointSize.max', | ||||
|           name: 'Max point size', | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addRadio({ | ||||
|           path: 'pointShape', | ||||
|           name: 'Point shape', | ||||
|           defaultValue: PointShape.Circle, | ||||
|           settings: { | ||||
|             options: [ | ||||
|               { value: PointShape.Circle, label: 'Circle' }, | ||||
|               { value: PointShape.Square, label: 'Square' }, | ||||
|             ], | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addSliderInput({ | ||||
|           path: 'pointStrokeWidth', | ||||
|           name: 'Point stroke width', | ||||
|           defaultValue: 1, | ||||
|           settings: { | ||||
|             min: 0, | ||||
|             max: 10, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addSliderInput({ | ||||
|           path: 'fillOpacity', | ||||
|           name: 'Fill opacity', | ||||
|           defaultValue: 50, | ||||
|           settings: { | ||||
|             min: 0, | ||||
|             max: 100, | ||||
|             step: 1, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Lines, | ||||
|         }) | ||||
|         .addCustomEditor<void, LineStyle>({ | ||||
|           id: 'lineStyle', | ||||
|           path: 'lineStyle', | ||||
|           name: 'Line style', | ||||
|           showIf: (c) => c.show !== XYShowMode.Points, | ||||
|           editor: LineStyleEditor, | ||||
|           override: LineStyleEditor, | ||||
|           process: identityOverrideProcessor, | ||||
|           shouldApply: (f) => f.type === FieldType.number, | ||||
|         }) | ||||
|         .addSliderInput({ | ||||
|           path: 'lineWidth', | ||||
|           name: 'Line width', | ||||
|           defaultValue: cfg.lineWidth, | ||||
|           settings: { | ||||
|             min: 0, | ||||
|             max: 10, | ||||
|             step: 1, | ||||
|           }, | ||||
|           showIf: (c) => c.show !== XYShowMode.Points, | ||||
|         }); | ||||
| 
 | ||||
|       commonOptionsBuilder.addAxisConfig(builder, cfg); | ||||
|       commonOptionsBuilder.addHideFrom(builder); | ||||
|     }, | ||||
|   }; | ||||
| } | ||||
|  | @ -1,37 +0,0 @@ | |||
| import { PanelPlugin } from '@grafana/data'; | ||||
| import { commonOptionsBuilder } from '@grafana/ui'; | ||||
| 
 | ||||
| import { SeriesEditor } from './SeriesEditor'; | ||||
| import { XYChartPanel2 } from './XYChartPanel'; | ||||
| import { getScatterFieldConfig } from './config'; | ||||
| import { xyChartMigrationHandler } from './migrations'; | ||||
| import { FieldConfig, defaultFieldConfig, Options } from './panelcfg.gen'; | ||||
| 
 | ||||
| export const plugin = new PanelPlugin<Options, FieldConfig>(XYChartPanel2) | ||||
|   // .setPanelChangeHandler(xyChartChangeHandler)
 | ||||
|   .setMigrationHandler(xyChartMigrationHandler) | ||||
|   .useFieldConfig(getScatterFieldConfig(defaultFieldConfig)) | ||||
|   .setPanelOptions((builder) => { | ||||
|     builder | ||||
|       .addRadio({ | ||||
|         path: 'mapping', | ||||
|         name: 'Series mapping', | ||||
|         defaultValue: 'auto', | ||||
|         settings: { | ||||
|           options: [ | ||||
|             { value: 'auto', label: 'Auto' }, | ||||
|             { value: 'manual', label: 'Manual' }, | ||||
|           ], | ||||
|         }, | ||||
|       }) | ||||
|       .addCustomEditor({ | ||||
|         id: 'series', | ||||
|         path: 'series', | ||||
|         name: '', | ||||
|         editor: SeriesEditor, | ||||
|         defaultValue: [{}], | ||||
|       }); | ||||
| 
 | ||||
|     commonOptionsBuilder.addTooltipOptions(builder, true); | ||||
|     commonOptionsBuilder.addLegendOptions(builder); | ||||
|   }); | ||||
|  | @ -1,85 +0,0 @@ | |||
| // Copyright 2023 Grafana Labs | ||||
| // | ||||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| // you may not use this file except in compliance with the License. | ||||
| // You may obtain a copy of the License at | ||||
| // | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0 | ||||
| // | ||||
| // Unless required by applicable law or agreed to in writing, software | ||||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| // See the License for the specific language governing permissions and | ||||
| // limitations under the License. | ||||
| 
 | ||||
| package grafanaplugin | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/grafana/grafana/packages/grafana-schema/src/common" | ||||
| ) | ||||
| 
 | ||||
| composableKinds: PanelCfg: { | ||||
| 	maturity: "experimental" | ||||
| 
 | ||||
| 	lineage: { | ||||
| 		schemas: [{ | ||||
| 			version: [0, 0] | ||||
| 			schema: { | ||||
| 				PointShape:    "circle" | "square"                 @cuetsy(kind="enum") | ||||
| 				SeriesMapping: "auto" | "manual"                   @cuetsy(kind="enum") | ||||
| 				XYShowMode:    "points" | "lines" | "points+lines" @cuetsy(kind="enum", memberNames="Points|Lines|PointsAndLines") | ||||
| 
 | ||||
| 				// NOTE: (copied from dashboard_kind.cue, since not exported) | ||||
| 				// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. | ||||
| 				// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | ||||
| 				#MatcherConfig: { | ||||
| 					// The matcher id. This is used to find the matcher implementation from registry. | ||||
| 					id: string | *"" @grafanamaturity(NeedsExpertReview)
 | ||||
| 					// The matcher options. This is specific to the matcher implementation. | ||||
| 					options?: _ @grafanamaturity(NeedsExpertReview) | ||||
| 				} @cuetsy(kind="interface") @grafana(TSVeneer="type") | ||||
| 
 | ||||
| 				FieldConfig: { | ||||
| 					common.HideableFieldConfig | ||||
| 					common.AxisConfig | ||||
| 
 | ||||
| 					show?: XYShowMode & (*"points" | _) | ||||
| 
 | ||||
| 					pointSize?:  { | ||||
| 						fixed?: int32 & >=0 | ||||
| 						min?:   int32 & >=0 | ||||
| 						max?:   int32 & >=0 | ||||
| 					} | ||||
| 
 | ||||
| 					pointShape?: PointShape | ||||
| 
 | ||||
| 					pointStrokeWidth?: int32 & >=0 | ||||
| 
 | ||||
| 					fillOpacity?: uint32 & <=100 | *50 | ||||
| 
 | ||||
| 					lineWidth?: int32 & >=0 | ||||
| 					lineStyle?: common.LineStyle | ||||
| 				} @cuetsy(kind="interface",TSVeneer="type") | ||||
| 
 | ||||
| 				XYSeriesConfig: { | ||||
| 					name?:   { fixed?: string } | ||||
| 					frame?:  { matcher: #MatcherConfig } | ||||
| 					x?:      { matcher: #MatcherConfig } | ||||
| 					y?:      { matcher: #MatcherConfig } | ||||
| 					color?:  { matcher: #MatcherConfig } | ||||
| 					size?:   { matcher: #MatcherConfig } | ||||
| 				} @cuetsy(kind="interface") | ||||
| 
 | ||||
| 				Options: { | ||||
| 					common.OptionsWithLegend | ||||
| 					common.OptionsWithTooltip | ||||
| 
 | ||||
| 					mapping: SeriesMapping | ||||
| 
 | ||||
| 					series: [...XYSeriesConfig] | ||||
| 				} @cuetsy(kind="interface") | ||||
| 			} | ||||
| 		}] | ||||
| 		lenses: [] | ||||
| 	} | ||||
| } | ||||
|  | @ -1,96 +0,0 @@ | |||
| // Code generated - EDITING IS FUTILE. DO NOT EDIT.
 | ||||
| //
 | ||||
| // Generated by:
 | ||||
| //     public/app/plugins/gen.go
 | ||||
| // Using jennies:
 | ||||
| //     TSTypesJenny
 | ||||
| //     PluginTsTypesJenny
 | ||||
| //
 | ||||
| // Run 'make gen-cue' from repository root to regenerate.
 | ||||
| 
 | ||||
| import * as common from '@grafana/schema'; | ||||
| 
 | ||||
| export enum PointShape { | ||||
|   Circle = 'circle', | ||||
|   Square = 'square', | ||||
| } | ||||
| 
 | ||||
| export enum SeriesMapping { | ||||
|   Auto = 'auto', | ||||
|   Manual = 'manual', | ||||
| } | ||||
| 
 | ||||
| export enum XYShowMode { | ||||
|   Lines = 'lines', | ||||
|   Points = 'points', | ||||
|   PointsAndLines = 'points+lines', | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * NOTE: (copied from dashboard_kind.cue, since not exported) | ||||
|  * Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. | ||||
|  * It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | ||||
|  */ | ||||
| export interface MatcherConfig { | ||||
|   /** | ||||
|    * The matcher id. This is used to find the matcher implementation from registry. | ||||
|    */ | ||||
|   id: string; | ||||
|   /** | ||||
|    * The matcher options. This is specific to the matcher implementation. | ||||
|    */ | ||||
|   options?: unknown; | ||||
| } | ||||
| 
 | ||||
| export const defaultMatcherConfig: Partial<MatcherConfig> = { | ||||
|   id: '', | ||||
| }; | ||||
| 
 | ||||
| export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | ||||
|   fillOpacity?: number; | ||||
|   lineStyle?: common.LineStyle; | ||||
|   lineWidth?: number; | ||||
|   pointShape?: PointShape; | ||||
|   pointSize?: { | ||||
|     fixed?: number; | ||||
|     min?: number; | ||||
|     max?: number; | ||||
|   }; | ||||
|   pointStrokeWidth?: number; | ||||
|   show?: XYShowMode; | ||||
| } | ||||
| 
 | ||||
| export const defaultFieldConfig: Partial<FieldConfig> = { | ||||
|   fillOpacity: 50, | ||||
|   show: XYShowMode.Points, | ||||
| }; | ||||
| 
 | ||||
| export interface XYSeriesConfig { | ||||
|   color?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   frame?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   name?: { | ||||
|     fixed?: string; | ||||
|   }; | ||||
|   size?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   x?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
|   y?: { | ||||
|     matcher: MatcherConfig; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | ||||
|   mapping: SeriesMapping; | ||||
|   series: Array<XYSeriesConfig>; | ||||
| } | ||||
| 
 | ||||
| export const defaultOptions: Partial<Options> = { | ||||
|   series: [], | ||||
| }; | ||||
|  | @ -1,19 +0,0 @@ | |||
| { | ||||
|   "type": "panel", | ||||
|   "name": "XY Chart", | ||||
|   "id": "xychart", | ||||
|   "state": "beta", | ||||
| 
 | ||||
|   "info": { | ||||
|     "description": "Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.", | ||||
|     "keywords": ["scatter", "plot"], | ||||
|     "author": { | ||||
|       "name": "Grafana Labs", | ||||
|       "url": "https://grafana.com" | ||||
|     }, | ||||
|     "logos": { | ||||
|       "small": "img/icn-xychart.svg", | ||||
|       "large": "img/icn-xychart.svg" | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -1,689 +0,0 @@ | |||
| import tinycolor from 'tinycolor2'; | ||||
| import uPlot from 'uplot'; | ||||
| 
 | ||||
| import { | ||||
|   FALLBACK_COLOR, | ||||
|   Field, | ||||
|   FieldType, | ||||
|   formattedValueToString, | ||||
|   getFieldColorModeForField, | ||||
|   GrafanaTheme2, | ||||
|   MappingType, | ||||
|   SpecialValueMatch, | ||||
|   ThresholdsMode, | ||||
| } from '@grafana/data'; | ||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||
| import { AxisPlacement, FieldColorModeId, ScaleDirection, ScaleOrientation, VisibilityMode } from '@grafana/schema'; | ||||
| import { UPlotConfigBuilder } from '@grafana/ui'; | ||||
| import { FacetedData, FacetSeries } from '@grafana/ui/src/components/uPlot/types'; | ||||
| 
 | ||||
| import { pointWithin, Quadtree, Rect } from '../../barchart/quadtree'; | ||||
| import { valuesToFills } from '../../heatmap/utils'; | ||||
| 
 | ||||
| import { PointShape } from './panelcfg.gen'; | ||||
| import { XYSeries } from './types2'; | ||||
| import { getCommonPrefixSuffix } from './utils'; | ||||
| 
 | ||||
| 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[]; | ||||
|     }; | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => { | ||||
|   if (xySeries.length === 0) { | ||||
|     return { builder: null, prepData: () => [] }; | ||||
|   } | ||||
| 
 | ||||
|   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 pxRatio = uPlot.pxRatio; | ||||
|           const scatterInfo = xySeries[seriesIdx - 1]; | ||||
|           let d = u.data[seriesIdx] as unknown as FacetSeries; | ||||
| 
 | ||||
|           // showLine: boolean;
 | ||||
|           // lineStyle: common.LineStyle;
 | ||||
|           // showPoints: common.VisibilityMode;
 | ||||
| 
 | ||||
|           let showLine = scatterInfo.showLine; | ||||
|           let showPoints = scatterInfo.showPoints === VisibilityMode.Always; | ||||
|           let strokeWidth = scatterInfo.pointStrokeWidth ?? 0; | ||||
| 
 | ||||
|           u.ctx.save(); | ||||
| 
 | ||||
|           u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); | ||||
|           u.ctx.clip(); | ||||
| 
 | ||||
|           let pointAlpha = scatterInfo.fillOpacity / 100; | ||||
| 
 | ||||
|           u.ctx.fillStyle = alpha((series.fill as any)(), pointAlpha); | ||||
|           u.ctx.strokeStyle = alpha((series.stroke as any)(), 1); | ||||
|           u.ctx.lineWidth = strokeWidth; | ||||
| 
 | ||||
|           let deg360 = 2 * Math.PI; | ||||
| 
 | ||||
|           let xKey = scaleX.key!; | ||||
|           let yKey = scaleY.key!; | ||||
| 
 | ||||
|           //const colorMode = getFieldColorModeForField(field); // isByValue
 | ||||
|           const pointSize = scatterInfo.y.field.config.custom.pointSize; | ||||
|           const colorByValue = scatterInfo.color.field != null; // && colorMode.isByValue;
 | ||||
| 
 | ||||
|           let maxSize = (pointSize.max ?? pointSize.fixed) * pxRatio; | ||||
| 
 | ||||
|           // 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 / pxRatio + maxSize / 2, xKey); | ||||
|           let filtBtm = u.posToVal(u.bbox.height / pxRatio + 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 pointColors = dispColors[seriesIdx - 1].values; // idxs
 | ||||
|           let pointPalette = dispColors[seriesIdx - 1].index as Array<CanvasRenderingContext2D['fillStyle']>; | ||||
|           let paletteHasAlpha = dispColors[seriesIdx - 1].hasAlpha; | ||||
| 
 | ||||
|           let isSquare = scatterInfo.pointShape === PointShape.Square; | ||||
| 
 | ||||
|           let linePath: Path2D | null = showLine ? new Path2D() : null; | ||||
| 
 | ||||
|           let curColorIdx = -1; | ||||
| 
 | ||||
|           for (let i = 0; i < d[0].length; i++) { | ||||
|             let xVal = d[0][i]; | ||||
|             let yVal = d[1][i]; | ||||
| 
 | ||||
|             if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { | ||||
|               let size = Math.round(sizes[i] * pxRatio); | ||||
|               let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||
|               let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||
| 
 | ||||
|               if (showLine) { | ||||
|                 linePath!.lineTo(cx, cy); | ||||
|               } | ||||
| 
 | ||||
|               if (showPoints) { | ||||
|                 if (colorByValue) { | ||||
|                   if (pointColors[i] !== curColorIdx) { | ||||
|                     curColorIdx = pointColors[i]; | ||||
|                     let c = curColorIdx === -1 ? FALLBACK_COLOR : pointPalette[curColorIdx]; | ||||
|                     u.ctx.fillStyle = paletteHasAlpha ? c : alpha(c as string, pointAlpha); | ||||
|                     u.ctx.strokeStyle = alpha(c as string, 1); | ||||
|                   } | ||||
|                 } | ||||
| 
 | ||||
|                 if (isSquare) { | ||||
|                   let x = Math.round(cx - size / 2); | ||||
|                   let y = Math.round(cy - size / 2); | ||||
| 
 | ||||
|                   if (colorByValue || pointAlpha > 0) { | ||||
|                     u.ctx.fillRect(x, y, size, size); | ||||
|                   } | ||||
| 
 | ||||
|                   if (strokeWidth > 0) { | ||||
|                     u.ctx.strokeRect(x, y, size, size); | ||||
|                   } | ||||
|                 } else { | ||||
|                   u.ctx.beginPath(); | ||||
|                   u.ctx.arc(cx, cy, size / 2, 0, deg360); | ||||
| 
 | ||||
|                   if (colorByValue || pointAlpha > 0) { | ||||
|                     u.ctx.fill(); | ||||
|                   } | ||||
| 
 | ||||
|                   if (strokeWidth > 0) { | ||||
|                     u.ctx.stroke(); | ||||
|                   } | ||||
|                 } | ||||
| 
 | ||||
|                 opts.each( | ||||
|                   u, | ||||
|                   seriesIdx, | ||||
|                   i, | ||||
|                   cx - size / 2 - strokeWidth / 2, | ||||
|                   cy - size / 2 - strokeWidth / 2, | ||||
|                   size + strokeWidth, | ||||
|                   size + strokeWidth | ||||
|                 ); | ||||
|               } | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|           if (showLine) { | ||||
|             u.ctx.strokeStyle = scatterInfo.color.fixed!; | ||||
|             u.ctx.lineWidth = scatterInfo.lineWidth * pxRatio; | ||||
| 
 | ||||
|             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; | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     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) { | ||||
|         const pxRatio = uPlot.pxRatio; | ||||
| 
 | ||||
|         hRect = null; | ||||
| 
 | ||||
|         let dist = Infinity; | ||||
|         let cx = u.cursor.left! * pxRatio; | ||||
|         let cy = u.cursor.top! * pxRatio; | ||||
| 
 | ||||
|         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 / uPlot.pxRatio : 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'; | ||||
|   }); | ||||
| 
 | ||||
|   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); | ||||
| 
 | ||||
|   let xField = xySeries[0].x.field; | ||||
| 
 | ||||
|   let fieldConfig = xField.config; | ||||
|   let customConfig = fieldConfig.custom; | ||||
|   let scaleDistr = customConfig?.scaleDistribution; | ||||
| 
 | ||||
|   builder.addScale({ | ||||
|     scaleKey: 'x', | ||||
|     isTime: false, | ||||
|     orientation: ScaleOrientation.Horizontal, | ||||
|     direction: ScaleDirection.Right, | ||||
|     distribution: scaleDistr?.type, | ||||
|     log: scaleDistr?.log, | ||||
|     linearThreshold: scaleDistr?.linearThreshold, | ||||
|     min: fieldConfig.min, | ||||
|     max: fieldConfig.max, | ||||
|     softMin: customConfig?.axisSoftMin, | ||||
|     softMax: customConfig?.axisSoftMax, | ||||
|     centeredZero: customConfig?.axisCenteredZero, | ||||
|     decimals: fieldConfig.decimals, | ||||
|   }); | ||||
| 
 | ||||
|   // why does this fall back to '' instead of null or undef?
 | ||||
|   let xAxisLabel = customConfig.axisLabel; | ||||
| 
 | ||||
|   if (xAxisLabel == null || xAxisLabel === '') { | ||||
|     let dispNames = xySeries.map((s) => s.x.field.state?.displayName ?? ''); | ||||
| 
 | ||||
|     let xAxisAutoLabel = | ||||
|       xySeries.length === 1 | ||||
|         ? (xField.state?.displayName ?? xField.name) | ||||
|         : new Set(dispNames).size === 1 | ||||
|           ? dispNames[0] | ||||
|           : getCommonPrefixSuffix(dispNames); | ||||
| 
 | ||||
|     if (xAxisAutoLabel !== '') { | ||||
|       xAxisLabel = xAxisAutoLabel; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   builder.addAxis({ | ||||
|     scaleKey: 'x', | ||||
|     placement: customConfig?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden, | ||||
|     show: customConfig?.axisPlacement !== AxisPlacement.Hidden, | ||||
|     grid: { show: customConfig?.axisGridShow }, | ||||
|     border: { show: customConfig?.axisBorderShow }, | ||||
|     theme, | ||||
|     label: xAxisLabel, | ||||
|     formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), | ||||
|   }); | ||||
| 
 | ||||
|   xySeries.forEach((s, si) => { | ||||
|     let field = s.y.field; | ||||
| 
 | ||||
|     const lineColor = s.color.fixed; | ||||
|     const pointColor = s.color.fixed; | ||||
|     //const lineColor = s.lineColor(frame);
 | ||||
|     //const lineWidth = s.lineWidth;
 | ||||
| 
 | ||||
|     let scaleKey = field.config.unit ?? 'y'; | ||||
|     let config = field.config; | ||||
|     let customConfig = config.custom; | ||||
|     let scaleDistr = customConfig?.scaleDistribution; | ||||
| 
 | ||||
|     builder.addScale({ | ||||
|       scaleKey, | ||||
|       orientation: ScaleOrientation.Vertical, | ||||
|       direction: ScaleDirection.Up, | ||||
|       distribution: scaleDistr?.type, | ||||
|       log: scaleDistr?.log, | ||||
|       linearThreshold: scaleDistr?.linearThreshold, | ||||
|       min: config.min, | ||||
|       max: config.max, | ||||
|       softMin: customConfig?.axisSoftMin, | ||||
|       softMax: customConfig?.axisSoftMax, | ||||
|       centeredZero: customConfig?.axisCenteredZero, | ||||
|       decimals: config.decimals, | ||||
|     }); | ||||
| 
 | ||||
|     // why does this fall back to '' instead of null or undef?
 | ||||
|     let yAxisLabel = customConfig.axisLabel; | ||||
| 
 | ||||
|     if (yAxisLabel == null || yAxisLabel === '') { | ||||
|       let dispNames = xySeries.map((s) => s.y.field.state?.displayName ?? ''); | ||||
| 
 | ||||
|       let yAxisAutoLabel = | ||||
|         xySeries.length === 1 | ||||
|           ? (field.state?.displayName ?? field.name) | ||||
|           : new Set(dispNames).size === 1 | ||||
|             ? dispNames[0] | ||||
|             : getCommonPrefixSuffix(dispNames); | ||||
| 
 | ||||
|       if (yAxisAutoLabel !== '') { | ||||
|         yAxisLabel = yAxisAutoLabel; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     builder.addAxis({ | ||||
|       scaleKey, | ||||
|       theme, | ||||
|       placement: customConfig?.axisPlacement === AxisPlacement.Auto ? AxisPlacement.Left : customConfig?.axisPlacement, | ||||
|       show: customConfig?.axisPlacement !== AxisPlacement.Hidden, | ||||
|       grid: { show: customConfig?.axisGridShow }, | ||||
|       border: { show: customConfig?.axisBorderShow }, | ||||
|       size: customConfig?.axisWidth, | ||||
|       // label: yAxisLabel == null || yAxisLabel === '' ? fieldDisplayName : yAxisLabel,
 | ||||
|       label: yAxisLabel, | ||||
|       formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)), | ||||
|     }); | ||||
| 
 | ||||
|     builder.addSeries({ | ||||
|       facets: [ | ||||
|         { | ||||
|           scale: 'x', | ||||
|           auto: true, | ||||
|         }, | ||||
|         { | ||||
|           scale: scaleKey, | ||||
|           auto: true, | ||||
|         }, | ||||
|       ], | ||||
|       pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}})
 | ||||
|       theme, | ||||
|       scaleKey: '', // facets' scales used (above)
 | ||||
|       lineColor: alpha(lineColor ?? '#ffff', 1), | ||||
|       fillColor: alpha(pointColor ?? '#ffff', 0.5), | ||||
|       show: !field.state?.hideFrom?.viz, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   const dispColors = xySeries.map((s): FieldColorValuesWithCache => { | ||||
|     const cfg: FieldColorValuesWithCache = { | ||||
|       index: [], | ||||
|       getAll: () => [], | ||||
|       getOne: () => -1, | ||||
|       // cache for renderer, refreshed in prepData()
 | ||||
|       values: [], | ||||
|       hasAlpha: false, | ||||
|     }; | ||||
| 
 | ||||
|     const f = s.color.field; | ||||
| 
 | ||||
|     if (f != null) { | ||||
|       Object.assign(cfg, fieldValueColors(f, theme)); | ||||
|       cfg.hasAlpha = cfg.index.some((v) => !(v as string).endsWith('ff')); | ||||
|     } | ||||
| 
 | ||||
|     return cfg; | ||||
|   }); | ||||
| 
 | ||||
|   function prepData(xySeries: XYSeries[]): FacetedData { | ||||
|     // if (info.error || !data.length) {
 | ||||
|     //   return [null];
 | ||||
|     // }
 | ||||
| 
 | ||||
|     const { size: sizeRange, color: colorRange } = getGlobalRanges(xySeries); | ||||
| 
 | ||||
|     xySeries.forEach((s, i) => { | ||||
|       dispColors[i].values = dispColors[i].getAll(s.color.field?.values ?? [], colorRange.min, colorRange.max); | ||||
|     }); | ||||
| 
 | ||||
|     return [ | ||||
|       null, | ||||
|       ...xySeries.map((s, idx) => { | ||||
|         let len = s.x.field.values.length; | ||||
| 
 | ||||
|         let diams: number[]; | ||||
| 
 | ||||
|         if (s.size.field != null) { | ||||
|           let { min, max } = s.size; | ||||
| 
 | ||||
|           // todo: this scaling should be in renderer from raw values (not by passing css pixel diams via data)
 | ||||
|           let minPx = min! ** 2; | ||||
|           let maxPx = max! ** 2; | ||||
|           // use quadratic size scaling in byValue modes
 | ||||
|           let pxRange = maxPx - minPx; | ||||
| 
 | ||||
|           let vals = s.size.field.values; | ||||
|           let minVal = sizeRange.min; | ||||
|           let maxVal = sizeRange.max; | ||||
|           let valRange = maxVal - minVal; | ||||
| 
 | ||||
|           diams = Array(len); | ||||
| 
 | ||||
|           for (let i = 0; i < vals.length; i++) { | ||||
|             let val = vals[i]; | ||||
| 
 | ||||
|             let valPct = (val - minVal) / valRange; | ||||
|             let pxArea = minPx + valPct * pxRange; | ||||
|             diams[i] = pxArea ** 0.5; | ||||
|           } | ||||
|         } else { | ||||
|           diams = Array(len).fill(s.size.fixed!); | ||||
|         } | ||||
| 
 | ||||
|         return [ | ||||
|           s.x.field.values, // X
 | ||||
|           s.y.field.values, // Y
 | ||||
|           diams, | ||||
|           Array(len).fill(s.color.fixed!), // TODO: fails for by value
 | ||||
|         ]; | ||||
|       }), | ||||
|     ]; | ||||
|   } | ||||
| 
 | ||||
|   return { builder, prepData }; | ||||
| }; | ||||
| 
 | ||||
| export type PrepData = (xySeries: XYSeries[]) => FacetedData; | ||||
| 
 | ||||
| const getGlobalRanges = (xySeries: XYSeries[]) => { | ||||
|   const ranges = { | ||||
|     size: { | ||||
|       min: Infinity, | ||||
|       max: -Infinity, | ||||
|     }, | ||||
|     color: { | ||||
|       min: Infinity, | ||||
|       max: -Infinity, | ||||
|     }, | ||||
|   }; | ||||
| 
 | ||||
|   xySeries.forEach((series) => { | ||||
|     [series.size, series.color].forEach((facet, fi) => { | ||||
|       if (facet.field != null) { | ||||
|         let range = fi === 0 ? ranges.size : ranges.color; | ||||
| 
 | ||||
|         const vals = facet.field.values; | ||||
| 
 | ||||
|         for (let i = 0; i < vals.length; i++) { | ||||
|           const v = vals[i]; | ||||
| 
 | ||||
|           if (v != null) { | ||||
|             if (v < range.min) { | ||||
|               range.min = v; | ||||
|             } | ||||
| 
 | ||||
|             if (v > range.max) { | ||||
|               range.max = v; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   return ranges; | ||||
| }; | ||||
| 
 | ||||
| function getHex8Color(color: string, theme: GrafanaTheme2) { | ||||
|   return tinycolor(theme.visualization.getColorByName(color)).toHex8String(); | ||||
| } | ||||
| 
 | ||||
| interface FieldColorValues { | ||||
|   index: unknown[]; | ||||
|   getOne: GetOneValue; | ||||
|   getAll: GetAllValues; | ||||
| } | ||||
| interface FieldColorValuesWithCache extends FieldColorValues { | ||||
|   values: number[]; | ||||
|   hasAlpha: boolean; | ||||
| } | ||||
| type GetAllValues = (values: unknown[], min?: number, max?: number) => number[]; | ||||
| type GetOneValue = (value: unknown, min?: number, max?: number) => number; | ||||
| 
 | ||||
| /** compiler for values to palette color idxs (from thresholds, mappings, by-value gradients) */ | ||||
| function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { | ||||
|   let index: unknown[] = []; | ||||
|   let getAll: GetAllValues = () => []; | ||||
|   let getOne: GetOneValue = () => -1; | ||||
| 
 | ||||
|   let conds = ''; | ||||
| 
 | ||||
|   // if any mappings exist, use them regardless of other settings
 | ||||
|   if (f.config.mappings?.length ?? 0 > 0) { | ||||
|     let mappings = f.config.mappings!; | ||||
| 
 | ||||
|     for (let i = 0; i < mappings.length; i++) { | ||||
|       let m = mappings[i]; | ||||
| 
 | ||||
|       if (m.type === MappingType.ValueToText) { | ||||
|         for (let k in m.options) { | ||||
|           let { color } = m.options[k]; | ||||
| 
 | ||||
|           if (color != null) { | ||||
|             let rhs = f.type === FieldType.string ? JSON.stringify(k) : Number(k); | ||||
|             conds += `v === ${rhs} ? ${index.length} : `; | ||||
|             index.push(getHex8Color(color, theme)); | ||||
|           } | ||||
|         } | ||||
|       } else if (m.options.result.color != null) { | ||||
|         let { color } = m.options.result; | ||||
| 
 | ||||
|         if (m.type === MappingType.RangeToText) { | ||||
|           let range = []; | ||||
| 
 | ||||
|           if (m.options.from != null) { | ||||
|             range.push(`v >= ${Number(m.options.from)}`); | ||||
|           } | ||||
| 
 | ||||
|           if (m.options.to != null) { | ||||
|             range.push(`v <= ${Number(m.options.to)}`); | ||||
|           } | ||||
| 
 | ||||
|           if (range.length > 0) { | ||||
|             conds += `${range.join(' && ')} ? ${index.length} : `; | ||||
|             index.push(getHex8Color(color, theme)); | ||||
|           } | ||||
|         } else if (m.type === MappingType.SpecialValue) { | ||||
|           let spl = m.options.match; | ||||
| 
 | ||||
|           if (spl === SpecialValueMatch.NaN) { | ||||
|             conds += `isNaN(v)`; | ||||
|           } else if (spl === SpecialValueMatch.NullAndNaN) { | ||||
|             conds += `v == null || isNaN(v)`; | ||||
|           } else { | ||||
|             conds += `v ${ | ||||
|               spl === SpecialValueMatch.True | ||||
|                 ? '=== true' | ||||
|                 : spl === SpecialValueMatch.False | ||||
|                   ? '=== false' | ||||
|                   : spl === SpecialValueMatch.Null | ||||
|                     ? '== null' | ||||
|                     : spl === SpecialValueMatch.Empty | ||||
|                       ? '=== ""' | ||||
|                       : '== null' | ||||
|             }`;
 | ||||
|           } | ||||
| 
 | ||||
|           conds += ` ? ${index.length} : `; | ||||
|           index.push(getHex8Color(color, theme)); | ||||
|         } else if (m.type === MappingType.RegexToText) { | ||||
|           // TODO
 | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     conds += '-1'; // ?? what default here? null? FALLBACK_COLOR?
 | ||||
|   } else if (f.config.color?.mode === FieldColorModeId.Thresholds) { | ||||
|     if (f.config.thresholds?.mode === ThresholdsMode.Absolute) { | ||||
|       let steps = f.config.thresholds.steps; | ||||
|       let lasti = steps.length - 1; | ||||
| 
 | ||||
|       for (let i = lasti; i > 0; i--) { | ||||
|         conds += `v >= ${steps[i].value} ? ${i} : `; | ||||
|       } | ||||
| 
 | ||||
|       conds += '0'; | ||||
| 
 | ||||
|       index = steps.map((s) => getHex8Color(s.color, theme)); | ||||
|     } else { | ||||
|       // TODO: percent thresholds?
 | ||||
|     } | ||||
|   } else if (f.config.color?.mode?.startsWith('continuous')) { | ||||
|     let calc = getFieldColorModeForField(f).getCalculator(f, theme); | ||||
| 
 | ||||
|     index = Array(32); | ||||
| 
 | ||||
|     for (let i = 0; i < index.length; i++) { | ||||
|       let pct = i / (index.length - 1); | ||||
|       index[i] = getHex8Color(calc(pct, pct), theme); | ||||
|     } | ||||
| 
 | ||||
|     getAll = (vals, min, max) => valuesToFills(vals as number[], index as string[], min!, max!); | ||||
|   } | ||||
| 
 | ||||
|   if (conds !== '') { | ||||
|     getOne = new Function('v', `return ${conds};`) as GetOneValue; | ||||
| 
 | ||||
|     getAll = new Function( | ||||
|       'vals', | ||||
|       ` | ||||
|       let idxs = Array(vals.length); | ||||
| 
 | ||||
|       for (let i = 0; i < vals.length; i++) { | ||||
|         let v = vals[i]; | ||||
|         idxs[i] = ${conds}; | ||||
|       } | ||||
| 
 | ||||
|       return idxs; | ||||
|     ` | ||||
|     ) as GetAllValues; | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     index, | ||||
|     getOne, | ||||
|     getAll, | ||||
|   }; | ||||
| } | ||||
|  | @ -1,326 +0,0 @@ | |||
| import { | ||||
|   Field, | ||||
|   formattedValueToString, | ||||
|   getFieldMatcher, | ||||
|   FieldType, | ||||
|   getFieldDisplayName, | ||||
|   DataFrame, | ||||
|   FrameMatcherID, | ||||
|   MatcherConfig, | ||||
|   FieldColorModeId, | ||||
|   cacheFieldDisplayNames, | ||||
|   FieldMatcherID, | ||||
|   FieldConfigSource, | ||||
| } from '@grafana/data'; | ||||
| import { decoupleHideFromState } from '@grafana/data/src/field/fieldState'; | ||||
| import { config } from '@grafana/runtime'; | ||||
| import { VisibilityMode } from '@grafana/schema'; | ||||
| 
 | ||||
| import { XYShowMode, SeriesMapping, XYSeriesConfig } from './panelcfg.gen'; | ||||
| import { XYSeries } from './types2'; | ||||
| 
 | ||||
| export function fmt(field: Field, val: number): string { | ||||
|   if (field.display) { | ||||
|     return formattedValueToString(field.display(val)); | ||||
|   } | ||||
| 
 | ||||
|   return `${val}`; | ||||
| } | ||||
| 
 | ||||
| // cause we dont have a proper matcher for this currently
 | ||||
| function getFrameMatcher2(config: MatcherConfig) { | ||||
|   if (config.id === FrameMatcherID.byIndex) { | ||||
|     return (frame: DataFrame, index: number) => index === config.options; | ||||
|   } | ||||
| 
 | ||||
|   return () => false; | ||||
| } | ||||
| 
 | ||||
| export function prepSeries( | ||||
|   mapping: SeriesMapping, | ||||
|   mappedSeries: XYSeriesConfig[], | ||||
|   frames: DataFrame[], | ||||
|   fieldConfig: FieldConfigSource | ||||
| ) { | ||||
|   cacheFieldDisplayNames(frames); | ||||
|   decoupleHideFromState(frames, fieldConfig); | ||||
| 
 | ||||
|   let series: XYSeries[] = []; | ||||
| 
 | ||||
|   if (mappedSeries.length === 0) { | ||||
|     mappedSeries = [{}]; | ||||
|   } | ||||
| 
 | ||||
|   const { palette, getColorByName } = config.theme2.visualization; | ||||
| 
 | ||||
|   mappedSeries.forEach((seriesCfg, seriesIdx) => { | ||||
|     if (mapping === SeriesMapping.Manual) { | ||||
|       if (seriesCfg.frame?.matcher == null || seriesCfg.x?.matcher == null || seriesCfg.y?.matcher == null) { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     let xMatcher = getFieldMatcher( | ||||
|       seriesCfg.x?.matcher ?? { | ||||
|         id: FieldMatcherID.byType, | ||||
|         options: 'number', | ||||
|       } | ||||
|     ); | ||||
|     let yMatcher = getFieldMatcher( | ||||
|       seriesCfg.y?.matcher ?? { | ||||
|         id: FieldMatcherID.byType, | ||||
|         options: 'number', | ||||
|       } | ||||
|     ); | ||||
|     let colorMatcher = seriesCfg.color ? getFieldMatcher(seriesCfg.color.matcher) : null; | ||||
|     let sizeMatcher = seriesCfg.size ? getFieldMatcher(seriesCfg.size.matcher) : null; | ||||
|     // let frameMatcher = seriesCfg.frame ? getFrameMatchers(seriesCfg.frame) : null;
 | ||||
|     let frameMatcher = seriesCfg.frame ? getFrameMatcher2(seriesCfg.frame.matcher) : null; | ||||
| 
 | ||||
|     // loop over all frames and fields, adding a new series for each y dim
 | ||||
|     frames.forEach((frame, frameIdx) => { | ||||
|       // must match frame in manual mode
 | ||||
|       if (frameMatcher != null && !frameMatcher(frame, frameIdx)) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // shared across each series in this frame
 | ||||
|       let restFields: Field[] = []; | ||||
| 
 | ||||
|       let frameSeries: XYSeries[] = []; | ||||
| 
 | ||||
|       // only grabbing number fields (exclude time, string, enum, other)
 | ||||
|       let onlyNumFields = frame.fields.filter((field) => field.type === FieldType.number); | ||||
| 
 | ||||
|       // only one of these per frame
 | ||||
|       let x = onlyNumFields.find((field) => xMatcher(field, frame, frames)); | ||||
|       let color = | ||||
|         colorMatcher != null | ||||
|           ? onlyNumFields.find((field) => field !== x && colorMatcher!(field, frame, frames)) | ||||
|           : undefined; | ||||
|       let size = | ||||
|         sizeMatcher != null | ||||
|           ? onlyNumFields.find((field) => field !== x && field !== color && sizeMatcher!(field, frame, frames)) | ||||
|           : undefined; | ||||
| 
 | ||||
|       // x field is required
 | ||||
|       if (x != null) { | ||||
|         // match y fields and create series
 | ||||
|         onlyNumFields.forEach((field) => { | ||||
|           if (field === x) { | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // in auto mode don't reuse already-mapped fields
 | ||||
|           if (mapping === SeriesMapping.Auto && (field === color || field === size)) { | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // in manual mode only add single series for this config
 | ||||
|           if (mapping === SeriesMapping.Manual && frameSeries.length > 0) { | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // if we match non-excluded y, create series
 | ||||
|           if (yMatcher(field, frame, frames) && !field.config.custom?.hideFrom?.viz) { | ||||
|             let y = field; | ||||
|             let name = seriesCfg.name?.fixed ?? getFieldDisplayName(y, frame, frames); | ||||
| 
 | ||||
|             let ser: XYSeries = { | ||||
|               // these typically come from y field
 | ||||
|               name: { | ||||
|                 value: name, | ||||
|               }, | ||||
| 
 | ||||
|               showPoints: y.config.custom.show === XYShowMode.Lines ? VisibilityMode.Never : VisibilityMode.Always, | ||||
|               pointShape: y.config.custom.pointShape, | ||||
|               pointStrokeWidth: y.config.custom.pointStrokeWidth, | ||||
|               fillOpacity: y.config.custom.fillOpacity, | ||||
| 
 | ||||
|               showLine: y.config.custom.show !== XYShowMode.Points, | ||||
|               lineWidth: y.config.custom.lineWidth ?? 2, | ||||
|               lineStyle: y.config.custom.lineStyle, | ||||
| 
 | ||||
|               x: { | ||||
|                 field: x!, | ||||
|               }, | ||||
|               y: { | ||||
|                 field: y, | ||||
|               }, | ||||
|               color: {}, | ||||
|               size: {}, | ||||
|               _rest: restFields, | ||||
|             }; | ||||
| 
 | ||||
|             if (color != null) { | ||||
|               ser.color.field = color; | ||||
|             } | ||||
| 
 | ||||
|             if (size != null) { | ||||
|               ser.size.field = size; | ||||
|               ser.size.min = size.config.custom.pointSize?.min ?? 5; | ||||
|               ser.size.max = size.config.custom.pointSize?.max ?? 100; | ||||
|               // ser.size.mode =
 | ||||
|             } | ||||
| 
 | ||||
|             frameSeries.push(ser); | ||||
|           } | ||||
|         }); | ||||
| 
 | ||||
|         if (frameSeries.length === 0) { | ||||
|           // TODO: could not create series, skip & show error?
 | ||||
|         } | ||||
| 
 | ||||
|         // populate rest fields
 | ||||
|         frame.fields.forEach((field) => { | ||||
|           let isUsedField = frameSeries.some( | ||||
|             ({ x, y, color, size }) => | ||||
|               x.field === field || y.field === field || color.field === field || size.field === field | ||||
|           ); | ||||
| 
 | ||||
|           if (!isUsedField) { | ||||
|             restFields.push(field); | ||||
|           } | ||||
|         }); | ||||
| 
 | ||||
|         series.push(...frameSeries); | ||||
|       } else { | ||||
|         // x is missing in this frame!
 | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   if (series.length === 0) { | ||||
|     // TODO: could not create series, skip & show error?
 | ||||
|   } else { | ||||
|     // assign classic palette colors by index, as fallbacks for all series
 | ||||
| 
 | ||||
|     let paletteIdx = 0; | ||||
| 
 | ||||
|     // todo: populate min, max, mode from field + hints
 | ||||
|     series.forEach((s, i) => { | ||||
|       if (s.color.field == null) { | ||||
|         // derive fixed color from y field config
 | ||||
|         let colorCfg = s.y.field.config.color ?? { mode: FieldColorModeId.PaletteClassic }; | ||||
| 
 | ||||
|         let value = ''; | ||||
| 
 | ||||
|         if (colorCfg.mode === FieldColorModeId.PaletteClassic) { | ||||
|           value = getColorByName(palette[paletteIdx++ % palette.length]); // todo: do this via state.seriesIdx and re-init displayProcessor
 | ||||
|         } else if (colorCfg.mode === FieldColorModeId.Fixed) { | ||||
|           value = getColorByName(colorCfg.fixedColor!); | ||||
|         } | ||||
| 
 | ||||
|         s.color.fixed = value; | ||||
|       } | ||||
| 
 | ||||
|       if (s.size.field == null) { | ||||
|         // derive fixed size from y field config
 | ||||
|         s.size.fixed = s.y.field.config.custom.pointSize?.fixed ?? 5; | ||||
|         // ser.size.mode =
 | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     autoNameSeries(series); | ||||
| 
 | ||||
|     // TODO: re-assign y display names?
 | ||||
|     // y.state = {
 | ||||
|     //   ...y.state,
 | ||||
|     //   seriesIndex: series.length + ,
 | ||||
|     // };
 | ||||
|     // y.display = getDisplayProcessor({ field, theme });
 | ||||
|   } | ||||
| 
 | ||||
|   return series; | ||||
| } | ||||
| 
 | ||||
| // strip common prefixes and suffixes from y field names
 | ||||
| function autoNameSeries(series: XYSeries[]) { | ||||
|   let names = series.map((s) => s.name.value.split(/\s+/g)); | ||||
| 
 | ||||
|   const { prefix, suffix } = findCommonPrefixSuffixLengths(names); | ||||
| 
 | ||||
|   if (prefix < Infinity || suffix < Infinity) { | ||||
|     series.forEach((s, i) => { | ||||
|       s.name.value = names[i].slice(prefix, names[i].length - suffix).join(' '); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export function getCommonPrefixSuffix(strs: string[]) { | ||||
|   let names = strs.map((s) => s.split(/\s+/g)); | ||||
| 
 | ||||
|   let { prefix, suffix } = findCommonPrefixSuffixLengths(names); | ||||
| 
 | ||||
|   let n = names[0]; | ||||
| 
 | ||||
|   if (n.length === 1 && prefix === 1 && suffix === 1) { | ||||
|     return ''; | ||||
|   } | ||||
| 
 | ||||
|   let parts = []; | ||||
| 
 | ||||
|   if (prefix > 0) { | ||||
|     parts.push(...n.slice(0, prefix)); | ||||
|   } | ||||
| 
 | ||||
|   if (suffix > 0) { | ||||
|     parts.push(...n.slice(-suffix)); | ||||
|   } | ||||
| 
 | ||||
|   return parts.join(' '); | ||||
| } | ||||
| 
 | ||||
| // lengths are in number of tokens (segments) in a phrase
 | ||||
| function findCommonPrefixSuffixLengths(names: string[][]) { | ||||
|   let commonPrefixLen = Infinity; | ||||
|   let commonSuffixLen = Infinity; | ||||
| 
 | ||||
|   // if auto naming strategy, rename fields by stripping common prefixes and suffixes
 | ||||
|   let segs0: string[] = names[0]; | ||||
| 
 | ||||
|   for (let i = 1; i < names.length; i++) { | ||||
|     if (names[i].length < segs0.length) { | ||||
|       segs0 = names[i]; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   for (let i = 1; i < names.length; i++) { | ||||
|     let segs = names[i]; | ||||
| 
 | ||||
|     if (segs !== segs0) { | ||||
|       // prefixes
 | ||||
|       let preLen = 0; | ||||
|       for (let j = 0; j < segs0.length; j++) { | ||||
|         if (segs[j] === segs0[j]) { | ||||
|           preLen++; | ||||
|         } else { | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (preLen < commonPrefixLen) { | ||||
|         commonPrefixLen = preLen; | ||||
|       } | ||||
| 
 | ||||
|       // suffixes
 | ||||
|       let sufLen = 0; | ||||
|       for (let j = segs0.length - 1; j >= 0; j--) { | ||||
|         if (segs[j] === segs0[j]) { | ||||
|           sufLen++; | ||||
|         } else { | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       if (sufLen < commonSuffixLen) { | ||||
|         commonSuffixLen = sufLen; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return { | ||||
|     prefix: commonPrefixLen, | ||||
|     suffix: commonSuffixLen, | ||||
|   }; | ||||
| } | ||||
		Loading…
	
		Reference in New Issue