mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alpha panel: new Timeline/Discrete panel (#31973)
This commit is contained in:
parent
ea202513cd
commit
6082a9360e
289
devenv/dev-dashboards/panel-timeline/timeline-modes.json
Normal file
289
devenv/dev-dashboards/panel-timeline/timeline-modes.json
Normal 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
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
195
packages/grafana-ui/src/components/Timeline/Timeline.tsx
Executable file
195
packages/grafana-ui/src/components/Timeline/Timeline.tsx
Executable 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';
|
432
packages/grafana-ui/src/components/Timeline/timeline.ts
Normal file
432
packages/grafana-ui/src/components/Timeline/timeline.ts
Normal 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,
|
||||
};
|
||||
}
|
59
packages/grafana-ui/src/components/Timeline/types.ts
Normal file
59
packages/grafana-ui/src/components/Timeline/types.ts
Normal 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;
|
||||
}
|
203
packages/grafana-ui/src/components/Timeline/utils.ts
Normal file
203
packages/grafana-ui/src/components/Timeline/utils.ts
Normal 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;
|
||||
}
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
56
public/app/plugins/panel/timeline/TimelinePanel.tsx
Executable file
56
public/app/plugins/panel/timeline/TimelinePanel.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
};
|
1
public/app/plugins/panel/timeline/img/timeline.svg
Normal file
1
public/app/plugins/panel/timeline/img/timeline.svg
Normal 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 |
107
public/app/plugins/panel/timeline/module.tsx
Executable file
107
public/app/plugins/panel/timeline/module.tsx
Executable 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);
|
||||
});
|
18
public/app/plugins/panel/timeline/plugin.json
Executable file
18
public/app/plugins/panel/timeline/plugin.json
Executable 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user