Compare commits

...

6 Commits

Author SHA1 Message Date
Leon Sorokin
2723cdf08f Merge branch 'main' into leeoniya/state-timeline-value-mappings
Some checks failed
CodeQL checks / Analyze (actions) (push) Has been cancelled
CodeQL checks / Analyze (go) (push) Has been cancelled
CodeQL checks / Analyze (javascript) (push) Has been cancelled
2025-07-02 17:54:06 -05:00
Leon Sorokin
9c946c41b1 wip 2025-02-28 09:02:35 -06:00
Leon Sorokin
34d6434785 Merge branch 'main' into leeoniya/state-timeline-value-mappings 2025-02-27 17:59:39 -06:00
Leon Sorokin
70570389d1 add labels 2025-02-26 21:28:30 -06:00
Leon Sorokin
df4ca9cab5 Merge branch 'main' into leeoniya/state-timeline-value-mappings 2025-02-26 20:00:00 -06:00
Leon Sorokin
2815a5631a make xychart color compiler handle text and icon, and de-duplicate states 2025-01-22 21:04:07 -06:00
2 changed files with 249 additions and 28 deletions

View File

@@ -2,6 +2,7 @@ import tinycolor from 'tinycolor2';
import uPlot from 'uplot';
import {
EnumFieldConfig,
FALLBACK_COLOR,
Field,
FieldType,
@@ -433,7 +434,11 @@ export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => {
const dispColors = xySeries.map((s): FieldColorValuesWithCache => {
const cfg: FieldColorValuesWithCache = {
index: [],
index: {
color: [],
text: [],
icon: [],
},
getAll: () => [],
getOne: () => -1,
// cache for renderer, refreshed in prepData()
@@ -444,8 +449,8 @@ export const prepConfig = (xySeries: XYSeries[], theme: GrafanaTheme2) => {
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'));
Object.assign(cfg, getEnumConfig(f, theme));
cfg.hasAlpha = cfg.index.color!.some((v) => !(v as string).endsWith('ff'));
}
return cfg;
@@ -555,7 +560,7 @@ function getHex8Color(color: string, theme: GrafanaTheme2) {
}
interface FieldColorValues {
index: unknown[];
index: EnumFieldConfig;
getOne: GetOneValue;
getAll: GetAllValues;
}
@@ -566,9 +571,40 @@ interface FieldColorValuesWithCache extends FieldColorValues {
type GetAllValues = (values: unknown[], min?: number, max?: number) => number[];
type GetOneValue = (value: unknown, min?: number, max?: number) => number;
function getLabelForRange(from: number | null, to: number | null) {
let text: string;
if (from != null) {
if (to != null) {
text = `${from} - ${to}`;
} else {
text = `${from}`;
}
} else {
if (to != null) {
text = `${to}`;
} else {
text = '';
}
}
return text;
}
// percent enum configs can be combined, percent threasholds are global across multi frames and fields
// classic palette by value
// get and getAll, should accept shared min/max to scale percentage
// merge enums for legend by combo of color+icon+text
// auto-threshold by % into 10 bukkits?
/** compiler for values to palette color idxs (from thresholds, mappings, by-value gradients) */
function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues {
let index: unknown[] = [];
export function getEnumConfig(f: Field, theme: GrafanaTheme2): FieldColorValues {
const index: EnumFieldConfig = {
color: [],
text: [],
icon: [],
};
let getAll: GetAllValues = () => [];
let getOne: GetOneValue = () => -1;
@@ -578,46 +614,72 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues {
if (f.config.mappings?.length ?? 0 > 0) {
let mappings = f.config.mappings!;
// this is color+text+icon that deduplicates the index above
// e.g. if multiple values + ranges map "OK"+"green", this ensures they map to same state by key
let keys: string[] = [];
function indexOf(color = '', text = '', icon = '') {
let key = `${color}|${text}|${icon}`;
let idx = keys.indexOf(key);
if (idx === -1) {
idx = keys.length;
keys.push(key);
index.color!.push(getHex8Color(color, theme));
index.text!.push(text);
index.icon!.push(icon);
}
return idx;
}
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];
let { color, text, icon } = 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));
let idx = indexOf(color, text, icon);
let rhs = k.toLowerCase() === 'null' ? 'null' : f.type === FieldType.string ? JSON.stringify(k) : Number(k);
conds += `v === ${rhs} ? ${idx} : `;
}
}
} else if (m.options.result.color != null) {
let { color } = m.options.result;
let { color, text, icon } = m.options.result;
if (m.type === MappingType.RangeToText) {
let { from, to } = m.options;
text ??= getLabelForRange(from, to);
let range = [];
if (m.options.from != null) {
range.push(`v >= ${Number(m.options.from)}`);
if (from != null) {
range.push(`v >= ${Number(from)}`);
}
if (m.options.to != null) {
range.push(`v <= ${Number(m.options.to)}`);
if (to != null) {
range.push(`v <= ${Number(to)}`);
}
if (range.length > 0) {
conds += `${range.join(' && ')} ? ${index.length} : `;
index.push(getHex8Color(color, theme));
let idx = indexOf(color, text, icon);
conds += `${range.join(' && ')} ? ${idx} : `;
}
} else if (m.type === MappingType.SpecialValue) {
let spl = m.options.match;
if (spl === SpecialValueMatch.NaN) {
text ??= 'NaN';
conds += `isNaN(v)`;
} else if (spl === SpecialValueMatch.NullAndNaN) {
text ??= 'null/NaN';
conds += `v == null || isNaN(v)`;
} else {
conds += `v ${
let cond =
spl === SpecialValueMatch.True
? '=== true'
: spl === SpecialValueMatch.False
@@ -626,12 +688,14 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues {
? '== null'
: spl === SpecialValueMatch.Empty
? '=== ""'
: '== null'
}`;
: '== null';
conds += `v ${cond}`;
text ??= cond.replace(/[= ]+/g, '');
}
conds += ` ? ${index.length} : `;
index.push(getHex8Color(color, theme));
let idx = indexOf(color, text, icon);
conds += ` ? ${idx} : `;
} else if (m.type === MappingType.RegexToText) {
// TODO
}
@@ -639,7 +703,7 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues {
}
conds += '-1'; // ?? what default here? null? FALLBACK_COLOR?
} else if (f.config.color?.mode === FieldColorModeId.Thresholds) {
} else if (f.config.color?.mode === FieldColorModeId.Thresholds && (f.config.thresholds?.steps.length ?? 0) > 1) {
if (f.config.thresholds?.mode === ThresholdsMode.Absolute) {
let steps = f.config.thresholds.steps;
let lasti = steps.length - 1;
@@ -651,18 +715,20 @@ function fieldValueColors(f: Field, theme: GrafanaTheme2): FieldColorValues {
conds += '0';
index = steps.map((s) => getHex8Color(s.color, theme));
index.color = steps.map((s) => getHex8Color(s.color, theme));
index.text = steps.map((s, i) => (i === 0 ? `< ${steps[i + 1].value}` : getLabelForRange(s.value, null)));
index.icon = Array(steps.length).fill('');
} else {
// TODO: percent thresholds?
}
} else if (f.config.color?.mode?.startsWith('continuous')) {
let calc = getFieldColorModeForField(f).getCalculator(f, theme);
index = Array(32);
index.color = Array(32);
for (let i = 0; i < index.length; i++) {
let pct = i / (index.length - 1);
index[i] = getHex8Color(calc(pct, pct), theme);
for (let i = 0; i < index.color.length; i++) {
let pct = i / (index.color.length - 1);
index.color[i] = getHex8Color(calc(pct, pct), theme);
}
getAll = (vals, min, max) => valuesToFills(vals as number[], index as string[], min!, max!);

View File

@@ -0,0 +1,155 @@
import { createTheme, FieldType, Field, ThresholdsMode, MappingType } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen';
import { getEnumConfig } from './scatter';
describe('value mapping function', () => {
it('thresholds', () => {
const field: Field<number | null> = {
name: 'A',
type: FieldType.number,
values: [0, 10, 20, 30, 40, 50],
config: {
mappings: undefined,
thresholds: {
mode: ThresholdsMode.Absolute,
steps: [
{
value: -Infinity,
color: 'green',
},
{
value: 30,
color: 'red',
},
],
},
color: {
mode: FieldColorModeId.Thresholds,
},
},
};
const { index, getAll } = getEnumConfig(field, createTheme());
expect(index).toEqual({ color: ['#73bf69ff', '#f2495cff'], icon: ['', ''], text: ['< 30', '≥ 30'] });
expect(getAll(field.values)).toEqual([0, 0, 0, 1, 1, 1]);
});
it('mappings (with dedupe)', () => {
const field: Field<number | null> = {
name: 'A',
type: FieldType.number,
values: [5, 6, 7, 8, 9, 10, 11, 32, 40, null],
config: {
mappings: [
{
options: {
'21': {
color: '#fade2a',
index: 10,
text: 'Manual Stop',
},
'22': {
color: '#f2495c',
index: 9,
text: 'Instant Shutdown',
},
'23': {
color: '#ff9830',
index: 8,
text: 'Delayed Shutdown',
},
'30': {
color: '#5794f2',
index: 7,
text: 'Propel',
},
'31': {
color: '#ffa6b0',
index: 6,
text: 'Limits Mode',
},
'32': {
color: '#73bf69',
index: 5,
text: 'Production',
},
'33': {
color: '#ffcb7d',
index: 4,
text: 'Motivator Mode',
},
'40': {
color: '#73bf69',
index: 3,
text: 'Production',
},
null: {
color: '#808080',
index: 2,
text: 'N/A',
},
},
type: MappingType.ValueToText,
},
{
options: {
from: 41,
result: {
color: '#a352cc',
index: 0,
text: 'Maintenance Mode',
},
to: 45,
},
type: MappingType.RangeToText,
},
{
options: {
from: 5,
result: {
color: '#73bf69',
index: 1,
text: 'Production',
},
to: 11,
},
type: MappingType.RangeToText,
},
],
color: {
mode: FieldColorModeId.Fixed,
},
},
};
// this should merge states with equal text+color+icon
const { index, getAll } = getEnumConfig(field, createTheme());
expect(index).toEqual({
color: [
'#fade2aff',
'#f2495cff',
'#ff9830ff',
'#5794f2ff',
'#ffa6b0ff',
'#73bf69ff',
'#ffcb7dff',
'#808080ff',
'#a352ccff',
],
icon: ['', '', '', '', '', '', '', '', ''],
text: [
'Manual Stop',
'Instant Shutdown',
'Delayed Shutdown',
'Propel',
'Limits Mode',
'Production',
'Motivator Mode',
'N/A',
'Maintenance Mode',
],
});
expect(getAll(field.values)).toEqual([5, 5, 5, 5, 5, 5, 5, 5, 5, 7]);
});
});