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;
|
groupWidth: number;
|
||||||
barWidth: number;
|
barWidth: number;
|
||||||
formatValue?: (seriesIdx: number, value: any) => string;
|
formatValue?: (seriesIdx: number, value: any) => string;
|
||||||
onHover?: (seriesIdx: number, valueIdx: any) => void;
|
onHover?: (seriesIdx: number, valueIdx: number) => void;
|
||||||
onLeave?: (seriesIdx: number, valueIdx: any) => void;
|
onLeave?: (seriesIdx: number, valueIdx: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -41,7 +41,10 @@ export interface GraphNGProps extends Themeable {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GraphNGState {
|
/**
|
||||||
|
* @internal -- not a public API
|
||||||
|
*/
|
||||||
|
export interface GraphNGState {
|
||||||
data: AlignedData;
|
data: AlignedData;
|
||||||
alignedDataFrame: DataFrame;
|
alignedDataFrame: DataFrame;
|
||||||
dimFields: XYFieldMatchers;
|
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 { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
|
||||||
export { useGraphNGContext } from './GraphNG/hooks';
|
export { useGraphNGContext } from './GraphNG/hooks';
|
||||||
export { BarChart } from './BarChart/BarChart';
|
export { BarChart } from './BarChart/BarChart';
|
||||||
|
export { Timeline } from './Timeline/Timeline';
|
||||||
export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
|
export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
|
||||||
|
export { TimelineOptions, TimelineFieldConfig } from './Timeline/types';
|
||||||
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';
|
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';
|
||||||
export * from './NodeGraph';
|
export * from './NodeGraph';
|
||||||
|
@ -2,6 +2,9 @@ import React, { useContext } from 'react';
|
|||||||
import uPlot, { AlignedData, Series } from 'uplot';
|
import uPlot, { AlignedData, Series } from 'uplot';
|
||||||
import { PlotPlugin } from './types';
|
import { PlotPlugin } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
interface PlotCanvasContextType {
|
interface PlotCanvasContextType {
|
||||||
// canvas size css pxs
|
// canvas size css pxs
|
||||||
width: number;
|
width: number;
|
||||||
@ -15,6 +18,9 @@ interface PlotCanvasContextType {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
interface PlotPluginsContextType {
|
interface PlotPluginsContextType {
|
||||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
registerPlugin: (plugin: PlotPlugin) => () => void;
|
||||||
}
|
}
|
||||||
@ -28,6 +34,9 @@ interface PlotContextType extends PlotPluginsContextType {
|
|||||||
data: AlignedData;
|
data: AlignedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
|
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
|
||||||
|
|
||||||
// Exposes uPlot instance and bounding box of the entire canvas and plot area
|
// 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!`);
|
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 => {
|
export const usePlotPluginContext = (): PlotPluginsContextType => {
|
||||||
const ctx = useContext(PlotContext);
|
const ctx = useContext(PlotContext);
|
||||||
if (Object.keys(ctx).length === 0) {
|
if (Object.keys(ctx).length === 0) {
|
||||||
@ -50,6 +63,9 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
export const buildPlotContext = (
|
export const buildPlotContext = (
|
||||||
isPlotReady: boolean,
|
isPlotReady: boolean,
|
||||||
canvasRef: any,
|
canvasRef: any,
|
||||||
|
@ -40,6 +40,7 @@ const tempoPlugin = async () =>
|
|||||||
|
|
||||||
import * as textPanel from 'app/plugins/panel/text/module';
|
import * as textPanel from 'app/plugins/panel/text/module';
|
||||||
import * as timeseriesPanel from 'app/plugins/panel/timeseries/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 graphPanel from 'app/plugins/panel/graph/module';
|
||||||
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
|
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
|
||||||
import * as dashListPanel from 'app/plugins/panel/dashlist/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/text/module': textPanel,
|
||||||
'app/plugins/panel/timeseries/module': timeseriesPanel,
|
'app/plugins/panel/timeseries/module': timeseriesPanel,
|
||||||
|
'app/plugins/panel/timeline/module': timelinePanel,
|
||||||
'app/plugins/panel/graph/module': graphPanel,
|
'app/plugins/panel/graph/module': graphPanel,
|
||||||
'app/plugins/panel/xychart/module': xyChartPanel,
|
'app/plugins/panel/xychart/module': xyChartPanel,
|
||||||
'app/plugins/panel/dashlist/module': dashListPanel,
|
'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,
|
GraphGradientMode,
|
||||||
LegendDisplayMode,
|
LegendDisplayMode,
|
||||||
AxisConfig,
|
AxisConfig,
|
||||||
|
HideableFieldConfig,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { SeriesConfigEditor } from './HideSeriesConfigEditor';
|
import { SeriesConfigEditor } from './HideSeriesConfigEditor';
|
||||||
import { ScaleDistributionEditor } from './ScaleDistributionEditor';
|
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({
|
builder.addCustomEditor({
|
||||||
id: 'hideFrom',
|
id: 'hideFrom',
|
||||||
name: 'Hide in area',
|
name: 'Hide in area',
|
||||||
|
Loading…
Reference in New Issue
Block a user