mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Refactor state timeline/status history to cue model and refactor TimelineChart component (#61631)
* Adapt state timeline to scuemata * Refactor status history to cue model * Refactor * Refactor TimelineChart as a core component * wip * Change as per CR Co-authored-by: sam boyer <sdboyer@grafana.com>
This commit is contained in:
98
public/app/core/components/TimelineChart/TimelineChart.tsx
Normal file
98
public/app/core/components/TimelineChart/TimelineChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
|
||||
import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema';
|
||||
import {
|
||||
PanelContext,
|
||||
PanelContextRoot,
|
||||
GraphNG,
|
||||
GraphNGProps,
|
||||
UPlotConfigBuilder,
|
||||
VizLayout,
|
||||
VizLegend,
|
||||
VizLegendItem,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { preparePlotConfigBuilder, TimelineMode } from './utils';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
|
||||
mode: TimelineMode;
|
||||
rowHeight?: number;
|
||||
showValue: VisibilityMode;
|
||||
alignValue?: TimelineValueAlignment;
|
||||
colWidth?: number;
|
||||
legendItems?: VizLegendItem[];
|
||||
}
|
||||
|
||||
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue'];
|
||||
|
||||
export class TimelineChart extends React.Component<TimelineProps> {
|
||||
static contextType = PanelContextRoot;
|
||||
panelContext: PanelContext = {} as PanelContext;
|
||||
|
||||
getValueColor = (frameIdx: number, fieldIdx: number, value: any) => {
|
||||
const field = this.props.frames[frameIdx].fields[fieldIdx];
|
||||
|
||||
if (field.display) {
|
||||
const disp = field.display(value); // will apply color modes
|
||||
if (disp.color) {
|
||||
return disp.color;
|
||||
}
|
||||
}
|
||||
|
||||
return FALLBACK_COLOR;
|
||||
};
|
||||
|
||||
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
|
||||
this.panelContext = this.context;
|
||||
const { eventBus, sync } = this.panelContext;
|
||||
|
||||
return preparePlotConfigBuilder({
|
||||
frame: alignedFrame,
|
||||
getTimeRange,
|
||||
eventBus,
|
||||
sync,
|
||||
allFrames: this.props.frames,
|
||||
...this.props,
|
||||
|
||||
// Ensure timezones is passed as an array
|
||||
timeZones: Array.isArray(this.props.timeZone) ? this.props.timeZone : [this.props.timeZone],
|
||||
|
||||
// When there is only one row, use the full space
|
||||
rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1,
|
||||
getValueColor: this.getValueColor,
|
||||
});
|
||||
};
|
||||
|
||||
renderLegend = (config: UPlotConfigBuilder) => {
|
||||
const { legend, legendItems } = this.props;
|
||||
|
||||
if (!config || !legendItems || !legend || legend.showLegend === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout.Legend placement={legend.placement}>
|
||||
<VizLegend placement={legend.placement} items={legendItems} displayMode={legend.displayMode} readonly />
|
||||
</VizLayout.Legend>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<GraphNG
|
||||
{...this.props}
|
||||
fields={{
|
||||
x: (f) => f.type === FieldType.time,
|
||||
y: (f) => f.type === FieldType.number || f.type === FieldType.boolean || f.type === FieldType.string,
|
||||
}}
|
||||
prepConfig={this.prepConfig}
|
||||
propsToDiff={propsToDiff}
|
||||
renderLegend={this.renderLegend}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
581
public/app/core/components/TimelineChart/timeline.ts
Normal file
581
public/app/core/components/TimelineChart/timeline.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import uPlot, { Cursor, Series } from 'uplot';
|
||||
|
||||
import { GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { alpha } from '@grafana/data/src/themes/colorManipulator';
|
||||
import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema';
|
||||
import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG';
|
||||
import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute';
|
||||
import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree';
|
||||
import { PanelFieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen';
|
||||
import { PanelFieldConfig as StatusHistoryFieldConfig } from 'app/plugins/panel/status-history/panelcfg.gen';
|
||||
|
||||
import { TimelineMode } from './utils';
|
||||
|
||||
const { round, min, ceil } = Math;
|
||||
|
||||
const textPadding = 2;
|
||||
|
||||
let pxPerChar = 6;
|
||||
|
||||
const laneDistr = SPACE_BETWEEN;
|
||||
|
||||
type WalkCb = (idx: number, offPx: number, dimPx: number) => void;
|
||||
|
||||
function walk(rowHeight: number, yIdx: number | null, count: number, dim: number, draw: WalkCb) {
|
||||
distribute(count, rowHeight, laneDistr, yIdx, (i, offPct, dimPct) => {
|
||||
let laneOffPx = dim * offPct;
|
||||
let laneWidPx = dim * dimPct;
|
||||
|
||||
draw(i, laneOffPx, laneWidPx);
|
||||
});
|
||||
}
|
||||
|
||||
interface TimelineBoxRect extends Rect {
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface TimelineCoreOptions {
|
||||
mode: TimelineMode;
|
||||
alignValue?: TimelineValueAlignment;
|
||||
numSeries: number;
|
||||
rowHeight?: number;
|
||||
colWidth?: number;
|
||||
theme: GrafanaTheme2;
|
||||
showValue: VisibilityMode;
|
||||
mergeValues?: boolean;
|
||||
isDiscrete: (seriesIdx: number) => boolean;
|
||||
getValueColor: (seriesIdx: number, value: any) => string;
|
||||
label: (seriesIdx: number) => string;
|
||||
getTimeRange: () => TimeRange;
|
||||
formatValue?: (seriesIdx: number, value: any) => string;
|
||||
getFieldConfig: (seriesIdx: number) => StateTimeLineFieldConfig | StatusHistoryFieldConfig;
|
||||
onHover: (seriesIdx: number, valueIdx: number, rect: Rect) => void;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function getConfig(opts: TimelineCoreOptions) {
|
||||
const {
|
||||
mode,
|
||||
numSeries,
|
||||
isDiscrete,
|
||||
rowHeight = 0,
|
||||
colWidth = 0,
|
||||
showValue,
|
||||
mergeValues = false,
|
||||
theme,
|
||||
label,
|
||||
formatValue,
|
||||
alignValue = 'left',
|
||||
getTimeRange,
|
||||
getValueColor,
|
||||
getFieldConfig,
|
||||
onHover,
|
||||
onLeave,
|
||||
} = opts;
|
||||
|
||||
let qt: Quadtree;
|
||||
|
||||
const hoverMarks = Array(numSeries)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
let mark = document.createElement('div');
|
||||
mark.classList.add('bar-mark');
|
||||
mark.style.position = 'absolute';
|
||||
mark.style.background = 'rgba(255,255,255,0.2)';
|
||||
return mark;
|
||||
});
|
||||
|
||||
// Needed for to calculate text positions
|
||||
let boxRectsBySeries: TimelineBoxRect[][];
|
||||
|
||||
const resetBoxRectsBySeries = (count: number) => {
|
||||
boxRectsBySeries = Array(numSeries)
|
||||
.fill(null)
|
||||
.map((v) => Array(count).fill(null));
|
||||
};
|
||||
|
||||
const font = `500 ${Math.round(12 * devicePixelRatio)}px ${theme.typography.fontFamily}`;
|
||||
const hovered: Array<Rect | null> = Array(numSeries).fill(null);
|
||||
|
||||
const size = [colWidth, Infinity];
|
||||
const gapFactor = 1 - size[0];
|
||||
const maxWidth = (size[1] ?? Infinity) * uPlot.pxRatio;
|
||||
|
||||
const fillPaths: Map<CanvasRenderingContext2D['fillStyle'], Path2D> = new Map();
|
||||
const strokePaths: Map<CanvasRenderingContext2D['strokeStyle'], Path2D> = new Map();
|
||||
|
||||
function drawBoxes(ctx: CanvasRenderingContext2D) {
|
||||
fillPaths.forEach((fillPath, fillStyle) => {
|
||||
ctx.fillStyle = fillStyle;
|
||||
ctx.fill(fillPath);
|
||||
});
|
||||
|
||||
strokePaths.forEach((strokePath, strokeStyle) => {
|
||||
ctx.strokeStyle = strokeStyle;
|
||||
ctx.stroke(strokePath);
|
||||
});
|
||||
|
||||
fillPaths.clear();
|
||||
strokePaths.clear();
|
||||
}
|
||||
|
||||
function putBox(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
rect: uPlot.RectH,
|
||||
xOff: number,
|
||||
yOff: number,
|
||||
left: number,
|
||||
top: number,
|
||||
boxWidth: number,
|
||||
boxHeight: number,
|
||||
strokeWidth: number,
|
||||
seriesIdx: number,
|
||||
valueIdx: number,
|
||||
value: any,
|
||||
discrete: boolean
|
||||
) {
|
||||
// do not render super small boxes
|
||||
if (boxWidth < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valueColor = getValueColor(seriesIdx + 1, value);
|
||||
const fieldConfig = getFieldConfig(seriesIdx);
|
||||
const fillColor = getFillColor(fieldConfig, valueColor);
|
||||
|
||||
boxRectsBySeries[seriesIdx][valueIdx] = {
|
||||
x: round(left - xOff),
|
||||
y: round(top - yOff),
|
||||
w: boxWidth,
|
||||
h: boxHeight,
|
||||
sidx: seriesIdx + 1,
|
||||
didx: valueIdx,
|
||||
// for computing label contrast
|
||||
fillColor,
|
||||
};
|
||||
|
||||
if (discrete) {
|
||||
let fillStyle = fillColor;
|
||||
let fillPath = fillPaths.get(fillStyle);
|
||||
|
||||
if (fillPath == null) {
|
||||
fillPaths.set(fillStyle, (fillPath = new Path2D()));
|
||||
}
|
||||
|
||||
rect(fillPath, left, top, boxWidth, boxHeight);
|
||||
|
||||
if (strokeWidth) {
|
||||
let strokeStyle = valueColor;
|
||||
let strokePath = strokePaths.get(strokeStyle);
|
||||
|
||||
if (strokePath == null) {
|
||||
strokePaths.set(strokeStyle, (strokePath = new Path2D()));
|
||||
}
|
||||
|
||||
rect(
|
||||
strokePath,
|
||||
left + strokeWidth / 2,
|
||||
top + strokeWidth / 2,
|
||||
boxWidth - strokeWidth,
|
||||
boxHeight - strokeWidth
|
||||
);
|
||||
}
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
rect(ctx, left, top, boxWidth, boxHeight);
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
if (strokeWidth) {
|
||||
ctx.beginPath();
|
||||
rect(ctx, left + strokeWidth / 2, top + strokeWidth / 2, boxWidth - strokeWidth, boxHeight - strokeWidth);
|
||||
ctx.strokeStyle = valueColor;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const drawPaths: Series.PathBuilder = (u, sidx, idx0, idx1) => {
|
||||
uPlot.orient(
|
||||
u,
|
||||
sidx,
|
||||
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
|
||||
let strokeWidth = round((series.width || 0) * uPlot.pxRatio);
|
||||
|
||||
let discrete = isDiscrete(sidx);
|
||||
|
||||
u.ctx.save();
|
||||
rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
u.ctx.clip();
|
||||
|
||||
walk(rowHeight, sidx - 1, numSeries, yDim, (iy, y0, height) => {
|
||||
if (mode === TimelineMode.Changes) {
|
||||
for (let ix = 0; ix < dataY.length; ix++) {
|
||||
let yVal = dataY[ix];
|
||||
|
||||
if (yVal != null) {
|
||||
let left = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff));
|
||||
|
||||
let nextIx = ix;
|
||||
while (
|
||||
++nextIx < dataY.length &&
|
||||
(dataY[nextIx] === undefined || (mergeValues && dataY[nextIx] === yVal))
|
||||
) {}
|
||||
|
||||
// to now (not to end of chart)
|
||||
let right =
|
||||
nextIx === dataY.length
|
||||
? xOff + xDim + strokeWidth
|
||||
: Math.round(valToPosX(dataX[nextIx], scaleX, xDim, xOff));
|
||||
|
||||
putBox(
|
||||
u.ctx,
|
||||
rect,
|
||||
xOff,
|
||||
yOff,
|
||||
left,
|
||||
round(yOff + y0),
|
||||
right - left,
|
||||
round(height),
|
||||
strokeWidth,
|
||||
iy,
|
||||
ix,
|
||||
yVal,
|
||||
discrete
|
||||
);
|
||||
|
||||
ix = nextIx - 1;
|
||||
}
|
||||
}
|
||||
} else if (mode === TimelineMode.Samples) {
|
||||
let colWid = valToPosX(dataX[1], scaleX, xDim, xOff) - valToPosX(dataX[0], scaleX, xDim, xOff);
|
||||
let gapWid = colWid * gapFactor;
|
||||
let barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth);
|
||||
let xShift = barWid / 2;
|
||||
//let xShift = align === 1 ? 0 : align === -1 ? barWid : barWid / 2;
|
||||
|
||||
for (let ix = idx0; ix <= idx1; ix++) {
|
||||
if (dataY[ix] != null) {
|
||||
// TODO: all xPos can be pre-computed once for all series in aligned set
|
||||
let left = valToPosX(dataX[ix], scaleX, xDim, xOff);
|
||||
|
||||
putBox(
|
||||
u.ctx,
|
||||
rect,
|
||||
xOff,
|
||||
yOff,
|
||||
round(left - xShift),
|
||||
round(yOff + y0),
|
||||
barWid,
|
||||
round(height),
|
||||
strokeWidth,
|
||||
iy,
|
||||
ix,
|
||||
dataY[ix],
|
||||
discrete
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (discrete) {
|
||||
u.ctx.lineWidth = strokeWidth;
|
||||
drawBoxes(u.ctx);
|
||||
}
|
||||
|
||||
u.ctx.restore();
|
||||
}
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const drawPoints: Series.Points.Show =
|
||||
formatValue == null || showValue === VisibilityMode.Never
|
||||
? false
|
||||
: (u, sidx, i0, i1) => {
|
||||
u.ctx.save();
|
||||
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
u.ctx.clip();
|
||||
|
||||
u.ctx.font = font;
|
||||
u.ctx.textAlign = mode === TimelineMode.Changes ? alignValue : 'center';
|
||||
u.ctx.textBaseline = 'middle';
|
||||
|
||||
uPlot.orient(
|
||||
u,
|
||||
sidx,
|
||||
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => {
|
||||
let strokeWidth = round((series.width || 0) * uPlot.pxRatio);
|
||||
|
||||
let y = round(yOff + yMids[sidx - 1]);
|
||||
|
||||
for (let ix = 0; ix < dataY.length; ix++) {
|
||||
if (dataY[ix] != null) {
|
||||
const boxRect = boxRectsBySeries[sidx - 1][ix];
|
||||
|
||||
if (!boxRect || boxRect.x >= xDim) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let maxChars = Math.floor(boxRect?.w / pxPerChar);
|
||||
|
||||
if (showValue === VisibilityMode.Auto && maxChars < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let txt = formatValue(sidx, dataY[ix]);
|
||||
|
||||
// center-aligned
|
||||
let x = round(boxRect.x + xOff + boxRect.w / 2);
|
||||
if (mode === TimelineMode.Changes) {
|
||||
if (alignValue === 'left') {
|
||||
x = round(boxRect.x + xOff + strokeWidth + textPadding);
|
||||
} else if (alignValue === 'right') {
|
||||
x = round(boxRect.x + xOff + boxRect.w - strokeWidth - textPadding);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: cache by fillColor to avoid setting ctx for label
|
||||
u.ctx.fillStyle = theme.colors.getContrastText(boxRect.fillColor, 3);
|
||||
u.ctx.fillText(txt.slice(0, maxChars), x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
u.ctx.restore();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const init = (u: uPlot) => {
|
||||
let over = u.over;
|
||||
let chars = '';
|
||||
for (let i = 32; i <= 126; i++) {
|
||||
chars += String.fromCharCode(i);
|
||||
}
|
||||
pxPerChar = Math.ceil((u.ctx.measureText(chars).width / chars.length) * uPlot.pxRatio);
|
||||
|
||||
// be a bit more conservtive to prevent overlap
|
||||
pxPerChar += 2.5;
|
||||
|
||||
over.style.overflow = 'hidden';
|
||||
hoverMarks.forEach((m) => {
|
||||
over.appendChild(m);
|
||||
});
|
||||
};
|
||||
|
||||
const drawClear = (u: uPlot) => {
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
|
||||
qt.clear();
|
||||
resetBoxRectsBySeries(u.data[0].length);
|
||||
|
||||
// force-clear the path cache to cause drawBars() to rebuild new quadtree
|
||||
u.series.forEach((s) => {
|
||||
// @ts-ignore
|
||||
s._paths = null;
|
||||
});
|
||||
};
|
||||
|
||||
function setHoverMark(i: number, o: Rect | null) {
|
||||
let h = hoverMarks[i];
|
||||
|
||||
let pxRatio = uPlot.pxRatio;
|
||||
|
||||
if (o) {
|
||||
h.style.display = '';
|
||||
h.style.left = round(o.x / pxRatio) + 'px';
|
||||
h.style.top = round(o.y / pxRatio) + 'px';
|
||||
h.style.width = round(o.w / pxRatio) + 'px';
|
||||
h.style.height = round(o.h / pxRatio) + 'px';
|
||||
} else {
|
||||
h.style.display = 'none';
|
||||
}
|
||||
|
||||
hovered[i] = o;
|
||||
}
|
||||
|
||||
let hoveredAtCursor: Rect | undefined;
|
||||
|
||||
function hoverMulti(cx: number, cy: number) {
|
||||
let foundAtCursor: Rect | undefined;
|
||||
|
||||
for (let i = 0; i < numSeries; i++) {
|
||||
let found: Rect | undefined;
|
||||
|
||||
if (cx >= 0) {
|
||||
let cy2 = yMids[i];
|
||||
|
||||
qt.get(cx, cy2, 1, 1, (o) => {
|
||||
if (pointWithin(cx, cy2, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
found = o;
|
||||
|
||||
if (Math.abs(cy - cy2) <= o.h / 2) {
|
||||
foundAtCursor = o;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (found) {
|
||||
if (found !== hovered[i]) {
|
||||
setHoverMark(i, found);
|
||||
}
|
||||
} else if (hovered[i] != null) {
|
||||
setHoverMark(i, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAtCursor) {
|
||||
if (foundAtCursor !== hoveredAtCursor) {
|
||||
hoveredAtCursor = foundAtCursor;
|
||||
onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
|
||||
}
|
||||
} else if (hoveredAtCursor) {
|
||||
hoveredAtCursor = undefined;
|
||||
onLeave();
|
||||
}
|
||||
}
|
||||
|
||||
function hoverOne(cx: number, cy: number) {
|
||||
let foundAtCursor: Rect | undefined;
|
||||
|
||||
qt.get(cx, cy, 1, 1, (o) => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||
foundAtCursor = o;
|
||||
}
|
||||
});
|
||||
|
||||
if (foundAtCursor) {
|
||||
setHoverMark(0, foundAtCursor);
|
||||
|
||||
if (foundAtCursor !== hoveredAtCursor) {
|
||||
hoveredAtCursor = foundAtCursor;
|
||||
onHover(foundAtCursor.sidx, foundAtCursor.didx, foundAtCursor);
|
||||
}
|
||||
} else if (hoveredAtCursor) {
|
||||
setHoverMark(0, null);
|
||||
hoveredAtCursor = undefined;
|
||||
onLeave();
|
||||
}
|
||||
}
|
||||
|
||||
const doHover = mode === TimelineMode.Changes ? hoverMulti : hoverOne;
|
||||
|
||||
const setCursor = (u: uPlot) => {
|
||||
let cx = round(u.cursor.left! * uPlot.pxRatio);
|
||||
let cy = round(u.cursor.top! * uPlot.pxRatio);
|
||||
|
||||
// if quadtree is empty, fill it
|
||||
if (!qt.o.length && qt.q == null) {
|
||||
for (const seriesRects of boxRectsBySeries) {
|
||||
for (const rect of seriesRects) {
|
||||
rect && qt.add(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doHover(cx, cy);
|
||||
};
|
||||
|
||||
// hide y crosshair & hover points
|
||||
const cursor: Partial<Cursor> = {
|
||||
y: false,
|
||||
x: mode === TimelineMode.Changes,
|
||||
points: { show: false },
|
||||
};
|
||||
|
||||
const yMids: number[] = Array(numSeries).fill(0);
|
||||
const ySplits: number[] = Array(numSeries).fill(0);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
|
||||
xSplits:
|
||||
mode === TimelineMode.Samples
|
||||
? (u: uPlot, axisIdx: number, scaleMin: number, scaleMax: number, foundIncr: number, foundSpace: number) => {
|
||||
let splits = [];
|
||||
|
||||
let dataIncr = u.data[0][1] - u.data[0][0];
|
||||
let skipFactor = ceil(foundIncr / dataIncr);
|
||||
|
||||
for (let i = 0; i < u.data[0].length; i += skipFactor) {
|
||||
let v = u.data[0][i];
|
||||
|
||||
if (v >= scaleMin && v <= scaleMax) {
|
||||
splits.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
return splits;
|
||||
}
|
||||
: null,
|
||||
|
||||
xRange: (u: uPlot) => {
|
||||
const r = getTimeRange();
|
||||
|
||||
let min = r.from.valueOf();
|
||||
let max = r.to.valueOf();
|
||||
|
||||
if (mode === TimelineMode.Samples) {
|
||||
let colWid = u.data[0][1] - u.data[0][0];
|
||||
let scalePad = colWid / 2;
|
||||
|
||||
if (min <= u.data[0][0]) {
|
||||
min = u.data[0][0] - scalePad;
|
||||
}
|
||||
|
||||
let lastIdx = u.data[0].length - 1;
|
||||
|
||||
if (max >= u.data[0][lastIdx]) {
|
||||
max = u.data[0][lastIdx] + scalePad;
|
||||
}
|
||||
}
|
||||
|
||||
return [min, max] as uPlot.Range.MinMax;
|
||||
},
|
||||
|
||||
ySplits: (u: uPlot) => {
|
||||
walk(rowHeight, null, numSeries, u.bbox.height, (iy, y0, hgt) => {
|
||||
// vertical midpoints of each series' timeline (stored relative to .u-over)
|
||||
yMids[iy] = round(y0 + hgt / 2);
|
||||
ySplits[iy] = u.posToVal(yMids[iy] / uPlot.pxRatio, FIXED_UNIT);
|
||||
});
|
||||
|
||||
return ySplits;
|
||||
},
|
||||
|
||||
yValues: (u: uPlot, splits: number[]) => splits.map((v, i) => label(i + 1)),
|
||||
yRange: [0, 1] as uPlot.Range.MinMax,
|
||||
|
||||
// pathbuilders
|
||||
drawPaths,
|
||||
drawPoints,
|
||||
|
||||
// hooks
|
||||
init,
|
||||
drawClear,
|
||||
setCursor,
|
||||
};
|
||||
}
|
||||
|
||||
function getFillColor(fieldConfig: { fillOpacity?: number; lineWidth?: number }, color: string) {
|
||||
// if #rgba with pre-existing alpha. ignore fieldConfig.fillOpacity
|
||||
// e.g. thresholds with opacity
|
||||
if (color[0] === '#' && color.length === 9) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const opacityPercent = (fieldConfig.fillOpacity ?? 100) / 100;
|
||||
return alpha(color, opacityPercent);
|
||||
}
|
||||
283
public/app/core/components/TimelineChart/utils.test.ts
Normal file
283
public/app/core/components/TimelineChart/utils.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { ArrayVector, createTheme, FieldType, ThresholdsMode, TimeRange, toDataFrame, dateTime } from '@grafana/data';
|
||||
import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema';
|
||||
|
||||
import {
|
||||
findNextStateIndex,
|
||||
fmtDuration,
|
||||
getThresholdItems,
|
||||
prepareTimelineFields,
|
||||
prepareTimelineLegendItems,
|
||||
} from './utils';
|
||||
|
||||
const theme = createTheme();
|
||||
|
||||
describe('prepare timeline graph', () => {
|
||||
const timeRange: TimeRange = {
|
||||
from: dateTime(1),
|
||||
to: dateTime(3),
|
||||
raw: {
|
||||
from: dateTime(1),
|
||||
to: dateTime(3),
|
||||
},
|
||||
};
|
||||
it('errors with no time fields', () => {
|
||||
const frames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', values: [1, 2, 3] },
|
||||
{ name: 'b', values: ['a', 'b', 'c'] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const info = prepareTimelineFields(frames, true, timeRange, theme);
|
||||
expect(info.warn).toEqual('Data does not have a time field');
|
||||
});
|
||||
|
||||
it('requires a number, string, or boolean value', () => {
|
||||
const frames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'b', type: FieldType.other, values: [{}, {}, {}] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const info = prepareTimelineFields(frames, true, timeRange, theme);
|
||||
expect(info.warn).toEqual('No graphable fields');
|
||||
});
|
||||
|
||||
it('will merge duplicate values', () => {
|
||||
const frames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', type: FieldType.time, values: [1, 2, 3, 4, 5, 6, 7] },
|
||||
{ name: 'b', values: [1, 1, undefined, 1, 2, 2, null, 2, 3] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const info = prepareTimelineFields(frames, true, timeRange, theme);
|
||||
expect(info.warn).toBeUndefined();
|
||||
|
||||
const out = info.frames![0];
|
||||
|
||||
const field = out.fields.find((f) => f.name === 'b');
|
||||
expect(field?.values.toArray()).toMatchInlineSnapshot(`
|
||||
[
|
||||
1,
|
||||
1,
|
||||
undefined,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
null,
|
||||
2,
|
||||
3,
|
||||
]
|
||||
`);
|
||||
});
|
||||
it('should try to sort time fields', () => {
|
||||
const frames = [
|
||||
toDataFrame({
|
||||
fields: [
|
||||
{ name: 'a', type: FieldType.time, values: [4, 3, 1, 2] },
|
||||
{ name: 'b', values: [1, 1, 2, 2] },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const result = prepareTimelineFields(frames, true, timeRange, theme);
|
||||
expect(result.frames?.[0].fields[0].values.toArray()).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextStateIndex', () => {
|
||||
it('handles leading datapoint index', () => {
|
||||
const field = {
|
||||
name: 'time',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, undefined, undefined, 2, undefined, undefined]),
|
||||
config: {},
|
||||
};
|
||||
const result = findNextStateIndex(field, 0);
|
||||
expect(result).toEqual(3);
|
||||
});
|
||||
|
||||
it('handles trailing datapoint index', () => {
|
||||
const field = {
|
||||
name: 'time',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, undefined, undefined, 2, undefined, 3]),
|
||||
config: {},
|
||||
};
|
||||
const result = findNextStateIndex(field, 5);
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it('handles trailing undefined', () => {
|
||||
const field = {
|
||||
name: 'time',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, undefined, undefined, 2, undefined, 3, undefined]),
|
||||
config: {},
|
||||
};
|
||||
const result = findNextStateIndex(field, 5);
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it('handles datapoint index inside range', () => {
|
||||
const field = {
|
||||
name: 'time',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([
|
||||
1,
|
||||
undefined,
|
||||
undefined,
|
||||
3,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
2,
|
||||
undefined,
|
||||
undefined,
|
||||
]),
|
||||
config: {},
|
||||
};
|
||||
const result = findNextStateIndex(field, 3);
|
||||
expect(result).toEqual(8);
|
||||
});
|
||||
|
||||
describe('single data points', () => {
|
||||
const field = {
|
||||
name: 'time',
|
||||
type: FieldType.number,
|
||||
values: new ArrayVector([1, 3, 2]),
|
||||
config: {},
|
||||
};
|
||||
|
||||
test('leading', () => {
|
||||
const result = findNextStateIndex(field, 0);
|
||||
expect(result).toEqual(1);
|
||||
});
|
||||
test('trailing', () => {
|
||||
const result = findNextStateIndex(field, 2);
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
test('inside', () => {
|
||||
const result = findNextStateIndex(field, 1);
|
||||
expect(result).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdItems', () => {
|
||||
it('should handle only one threshold', () => {
|
||||
const result = getThresholdItems(
|
||||
{ thresholds: { mode: ThresholdsMode.Absolute, steps: [{ color: 'black', value: 0 }] } },
|
||||
theme
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareTimelineLegendItems', () => {
|
||||
it('should return legend items without crashing when single (base) threshold', () => {
|
||||
const frame: any = [
|
||||
{
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
config: {
|
||||
color: {
|
||||
mode: 'thresholds',
|
||||
},
|
||||
thresholds: {
|
||||
mode: 'absolute',
|
||||
steps: [
|
||||
{
|
||||
color: 'green',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
values: new ArrayVector([
|
||||
1634092733455, 1634092763455, 1634092793455, 1634092823455, 1634092853455, 1634092883455, 1634092913455,
|
||||
1634092943455, 1634092973455, 1634093003455,
|
||||
]),
|
||||
display: (value: string) => ({
|
||||
text: value,
|
||||
color: undefined,
|
||||
numeric: NaN,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'A-series',
|
||||
config: {
|
||||
color: {
|
||||
mode: 'thresholds',
|
||||
},
|
||||
thresholds: {
|
||||
mode: 'absolute',
|
||||
steps: [
|
||||
{
|
||||
color: 'green',
|
||||
value: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
values: new ArrayVector(['< -∞', null, null, null, null, null, null, null, null, null]),
|
||||
display: (value?: string) => ({
|
||||
text: value || '',
|
||||
color: 'green',
|
||||
numeric: NaN,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = prepareTimelineLegendItems(
|
||||
frame,
|
||||
{ displayMode: LegendDisplayMode.List } as VizLegendOptions,
|
||||
theme
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration', () => {
|
||||
it.each`
|
||||
value | expected
|
||||
${-1} | ${''}
|
||||
${20} | ${'20ms'}
|
||||
${1000} | ${'1s'}
|
||||
${1020} | ${'1s 20ms'}
|
||||
${60000} | ${'1m'}
|
||||
${61020} | ${'1m 1s'}
|
||||
${3600000} | ${'1h'}
|
||||
${6600000} | ${'1h 50m'}
|
||||
${86400000} | ${'1d'}
|
||||
${96640000} | ${'1d 2h'}
|
||||
${604800000} | ${'1w'}
|
||||
${691200000} | ${'1w 1d'}
|
||||
${2419200000} | ${'4w'}
|
||||
${2678400000} | ${'1mo 1d'}
|
||||
${3196800000} | ${'1mo 1w'}
|
||||
${3456000000} | ${'1mo 1w 3d'}
|
||||
${6739200000} | ${'2mo 2w 4d'}
|
||||
${31536000000} | ${'1y'}
|
||||
${31968000000} | ${'1y 5d'}
|
||||
${32140800000} | ${'1y 1w'}
|
||||
${67910400000} | ${'2y 1mo 3w 5d'}
|
||||
${40420800000} | ${'1y 3mo 1w 5d'}
|
||||
${9007199254740991} | ${'285616y 5mo 1d'}
|
||||
`(' function should format $value ms to $expected', ({ value, expected }) => {
|
||||
const result = fmtDuration(value);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
691
public/app/core/components/TimelineChart/utils.ts
Normal file
691
public/app/core/components/TimelineChart/utils.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
import React from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
ArrayVector,
|
||||
DataFrame,
|
||||
DashboardCursorSync,
|
||||
DataHoverPayload,
|
||||
DataHoverEvent,
|
||||
DataHoverClearEvent,
|
||||
FALLBACK_COLOR,
|
||||
Field,
|
||||
FieldColorModeId,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
getValueFormat,
|
||||
GrafanaTheme2,
|
||||
getActiveThreshold,
|
||||
Threshold,
|
||||
getFieldConfigWithMinMax,
|
||||
ThresholdsMode,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames';
|
||||
import {
|
||||
VizLegendOptions,
|
||||
AxisPlacement,
|
||||
ScaleDirection,
|
||||
ScaleOrientation,
|
||||
VisibilityMode,
|
||||
TimelineValueAlignment,
|
||||
HideableFieldConfig,
|
||||
} from '@grafana/schema';
|
||||
import {
|
||||
FIXED_UNIT,
|
||||
SeriesVisibilityChangeMode,
|
||||
UPlotConfigBuilder,
|
||||
UPlotConfigPrepFn,
|
||||
VizLegendItem,
|
||||
} from '@grafana/ui';
|
||||
import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold';
|
||||
import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue';
|
||||
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
|
||||
import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
|
||||
|
||||
import { getConfig, TimelineCoreOptions } from './timeline';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface UPlotConfigOptions {
|
||||
frame: DataFrame;
|
||||
theme: GrafanaTheme2;
|
||||
mode: TimelineMode;
|
||||
sync?: () => DashboardCursorSync;
|
||||
rowHeight?: number;
|
||||
colWidth?: number;
|
||||
showValue: VisibilityMode;
|
||||
alignValue?: TimelineValueAlignment;
|
||||
mergeValues?: boolean;
|
||||
getValueColor: (frameIdx: number, fieldIdx: number, value: any) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface PanelFieldConfig extends HideableFieldConfig {
|
||||
fillOpacity?: number;
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
export enum TimelineMode {
|
||||
Changes = 'changes',
|
||||
Samples = 'samples',
|
||||
}
|
||||
|
||||
const defaultConfig: PanelFieldConfig = {
|
||||
lineWidth: 0,
|
||||
fillOpacity: 80,
|
||||
};
|
||||
|
||||
export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
return SeriesVisibilityChangeMode.AppendToSelection;
|
||||
}
|
||||
return SeriesVisibilityChangeMode.ToggleSelection;
|
||||
}
|
||||
|
||||
export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ({
|
||||
frame,
|
||||
theme,
|
||||
timeZones,
|
||||
getTimeRange,
|
||||
mode,
|
||||
eventBus,
|
||||
sync,
|
||||
rowHeight,
|
||||
colWidth,
|
||||
showValue,
|
||||
alignValue,
|
||||
mergeValues,
|
||||
getValueColor,
|
||||
}) => {
|
||||
const builder = new UPlotConfigBuilder(timeZones[0]);
|
||||
|
||||
const xScaleUnit = 'time';
|
||||
const xScaleKey = 'x';
|
||||
|
||||
const isDiscrete = (field: Field) => {
|
||||
const mode = field.config?.color?.mode;
|
||||
return !(mode && field.display && mode.startsWith('continuous-'));
|
||||
};
|
||||
|
||||
const getValueColorFn = (seriesIdx: number, value: any) => {
|
||||
const field = frame.fields[seriesIdx];
|
||||
|
||||
if (
|
||||
field.state?.origin?.fieldIndex !== undefined &&
|
||||
field.state?.origin?.frameIndex !== undefined &&
|
||||
getValueColor
|
||||
) {
|
||||
return getValueColor(field.state?.origin?.frameIndex, field.state?.origin?.fieldIndex, value);
|
||||
}
|
||||
|
||||
return FALLBACK_COLOR;
|
||||
};
|
||||
|
||||
const opts: TimelineCoreOptions = {
|
||||
mode: mode!,
|
||||
numSeries: frame.fields.length - 1,
|
||||
isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
|
||||
mergeValues,
|
||||
rowHeight: rowHeight,
|
||||
colWidth: colWidth,
|
||||
showValue: showValue!,
|
||||
alignValue,
|
||||
theme,
|
||||
label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
|
||||
getFieldConfig: (seriesIdx) => frame.fields[seriesIdx].config.custom,
|
||||
getValueColor: getValueColorFn,
|
||||
getTimeRange,
|
||||
// hardcoded formatter for state values
|
||||
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
|
||||
onHover: (seriesIndex, valueIndex) => {
|
||||
hoveredSeriesIdx = seriesIndex;
|
||||
hoveredDataIdx = valueIndex;
|
||||
shouldChangeHover = true;
|
||||
},
|
||||
onLeave: () => {
|
||||
hoveredSeriesIdx = null;
|
||||
hoveredDataIdx = null;
|
||||
shouldChangeHover = true;
|
||||
},
|
||||
};
|
||||
|
||||
let shouldChangeHover = false;
|
||||
let hoveredSeriesIdx: number | null = null;
|
||||
let hoveredDataIdx: number | null = null;
|
||||
|
||||
const coreConfig = getConfig(opts);
|
||||
const payload: DataHoverPayload = {
|
||||
point: {
|
||||
[xScaleUnit]: null,
|
||||
[FIXED_UNIT]: null,
|
||||
},
|
||||
data: frame,
|
||||
};
|
||||
|
||||
builder.addHook('init', coreConfig.init);
|
||||
builder.addHook('drawClear', coreConfig.drawClear);
|
||||
builder.addHook('setCursor', coreConfig.setCursor);
|
||||
|
||||
// in TooltipPlugin, this gets invoked and the result is bound to a setCursor hook
|
||||
// which fires after the above setCursor hook, so can take advantage of hoveringOver
|
||||
// already set by the above onHover/onLeave callbacks that fire from coreConfig.setCursor
|
||||
const interpolateTooltip: PlotTooltipInterpolator = (
|
||||
updateActiveSeriesIdx,
|
||||
updateActiveDatapointIdx,
|
||||
updateTooltipPosition
|
||||
) => {
|
||||
if (shouldChangeHover) {
|
||||
if (hoveredSeriesIdx != null) {
|
||||
updateActiveSeriesIdx(hoveredSeriesIdx);
|
||||
updateActiveDatapointIdx(hoveredDataIdx);
|
||||
}
|
||||
|
||||
shouldChangeHover = false;
|
||||
}
|
||||
|
||||
updateTooltipPosition(hoveredSeriesIdx == null);
|
||||
};
|
||||
|
||||
builder.setTooltipInterpolator(interpolateTooltip);
|
||||
|
||||
builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
|
||||
|
||||
builder.setCursor(coreConfig.cursor);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: xScaleKey,
|
||||
isTime: true,
|
||||
orientation: ScaleOrientation.Horizontal,
|
||||
direction: ScaleDirection.Right,
|
||||
range: coreConfig.xRange,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: FIXED_UNIT, // y
|
||||
isTime: false,
|
||||
orientation: ScaleOrientation.Vertical,
|
||||
direction: ScaleDirection.Up,
|
||||
range: coreConfig.yRange,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: xScaleKey,
|
||||
isTime: true,
|
||||
splits: coreConfig.xSplits!,
|
||||
placement: AxisPlacement.Bottom,
|
||||
timeZone: timeZones[0],
|
||||
theme,
|
||||
grid: { show: true },
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: FIXED_UNIT, // y
|
||||
isTime: false,
|
||||
placement: AxisPlacement.Left,
|
||||
splits: coreConfig.ySplits,
|
||||
values: coreConfig.yValues,
|
||||
grid: { show: false },
|
||||
ticks: { show: false },
|
||||
gap: 16,
|
||||
theme,
|
||||
});
|
||||
|
||||
let seriesIndex = 0;
|
||||
|
||||
for (let i = 0; i < frame.fields.length; i++) {
|
||||
if (i === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = frame.fields[i];
|
||||
const config: FieldConfig<PanelFieldConfig> = field.config;
|
||||
const customConfig: PanelFieldConfig = {
|
||||
...defaultConfig,
|
||||
...config.custom,
|
||||
};
|
||||
|
||||
field.state!.seriesIndex = seriesIndex++;
|
||||
|
||||
// const scaleKey = config.unit || FIXED_UNIT;
|
||||
// const colorMode = getFieldColorModeForField(field);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: FIXED_UNIT,
|
||||
pathBuilder: coreConfig.drawPaths,
|
||||
pointsBuilder: coreConfig.drawPoints,
|
||||
//colorMode,
|
||||
lineWidth: customConfig.lineWidth,
|
||||
fillOpacity: customConfig.fillOpacity,
|
||||
theme,
|
||||
show: !customConfig.hideFrom?.viz,
|
||||
thresholds: config.thresholds,
|
||||
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
|
||||
dataFrameFieldIndex: field.state?.origin,
|
||||
});
|
||||
}
|
||||
|
||||
if (sync && sync() !== DashboardCursorSync.Off) {
|
||||
let cursor: Partial<uPlot.Cursor> = {};
|
||||
|
||||
cursor.sync = {
|
||||
key: '__global_',
|
||||
filters: {
|
||||
pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => {
|
||||
if (sync && sync() === DashboardCursorSync.Off) {
|
||||
return false;
|
||||
}
|
||||
payload.rowIndex = dataIdx;
|
||||
if (x < 0 && y < 0) {
|
||||
payload.point[xScaleUnit] = null;
|
||||
payload.point[FIXED_UNIT] = null;
|
||||
eventBus.publish(new DataHoverClearEvent());
|
||||
} else {
|
||||
payload.point[xScaleUnit] = src.posToVal(x, xScaleKey);
|
||||
payload.point.panelRelY = y > 0 ? y / h : 1; // used for old graph panel to position tooltip
|
||||
payload.down = undefined;
|
||||
eventBus.publish(new DataHoverEvent(payload));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
scales: [xScaleKey, null],
|
||||
};
|
||||
builder.setSync();
|
||||
builder.setCursor(cursor);
|
||||
}
|
||||
|
||||
return builder;
|
||||
};
|
||||
|
||||
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
|
||||
const names = new Map<string, number>();
|
||||
for (let i = 0; i < frame.fields.length; i++) {
|
||||
names.set(getFieldDisplayName(frame.fields[i], frame), i);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* If any sequential duplicate values exist, this will return a new array
|
||||
* with the future values set to undefined.
|
||||
*
|
||||
* in: 1, 1,undefined, 1,2, 2,null,2,3
|
||||
* out: 1,undefined,undefined,undefined,2,undefined,null,2,3
|
||||
*/
|
||||
export function unsetSameFutureValues(values: unknown[]): unknown[] | undefined {
|
||||
let prevVal = values[0];
|
||||
let clone: unknown[] | undefined = undefined;
|
||||
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
let value = values[i];
|
||||
|
||||
if (value === null) {
|
||||
prevVal = null;
|
||||
} else {
|
||||
if (value === prevVal) {
|
||||
if (!clone) {
|
||||
clone = [...values];
|
||||
}
|
||||
clone[i] = undefined;
|
||||
} else if (value != null) {
|
||||
prevVal = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
function getSpanNulls(field: Field) {
|
||||
let spanNulls = field.config.custom?.spanNulls;
|
||||
|
||||
// magic value for join() to leave nulls alone instead of expanding null ranges
|
||||
// should be set to -1 when spanNulls = null|undefined|false|0, which is "retain nulls, without expanding"
|
||||
// Infinity is not optimal here since it causes spanNulls to be more expensive than simply removing all nulls unconditionally
|
||||
return !spanNulls ? -1 : spanNulls === true ? Infinity : spanNulls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge values by the threshold
|
||||
*/
|
||||
export function mergeThresholdValues(field: Field, theme: GrafanaTheme2): Field | undefined {
|
||||
const thresholds = field.config.thresholds;
|
||||
if (field.type !== FieldType.number || !thresholds || !thresholds.steps.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items = getThresholdItems(field.config, theme);
|
||||
if (items.length !== thresholds.steps.length) {
|
||||
return undefined; // should not happen
|
||||
}
|
||||
|
||||
const thresholdToText = new Map<Threshold, string>();
|
||||
const textToColor = new Map<string, string>();
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
thresholdToText.set(thresholds.steps[i], items[i].label);
|
||||
textToColor.set(items[i].label, items[i].color!);
|
||||
}
|
||||
|
||||
let input = field.values.toArray();
|
||||
const vals = new Array<String | undefined>(field.values.length);
|
||||
if (thresholds.mode === ThresholdsMode.Percentage) {
|
||||
const { min, max } = getFieldConfigWithMinMax(field);
|
||||
const delta = max! - min!;
|
||||
input = input.map((v) => {
|
||||
if (v == null) {
|
||||
return v;
|
||||
}
|
||||
return ((v - min!) / delta) * 100;
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
const v = input[i];
|
||||
if (v == null) {
|
||||
vals[i] = v;
|
||||
} else {
|
||||
vals[i] = thresholdToText.get(getActiveThreshold(v, thresholds.steps));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
config: {
|
||||
...field.config,
|
||||
custom: {
|
||||
...field.config.custom,
|
||||
spanNulls: getSpanNulls(field),
|
||||
},
|
||||
},
|
||||
type: FieldType.string,
|
||||
values: new ArrayVector(vals),
|
||||
display: (value: string) => ({
|
||||
text: value,
|
||||
color: textToColor.get(value),
|
||||
numeric: NaN,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// This will return a set of frames with only graphable values included
|
||||
export function prepareTimelineFields(
|
||||
series: DataFrame[] | undefined,
|
||||
mergeValues: boolean,
|
||||
timeRange: TimeRange,
|
||||
theme: GrafanaTheme2
|
||||
): { frames?: DataFrame[]; warn?: string } {
|
||||
if (!series?.length) {
|
||||
return { warn: 'No data in response' };
|
||||
}
|
||||
let hasTimeseries = false;
|
||||
const frames: DataFrame[] = [];
|
||||
|
||||
for (let frame of series) {
|
||||
let isTimeseries = false;
|
||||
let changed = false;
|
||||
let maybeSortedFrame = maybeSortFrame(
|
||||
frame,
|
||||
frame.fields.findIndex((f) => f.type === FieldType.time)
|
||||
);
|
||||
|
||||
let nulledFrame = applyNullInsertThreshold({
|
||||
frame: maybeSortedFrame,
|
||||
refFieldPseudoMin: timeRange.from.valueOf(),
|
||||
refFieldPseudoMax: timeRange.to.valueOf(),
|
||||
});
|
||||
|
||||
if (nulledFrame !== frame) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const fields: Field[] = [];
|
||||
for (let field of nullToValue(nulledFrame).fields) {
|
||||
switch (field.type) {
|
||||
case FieldType.time:
|
||||
isTimeseries = true;
|
||||
hasTimeseries = true;
|
||||
fields.push(field);
|
||||
break;
|
||||
case FieldType.number:
|
||||
if (mergeValues && field.config.color?.mode === FieldColorModeId.Thresholds) {
|
||||
const f = mergeThresholdValues(field, theme);
|
||||
if (f) {
|
||||
fields.push(f);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
case FieldType.boolean:
|
||||
case FieldType.string:
|
||||
field = {
|
||||
...field,
|
||||
config: {
|
||||
...field.config,
|
||||
custom: {
|
||||
...field.config.custom,
|
||||
spanNulls: getSpanNulls(field),
|
||||
},
|
||||
},
|
||||
};
|
||||
fields.push(field);
|
||||
break;
|
||||
default:
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (isTimeseries && fields.length > 1) {
|
||||
hasTimeseries = true;
|
||||
if (changed) {
|
||||
frames.push({
|
||||
...maybeSortedFrame,
|
||||
fields,
|
||||
});
|
||||
} else {
|
||||
frames.push(maybeSortedFrame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTimeseries) {
|
||||
return { warn: 'Data does not have a time field' };
|
||||
}
|
||||
if (!frames.length) {
|
||||
return { warn: 'No graphable fields' };
|
||||
}
|
||||
return { frames };
|
||||
}
|
||||
|
||||
export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] {
|
||||
const items: VizLegendItem[] = [];
|
||||
const thresholds = fieldConfig.thresholds;
|
||||
if (!thresholds || !thresholds.steps.length) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const steps = thresholds.steps;
|
||||
const disp = getValueFormat(thresholds.mode === ThresholdsMode.Percentage ? 'percent' : fieldConfig.unit ?? '');
|
||||
|
||||
const fmt = (v: number) => formattedValueToString(disp(v));
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
let step = steps[i];
|
||||
let value = step.value;
|
||||
let pre = '';
|
||||
let suf = '';
|
||||
|
||||
if (value === -Infinity && i < steps.length - 1) {
|
||||
value = steps[i + 1].value;
|
||||
pre = '< ';
|
||||
} else {
|
||||
suf = '+';
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: `${pre}${fmt(value)}${suf}`,
|
||||
color: theme.visualization.getColorByName(step.color),
|
||||
yAxis: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function prepareTimelineLegendItems(
|
||||
frames: DataFrame[] | undefined,
|
||||
options: VizLegendOptions,
|
||||
theme: GrafanaTheme2
|
||||
): VizLegendItem[] | undefined {
|
||||
if (!frames || options.showLegend === false) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getFieldLegendItem(allNonTimeFields(frames), theme);
|
||||
}
|
||||
|
||||
export function getFieldLegendItem(fields: Field[], theme: GrafanaTheme2): VizLegendItem[] | undefined {
|
||||
if (!fields.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: VizLegendItem[] = [];
|
||||
const fieldConfig = fields[0].config;
|
||||
const colorMode = fieldConfig.color?.mode ?? FieldColorModeId.Fixed;
|
||||
|
||||
// If thresholds are enabled show each step in the legend
|
||||
if (colorMode === FieldColorModeId.Thresholds) {
|
||||
return getThresholdItems(fieldConfig, theme);
|
||||
}
|
||||
|
||||
// If thresholds are enabled show each step in the legend
|
||||
if (colorMode.startsWith('continuous')) {
|
||||
return undefined; // eventually a color bar
|
||||
}
|
||||
|
||||
let stateColors: Map<string, string | undefined> = new Map();
|
||||
|
||||
fields.forEach((field) => {
|
||||
field.values.toArray().forEach((v) => {
|
||||
let state = field.display!(v);
|
||||
if (state.color) {
|
||||
stateColors.set(state.text, state.color!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
stateColors.forEach((color, label) => {
|
||||
if (label.length > 0) {
|
||||
items.push({
|
||||
label: label!,
|
||||
color: theme.visualization.getColorByName(color ?? FALLBACK_COLOR),
|
||||
yAxis: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function allNonTimeFields(frames: DataFrame[]): Field[] {
|
||||
const fields: Field[] = [];
|
||||
for (const frame of frames) {
|
||||
for (const field of frame.fields) {
|
||||
if (field.type !== FieldType.time) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function findNextStateIndex(field: Field, datapointIdx: number) {
|
||||
let end;
|
||||
let rightPointer = datapointIdx + 1;
|
||||
|
||||
if (rightPointer >= field.values.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startValue = field.values.get(datapointIdx);
|
||||
|
||||
while (end === undefined) {
|
||||
if (rightPointer >= field.values.length) {
|
||||
return null;
|
||||
}
|
||||
const rightValue = field.values.get(rightPointer);
|
||||
|
||||
if (rightValue === undefined || rightValue === startValue) {
|
||||
rightPointer++;
|
||||
} else {
|
||||
end = rightPointer;
|
||||
}
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the precise duration of a time range passed in milliseconds.
|
||||
* This function calculates with 30 days month and 365 days year.
|
||||
* adapted from https://gist.github.com/remino/1563878
|
||||
* @param milliSeconds The duration in milliseconds
|
||||
* @returns A formated string of the duration
|
||||
*/
|
||||
export function fmtDuration(milliSeconds: number): string {
|
||||
if (milliSeconds < 0 || Number.isNaN(milliSeconds)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let yr: number, mo: number, wk: number, d: number, h: number, m: number, s: number, ms: number;
|
||||
|
||||
s = Math.floor(milliSeconds / 1000);
|
||||
m = Math.floor(s / 60);
|
||||
s = s % 60;
|
||||
h = Math.floor(m / 60);
|
||||
m = m % 60;
|
||||
d = Math.floor(h / 24);
|
||||
h = h % 24;
|
||||
|
||||
yr = Math.floor(d / 365);
|
||||
if (yr > 0) {
|
||||
d = d % 365;
|
||||
}
|
||||
|
||||
mo = Math.floor(d / 30);
|
||||
if (mo > 0) {
|
||||
d = d % 30;
|
||||
}
|
||||
|
||||
wk = Math.floor(d / 7);
|
||||
|
||||
if (wk > 0) {
|
||||
d = d % 7;
|
||||
}
|
||||
|
||||
ms = Math.round((milliSeconds % 1000) * 1000) / 1000;
|
||||
|
||||
return (
|
||||
yr > 0
|
||||
? yr + 'y ' + (mo > 0 ? mo + 'mo ' : '') + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
|
||||
: mo > 0
|
||||
? mo + 'mo ' + (wk > 0 ? wk + 'w ' : '') + (d > 0 ? d + 'd ' : '')
|
||||
: wk > 0
|
||||
? wk + 'w ' + (d > 0 ? d + 'd ' : '')
|
||||
: d > 0
|
||||
? d + 'd ' + (h > 0 ? h + 'h ' : '')
|
||||
: h > 0
|
||||
? h + 'h ' + (m > 0 ? m + 'm ' : '')
|
||||
: m > 0
|
||||
? m + 'm ' + (s > 0 ? s + 's ' : '')
|
||||
: s > 0
|
||||
? s + 's ' + (ms > 0 ? ms + 'ms ' : '')
|
||||
: ms > 0
|
||||
? ms + 'ms '
|
||||
: '0'
|
||||
).trim();
|
||||
}
|
||||
Reference in New Issue
Block a user