Alpha panel: new Timeline/Discrete panel (#31973)

This commit is contained in:
Leon Sorokin 2021-04-06 18:06:46 -05:00 committed by GitHub
parent ea202513cd
commit 6082a9360e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1389 additions and 5 deletions

View File

@ -0,0 +1,289 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": null,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 11,
"w": 24,
"x": 0,
"y": 0
},
"id": 8,
"options": {
"colWidth": 0.9,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"mode": "spans",
"rowHeight": 0.9,
"showValue": "always"
},
"pluginVersion": "7.5.0-pre",
"targets": [
{
"alias": "",
"csvWave": {
"timeStep": 60,
"valuesCSV": "0,0,2,2,1,1"
},
"lines": 10,
"points": [
[
0,
1616551651000
],
[
1,
1616556554000
],
[
2,
1616559873000
],
[
0,
1616561077000
],
[
3,
1616563090000
]
],
"pulseWave": {
"offCount": 3,
"offValue": 1,
"onCount": 3,
"onValue": 2,
"timeStep": 60
},
"refId": "A",
"scenarioId": "manual_entry",
"stream": {
"bands": 1,
"noise": 2.2,
"speed": 250,
"spread": 3.5,
"type": "signal"
},
"stringInput": ""
},
{
"alias": "",
"csvWave": {
"timeStep": 60,
"valuesCSV": "0,0,2,2,1,1"
},
"hide": false,
"lines": 10,
"points": [
[
4,
1616555060000
],
[
5,
1616560081000
],
[
4,
1616562217000
],
[
5,
1616565458000
]
],
"pulseWave": {
"offCount": 3,
"offValue": 1,
"onCount": 3,
"onValue": 2,
"timeStep": 60
},
"refId": "B",
"scenarioId": "manual_entry",
"stream": {
"bands": 1,
"noise": 2.2,
"speed": 250,
"spread": 3.5,
"type": "signal"
},
"stringInput": ""
},
{
"points": [
[
4,
1616557148000
],
[
null,
1616558756000
],
[
4,
1616561658000
],
[
null,
1616562446000
],
[
4,
1616564104000
],
[
null,
1616564548000
],
[
4,
1616564871000
]
],
"refId": "C",
"scenarioId": "manual_entry"
}
],
"title": "Spans Mode",
"type": "timeline"
},
{
"datasource": null,
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 11,
"w": 24,
"x": 0,
"y": 11
},
"id": 4,
"interval": null,
"maxDataPoints": 20,
"options": {
"colWidth": 0.9,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom"
},
"mode": "grid",
"rowHeight": 0.9,
"showValue": "always"
},
"pluginVersion": "7.5.0-pre",
"targets": [
{
"alias": "",
"csvWave": {
"timeStep": 60,
"valuesCSV": "0,0,2,2,1,1"
},
"lines": 10,
"points": [],
"pulseWave": {
"offCount": 3,
"offValue": 1,
"onCount": 3,
"onValue": 2,
"timeStep": 60
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 4,
"spread": 14.9,
"stream": {
"bands": 1,
"noise": 2.2,
"speed": 250,
"spread": 3.5,
"type": "signal"
},
"stringInput": ""
}
],
"title": "Grid Mode",
"type": "timeline"
}
],
"refresh": false,
"schemaVersion": 27,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "2021-03-24T03:00:00.000Z",
"to": "2021-03-24T07:00:00.000Z"
},
"timepicker": {},
"timezone": "utc",
"title": "Timeline Modes",
"uid": "mIJjFy8Gz",
"version": 1
}

View File

@ -46,8 +46,8 @@ export interface BarsOptions {
groupWidth: number;
barWidth: number;
formatValue?: (seriesIdx: number, value: any) => string;
onHover?: (seriesIdx: number, valueIdx: any) => void;
onLeave?: (seriesIdx: number, valueIdx: any) => void;
onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (seriesIdx: number, valueIdx: number) => void;
}
/**

View File

@ -41,7 +41,10 @@ export interface GraphNGProps extends Themeable {
children?: React.ReactNode;
}
interface GraphNGState {
/**
* @internal -- not a public API
*/
export interface GraphNGState {
data: AlignedData;
alignedDataFrame: DataFrame;
dimFields: XYFieldMatchers;

View File

@ -0,0 +1,195 @@
import React from 'react';
import { compareArrayValues, compareDataFrameStructures, FieldMatcherID, fieldMatchers } from '@grafana/data';
import { withTheme } from '../../themes';
import { GraphNGContext } from '../GraphNG/hooks';
import { GraphNGState } from '../GraphNG/GraphNG';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG
import { preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend';
import { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode } from '../VizLegend/types';
import { VizLayout } from '../VizLayout/VizLayout';
import { TimelineProps } from './types';
class UnthemedTimeline extends React.Component<TimelineProps, GraphNGState> {
constructor(props: TimelineProps) {
super(props);
let dimFields = props.fields;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), // this may be either numeric or strings, (or bools?)
};
}
this.state = { dimFields } as GraphNGState;
}
/**
* Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
* It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume
* that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32)
*
* Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle.
* If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update
*
* This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
* the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that
* often (apart from the edit mode interactions) this should be a fair performance compromise.
*/
static getDerivedStateFromProps(props: TimelineProps, state: GraphNGState) {
let dimFields = props.fields;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
}
const frame = preparePlotFrame(props.data, dimFields);
if (!frame) {
return { ...state, dimFields };
}
return {
...state,
data: preparePlotData(frame),
alignedDataFrame: frame,
seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
dimFields,
};
}
componentDidMount() {
const { theme, mode, rowHeight, colWidth, showValue } = this.props;
// alignedDataFrame is already prepared by getDerivedStateFromProps method
const { alignedDataFrame } = this.state;
if (!alignedDataFrame) {
return;
}
this.setState({
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
}),
});
}
componentDidUpdate(prevProps: TimelineProps) {
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue } = this.props;
const { alignedDataFrame } = this.state;
let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState;
if (
this.state.config === undefined ||
timeZone !== prevProps.timeZone ||
mode !== prevProps.mode ||
rowHeight !== prevProps.rowHeight ||
colWidth !== prevProps.colWidth ||
showValue !== prevProps.showValue
) {
shouldConfigUpdate = true;
}
if (data !== prevProps.data) {
if (!alignedDataFrame) {
return;
}
if (!compareArrayValues(data, prevProps.data, compareDataFrameStructures)) {
shouldConfigUpdate = true;
}
}
if (shouldConfigUpdate) {
const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
});
stateUpdate = { ...stateUpdate, config: builder };
}
if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate);
}
}
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
return this.state.seriesToDataFrameFieldIndexMap[i];
};
getTimeRange = () => {
return this.props.timeRange;
};
getTimeZone = () => {
return this.props.timeZone;
};
renderLegend() {
const { legend, onSeriesColorChange, onLegendClick, data } = this.props;
const { config } = this.state;
if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) {
return;
}
return (
<PlotLegend
data={data}
config={config}
onSeriesColorChange={onSeriesColorChange}
onLegendClick={onLegendClick}
maxHeight="35%"
maxWidth="60%"
{...legend}
/>
);
}
render() {
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props;
if (!this.state.data || !this.state.config) {
return null;
}
return (
<GraphNGContext.Provider
value={{
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
dimFields: this.state.dimFields,
data: this.state.alignedDataFrame,
}}
>
<VizLayout width={width} height={height}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
{...plotProps}
config={this.state.config!}
data={this.state.data}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children}
</UPlotChart>
)}
</VizLayout>
</GraphNGContext.Provider>
);
}
}
export const Timeline = withTheme(UnthemedTimeline);
Timeline.displayName = 'Timeline';

View File

@ -0,0 +1,432 @@
import uPlot, { Series, Cursor } from 'uplot';
import { FIXED_UNIT } from '../GraphNG/GraphNG';
import { Quadtree, Rect, pointWithin } from '../BarChart/quadtree';
import { distribute, SPACE_BETWEEN } from '../BarChart/distribute';
import { TimelineMode } from './types';
import { TimeRange } from '@grafana/data';
import { BarValueVisibility } from '../BarChart/types';
const { round, min, ceil } = Math;
const pxRatio = devicePixelRatio;
const laneDistr = SPACE_BETWEEN;
const font = Math.round(10 * pxRatio) + 'px Roboto';
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);
});
}
/**
* @internal
*/
export interface TimelineCoreOptions {
mode: TimelineMode;
numSeries: number;
rowHeight: number;
colWidth?: number;
showValue: BarValueVisibility;
isDiscrete: (seriesIdx: number) => boolean;
label: (seriesIdx: number) => string;
fill: (seriesIdx: number, valueIdx: number, value: any) => CanvasRenderingContext2D['fillStyle'];
stroke: (seriesIdx: number, valueIdx: number, value: any) => CanvasRenderingContext2D['strokeStyle'];
getTimeRange: () => TimeRange;
formatValue?: (seriesIdx: number, value: any) => string;
onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (seriesIdx: number, valueIdx: number) => void;
}
/**
* @internal
*/
export function getConfig(opts: TimelineCoreOptions) {
const {
mode,
numSeries,
isDiscrete,
rowHeight = 0,
colWidth = 0,
showValue,
label,
fill,
stroke,
formatValue,
getTimeRange,
// 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.4)';
return mark;
});
const hovered: Array<Rect | null> = Array(numSeries).fill(null);
const size = [colWidth, 100];
const gapFactor = 1 - size[0];
const maxWidth = (size[1] ?? Infinity) * 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,
lft: number,
top: number,
wid: number,
hgt: number,
strokeWidth: number,
seriesIdx: number,
valueIdx: number,
value: any,
discrete: boolean
) {
if (discrete) {
let fillStyle = fill(seriesIdx + 1, valueIdx, value);
let fillPath = fillPaths.get(fillStyle);
if (fillPath == null) {
fillPaths.set(fillStyle, (fillPath = new Path2D()));
}
rect(fillPath, lft, top, wid, hgt);
if (strokeWidth) {
let strokeStyle = stroke(seriesIdx + 1, valueIdx, value);
let strokePath = strokePaths.get(strokeStyle);
if (strokePath == null) {
strokePaths.set(strokeStyle, (strokePath = new Path2D()));
}
rect(strokePath, lft + strokeWidth / 2, top + strokeWidth / 2, wid - strokeWidth, hgt - strokeWidth);
}
} else {
ctx.beginPath();
rect(ctx, lft, top, wid, hgt);
ctx.fillStyle = fill(seriesIdx, valueIdx, value);
ctx.fill();
if (strokeWidth) {
ctx.beginPath();
rect(ctx, lft + strokeWidth / 2, top + strokeWidth / 2, wid - strokeWidth, hgt - strokeWidth);
ctx.strokeStyle = stroke(seriesIdx, valueIdx, value);
ctx.stroke();
}
}
qt.add({
x: round(lft - xOff),
y: round(top - yOff),
w: wid,
h: hgt,
sidx: seriesIdx + 1,
didx: valueIdx,
});
}
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) * 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, hgt) => {
if (mode === TimelineMode.Spans) {
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null) {
let lft = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff));
let nextIx = ix;
while (dataY[++nextIx] === undefined && nextIx < dataY.length) {}
// to now (not to end of chart)
let rgt =
nextIx === dataY.length
? xOff + xDim + strokeWidth
: Math.round(valToPosX(dataX[nextIx], scaleX, xDim, xOff));
putBox(
u.ctx,
rect,
xOff,
yOff,
lft,
round(yOff + y0),
rgt - lft,
round(hgt),
strokeWidth,
iy,
ix,
dataY[ix],
discrete
);
ix = nextIx - 1;
}
}
} else if (mode === TimelineMode.Grid) {
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 lft = valToPosX(dataX[ix], scaleX, xDim, xOff);
putBox(
u.ctx,
rect,
xOff,
yOff,
round(lft - xShift),
round(yOff + y0),
barWid,
round(hgt),
strokeWidth,
iy,
ix,
dataY[ix],
discrete
);
}
}
}
});
discrete && drawBoxes(u.ctx);
u.ctx.restore();
}
);
return null;
};
const drawPoints: Series.Points.Show =
formatValue == null || showValue === BarValueVisibility.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.fillStyle = 'black';
u.ctx.textAlign = mode === TimelineMode.Spans ? 'left' : 'center';
u.ctx.textBaseline = 'middle';
uPlot.orient(
u,
sidx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect
) => {
let y = round(yOff + yMids[sidx - 1]);
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null) {
let x = valToPosX(dataX[ix], scaleX, xDim, xOff);
u.ctx.fillText(formatValue(sidx, dataY[ix]), x, y);
}
}
}
);
u.ctx.restore();
return false;
};
const init = (u: uPlot) => {
let over = u.root.querySelector('.u-over')! as HTMLElement;
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();
// force-clear the path cache to cause drawBars() to rebuild new quadtree
u.series.forEach((s) => {
// @ts-ignore
s._paths = null;
});
};
const setCursor = (u: uPlot) => {
let cx = round(u.cursor!.left! * pxRatio);
for (let i = 0; i < numSeries; i++) {
let found: Rect | null = null;
if (cx >= 0) {
let cy = yMids[i];
qt.get(cx, cy, 1, 1, (o) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
found = o;
}
});
}
let h = hoverMarks[i];
if (found) {
if (found !== hovered[i]) {
hovered[i] = found;
h.style.display = '';
h.style.left = round(found!.x / pxRatio) + 'px';
h.style.top = round(found!.y / pxRatio) + 'px';
h.style.width = round(found!.w / pxRatio) + 'px';
h.style.height = round(found!.h / pxRatio) + 'px';
}
} else if (hovered[i] != null) {
h.style.display = 'none';
hovered[i] = null;
}
}
};
// hide y crosshair & hover points
const cursor: Partial<Cursor> = {
y: false,
points: { show: false },
};
const yMids: number[] = Array(numSeries).fill(0);
const ySplits: number[] = Array(numSeries).fill(0);
return {
cursor,
xSplits:
mode === TimelineMode.Grid
? (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.Grid) {
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] / 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,
};
}

View File

@ -0,0 +1,59 @@
import { GraphNGProps } from '../GraphNG/GraphNG';
import { GraphGradientMode, HideableFieldConfig } from '../uPlot/config';
import { VizLegendOptions } from '../VizLegend/types';
/**
* @alpha
*/
export enum BarValueVisibility {
Auto = 'auto',
Never = 'never',
Always = 'always',
}
/**
* @alpha
*/
export interface TimelineOptions {
mode: TimelineMode;
legend: VizLegendOptions;
showValue: BarValueVisibility;
rowHeight: number;
colWidth?: number;
}
/**
* @alpha
*/
export interface TimelineFieldConfig extends HideableFieldConfig {
lineWidth?: number; // 0
fillOpacity?: number; // 100
gradientMode?: GraphGradientMode;
}
/**
* @alpha
*/
export const defaultTimelineFieldConfig: TimelineFieldConfig = {
lineWidth: 1,
fillOpacity: 80,
gradientMode: GraphGradientMode.None,
};
/**
* @alpha
*/
export enum TimelineMode {
Spans = 'spans',
Grid = 'grid',
}
/**
* @alpha
*/
export interface TimelineProps extends GraphNGProps {
mode: TimelineMode;
rowHeight: number;
showValue: BarValueVisibility;
colWidth?: number;
}

View File

@ -0,0 +1,203 @@
import React from 'react';
import { GraphNGLegendEventMode, XYFieldMatchers } from '../GraphNG/types';
import {
DataFrame,
FieldColorModeId,
FieldConfig,
formattedValueToString,
getFieldDisplayName,
GrafanaTheme,
outerJoinDataFrames,
TimeRange,
TimeZone,
classicColors,
Field,
} from '@grafana/data';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { TimelineCoreOptions, getConfig } from './timeline';
import { FIXED_UNIT } from '../GraphNG/GraphNG';
import { AxisPlacement, GraphGradientMode, ScaleDirection, ScaleOrientation } from '../uPlot/config';
import { measureText } from '../../utils/measureText';
import { TimelineFieldConfig } from '../..';
const defaultConfig: TimelineFieldConfig = {
lineWidth: 0,
fillOpacity: 80,
gradientMode: GraphGradientMode.None,
};
export function mapMouseEventToMode(event: React.MouseEvent): GraphNGLegendEventMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return GraphNGLegendEventMode.AppendToSelection;
}
return GraphNGLegendEventMode.ToggleSelection;
}
export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers) {
return outerJoinDataFrames({
frames: data,
joinBy: dimFields.x,
keep: dimFields.y,
keepOriginIndices: true,
});
}
export type uPlotConfigBuilderSupplier = (
frame: DataFrame,
theme: GrafanaTheme,
getTimeRange: () => TimeRange,
getTimeZone: () => TimeZone
) => UPlotConfigBuilder;
export function preparePlotConfigBuilder(
frame: DataFrame,
theme: GrafanaTheme,
getTimeRange: () => TimeRange,
getTimeZone: () => TimeZone,
coreOptions: Partial<TimelineCoreOptions>
): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder(getTimeZone);
const isDiscrete = (field: Field) => {
const mode = field.config?.color?.mode;
return !(mode && field.display && mode.startsWith('continuous-'));
};
const colorLookup = (seriesIdx: number, valueIdx: number, value: any) => {
const field = frame.fields[seriesIdx];
const mode = field.config?.color?.mode;
if (mode && field.display && (mode === FieldColorModeId.Thresholds || mode.startsWith('continuous-'))) {
const disp = field.display(value); // will apply color modes
if (disp.color) {
return disp.color;
}
}
return classicColors[Math.floor(value % classicColors.length)];
};
const yAxisWidth =
frame.fields.reduce((maxWidth, field) => {
return Math.max(
maxWidth,
measureText(getFieldDisplayName(field, frame), Math.round(10 * devicePixelRatio)).width
);
}, 0) + 24;
const opts: TimelineCoreOptions = {
// should expose in panel config
mode: coreOptions.mode!,
numSeries: frame.fields.length - 1,
isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
rowHeight: coreOptions.rowHeight!,
colWidth: coreOptions.colWidth,
showValue: coreOptions.showValue!,
label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
fill: colorLookup,
stroke: colorLookup,
getTimeRange,
// hardcoded formatter for state values
formatValue: (seriesIdx, value) => formattedValueToString(frame.fields[seriesIdx].display!(value)),
// TODO: unimplemeted for now
onHover: (seriesIdx: number, valueIdx: number) => {
console.log('hover', { seriesIdx, valueIdx });
},
onLeave: (seriesIdx: number, valueIdx: number) => {
console.log('leave', { seriesIdx, valueIdx });
},
};
const coreConfig = getConfig(opts);
builder.addHook('init', coreConfig.init);
builder.addHook('drawClear', coreConfig.drawClear);
builder.addHook('setCursor', coreConfig.setCursor);
builder.setCursor(coreConfig.cursor);
builder.addScale({
scaleKey: 'x',
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: 'x',
isTime: true,
splits: coreConfig.xSplits!,
placement: AxisPlacement.Bottom,
timeZone: getTimeZone(),
theme,
});
builder.addAxis({
scaleKey: FIXED_UNIT, // y
isTime: false,
placement: AxisPlacement.Left,
splits: coreConfig.ySplits,
values: coreConfig.yValues,
grid: false,
ticks: false,
size: yAxisWidth,
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 = field.config as FieldConfig<TimelineFieldConfig>;
const customConfig: TimelineFieldConfig = {
...defaultConfig,
...config.custom,
};
field.state!.seriesIndex = seriesIndex++;
//const scaleKey = config.unit || FIXED_UNIT;
//const colorMode = getFieldColorModeForField(field);
let { fillOpacity } = customConfig;
builder.addSeries({
scaleKey: FIXED_UNIT,
pathBuilder: coreConfig.drawPaths,
pointsBuilder: coreConfig.drawPoints,
//colorMode,
fillOpacity,
theme,
show: !customConfig.hideFrom?.graph,
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,
fieldName: getFieldDisplayName(field, frame),
hideInLegend: customConfig.hideFrom?.legend,
});
}
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;
}

View File

@ -221,6 +221,8 @@ export { usePlotContext, usePlotPluginContext } from './uPlot/context';
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
export { useGraphNGContext } from './GraphNG/hooks';
export { BarChart } from './BarChart/BarChart';
export { Timeline } from './Timeline/Timeline';
export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
export { TimelineOptions, TimelineFieldConfig } from './Timeline/types';
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';
export * from './NodeGraph';

View File

@ -2,6 +2,9 @@ import React, { useContext } from 'react';
import uPlot, { AlignedData, Series } from 'uplot';
import { PlotPlugin } from './types';
/**
* @alpha
*/
interface PlotCanvasContextType {
// canvas size css pxs
width: number;
@ -15,6 +18,9 @@ interface PlotCanvasContextType {
};
}
/**
* @alpha
*/
interface PlotPluginsContextType {
registerPlugin: (plugin: PlotPlugin) => () => void;
}
@ -28,6 +34,9 @@ interface PlotContextType extends PlotPluginsContextType {
data: AlignedData;
}
/**
* @alpha
*/
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
// Exposes uPlot instance and bounding box of the entire canvas and plot area
@ -39,7 +48,11 @@ const throwWhenNoContext = (name: string) => {
throw new Error(`${name} must be used within PlotContext or PlotContext is not ready yet!`);
};
// Exposes API for registering uPlot plugins
/**
* Exposes API for registering uPlot plugins
*
* @alpha
*/
export const usePlotPluginContext = (): PlotPluginsContextType => {
const ctx = useContext(PlotContext);
if (Object.keys(ctx).length === 0) {
@ -50,6 +63,9 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
};
};
/**
* @alpha
*/
export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any,

View File

@ -40,6 +40,7 @@ const tempoPlugin = async () =>
import * as textPanel from 'app/plugins/panel/text/module';
import * as timeseriesPanel from 'app/plugins/panel/timeseries/module';
import * as timelinePanel from 'app/plugins/panel/timeline/module';
import * as graphPanel from 'app/plugins/panel/graph/module';
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
@ -86,6 +87,7 @@ const builtInPlugins: any = {
'app/plugins/panel/text/module': textPanel,
'app/plugins/panel/timeseries/module': timeseriesPanel,
'app/plugins/panel/timeline/module': timelinePanel,
'app/plugins/panel/graph/module': graphPanel,
'app/plugins/panel/xychart/module': xyChartPanel,
'app/plugins/panel/dashlist/module': dashListPanel,

View File

@ -0,0 +1,56 @@
import React, { useCallback } from 'react';
import { PanelProps } from '@grafana/data';
import { Timeline, GraphNGLegendEvent, TimelineOptions } from '@grafana/ui';
import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory';
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
interface TimelinePanelProps extends PanelProps<TimelineOptions> {}
/**
* @alpha
*/
export const TimelinePanel: React.FC<TimelinePanelProps> = ({
data,
timeRange,
timeZone,
options,
width,
height,
fieldConfig,
onFieldConfigChange,
}) => {
const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => {
onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series));
},
[fieldConfig, onFieldConfigChange, data.series]
);
const onSeriesColorChange = useCallback(
(label: string, color: string) => {
onFieldConfigChange(changeSeriesColorConfigFactory(label, color, fieldConfig));
},
[fieldConfig, onFieldConfigChange]
);
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
return (
<Timeline
data={data.series}
timeRange={timeRange}
timeZone={timeZone}
width={width}
height={height}
onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange}
{...options}
/>
);
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81 80.91"><defs><style>.cls-1{fill:#3865ab;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:url(#linear-gradient-2);}.cls-4{fill:#84aff1;}.cls-5{fill:url(#linear-gradient-3);}.cls-6{fill:url(#linear-gradient-4);}.cls-7{fill:url(#linear-gradient-5);}.cls-8{fill:url(#linear-gradient-6);}</style><linearGradient id="linear-gradient" x1="8" y1="4" x2="70" y2="4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" x1="30" y1="18.58" x2="51" y2="18.58" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-3" x1="0" y1="33.16" x2="37" y2="33.16" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-4" x1="20" y1="47.74" x2="45" y2="47.74" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-5" x1="49" y1="62.33" x2="60" y2="62.33" xlink:href="#linear-gradient"/><linearGradient id="linear-gradient-6" x1="13" y1="76.91" x2="73.5" y2="76.91" xlink:href="#linear-gradient"/></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" width="81" height="8" rx="1"/><rect class="cls-1" y="72.91" width="81" height="8" rx="1"/><rect class="cls-1" y="58.33" width="81" height="8" rx="1"/><rect class="cls-1" y="43.74" width="81" height="8" rx="1"/><rect class="cls-1" y="29.16" width="81" height="8" rx="1"/><rect class="cls-1" y="14.58" width="81" height="8" rx="1"/><path class="cls-2" d="M39,8H8V0H39ZM70,0H59V8H70Z"/><rect class="cls-3" x="30" y="14.58" width="21" height="8"/><rect class="cls-4" x="57.93" y="14.58" width="6" height="8"/><rect class="cls-4" x="11.17" y="14.58" width="6" height="8"/><path class="cls-5" d="M37,37.16H1a1,1,0,0,1-1-1v-6a1,1,0,0,1,1-1H37Z"/><rect class="cls-4" x="62.5" y="29.16" width="11" height="8"/><rect class="cls-6" x="20" y="43.74" width="25" height="8"/><path class="cls-4" d="M80,51.74H52v-8H80a1,1,0,0,1,1,1v6A1,1,0,0,1,80,51.74Z"/><rect class="cls-7" x="49" y="58.33" width="11" height="8"/><path class="cls-4" d="M30,66.33H1a1,1,0,0,1-1-1v-6a1,1,0,0,1,1-1H30Z"/><path class="cls-8" d="M24,80.91H13v-8H24Zm49.5-8h-33v8h33Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,107 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data';
import { TimelinePanel } from './TimelinePanel';
import { TimelineOptions, TimelineFieldConfig, BarValueVisibility } from '@grafana/ui';
//import { addHideFrom, addLegendOptions } from '../timeseries/config';
//import { defaultBarChartFieldConfig } from '@grafana/ui/src/components/BarChart/types';
import { TimelineMode } from '@grafana/ui/src/components/Timeline/types';
export const plugin = new PanelPlugin<TimelineOptions, TimelineFieldConfig>(TimelinePanel)
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Color]: {
settings: {
byValueSupport: true,
},
defaultValue: {
mode: FieldColorModeId.PaletteClassic,
},
},
},
/*
useCustomConfig: (builder) => {
const cfg = defaultBarChartFieldConfig;
builder
.addSliderInput({
path: 'lineWidth',
name: 'Line width',
defaultValue: cfg.lineWidth,
settings: {
min: 0,
max: 10,
step: 1,
},
})
.addSliderInput({
path: 'fillOpacity',
name: 'Fill opacity',
defaultValue: cfg.fillOpacity,
settings: {
min: 0,
max: 100,
step: 1,
},
})
.addRadio({
path: 'gradientMode',
name: 'Gradient mode',
defaultValue: graphFieldOptions.fillGradient[0].value,
settings: {
options: graphFieldOptions.fillGradient,
},
});
// addAxisConfig(builder, cfg, true);
addHideFrom(builder);
},
*/
})
.setPanelOptions((builder) => {
builder
.addRadio({
path: 'mode',
name: 'Mode',
defaultValue: TimelineMode.Spans,
settings: {
options: [
{ label: 'Spans', value: TimelineMode.Spans },
{ label: 'Grid', value: TimelineMode.Grid },
],
},
})
.addRadio({
path: 'showValue',
name: 'Show values',
settings: {
options: [
//{ value: BarValueVisibility.Auto, label: 'Auto' },
{ value: BarValueVisibility.Always, label: 'Always' },
{ value: BarValueVisibility.Never, label: 'Never' },
],
},
defaultValue: BarValueVisibility.Always,
})
.addSliderInput({
path: 'rowHeight',
name: 'Row height',
defaultValue: 0.9,
settings: {
min: 0,
max: 1,
step: 0.01,
},
})
.addSliderInput({
path: 'colWidth',
name: 'Column width',
defaultValue: 0.9,
settings: {
min: 0,
max: 1,
step: 0.01,
},
showIf: ({ mode }) => mode === TimelineMode.Grid,
});
//addLegendOptions(builder);
});

View File

@ -0,0 +1,18 @@
{
"type": "panel",
"name": "Timeline",
"id": "timeline",
"state": "alpha",
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/timeline.svg",
"large": "img/timeline.svg"
}
}
}

View File

@ -24,6 +24,7 @@ import {
GraphGradientMode,
LegendDisplayMode,
AxisConfig,
HideableFieldConfig,
} from '@grafana/ui';
import { SeriesConfigEditor } from './HideSeriesConfigEditor';
import { ScaleDistributionEditor } from './ScaleDistributionEditor';
@ -185,7 +186,7 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption
};
}
export function addHideFrom(builder: FieldConfigEditorBuilder<AxisConfig>) {
export function addHideFrom(builder: FieldConfigEditorBuilder<HideableFieldConfig>) {
builder.addCustomEditor({
id: 'hideFrom',
name: 'Hide in area',