mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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,33 +106,39 @@ 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={data} width={vizWidth} height={vizHeight}>
|
||||||
<UPlotChart config={builder} data={facets} 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}
|
xySeries={series}
|
||||||
allSeries={series}
|
dismiss={dismiss}
|
||||||
dismiss={dismiss}
|
isPinned={isPinned}
|
||||||
isPinned={isPinned}
|
seriesIdx={seriesIdx!}
|
||||||
options={props.options}
|
replaceVariables={props.replaceVariables}
|
||||||
seriesIdx={seriesIdx}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
}}
|
||||||
}}
|
maxWidth={props.options.tooltip.maxWidth}
|
||||||
maxWidth={props.options.tooltip.maxWidth}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</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) {
|
contentItems.push({
|
||||||
extraFields.forEach((field) => {
|
label: stripSeriesName(sizeField.state?.displayName ?? sizeField.name, label),
|
||||||
contentItems.push({
|
value: fmt(sizeField, sizeField.values[rowIndex]),
|
||||||
label: field.name,
|
|
||||||
value: fmt(field, field.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;
|
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> = {
|
||||||
|
77
public/app/plugins/panel/xychart/panelcfgold.gen.ts
Normal file
77
public/app/plugins/panel/xychart/panelcfgold.gen.ts
Normal file
@ -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 pointColor has 0 opacity, draw as single path (assuming all strokes are alpha 1)
|
|
||||||
|
|
||||||
u.ctx.beginPath();
|
|
||||||
u.ctx.arc(cx, cy, size / 2, 0, deg360);
|
|
||||||
|
|
||||||
if (colorByValue) {
|
if (colorByValue) {
|
||||||
if (pointColors[i] !== curColor) {
|
if (pointColors[i] !== curColorIdx) {
|
||||||
curColor = pointColors[i];
|
curColorIdx = pointColors[i];
|
||||||
u.ctx.fillStyle = alpha(curColor, pointAlpha);
|
let c = curColorIdx === -1 ? FALLBACK_COLOR : pointPalette[curColorIdx];
|
||||||
u.ctx.strokeStyle = curColor;
|
u.ctx.fillStyle = paletteHasAlpha ? c : alpha(c as string, pointAlpha);
|
||||||
|
u.ctx.strokeStyle = alpha(c as string, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSquare) {
|
||||||
|
let x = Math.round(cx - size / 2);
|
||||||
|
let y = Math.round(cy - size / 2);
|
||||||
|
|
||||||
|
if (colorByValue || pointAlpha > 0) {
|
||||||
|
u.ctx.fillRect(x, y, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strokeWidth > 0) {
|
||||||
|
u.ctx.strokeRect(x, y, size, size);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
u.ctx.beginPath();
|
||||||
|
u.ctx.arc(cx, cy, size / 2, 0, deg360);
|
||||||
|
|
||||||
|
if (colorByValue || pointAlpha > 0) {
|
||||||
|
u.ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strokeWidth > 0) {
|
||||||
|
u.ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
u.ctx.fill();
|
|
||||||
u.ctx.stroke();
|
|
||||||
opts.each(
|
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 cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
function prepData(xySeries: XYSeries[]): FacetedData {
|
||||||
|
// if (info.error || !data.length) {
|
||||||
|
// return [null];
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { size: sizeRange, color: colorRange } = getGlobalRanges(xySeries);
|
||||||
|
|
||||||
|
xySeries.forEach((s, i) => {
|
||||||
|
dispColors[i].values = dispColors[i].getAll(s.color.field?.values ?? [], colorRange.min, colorRange.max);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
null,
|
||||||
|
...xySeries.map((s, idx) => {
|
||||||
|
let len = s.x.field.values.length;
|
||||||
|
|
||||||
|
let diams: number[];
|
||||||
|
|
||||||
|
if (s.size.field != null) {
|
||||||
|
let { min, max } = s.size;
|
||||||
|
|
||||||
|
// todo: this scaling should be in renderer from raw values (not by passing css pixel diams via data)
|
||||||
|
let minPx = min! ** 2;
|
||||||
|
let maxPx = max! ** 2;
|
||||||
|
// use quadratic size scaling in byValue modes
|
||||||
|
let pxRange = maxPx - minPx;
|
||||||
|
|
||||||
|
let vals = s.size.field.values;
|
||||||
|
let minVal = sizeRange.min;
|
||||||
|
let maxVal = sizeRange.max;
|
||||||
|
let valRange = maxVal - minVal;
|
||||||
|
|
||||||
|
diams = Array(len);
|
||||||
|
|
||||||
|
for (let i = 0; i < vals.length; i++) {
|
||||||
|
let val = vals[i];
|
||||||
|
|
||||||
|
let valPct = (val - minVal) / valRange;
|
||||||
|
let pxArea = minPx + valPct * pxRange;
|
||||||
|
diams[i] = pxArea ** 0.5;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
diams = Array(len).fill(s.size.fixed!);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [xValues, yValues, sizeValues];
|
return [
|
||||||
});
|
s.x.field.values, // X
|
||||||
});
|
s.y.field.values, // Y
|
||||||
|
diams,
|
||||||
|
Array(len).fill(s.color.fixed!), // TODO: fails for by value
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [null, ...seriesData];
|
return { builder, prepData };
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type PrepData = (xySeries: XYSeries[]) => FacetedData;
|
||||||
* This is called everytime the data changes
|
|
||||||
*
|
|
||||||
* from? is this where we would support that? -- need the previous values
|
|
||||||
*/
|
|
||||||
export function prepData(info: ScatterPanelInfo, data: DataFrame[], from?: number): FacetedData {
|
|
||||||
if (info.error || !data.length) {
|
|
||||||
return [null];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
null,
|
|
||||||
...info.series.map((s, idx) => {
|
|
||||||
const frame = s.frame(data);
|
|
||||||
|
|
||||||
let colorValues;
|
const getGlobalRanges = (xySeries: XYSeries[]) => {
|
||||||
const r = s.pointColor(frame);
|
const ranges = {
|
||||||
if (Array.isArray(r)) {
|
size: {
|
||||||
colorValues = r;
|
min: Infinity,
|
||||||
} else {
|
max: -Infinity,
|
||||||
colorValues = Array(frame.length).fill(r);
|
},
|
||||||
|
color: {
|
||||||
|
min: Infinity,
|
||||||
|
max: -Infinity,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
xySeries.forEach((series) => {
|
||||||
|
[series.size, series.color].forEach((facet, fi) => {
|
||||||
|
if (facet.field != null) {
|
||||||
|
let range = fi === 0 ? ranges.size : ranges.color;
|
||||||
|
|
||||||
|
const vals = facet.field.values;
|
||||||
|
|
||||||
|
for (let i = 0; i < vals.length; i++) {
|
||||||
|
const v = vals[i];
|
||||||
|
|
||||||
|
if (v != null) {
|
||||||
|
if (v < range.min) {
|
||||||
|
range.min = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v > range.max) {
|
||||||
|
range.max = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [
|
});
|
||||||
s.x(frame).values, // X
|
});
|
||||||
s.y(frame).values, // Y
|
|
||||||
asArray(frame, s.pointSize),
|
return ranges;
|
||||||
colorValues,
|
};
|
||||||
];
|
|
||||||
}),
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (m.options.result.color != null) {
|
||||||
|
let { color } = m.options.result;
|
||||||
|
|
||||||
|
if (m.type === MappingType.RangeToText) {
|
||||||
|
let range = [];
|
||||||
|
|
||||||
|
if (m.options.from != null) {
|
||||||
|
range.push(`v >= ${Number(m.options.from)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.options.to != null) {
|
||||||
|
range.push(`v <= ${Number(m.options.to)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.length > 0) {
|
||||||
|
conds += `${range.join(' && ')} ? ${index.length} : `;
|
||||||
|
index.push(getHex8Color(color, theme));
|
||||||
|
}
|
||||||
|
} else if (m.type === MappingType.SpecialValue) {
|
||||||
|
let spl = m.options.match;
|
||||||
|
|
||||||
|
if (spl === SpecialValueMatch.NaN) {
|
||||||
|
conds += `isNaN(v)`;
|
||||||
|
} else if (spl === SpecialValueMatch.NullAndNaN) {
|
||||||
|
conds += `v == null || isNaN(v)`;
|
||||||
|
} else {
|
||||||
|
conds += `v ${
|
||||||
|
spl === SpecialValueMatch.True
|
||||||
|
? '=== true'
|
||||||
|
: spl === SpecialValueMatch.False
|
||||||
|
? '=== false'
|
||||||
|
: spl === SpecialValueMatch.Null
|
||||||
|
? '== null'
|
||||||
|
: spl === SpecialValueMatch.Empty
|
||||||
|
? '=== ""'
|
||||||
|
: '== null'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
conds += ` ? ${index.length} : `;
|
||||||
|
index.push(getHex8Color(color, theme));
|
||||||
|
} else if (m.type === MappingType.RegexToText) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conds += '-1'; // ?? what default here? null? FALLBACK_COLOR?
|
||||||
|
} else if (f.config.color?.mode === FieldColorModeId.Thresholds) {
|
||||||
|
if (f.config.thresholds?.mode === ThresholdsMode.Absolute) {
|
||||||
|
let steps = f.config.thresholds.steps;
|
||||||
|
let lasti = steps.length - 1;
|
||||||
|
|
||||||
|
for (let i = lasti; i > 0; i--) {
|
||||||
|
conds += `v >= ${steps[i].value} ? ${i} : `;
|
||||||
|
}
|
||||||
|
|
||||||
|
conds += '0';
|
||||||
|
|
||||||
|
index = steps.map((s) => getHex8Color(s.color, theme));
|
||||||
|
} else {
|
||||||
|
// TODO: percent thresholds?
|
||||||
|
}
|
||||||
|
} else if (f.config.color?.mode?.startsWith('continuous')) {
|
||||||
|
let calc = getFieldColorModeForField(f).getCalculator(f, theme);
|
||||||
|
|
||||||
|
index = Array(32);
|
||||||
|
|
||||||
|
for (let i = 0; i < index.length; i++) {
|
||||||
|
let pct = i / (index.length - 1);
|
||||||
|
index[i] = getHex8Color(calc(pct, pct), theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll = (vals, min, max) => valuesToFills(vals as number[], index as string[], min!, max!);
|
||||||
}
|
}
|
||||||
return r;
|
|
||||||
|
if (conds !== '') {
|
||||||
|
getOne = new Function('v', `return ${conds};`) as GetOneValue;
|
||||||
|
|
||||||
|
getAll = new Function(
|
||||||
|
'vals',
|
||||||
|
`
|
||||||
|
let idxs = Array(vals.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < vals.length; i++) {
|
||||||
|
let v = vals[i];
|
||||||
|
idxs[i] = ${conds};
|
||||||
|
}
|
||||||
|
|
||||||
|
return idxs;
|
||||||
|
`
|
||||||
|
) as GetAllValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
getOne,
|
||||||
|
getAll,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
import { DataFrame, Field, FieldColorMode } from '@grafana/data';
|
|
||||||
import { LineStyle, ScaleDimensionConfig, VisibilityMode } from '@grafana/schema';
|
|
||||||
import { VizLegendItem } from '@grafana/ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
export type DimensionValues<T> = (frame: DataFrame, from?: number) => T | T[];
|
|
||||||
|
|
||||||
// Using field where we will need formatting/scale/axis info
|
|
||||||
// Use raw or DimensionValues when the values can be used directly
|
|
||||||
export interface ScatterSeries {
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/** Finds the relevant frame from the raw panel data */
|
|
||||||
frame: (frames: DataFrame[]) => DataFrame;
|
|
||||||
|
|
||||||
x: (frame: DataFrame) => Field;
|
|
||||||
y: (frame: DataFrame) => Field;
|
|
||||||
|
|
||||||
legend: () => VizLegendItem[]; // could be single if symbol is constant
|
|
||||||
|
|
||||||
showLine: boolean;
|
|
||||||
lineWidth: number;
|
|
||||||
lineStyle: LineStyle;
|
|
||||||
lineColor: (frame: DataFrame) => CanvasRenderingContext2D['strokeStyle'];
|
|
||||||
|
|
||||||
showPoints: VisibilityMode;
|
|
||||||
pointSize: DimensionValues<number>;
|
|
||||||
pointColor: DimensionValues<CanvasRenderingContext2D['strokeStyle']>;
|
|
||||||
pointSymbol: DimensionValues<string>; // single field, multiple symbols.... kinda equals multiple series
|
|
||||||
|
|
||||||
label: VisibilityMode;
|
|
||||||
labelValue: DimensionValues<string>;
|
|
||||||
show: boolean;
|
|
||||||
|
|
||||||
hints: {
|
|
||||||
pointSize: ScaleDimensionConfig;
|
|
||||||
pointColor: {
|
|
||||||
mode: FieldColorMode;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,4 +1,23 @@
|
|||||||
import { Field, formattedValueToString } from '@grafana/data';
|
import {
|
||||||
|
Field,
|
||||||
|
formattedValueToString,
|
||||||
|
getFieldMatcher,
|
||||||
|
FieldType,
|
||||||
|
getFieldDisplayName,
|
||||||
|
DataFrame,
|
||||||
|
FrameMatcherID,
|
||||||
|
MatcherConfig,
|
||||||
|
FieldColorModeId,
|
||||||
|
cacheFieldDisplayNames,
|
||||||
|
FieldMatcherID,
|
||||||
|
FieldConfigSource,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { decoupleHideFromState } from '@grafana/data/src/field/fieldState';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { VisibilityMode } from '@grafana/schema';
|
||||||
|
|
||||||
|
import { XYShowMode, SeriesMapping, XYSeriesConfig } from './panelcfg.gen';
|
||||||
|
import { XYSeries } from './types2';
|
||||||
|
|
||||||
export function fmt(field: Field, val: number): string {
|
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
Block a user