diff --git a/devenv/dev-dashboards/panel-timeline/timeline-modes.json b/devenv/dev-dashboards/panel-timeline/timeline-modes.json new file mode 100644 index 00000000000..19d6c3b3b12 --- /dev/null +++ b/devenv/dev-dashboards/panel-timeline/timeline-modes.json @@ -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 +} \ No newline at end of file diff --git a/packages/grafana-ui/src/components/BarChart/bars.ts b/packages/grafana-ui/src/components/BarChart/bars.ts index 0012a03cd97..44fb1f49efc 100644 --- a/packages/grafana-ui/src/components/BarChart/bars.ts +++ b/packages/grafana-ui/src/components/BarChart/bars.ts @@ -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; } /** diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 48759d79eb5..fd345b281ae 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -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; diff --git a/packages/grafana-ui/src/components/Timeline/Timeline.tsx b/packages/grafana-ui/src/components/Timeline/Timeline.tsx new file mode 100755 index 00000000000..e5ffc12875a --- /dev/null +++ b/packages/grafana-ui/src/components/Timeline/Timeline.tsx @@ -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 { + 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 ( + + ); + } + + render() { + const { width, height, children, timeZone, timeRange, ...plotProps } = this.props; + + if (!this.state.data || !this.state.config) { + return null; + } + + return ( + + + {(vizWidth: number, vizHeight: number) => ( + + {children} + + )} + + + ); + } +} + +export const Timeline = withTheme(UnthemedTimeline); +Timeline.displayName = 'Timeline'; diff --git a/packages/grafana-ui/src/components/Timeline/timeline.ts b/packages/grafana-ui/src/components/Timeline/timeline.ts new file mode 100644 index 00000000000..fff2984ed59 --- /dev/null +++ b/packages/grafana-ui/src/components/Timeline/timeline.ts @@ -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 = Array(numSeries).fill(null); + + const size = [colWidth, 100]; + const gapFactor = 1 - size[0]; + const maxWidth = (size[1] ?? Infinity) * pxRatio; + + const fillPaths: Map = new Map(); + const strokePaths: Map = 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 = { + 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, + }; +} diff --git a/packages/grafana-ui/src/components/Timeline/types.ts b/packages/grafana-ui/src/components/Timeline/types.ts new file mode 100644 index 00000000000..da0193d27fb --- /dev/null +++ b/packages/grafana-ui/src/components/Timeline/types.ts @@ -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; +} diff --git a/packages/grafana-ui/src/components/Timeline/utils.ts b/packages/grafana-ui/src/components/Timeline/utils.ts new file mode 100644 index 00000000000..2eb7d26a6e2 --- /dev/null +++ b/packages/grafana-ui/src/components/Timeline/utils.ts @@ -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 +): 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; + 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 { + const names = new Map(); + for (let i = 0; i < frame.fields.length; i++) { + names.set(getFieldDisplayName(frame.fields[i], frame), i); + } + return names; +} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 0d609de2171..a2ad1dde888 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index 24e743dfd2d..5706fc1922f 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -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({} 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, diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 679554f52b0..8dd35b09f1e 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -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, diff --git a/public/app/plugins/panel/timeline/TimelinePanel.tsx b/public/app/plugins/panel/timeline/TimelinePanel.tsx new file mode 100755 index 00000000000..8f4ea63921a --- /dev/null +++ b/public/app/plugins/panel/timeline/TimelinePanel.tsx @@ -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 {} + +/** + * @alpha + */ +export const TimelinePanel: React.FC = ({ + 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 ( +
+

No data found in response

+
+ ); + } + + return ( + + ); +}; diff --git a/public/app/plugins/panel/timeline/img/timeline.svg b/public/app/plugins/panel/timeline/img/timeline.svg new file mode 100644 index 00000000000..6e544a1c73b --- /dev/null +++ b/public/app/plugins/panel/timeline/img/timeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/timeline/module.tsx b/public/app/plugins/panel/timeline/module.tsx new file mode 100755 index 00000000000..52818352786 --- /dev/null +++ b/public/app/plugins/panel/timeline/module.tsx @@ -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(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); + }); diff --git a/public/app/plugins/panel/timeline/plugin.json b/public/app/plugins/panel/timeline/plugin.json new file mode 100755 index 00000000000..4e1248b6f6b --- /dev/null +++ b/public/app/plugins/panel/timeline/plugin.json @@ -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" + } + } +} diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index 3650e261c11..ca870d8dd03 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -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) { +export function addHideFrom(builder: FieldConfigEditorBuilder) { builder.addCustomEditor({ id: 'hideFrom', name: 'Hide in area',