mirror of https://github.com/grafana/grafana
Alpha panel: new Timeline/Discrete panel (#31973)
parent
ea202513cd
commit
6082a9360e
@ -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 |
||||
} |
||||
@ -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'; |
||||
@ -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, |
||||
}; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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} |
||||
/> |
||||
); |
||||
}; |
||||
|
After Width: | Height: | Size: 2.2 KiB |
@ -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);
|
||||
}); |
||||
@ -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" |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue