mirror of https://github.com/grafana/grafana
XYChart: Remove old implementation (#96416)
parent
c6f85579db
commit
39fe0b29ff
|
@ -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,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,106 +0,0 @@ |
||||
import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data'; |
||||
import { XYFieldMatchers } from 'app/core/components/GraphNG/types'; |
||||
|
||||
import { XYDimensionConfig } from './panelcfg.gen'; |
||||
|
||||
// TODO: fix import
|
||||
|
||||
export enum DimensionError { |
||||
NoData, |
||||
BadFrameSelection, |
||||
XNotFound, |
||||
} |
||||
|
||||
export interface XYDimensions { |
||||
frame: DataFrame; // matches order from configs, excluds non-graphable values
|
||||
x: Field; |
||||
fields: XYFieldMatchers; |
||||
hasData?: boolean; |
||||
hasTime?: boolean; |
||||
} |
||||
|
||||
export interface XYDimensionsError { |
||||
error: DimensionError; |
||||
} |
||||
|
||||
export function isGraphable(field: Field) { |
||||
return field.type === FieldType.number; |
||||
} |
||||
|
||||
export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions | XYDimensionsError { |
||||
if (!data || !data.length) { |
||||
return { error: DimensionError.NoData }; |
||||
} |
||||
if (!cfg) { |
||||
cfg = { |
||||
frame: 0, |
||||
}; |
||||
} |
||||
|
||||
let frame = data[cfg.frame ?? 0]; |
||||
if (!frame) { |
||||
return { error: DimensionError.BadFrameSelection }; |
||||
} |
||||
|
||||
let xIndex = -1; |
||||
for (let i = 0; i < frame.fields.length; i++) { |
||||
const f = frame.fields[i]; |
||||
if (cfg.x && cfg.x === getFieldDisplayName(f, frame, data)) { |
||||
xIndex = i; |
||||
break; |
||||
} |
||||
if (isGraphable(f) && !cfg.x) { |
||||
xIndex = i; |
||||
break; |
||||
} |
||||
} |
||||
|
||||
let hasTime = false; |
||||
const x = frame.fields[xIndex]; |
||||
const fields: Field[] = [x]; |
||||
for (const f of frame.fields) { |
||||
if (f.type === FieldType.time) { |
||||
hasTime = true; |
||||
} |
||||
if (f === x || !isGraphable(f)) { |
||||
continue; |
||||
} |
||||
if (cfg.exclude) { |
||||
const name = getFieldDisplayName(f, frame, data); |
||||
if (cfg.exclude.includes(name)) { |
||||
continue; |
||||
} |
||||
} |
||||
fields.push(f); |
||||
} |
||||
|
||||
return { |
||||
x, |
||||
fields: { |
||||
x: getSimpleFieldMatcher(x), |
||||
y: getSimpleFieldNotMatcher(x), // Not x
|
||||
}, |
||||
frame: { |
||||
...frame, |
||||
fields, |
||||
}, |
||||
hasData: frame.fields.length > 0, |
||||
hasTime, |
||||
}; |
||||
} |
||||
|
||||
function getSimpleFieldMatcher(f: Field): FieldMatcher { |
||||
if (!f) { |
||||
return () => false; |
||||
} |
||||
// the field may change if sorted
|
||||
return (field) => f === field || !!(f.state && f.state === field.state); |
||||
} |
||||
|
||||
function getSimpleFieldNotMatcher(f: Field): FieldMatcher { |
||||
if (!f) { |
||||
return () => false; |
||||
} |
||||
const m = getSimpleFieldMatcher(f); |
||||
return (field) => !m(field, { fields: [], length: 0 }, []); |
||||
} |
@ -1,8 +1,7 @@ |
||||
import { FieldMatcherID, FrameMatcherID, MatcherConfig, PanelModel } from '@grafana/data'; |
||||
|
||||
import { ScatterSeriesConfig, SeriesMapping, XYDimensionConfig, Options as PrevOptions } from '../panelcfg.gen'; |
||||
|
||||
import { XYSeriesConfig, Options } from './panelcfg.gen'; |
||||
import { ScatterSeriesConfig, SeriesMapping, XYDimensionConfig, Options as PrevOptions } from './panelcfgold.gen'; |
||||
|
||||
export const xyChartMigrationHandler = (panel: PanelModel): Options => { |
||||
const pluginVersion = panel?.pluginVersion ?? ''; |
@ -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: [], |
||||
}; |
@ -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,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