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, "\'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"] |       [0, 0, 0, "Unexpected any. Specify a different type.", "1"] | ||||||
|     ], |     ], | ||||||
|     "public/app/plugins/panel/xychart/ManualEditor.tsx:5381": [ |     "public/app/plugins/panel/xychart/SeriesEditor.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": [ |  | ||||||
|       [0, 0, 0, "Do not use any type assertions.", "0"], |       [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.", "1"], | ||||||
|       [0, 0, 0, "Do not use any type assertions.", "2"], |       [0, 0, 0, "Do not use any type assertions.", "2"], | ||||||
|       [0, 0, 0, "Do not use any type assertions.", "3"] |       [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"] |       [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.", "0"], | ||||||
|       [0, 0, 0, "Do not use any type assertions.", "1"], |       [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.", "2"], | ||||||
|  |  | ||||||
|  | @ -28,7 +28,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | ||||||
| | `publicDashboardsScene`                | Enables public dashboard rendering using scenes                                                                                                                           | Yes                | | | `publicDashboardsScene`                | Enables public dashboard rendering using scenes                                                                                                                           | Yes                | | ||||||
| | `featureHighlights`                    | Highlight Grafana Enterprise features                                                                                                                                     |                    | | | `featureHighlights`                    | Highlight Grafana Enterprise features                                                                                                                                     |                    | | ||||||
| | `correlations`                         | Correlations page                                                                                                                                                         | Yes                | | | `correlations`                         | Correlations page                                                                                                                                                         | Yes                | | ||||||
| | `autoMigrateXYChartPanel`              | Migrate old XYChart panel to new XYChart2 model                                                                                                                           | Yes                | |  | ||||||
| | `cloudWatchCrossAccountQuerying`       | Enables cross-account querying in CloudWatch datasources                                                                                                                  | Yes                | | | `cloudWatchCrossAccountQuerying`       | Enables cross-account querying in CloudWatch datasources                                                                                                                  | Yes                | | ||||||
| | `accessControlOnCall`                  | Access control primitives for OnCall                                                                                                                                      | Yes                | | | `accessControlOnCall`                  | Access control primitives for OnCall                                                                                                                                      | Yes                | | ||||||
| | `nestedFolders`                        | Enable folder nesting                                                                                                                                                     | Yes                | | | `nestedFolders`                        | Enable folder nesting                                                                                                                                                     | Yes                | | ||||||
|  |  | ||||||
|  | @ -35,7 +35,6 @@ export interface FeatureToggles { | ||||||
|   autoMigratePiechartPanel?: boolean; |   autoMigratePiechartPanel?: boolean; | ||||||
|   autoMigrateWorldmapPanel?: boolean; |   autoMigrateWorldmapPanel?: boolean; | ||||||
|   autoMigrateStatPanel?: boolean; |   autoMigrateStatPanel?: boolean; | ||||||
|   autoMigrateXYChartPanel?: boolean; |  | ||||||
|   disableAngular?: boolean; |   disableAngular?: boolean; | ||||||
|   canvasPanelNesting?: boolean; |   canvasPanelNesting?: boolean; | ||||||
|   vizActions?: boolean; |   vizActions?: boolean; | ||||||
|  |  | ||||||
|  | @ -12,66 +12,85 @@ import * as common from '@grafana/schema'; | ||||||
| 
 | 
 | ||||||
| export const pluginVersion = "11.4.0-pre"; | export const pluginVersion = "11.4.0-pre"; | ||||||
| 
 | 
 | ||||||
| /** | export enum PointShape { | ||||||
|  * Auto is "table" in the UI |   Circle = 'circle', | ||||||
|  */ |   Square = 'square', | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export enum SeriesMapping { | export enum SeriesMapping { | ||||||
|   Auto = 'auto', |   Auto = 'auto', | ||||||
|   Manual = 'manual', |   Manual = 'manual', | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export enum ScatterShow { | export enum XYShowMode { | ||||||
|   Lines = 'lines', |   Lines = 'lines', | ||||||
|   Points = 'points', |   Points = 'points', | ||||||
|   PointsAndLines = 'points+lines', |   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 { | export interface MatcherConfig { | ||||||
|   exclude?: Array<string>; |   /** | ||||||
|   frame: number; |    * The matcher id. This is used to find the matcher implementation from registry. | ||||||
|   x?: string; |    */ | ||||||
|  |   id: string; | ||||||
|  |   /** | ||||||
|  |    * The matcher options. This is specific to the matcher implementation. | ||||||
|  |    */ | ||||||
|  |   options?: unknown; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const defaultXYDimensionConfig: Partial<XYDimensionConfig> = { | export const defaultMatcherConfig: Partial<MatcherConfig> = { | ||||||
|   exclude: [], |   id: '', | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | ||||||
|   label?: common.VisibilityMode; |   fillOpacity?: number; | ||||||
|   labelValue?: common.TextDimensionConfig; |  | ||||||
|   lineColor?: common.ColorDimensionConfig; |  | ||||||
|   lineStyle?: common.LineStyle; |   lineStyle?: common.LineStyle; | ||||||
|   lineWidth?: number; |   lineWidth?: number; | ||||||
|   pointColor?: common.ColorDimensionConfig; |   pointShape?: PointShape; | ||||||
|   pointSize?: common.ScaleDimensionConfig; |   pointSize?: { | ||||||
|   show?: ScatterShow; |     fixed?: number; | ||||||
|  |     min?: number; | ||||||
|  |     max?: number; | ||||||
|  |   }; | ||||||
|  |   pointStrokeWidth?: number; | ||||||
|  |   show?: XYShowMode; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const defaultFieldConfig: Partial<FieldConfig> = { | export const defaultFieldConfig: Partial<FieldConfig> = { | ||||||
|   label: common.VisibilityMode.Auto, |   fillOpacity: 50, | ||||||
|   show: ScatterShow.Points, |   show: XYShowMode.Points, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export interface ScatterSeriesConfig extends FieldConfig { | export interface XYSeriesConfig { | ||||||
|   frame?: number; |   color?: { | ||||||
|   name?: string; |     matcher: MatcherConfig; | ||||||
|   x?: string; |   }; | ||||||
|   y?: string; |   frame?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
|  |   name?: { | ||||||
|  |     fixed?: string; | ||||||
|  |   }; | ||||||
|  |   size?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
|  |   x?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
|  |   y?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | ||||||
|   /** |   mapping: SeriesMapping; | ||||||
|    * Table Mode (auto) |   series: Array<XYSeriesConfig>; | ||||||
|    */ |  | ||||||
|   dims: XYDimensionConfig; |  | ||||||
|   /** |  | ||||||
|    * Manual Mode |  | ||||||
|    */ |  | ||||||
|   series: Array<ScatterSeriesConfig>; |  | ||||||
|   seriesMapping?: SeriesMapping; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const defaultOptions: Partial<Options> = { | export const defaultOptions: Partial<Options> = { | ||||||
|  |  | ||||||
|  | @ -142,14 +142,6 @@ var ( | ||||||
| 			FrontendOnly: true, | 			FrontendOnly: true, | ||||||
| 			Owner:        grafanaDatavizSquad, | 			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", | 			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.", | 			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 | autoMigratePiechartPanel,preview,@grafana/dataviz-squad,false,false,true | ||||||
| autoMigrateWorldmapPanel,preview,@grafana/dataviz-squad,false,false,true | autoMigrateWorldmapPanel,preview,@grafana/dataviz-squad,false,false,true | ||||||
| autoMigrateStatPanel,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 | disableAngular,preview,@grafana/dataviz-squad,false,false,true | ||||||
| canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true | canvasPanelNesting,experimental,@grafana/dataviz-squad,false,false,true | ||||||
| vizActions,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
 | 	// Migrate old stat panel to supported stat panel - broken out from autoMigrateOldPanels to enable granular tracking
 | ||||||
| 	FlagAutoMigrateStatPanel = "autoMigrateStatPanel" | 	FlagAutoMigrateStatPanel = "autoMigrateStatPanel" | ||||||
| 
 | 
 | ||||||
| 	// FlagAutoMigrateXYChartPanel
 |  | ||||||
| 	// Migrate old XYChart panel to new XYChart2 model
 |  | ||||||
| 	FlagAutoMigrateXYChartPanel = "autoMigrateXYChartPanel" |  | ||||||
| 
 |  | ||||||
| 	// FlagDisableAngular
 | 	// 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.
 | 	// 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" | 	FlagDisableAngular = "disableAngular" | ||||||
|  |  | ||||||
|  | @ -535,6 +535,7 @@ | ||||||
|         "name": "autoMigrateXYChartPanel", |         "name": "autoMigrateXYChartPanel", | ||||||
|         "resourceVersion": "1722537244598", |         "resourceVersion": "1722537244598", | ||||||
|         "creationTimestamp": "2024-03-22T15:44:37Z", |         "creationTimestamp": "2024-03-22T15:44:37Z", | ||||||
|  |         "deletionTimestamp": "2024-11-14T01:17:06Z", | ||||||
|         "annotations": { |         "annotations": { | ||||||
|           "grafana.app/updatedTimestamp": "2024-08-01 18:34:04.598082 +0000 UTC" |           "grafana.app/updatedTimestamp": "2024-08-01 18:34:04.598082 +0000 UTC" | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  | @ -2089,7 +2089,7 @@ | ||||||
|     "hasUpdate": false, |     "hasUpdate": false, | ||||||
|     "defaultNavUrl": "/plugins/xychart/", |     "defaultNavUrl": "/plugins/xychart/", | ||||||
|     "category": "", |     "category": "", | ||||||
|     "state": "beta", |     "state": "", | ||||||
|     "signature": "internal", |     "signature": "internal", | ||||||
|     "signatureType": "", |     "signatureType": "", | ||||||
|     "signatureOrg": "", |     "signatureOrg": "", | ||||||
|  |  | ||||||
|  | @ -20,8 +20,6 @@ const prometheusPlugin = async () => | ||||||
| const alertmanagerPlugin = async () => | const alertmanagerPlugin = async () => | ||||||
|   await import(/* webpackChunkName: "alertmanagerPlugin" */ 'app/plugins/datasource/alertmanager/module'); |   await import(/* webpackChunkName: "alertmanagerPlugin" */ 'app/plugins/datasource/alertmanager/module'); | ||||||
| 
 | 
 | ||||||
| import { config } from '@grafana/runtime'; |  | ||||||
| 
 |  | ||||||
| // Async loaded panels
 | // Async loaded panels
 | ||||||
| const alertListPanel = async () => | const alertListPanel = async () => | ||||||
|   await import(/* webpackChunkName: "alertListPanel" */ 'app/plugins/panel/alertlist/module'); |   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 geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module'); | ||||||
| const canvasPanel = async () => await import(/* webpackChunkName: "canvasPanel" */ 'app/plugins/panel/canvas/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 graphPanel = async () => await import(/* webpackChunkName: "graphPlugin" */ 'app/plugins/panel/graph/module'); | ||||||
| const xychartPanel = async () => { | const xychartPanel = async () => await import(/* webpackChunkName: "xychart" */ 'app/plugins/panel/xychart/module'); | ||||||
|   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 heatmapPanel = async () => | const heatmapPanel = async () => | ||||||
|   await import(/* webpackChunkName: "heatmapPanel" */ 'app/plugins/panel/heatmap/module'); |   await import(/* webpackChunkName: "heatmapPanel" */ 'app/plugins/panel/heatmap/module'); | ||||||
| const tableOldPanel = async () => | 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 { css } from '@emotion/css'; | ||||||
| import { useState, useEffect, useCallback } from 'react'; | import { useMemo } from 'react'; | ||||||
| import { usePrevious } from 'react-use'; |  | ||||||
| 
 | 
 | ||||||
| import { PanelProps } from '@grafana/data'; | import { FALLBACK_COLOR, PanelProps } from '@grafana/data'; | ||||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||||
| import { config } from '@grafana/runtime'; | import { config } from '@grafana/runtime'; | ||||||
| import { | import { | ||||||
|   TooltipDisplayMode, |   TooltipDisplayMode, | ||||||
|   TooltipPlugin2, |   TooltipPlugin2, | ||||||
|   UPlotChart, |   UPlotChart, | ||||||
|   UPlotConfigBuilder, |  | ||||||
|   useTheme2, |  | ||||||
|   VizLayout, |   VizLayout, | ||||||
|   VizLegend, |   VizLegend, | ||||||
|   VizLegendItem, |   VizLegendItem, | ||||||
|  |   useStyles2, | ||||||
|  |   useTheme2, | ||||||
| } from '@grafana/ui'; | } from '@grafana/ui'; | ||||||
| import { TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; | 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 { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils'; | ||||||
| 
 | 
 | ||||||
| import { XYChartTooltip } from './XYChartTooltip'; | import { XYChartTooltip } from './XYChartTooltip'; | ||||||
| import { Options, SeriesMapping } from './panelcfg.gen'; | import { Options } from './panelcfg.gen'; | ||||||
| import { prepData, prepScatter, ScatterPanelInfo } from './scatter'; | import { prepConfig } from './scatter'; | ||||||
| import { ScatterSeries } from './types'; | 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 theme = useTheme2(); | ||||||
| 
 | 
 | ||||||
|   const [error, setError] = useState<string | undefined>(); |   let { mapping, series: mappedSeries } = props.options; | ||||||
|   const [series, setSeries] = useState<ScatterSeries[]>([]); |  | ||||||
|   const [builder, setBuilder] = useState<UPlotConfigBuilder | undefined>(); |  | ||||||
|   const [facets, setFacets] = useState<FacetedData | undefined>(); |  | ||||||
| 
 | 
 | ||||||
|   const oldOptions = usePrevious(props.options); |   // regenerate series schema when mappings or data changes
 | ||||||
|   const oldData = usePrevious(props.data); |   let series = useMemo( | ||||||
| 
 |     () => prepSeries(mapping, mappedSeries, props.data.series, props.fieldConfig), | ||||||
|   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(); |  | ||||||
|     } |  | ||||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps
 |     // 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 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) { |     if (!props.options.legend.showLegend) { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const legendStyle = { |     const items: VizLegendItem[] = []; | ||||||
|       flexStart: css({ | 
 | ||||||
|         div: { |     series.forEach((s, idx) => { | ||||||
|           justifyContent: 'flex-start', |       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 ( |     return ( | ||||||
|       <VizLayout.Legend placement={props.options.legend.placement} width={props.options.legend.width}> |       <VizLayout.Legend placement={placement} width={width}> | ||||||
|         <VizLegend |         <VizLegend | ||||||
|           className={legendStyle.flexStart} |           className={styles.legend} | ||||||
|           placement={props.options.legend.placement} |           placement={placement} | ||||||
|           items={items} |           items={items} | ||||||
|           displayMode={props.options.legend.displayMode} |           displayMode={displayMode} | ||||||
|  |           sortBy={sortBy} | ||||||
|  |           sortDesc={sortDesc} | ||||||
|  |           isSortable={true} | ||||||
|         /> |         /> | ||||||
|       </VizLayout.Legend> |       </VizLayout.Legend> | ||||||
|     ); |     ); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   if (error || !builder || !facets) { |   if (error) { | ||||||
|     return ( |     return ( | ||||||
|       <div className="panel-empty"> |       <div className="panel-empty"> | ||||||
|         <p>{error}</p> |         <p>{error}</p> | ||||||
|  | @ -120,24 +106,23 @@ export const XYChartPanel = (props: Props) => { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <> |  | ||||||
|     <VizLayout width={props.width} height={props.height} legend={renderLegend()}> |     <VizLayout width={props.width} height={props.height} legend={renderLegend()}> | ||||||
|       {(vizWidth: number, vizHeight: number) => ( |       {(vizWidth: number, vizHeight: number) => ( | ||||||
|           <UPlotChart config={builder} data={facets} width={vizWidth} height={vizHeight}> |         <UPlotChart config={builder!} data={data} width={vizWidth} height={vizHeight}> | ||||||
|           {props.options.tooltip.mode !== TooltipDisplayMode.None && ( |           {props.options.tooltip.mode !== TooltipDisplayMode.None && ( | ||||||
|             <TooltipPlugin2 |             <TooltipPlugin2 | ||||||
|                 config={builder} |               config={builder!} | ||||||
|               hoverMode={TooltipHoverMode.xyOne} |               hoverMode={TooltipHoverMode.xyOne} | ||||||
|               render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { |               render={(u, dataIdxs, seriesIdx, isPinned, dismiss) => { | ||||||
|                 return ( |                 return ( | ||||||
|                   <XYChartTooltip |                   <XYChartTooltip | ||||||
|                     data={props.data.series} |                     data={props.data.series} | ||||||
|                     dataIdxs={dataIdxs} |                     dataIdxs={dataIdxs} | ||||||
|                       allSeries={series} |                     xySeries={series} | ||||||
|                     dismiss={dismiss} |                     dismiss={dismiss} | ||||||
|                     isPinned={isPinned} |                     isPinned={isPinned} | ||||||
|                       options={props.options} |                     seriesIdx={seriesIdx!} | ||||||
|                       seriesIdx={seriesIdx} |                     replaceVariables={props.replaceVariables} | ||||||
|                   /> |                   /> | ||||||
|                 ); |                 ); | ||||||
|               }} |               }} | ||||||
|  | @ -147,6 +132,13 @@ export const XYChartPanel = (props: Props) => { | ||||||
|         </UPlotChart> |         </UPlotChart> | ||||||
|       )} |       )} | ||||||
|     </VizLayout> |     </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 { 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 { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||||
| import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; | import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; | ||||||
| import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; | 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 { VizTooltipWrapper } from '@grafana/ui/src/components/VizTooltip/VizTooltipWrapper'; | ||||||
| import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; | 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 { XYSeries } from './types2'; | ||||||
| import { ScatterSeries } from './types'; |  | ||||||
| import { fmt } from './utils'; | import { fmt } from './utils'; | ||||||
| 
 | 
 | ||||||
| export interface Props { | export interface Props { | ||||||
|  | @ -19,73 +18,87 @@ export interface Props { | ||||||
|   seriesIdx: number | null | undefined; |   seriesIdx: number | null | undefined; | ||||||
|   isPinned: boolean; |   isPinned: boolean; | ||||||
|   dismiss: () => void; |   dismiss: () => void; | ||||||
|   options: Options; |   data: DataFrame[]; | ||||||
|   data: DataFrame[]; // source data
 |   xySeries: XYSeries[]; | ||||||
|   allSeries: ScatterSeries[]; |   replaceVariables: InterpolateFunction; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss, options, isPinned }: Props) => { | function stripSeriesName(fieldName: string, seriesName: string) { | ||||||
|   const rowIndex = dataIdxs.find((idx) => idx !== null); |   if (fieldName !== seriesName && fieldName.includes(' ')) { | ||||||
|   // @todo: remove -1 when uPlot v2 arrive
 |     fieldName = fieldName.replace(seriesName, '').trim(); | ||||||
|   // context: first value in dataIdxs always null and represent X series
 |  | ||||||
|   const hoveredPointIndex = seriesIdx! - 1; |  | ||||||
| 
 |  | ||||||
|   if (!allSeries || rowIndex == null) { |  | ||||||
|     return null; |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   const series = allSeries[hoveredPointIndex]; |   return fieldName; | ||||||
|   const frame = series.frame(data); | } | ||||||
|   const xField = series.x(frame); |  | ||||||
|   const yField = series.y(frame); |  | ||||||
| 
 | 
 | ||||||
|   let label = series.name; | export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, isPinned, replaceVariables }: Props) => { | ||||||
|   if (options.seriesMapping === 'manual') { |   const rowIndex = dataIdxs.find((idx) => idx !== null)!; | ||||||
|     label = options.series?.[hoveredPointIndex]?.name ?? `Series ${hoveredPointIndex + 1}`; |  | ||||||
|   } |  | ||||||
| 
 | 
 | ||||||
|   let colorThing = series.pointColor(frame); |   const series = xySeries[seriesIdx! - 1]; | ||||||
|  |   const xField = series.x.field; | ||||||
|  |   const yField = series.y.field; | ||||||
| 
 | 
 | ||||||
|   if (Array.isArray(colorThing)) { |   const sizeField = series.size.field; | ||||||
|     colorThing = colorThing[rowIndex]; |   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 = { |   const headerItem: VizTooltipItem = { | ||||||
|     label, |     label, | ||||||
|     value: '', |     value: '', | ||||||
|     // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
 |     color: alpha(seriesColor ?? '#fff', 0.5), | ||||||
|     color: alpha(colorThing as string, 0.5), |  | ||||||
|     colorIndicator: ColorIndicator.marker_md, |     colorIndicator: ColorIndicator.marker_md, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const contentItems: VizTooltipItem[] = [ |   const contentItems: VizTooltipItem[] = [ | ||||||
|     { |     { | ||||||
|       label: getFieldDisplayName(xField, frame), |       label: stripSeriesName(xField.state?.displayName ?? xField.name, label), | ||||||
|       value: fmt(xField, xField.values[rowIndex]), |       value: fmt(xField, xField.values[rowIndex]), | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       label: getFieldDisplayName(yField, frame), |       label: stripSeriesName(yField.state?.displayName ?? yField.name, label), | ||||||
|       value: fmt(yField, yField.values[rowIndex]), |       value: fmt(yField, yField.values[rowIndex]), | ||||||
|     }, |     }, | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   // add extra fields
 |   // mapped fields for size/color
 | ||||||
|   const extraFields: Field[] = frame.fields.filter((f) => f !== xField && f !== yField); |   if (sizeField != null && sizeField !== yField) { | ||||||
|   if (extraFields) { |  | ||||||
|     extraFields.forEach((field) => { |  | ||||||
|     contentItems.push({ |     contentItems.push({ | ||||||
|         label: field.name, |       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]), |       value: fmt(field, field.values[rowIndex]), | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|   } |  | ||||||
| 
 | 
 | ||||||
|   let footer: ReactNode; |   let footer: ReactNode; | ||||||
| 
 | 
 | ||||||
|   if (isPinned && seriesIdx != null) { |   if (isPinned && seriesIdx != null) { | ||||||
|     const links = getDataLinks(yField, rowIndex); |     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 ( |   return ( | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ import { commonOptionsBuilder } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { LineStyleEditor } from '../timeseries/LineStyleEditor'; | import { LineStyleEditor } from '../timeseries/LineStyleEditor'; | ||||||
| 
 | 
 | ||||||
| import { FieldConfig, ScatterShow } from './panelcfg.gen'; | import { FieldConfig, XYShowMode, PointShape } from './panelcfg.gen'; | ||||||
| 
 | 
 | ||||||
| export const DEFAULT_POINT_SIZE = 5; | export const DEFAULT_POINT_SIZE = 5; | ||||||
| 
 | 
 | ||||||
|  | @ -58,9 +58,9 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr | ||||||
|           defaultValue: cfg.show, |           defaultValue: cfg.show, | ||||||
|           settings: { |           settings: { | ||||||
|             options: [ |             options: [ | ||||||
|               { label: 'Points', value: ScatterShow.Points }, |               { label: 'Points', value: XYShowMode.Points }, | ||||||
|               { label: 'Lines', value: ScatterShow.Lines }, |               { label: 'Lines', value: XYShowMode.Lines }, | ||||||
|               { label: 'Both', value: ScatterShow.PointsAndLines }, |               { label: 'Both', value: XYShowMode.PointsAndLines }, | ||||||
|             ], |             ], | ||||||
|           }, |           }, | ||||||
|         }) |         }) | ||||||
|  | @ -92,24 +92,56 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr | ||||||
|             max: 100, |             max: 100, | ||||||
|             step: 1, |             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>({ |         .addCustomEditor<void, LineStyle>({ | ||||||
|           id: 'lineStyle', |           id: 'lineStyle', | ||||||
|           path: 'lineStyle', |           path: 'lineStyle', | ||||||
|           name: 'Line style', |           name: 'Line style', | ||||||
|           showIf: (c) => c.show !== ScatterShow.Points, |           showIf: (c) => c.show !== XYShowMode.Points, | ||||||
|           editor: LineStyleEditor, |           editor: LineStyleEditor, | ||||||
|           override: LineStyleEditor, |           override: LineStyleEditor, | ||||||
|           process: identityOverrideProcessor, |           process: identityOverrideProcessor, | ||||||
|  | @ -124,7 +156,7 @@ export function getScatterFieldConfig(cfg: FieldConfig): SetFieldConfigOptionsAr | ||||||
|             max: 10, |             max: 10, | ||||||
|             step: 1, |             step: 1, | ||||||
|           }, |           }, | ||||||
|           showIf: (c) => c.show !== ScatterShow.Points, |           showIf: (c) => c.show !== XYShowMode.Points, | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|       commonOptionsBuilder.addAxisConfig(builder, cfg); |       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 { FieldMatcherID, FrameMatcherID, MatcherConfig, PanelModel } from '@grafana/data'; | ||||||
| 
 | 
 | ||||||
| import { ScatterSeriesConfig, SeriesMapping, XYDimensionConfig, Options as PrevOptions } from '../panelcfg.gen'; |  | ||||||
| 
 |  | ||||||
| import { XYSeriesConfig, Options } 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 => { | export const xyChartMigrationHandler = (panel: PanelModel): Options => { | ||||||
|   const pluginVersion = panel?.pluginVersion ?? ''; |   const pluginVersion = panel?.pluginVersion ?? ''; | ||||||
|  | @ -1,41 +1,35 @@ | ||||||
| import { PanelPlugin } from '@grafana/data'; | import { PanelPlugin } from '@grafana/data'; | ||||||
| import { commonOptionsBuilder } from '@grafana/ui'; | import { commonOptionsBuilder } from '@grafana/ui'; | ||||||
| 
 | 
 | ||||||
| import { AutoEditor } from './AutoEditor'; | import { SeriesEditor } from './SeriesEditor'; | ||||||
| import { ManualEditor } from './ManualEditor'; | import { XYChartPanel2 } from './XYChartPanel'; | ||||||
| import { XYChartPanel } from './XYChartPanel'; |  | ||||||
| import { getScatterFieldConfig } from './config'; | 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)) |   .useFieldConfig(getScatterFieldConfig(defaultFieldConfig)) | ||||||
|   .setPanelOptions((builder) => { |   .setPanelOptions((builder) => { | ||||||
|     builder |     builder | ||||||
|       .addRadio({ |       .addRadio({ | ||||||
|         path: 'seriesMapping', |         path: 'mapping', | ||||||
|         name: 'Series mapping', |         name: 'Series mapping', | ||||||
|         defaultValue: 'auto', |         defaultValue: 'auto', | ||||||
|         settings: { |         settings: { | ||||||
|           options: [ |           options: [ | ||||||
|             { value: 'auto', label: 'Table', description: 'Plot values within a single table result' }, |             { value: 'auto', label: 'Auto' }, | ||||||
|             { value: 'manual', label: 'Manual', description: 'Construct values from any result' }, |             { value: 'manual', label: 'Manual' }, | ||||||
|           ], |           ], | ||||||
|         }, |         }, | ||||||
|       }) |       }) | ||||||
|       .addCustomEditor({ |  | ||||||
|         id: 'xyPlotConfig', |  | ||||||
|         path: 'dims', |  | ||||||
|         name: '', |  | ||||||
|         editor: AutoEditor, |  | ||||||
|         showIf: (cfg) => cfg.seriesMapping === 'auto', |  | ||||||
|       }) |  | ||||||
|       .addCustomEditor({ |       .addCustomEditor({ | ||||||
|         id: 'series', |         id: 'series', | ||||||
|         path: 'series', |         path: 'series', | ||||||
|         name: '', |         name: '', | ||||||
|         defaultValue: [], |         editor: SeriesEditor, | ||||||
|         editor: ManualEditor, |         defaultValue: [{}], | ||||||
|         showIf: (cfg) => cfg.seriesMapping === 'manual', |  | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|     commonOptionsBuilder.addTooltipOptions(builder, true); |     commonOptionsBuilder.addTooltipOptions(builder, true); | ||||||
|  |  | ||||||
|  | @ -25,55 +25,58 @@ composableKinds: PanelCfg: { | ||||||
| 		schemas: [{ | 		schemas: [{ | ||||||
| 			version: [0, 0] | 			version: [0, 0] | ||||||
| 			schema: { | 			schema: { | ||||||
| 				// Auto is "table" in the UI | 				PointShape:    "circle" | "square"                 @cuetsy(kind="enum") | ||||||
| 				SeriesMapping: "auto" | "manual"                   @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 | 				// NOTE: (copied from dashboard_kind.cue, since not exported) | ||||||
| 				XYDimensionConfig: { | 				// Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation. | ||||||
| 					frame: int32 & >=0 | 				// It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. | ||||||
| 					x?:    string | 				#MatcherConfig: { | ||||||
| 					exclude?: [...string] | 					// The matcher id. This is used to find the matcher implementation from registry. | ||||||
| 				} @cuetsy(kind="interface") | 					id: string | *"" @grafanamaturity(NeedsExpertReview)
 | ||||||
|  | 					// The matcher options. This is specific to the matcher implementation. | ||||||
|  | 					options?: _ @grafanamaturity(NeedsExpertReview) | ||||||
|  | 				} @cuetsy(kind="interface") @grafana(TSVeneer="type") | ||||||
| 
 | 
 | ||||||
| 				FieldConfig: { | 				FieldConfig: { | ||||||
| 					common.HideableFieldConfig | 					common.HideableFieldConfig | ||||||
| 					common.AxisConfig | 					common.AxisConfig | ||||||
| 
 | 
 | ||||||
| 					show?: ScatterShow & (*"points" | _) | 					show?: XYShowMode & (*"points" | _) | ||||||
| 
 | 
 | ||||||
| 					pointSize?:  common.ScaleDimensionConfig | 					pointSize?:  { | ||||||
| 					pointColor?: common.ColorDimensionConfig | 						fixed?: int32 & >=0 | ||||||
| 					// pointSymbol?: common.ResourceDimensionConfig | 						min?:   int32 & >=0 | ||||||
| 					// fillOpacity?: number & >=0 & <=1 | *0.5 | 						max?:   int32 & >=0 | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					pointShape?: PointShape | ||||||
|  | 
 | ||||||
|  | 					pointStrokeWidth?: int32 & >=0 | ||||||
|  | 
 | ||||||
|  | 					fillOpacity?: uint32 & <=100 | *50 | ||||||
| 
 | 
 | ||||||
| 					lineColor?: common.ColorDimensionConfig |  | ||||||
| 					lineWidth?: int32 & >=0 | 					lineWidth?: int32 & >=0 | ||||||
| 					lineStyle?: common.LineStyle | 					lineStyle?: common.LineStyle | ||||||
| 
 |  | ||||||
| 					label?:      common.VisibilityMode & (*"auto" | _) |  | ||||||
| 					labelValue?: common.TextDimensionConfig |  | ||||||
| 				} @cuetsy(kind="interface",TSVeneer="type") | 				} @cuetsy(kind="interface",TSVeneer="type") | ||||||
| 
 | 
 | ||||||
| 				ScatterSeriesConfig: { | 				XYSeriesConfig: { | ||||||
| 					FieldConfig | 					name?:   { fixed?: string } | ||||||
| 					x?:     string | 					frame?:  { matcher: #MatcherConfig } | ||||||
| 					y?:     string | 					x?:      { matcher: #MatcherConfig } | ||||||
| 					name?:  string | 					y?:      { matcher: #MatcherConfig } | ||||||
| 					frame?: number | 					color?:  { matcher: #MatcherConfig } | ||||||
|  | 					size?:   { matcher: #MatcherConfig } | ||||||
| 				} @cuetsy(kind="interface") | 				} @cuetsy(kind="interface") | ||||||
| 
 | 
 | ||||||
| 				Options: { | 				Options: { | ||||||
| 					common.OptionsWithLegend | 					common.OptionsWithLegend | ||||||
| 					common.OptionsWithTooltip | 					common.OptionsWithTooltip | ||||||
| 
 | 
 | ||||||
| 					seriesMapping?: SeriesMapping | 					mapping: SeriesMapping | ||||||
| 
 | 
 | ||||||
| 					// Table Mode (auto) | 					series: [...XYSeriesConfig] | ||||||
| 					dims: XYDimensionConfig |  | ||||||
| 
 |  | ||||||
| 					// Manual Mode |  | ||||||
| 					series: [...ScatterSeriesConfig] |  | ||||||
| 				} @cuetsy(kind="interface") | 				} @cuetsy(kind="interface") | ||||||
| 			} | 			} | ||||||
| 		}] | 		}] | ||||||
|  |  | ||||||
|  | @ -10,66 +10,85 @@ | ||||||
| 
 | 
 | ||||||
| import * as common from '@grafana/schema'; | import * as common from '@grafana/schema'; | ||||||
| 
 | 
 | ||||||
| /** | export enum PointShape { | ||||||
|  * Auto is "table" in the UI |   Circle = 'circle', | ||||||
|  */ |   Square = 'square', | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export enum SeriesMapping { | export enum SeriesMapping { | ||||||
|   Auto = 'auto', |   Auto = 'auto', | ||||||
|   Manual = 'manual', |   Manual = 'manual', | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export enum ScatterShow { | export enum XYShowMode { | ||||||
|   Lines = 'lines', |   Lines = 'lines', | ||||||
|   Points = 'points', |   Points = 'points', | ||||||
|   PointsAndLines = 'points+lines', |   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 { | export interface MatcherConfig { | ||||||
|   exclude?: Array<string>; |   /** | ||||||
|   frame: number; |    * The matcher id. This is used to find the matcher implementation from registry. | ||||||
|   x?: string; |    */ | ||||||
|  |   id: string; | ||||||
|  |   /** | ||||||
|  |    * The matcher options. This is specific to the matcher implementation. | ||||||
|  |    */ | ||||||
|  |   options?: unknown; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const defaultXYDimensionConfig: Partial<XYDimensionConfig> = { | export const defaultMatcherConfig: Partial<MatcherConfig> = { | ||||||
|   exclude: [], |   id: '', | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | export interface FieldConfig extends common.HideableFieldConfig, common.AxisConfig { | ||||||
|   label?: common.VisibilityMode; |   fillOpacity?: number; | ||||||
|   labelValue?: common.TextDimensionConfig; |  | ||||||
|   lineColor?: common.ColorDimensionConfig; |  | ||||||
|   lineStyle?: common.LineStyle; |   lineStyle?: common.LineStyle; | ||||||
|   lineWidth?: number; |   lineWidth?: number; | ||||||
|   pointColor?: common.ColorDimensionConfig; |   pointShape?: PointShape; | ||||||
|   pointSize?: common.ScaleDimensionConfig; |   pointSize?: { | ||||||
|   show?: ScatterShow; |     fixed?: number; | ||||||
|  |     min?: number; | ||||||
|  |     max?: number; | ||||||
|  |   }; | ||||||
|  |   pointStrokeWidth?: number; | ||||||
|  |   show?: XYShowMode; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const defaultFieldConfig: Partial<FieldConfig> = { | export const defaultFieldConfig: Partial<FieldConfig> = { | ||||||
|   label: common.VisibilityMode.Auto, |   fillOpacity: 50, | ||||||
|   show: ScatterShow.Points, |   show: XYShowMode.Points, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export interface ScatterSeriesConfig extends FieldConfig { | export interface XYSeriesConfig { | ||||||
|   frame?: number; |   color?: { | ||||||
|   name?: string; |     matcher: MatcherConfig; | ||||||
|   x?: string; |   }; | ||||||
|   y?: string; |   frame?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
|  |   name?: { | ||||||
|  |     fixed?: string; | ||||||
|  |   }; | ||||||
|  |   size?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
|  |   x?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
|  |   y?: { | ||||||
|  |     matcher: MatcherConfig; | ||||||
|  |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | export interface Options extends common.OptionsWithLegend, common.OptionsWithTooltip { | ||||||
|   /** |   mapping: SeriesMapping; | ||||||
|    * Table Mode (auto) |   series: Array<XYSeriesConfig>; | ||||||
|    */ |  | ||||||
|   dims: XYDimensionConfig; |  | ||||||
|   /** |  | ||||||
|    * Manual Mode |  | ||||||
|    */ |  | ||||||
|   series: Array<ScatterSeriesConfig>; |  | ||||||
|   seriesMapping?: SeriesMapping; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const defaultOptions: Partial<Options> = { | 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", |   "type": "panel", | ||||||
|   "name": "XY Chart", |   "name": "XY Chart", | ||||||
|   "id": "xychart", |   "id": "xychart", | ||||||
|   "state": "beta", |  | ||||||
| 
 | 
 | ||||||
|   "info": { |   "info": { | ||||||
|     "description": "Supports arbitrary X vs Y in a graph to visualize the relationship between two variables.", |     "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 uPlot from 'uplot'; | ||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|   DataFrame, |   FALLBACK_COLOR, | ||||||
|   FieldColorModeId, |   Field, | ||||||
|   fieldColorModeRegistry, |   FieldType, | ||||||
|   formattedValueToString, |   formattedValueToString, | ||||||
|   getDisplayProcessor, |  | ||||||
|   getFieldColorModeForField, |   getFieldColorModeForField, | ||||||
|   getFieldDisplayName, |  | ||||||
|   getFieldSeriesColor, |  | ||||||
|   GrafanaTheme2, |   GrafanaTheme2, | ||||||
|  |   MappingType, | ||||||
|  |   SpecialValueMatch, | ||||||
|  |   ThresholdsMode, | ||||||
| } from '@grafana/data'; | } from '@grafana/data'; | ||||||
| import { alpha } from '@grafana/data/src/themes/colorManipulator'; | import { alpha } from '@grafana/data/src/themes/colorManipulator'; | ||||||
| import { config } from '@grafana/runtime'; | import { AxisPlacement, FieldColorModeId, ScaleDirection, ScaleOrientation, VisibilityMode } from '@grafana/schema'; | ||||||
| import { |  | ||||||
|   AxisPlacement, |  | ||||||
|   ScaleDirection, |  | ||||||
|   ScaleOrientation, |  | ||||||
|   VisibilityMode, |  | ||||||
|   ScaleDimensionConfig, |  | ||||||
|   ScaleDimensionMode, |  | ||||||
| } from '@grafana/schema'; |  | ||||||
| import { UPlotConfigBuilder } from '@grafana/ui'; | import { UPlotConfigBuilder } from '@grafana/ui'; | ||||||
| import { FacetedData, FacetSeries } from '@grafana/ui/src/components/uPlot/types'; | 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 { pointWithin, Quadtree, Rect } from '../barchart/quadtree'; | ||||||
|  | import { valuesToFills } from '../heatmap/utils'; | ||||||
| 
 | 
 | ||||||
| import { DEFAULT_POINT_SIZE } from './config'; | import { PointShape } from './panelcfg.gen'; | ||||||
| import { isGraphable } from './dims'; | import { XYSeries } from './types2'; | ||||||
| import { FieldConfig, defaultFieldConfig, Options, ScatterShow } from './panelcfg.gen'; | import { getCommonPrefixSuffix } from './utils'; | ||||||
| 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, {})); |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| interface DrawBubblesOpts { | interface DrawBubblesOpts { | ||||||
|   each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; |   each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void; | ||||||
|  | @ -285,12 +33,15 @@ interface DrawBubblesOpts { | ||||||
|     }; |     }; | ||||||
|     color: { |     color: { | ||||||
|       values: (u: uPlot, seriesIdx: number) => string[]; |       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 qt: Quadtree; | ||||||
|   let hRect: Rect | null; |   let hRect: Rect | null; | ||||||
| 
 | 
 | ||||||
|  | @ -317,29 +68,26 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|           arc |           arc | ||||||
|         ) => { |         ) => { | ||||||
|           const pxRatio = uPlot.pxRatio; |           const pxRatio = uPlot.pxRatio; | ||||||
|           const scatterInfo = scatterSeries[seriesIdx - 1]; |           const scatterInfo = xySeries[seriesIdx - 1]; | ||||||
|           let d = u.data[seriesIdx] as unknown as FacetSeries; |           let d = u.data[seriesIdx] as unknown as FacetSeries; | ||||||
| 
 | 
 | ||||||
|  |           // showLine: boolean;
 | ||||||
|  |           // lineStyle: common.LineStyle;
 | ||||||
|  |           // showPoints: common.VisibilityMode;
 | ||||||
|  | 
 | ||||||
|           let showLine = scatterInfo.showLine; |           let showLine = scatterInfo.showLine; | ||||||
|           let showPoints = scatterInfo.showPoints === VisibilityMode.Always; |           let showPoints = scatterInfo.showPoints === VisibilityMode.Always; | ||||||
|           if (!showPoints && scatterInfo.showPoints === VisibilityMode.Auto) { |           let strokeWidth = scatterInfo.pointStrokeWidth ?? 0; | ||||||
|             showPoints = d[0].length < 1000; |  | ||||||
|           } |  | ||||||
| 
 |  | ||||||
|           // always show something
 |  | ||||||
|           if (!showPoints && !showLine) { |  | ||||||
|             showLine = true; |  | ||||||
|           } |  | ||||||
| 
 |  | ||||||
|           let strokeWidth = 1; |  | ||||||
| 
 | 
 | ||||||
|           u.ctx.save(); |           u.ctx.save(); | ||||||
| 
 | 
 | ||||||
|           u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); |           u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); | ||||||
|           u.ctx.clip(); |           u.ctx.clip(); | ||||||
| 
 | 
 | ||||||
|           u.ctx.fillStyle = (series.fill as any)(); // assumes constant
 |           let pointAlpha = scatterInfo.fillOpacity / 100; | ||||||
|           u.ctx.strokeStyle = (series.stroke as any)(); | 
 | ||||||
|  |           u.ctx.fillStyle = alpha((series.fill as any)(), pointAlpha); | ||||||
|  |           u.ctx.strokeStyle = alpha((series.stroke as any)(), 1); | ||||||
|           u.ctx.lineWidth = strokeWidth; |           u.ctx.lineWidth = strokeWidth; | ||||||
| 
 | 
 | ||||||
|           let deg360 = 2 * Math.PI; |           let deg360 = 2 * Math.PI; | ||||||
|  | @ -347,10 +95,11 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|           let xKey = scaleX.key!; |           let xKey = scaleX.key!; | ||||||
|           let yKey = scaleY.key!; |           let yKey = scaleY.key!; | ||||||
| 
 | 
 | ||||||
|           let pointHints = scatterInfo.hints.pointSize; |           //const colorMode = getFieldColorModeForField(field); // isByValue
 | ||||||
|           const colorByValue = scatterInfo.hints.pointColor.mode.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: this depends on direction & orientation
 | ||||||
|           // todo: calc once per redraw, not per path
 |           // 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 filtTop = u.posToVal(-maxSize / 2, yKey); | ||||||
| 
 | 
 | ||||||
|           let sizes = opts.disp.size.values(u, seriesIdx); |           let sizes = opts.disp.size.values(u, seriesIdx); | ||||||
|           let pointColors = opts.disp.color.values(u, seriesIdx); |           // let pointColors = opts.disp.color.values(u, seriesIdx);
 | ||||||
|           let pointAlpha = opts.disp.color.alpha; |           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 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++) { |           for (let i = 0; i < d[0].length; i++) { | ||||||
|             let xVal = d[0][i]; |             let xVal = d[0][i]; | ||||||
|             let yVal = d[1][i]; |             let yVal = d[1][i]; | ||||||
|             let size = sizes[i] * pxRatio; |  | ||||||
| 
 | 
 | ||||||
|             if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { |             if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) { | ||||||
|  |               let size = Math.round(sizes[i] * pxRatio); | ||||||
|               let cx = valToPosX(xVal, scaleX, xDim, xOff); |               let cx = valToPosX(xVal, scaleX, xDim, xOff); | ||||||
|               let cy = valToPosY(yVal, scaleY, yDim, yOff); |               let cy = valToPosY(yVal, scaleY, yDim, yOff); | ||||||
| 
 | 
 | ||||||
|  | @ -381,22 +134,39 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|               } |               } | ||||||
| 
 | 
 | ||||||
|               if (showPoints) { |               if (showPoints) { | ||||||
|                 // if pointHints.fixed? don't recalc size
 |                 if (colorByValue) { | ||||||
|                 // if pointColor has 0 opacity, draw as single path (assuming all strokes are alpha 1)
 |                   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.beginPath(); | ||||||
|                   u.ctx.arc(cx, cy, size / 2, 0, deg360); |                   u.ctx.arc(cx, cy, size / 2, 0, deg360); | ||||||
| 
 | 
 | ||||||
|                 if (colorByValue) { |                   if (colorByValue || pointAlpha > 0) { | ||||||
|                   if (pointColors[i] !== curColor) { |                     u.ctx.fill(); | ||||||
|                     curColor = pointColors[i]; |                   } | ||||||
|                     u.ctx.fillStyle = alpha(curColor, pointAlpha); | 
 | ||||||
|                     u.ctx.strokeStyle = curColor; |                   if (strokeWidth > 0) { | ||||||
|  |                     u.ctx.stroke(); | ||||||
|                   } |                   } | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 u.ctx.fill(); |  | ||||||
|                 u.ctx.stroke(); |  | ||||||
|                 opts.each( |                 opts.each( | ||||||
|                   u, |                   u, | ||||||
|                   seriesIdx, |                   seriesIdx, | ||||||
|  | @ -411,8 +181,7 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (showLine) { |           if (showLine) { | ||||||
|             let frame = scatterInfo.frame(getData()); |             u.ctx.strokeStyle = scatterInfo.color.fixed!; | ||||||
|             u.ctx.strokeStyle = scatterInfo.lineColor(frame); |  | ||||||
|             u.ctx.lineWidth = scatterInfo.lineWidth * pxRatio; |             u.ctx.lineWidth = scatterInfo.lineWidth * pxRatio; | ||||||
| 
 | 
 | ||||||
|             const { lineStyle } = scatterInfo; |             const { lineStyle } = scatterInfo; | ||||||
|  | @ -451,7 +220,6 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|         values: (u, seriesIdx) => { |         values: (u, seriesIdx) => { | ||||||
|           return u.data[seriesIdx][3] as any; |           return u.data[seriesIdx][3] as any; | ||||||
|         }, |         }, | ||||||
|         alpha: 0.5, |  | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|     each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { |     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) => { |   builder.addHook('drawClear', (u) => { | ||||||
|     qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); |     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); |   builder.setMode(2); | ||||||
| 
 | 
 | ||||||
|   const frames = getData(); |   let xField = xySeries[0].x.field; | ||||||
|   let xField = scatterSeries[0].x(scatterSeries[0].frame(frames)); |  | ||||||
| 
 | 
 | ||||||
|   let fieldConfig = xField.config; |   let fieldConfig = xField.config; | ||||||
|   let customConfig = fieldConfig.custom; |   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?
 |   // why does this fall back to '' instead of null or undef?
 | ||||||
|   let xAxisLabel = customConfig.axisLabel; |   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({ |   builder.addAxis({ | ||||||
|     scaleKey: 'x', |     scaleKey: 'x', | ||||||
|     placement: customConfig?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden, |     placement: customConfig?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden, | ||||||
|  | @ -557,19 +344,15 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|     grid: { show: customConfig?.axisGridShow }, |     grid: { show: customConfig?.axisGridShow }, | ||||||
|     border: { show: customConfig?.axisBorderShow }, |     border: { show: customConfig?.axisBorderShow }, | ||||||
|     theme, |     theme, | ||||||
|     label: |     label: xAxisLabel, | ||||||
|       xAxisLabel == null || xAxisLabel === '' |  | ||||||
|         ? getFieldDisplayName(xField, scatterSeries[0].frame(frames), frames) |  | ||||||
|         : xAxisLabel, |  | ||||||
|     formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), |     formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   scatterSeries.forEach((s, si) => { |   xySeries.forEach((s, si) => { | ||||||
|     let frame = s.frame(frames); |     let field = s.y.field; | ||||||
|     let field = s.y(frame); |  | ||||||
| 
 | 
 | ||||||
|     const lineColor = s.lineColor(frame); |     const lineColor = s.color.fixed; | ||||||
|     const pointColor = asSingleValue(frame, s.pointColor) as string; |     const pointColor = s.color.fixed; | ||||||
|     //const lineColor = s.lineColor(frame);
 |     //const lineColor = s.lineColor(frame);
 | ||||||
|     //const lineWidth = s.lineWidth;
 |     //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?
 |     // 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({ |     builder.addAxis({ | ||||||
|       scaleKey, |       scaleKey, | ||||||
|  | @ -604,10 +402,8 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|       grid: { show: customConfig?.axisGridShow }, |       grid: { show: customConfig?.axisGridShow }, | ||||||
|       border: { show: customConfig?.axisBorderShow }, |       border: { show: customConfig?.axisBorderShow }, | ||||||
|       size: customConfig?.axisWidth, |       size: customConfig?.axisWidth, | ||||||
|       label: |       // label: yAxisLabel == null || yAxisLabel === '' ? fieldDisplayName : yAxisLabel,
 | ||||||
|         yAxisLabel == null || yAxisLabel === '' |       label: yAxisLabel, | ||||||
|           ? getFieldDisplayName(field, scatterSeries[si].frame(frames), frames) |  | ||||||
|           : yAxisLabel, |  | ||||||
|       formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)), |       formatValue: (v, decimals) => formattedValueToString(field.display!(v, decimals)), | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  | @ -625,80 +421,269 @@ const prepConfig = (getData: () => DataFrame[], scatterSeries: ScatterSeries[], | ||||||
|       pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}})
 |       pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}})
 | ||||||
|       theme, |       theme, | ||||||
|       scaleKey: '', // facets' scales used (above)
 |       scaleKey: '', // facets' scales used (above)
 | ||||||
|       lineColor: alpha('' + lineColor, 1), |       lineColor: alpha(lineColor ?? '#ffff', 1), | ||||||
|       fillColor: alpha(pointColor, 0.5), |       fillColor: alpha(pointColor ?? '#ffff', 0.5), | ||||||
|       show: !customConfig.hideFrom?.viz, |       show: !field.state?.hideFrom?.viz, | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   /* |   const dispColors = xySeries.map((s): FieldColorValuesWithCache => { | ||||||
|   builder.setPrepData((frames) => { |     const cfg: FieldColorValuesWithCache = { | ||||||
|     let seriesData = lookup.fieldMaps.flatMap((f, i) => { |       index: [], | ||||||
|       let { fields } = frames[i]; |       getAll: () => [], | ||||||
|  |       getOne: () => -1, | ||||||
|  |       // cache for renderer, refreshed in prepData()
 | ||||||
|  |       values: [], | ||||||
|  |       hasAlpha: false, | ||||||
|  |     }; | ||||||
| 
 | 
 | ||||||
|       return f.y.map((yIndex, frameSeriesIndex) => { |     const f = s.color.field; | ||||||
|         let xValues = fields[f.x[frameSeriesIndex]].values; |  | ||||||
|         let yValues = fields[f.y[frameSeriesIndex]].values; |  | ||||||
|         let sizeValues = f.size; |  | ||||||
| 
 | 
 | ||||||
|         if (!Array.isArray(sizeValues)) { |     if (f != null) { | ||||||
|           sizeValues = Array(xValues.length).fill(sizeValues); |       Object.assign(cfg, fieldValueColors(f, theme)); | ||||||
|  |       cfg.hasAlpha = cfg.index.some((v) => !(v as string).endsWith('ff')); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|         return [xValues, yValues, sizeValues]; |     return cfg; | ||||||
|       }); |  | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|     return [null, ...seriesData]; |   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 builder; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * This is called everytime the data changes |  | ||||||
|  * |  | ||||||
|  * from?  is this where we would support that?  -- need the previous values |  | ||||||
|  */ |  | ||||||
| export function prepData(info: ScatterPanelInfo, data: DataFrame[], from?: number): FacetedData { |  | ||||||
|   if (info.error || !data.length) { |  | ||||||
|     return [null]; |  | ||||||
|   } |  | ||||||
|     return [ |     return [ | ||||||
|       null, |       null, | ||||||
|     ...info.series.map((s, idx) => { |       ...xySeries.map((s, idx) => { | ||||||
|       const frame = s.frame(data); |         let len = s.x.field.values.length; | ||||||
| 
 | 
 | ||||||
|       let colorValues; |         let diams: number[]; | ||||||
|       const r = s.pointColor(frame); | 
 | ||||||
|       if (Array.isArray(r)) { |         if (s.size.field != null) { | ||||||
|         colorValues = r; |           let { min, max } = s.size; | ||||||
|       } else { | 
 | ||||||
|         colorValues = Array(frame.length).fill(r); |           // 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 [ |         return [ | ||||||
|         s.x(frame).values, // X
 |           s.x.field.values, // X
 | ||||||
|         s.y(frame).values, // Y
 |           s.y.field.values, // Y
 | ||||||
|         asArray(frame, s.pointSize), |           diams, | ||||||
|         colorValues, |           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(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function asArray<T>(frame: DataFrame, lookup: DimensionValues<T>): T[] { | interface FieldColorValues { | ||||||
|   const r = lookup(frame); |   index: unknown[]; | ||||||
|   if (Array.isArray(r)) { |   getOne: GetOneValue; | ||||||
|     return r; |   getAll: GetAllValues; | ||||||
|   } |  | ||||||
|   return Array(frame.length).fill(r); |  | ||||||
| } | } | ||||||
|  | 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 { | /** compiler for values to palette color idxs (from thresholds, mappings, by-value gradients) */ | ||||||
|   const r = lookup(frame); | function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues { | ||||||
|   if (Array.isArray(r)) { |   let index: unknown[] = []; | ||||||
|     return r[0]; |   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)); | ||||||
|           } |           } | ||||||
|   return r; |         } | ||||||
|  |       } 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,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 { | export function fmt(field: Field, val: number): string { | ||||||
|   if (field.display) { |   if (field.display) { | ||||||
|  | @ -7,3 +26,301 @@ export function fmt(field: Field, val: number): string { | ||||||
| 
 | 
 | ||||||
|   return `${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, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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