forked from cockroachdb/cockroach
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
cluster-ui: create bar graph for time series data
Closes cockroachdb#74516 This commit creates a generic bar chart component in the cluster-ui package. The component is intended for use with time series data and should be wrapped in parent components that convert metrics responses from the server to the expected format. Release note: None
- Loading branch information
Showing
7 changed files
with
569 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
88 changes: 88 additions & 0 deletions
88
pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
// Copyright 2022 The Cockroach Authors. | ||
// | ||
// Use of this software is governed by the Business Source License | ||
// included in the file licenses/BSL.txt. | ||
// | ||
// As of the Change Date specified in that file, in accordance with | ||
// the Business Source License, use of this software will be governed | ||
// by the Apache License, Version 2.0, included in the file | ||
// licenses/APL.txt. | ||
|
||
import React from "react"; | ||
import { storiesOf } from "@storybook/react"; | ||
import { AlignedData, Options } from "uplot"; | ||
import { BarGraphTimeSeries } from "./index"; | ||
import { AxisUnits } from "../utils/domain"; | ||
import { getBarsBuilder } from "./bars"; | ||
|
||
function generateTimestampsMillis(start: number, length: number): number[] { | ||
return [...Array(length)].map( | ||
(_, idx): number => (60 * 60 * idx + start) * 1000, | ||
); | ||
} | ||
|
||
function genValuesInRange(range: [number, number], length: number): number[] { | ||
return [...Array(length)].map((): number => | ||
Math.random() > 0.1 ? Math.random() * (range[1] - range[0]) + range[0] : 0, | ||
); | ||
} | ||
|
||
const mockData: AlignedData = [ | ||
generateTimestampsMillis(1546300800, 20), | ||
genValuesInRange([0, 100], 20), | ||
genValuesInRange([0, 100], 20), | ||
genValuesInRange([0, 100], 20), | ||
]; | ||
|
||
const mockOpts: Partial<Options> = { | ||
axes: [{}, { label: "values" }], | ||
series: [ | ||
{}, | ||
{ | ||
label: "bar 1", | ||
}, | ||
{ | ||
label: "bar 2", | ||
}, | ||
{ | ||
label: "bar 3", | ||
}, | ||
], | ||
}; | ||
|
||
storiesOf("BarGraphTimeSeries", module) | ||
.add("with stacked multi-series", () => { | ||
return ( | ||
<BarGraphTimeSeries | ||
title="Example Stacked - Count" | ||
alignedData={mockData} | ||
uPlotOptions={mockOpts} | ||
tooltip={ | ||
<div>This is an example stacked bar graph axis unit = count.</div> | ||
} | ||
yAxisUnits={AxisUnits.Count} | ||
/> | ||
); | ||
}) | ||
.add("with single series", () => { | ||
const data: AlignedData = [ | ||
generateTimestampsMillis(1546300800, 50), | ||
genValuesInRange([0, 1], 50), | ||
]; | ||
const opts = { | ||
series: [{}, { label: "bar", paths: getBarsBuilder(0.8, 20) }], | ||
legend: { show: false }, | ||
axes: [{}, { label: "mock" }], | ||
}; | ||
return ( | ||
<BarGraphTimeSeries | ||
title="Example Single Series - Percent" | ||
alignedData={data} | ||
uPlotOptions={opts} | ||
tooltip={ | ||
<div>This is an example bar graph with axis unit = percent.</div> | ||
} | ||
yAxisUnits={AxisUnits.Percentage} | ||
/> | ||
); | ||
}); |
19 changes: 19 additions & 0 deletions
19
pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
:global { | ||
@import "uplot/dist/uPlot.min"; | ||
} | ||
|
||
.bargraph { | ||
height: 100%; | ||
:global(.uplot) { | ||
display: flex; | ||
flex-direction: column; | ||
:global(.u-legend) { | ||
text-align: left; | ||
font-size: 10px; | ||
margin-top: 20px; | ||
z-index: 100; | ||
width: fit-content; | ||
padding: 10px; | ||
} | ||
} | ||
} |
200 changes: 200 additions & 0 deletions
200
pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
// Copyright 2022 The Cockroach Authors. | ||
// | ||
// Use of this software is governed by the Business Source License | ||
// included in the file licenses/BSL.txt. | ||
// | ||
// As of the Change Date specified in that file, in accordance with | ||
// the Business Source License, use of this software will be governed | ||
// by the Apache License, Version 2.0, included in the file | ||
// licenses/APL.txt. | ||
|
||
import { merge } from "lodash"; | ||
import uPlot, { Options, Band, AlignedData } from "uplot"; | ||
import { | ||
// AxisUnits, | ||
AxisDomain, | ||
} from "../utils/domain"; | ||
import { barTooltipPlugin } from "./plugins"; | ||
|
||
const seriesPalette = [ | ||
"#475872", | ||
"#FFCD02", | ||
"#F16969", | ||
"#4E9FD1", | ||
"#49D990", | ||
"#D77FBF", | ||
"#87326D", | ||
"#A3415B", | ||
"#B59153", | ||
"#C9DB6D", | ||
"#203D9B", | ||
"#748BF2", | ||
"#91C8F2", | ||
"#FF9696", | ||
"#EF843C", | ||
"#DCCD4B", | ||
]; | ||
|
||
// Aggregate the series. | ||
export function stack( | ||
data: AlignedData, | ||
omit: (i: number) => boolean, | ||
): AlignedData { | ||
const stackedData = []; | ||
const xAxisLength = data[0].length; | ||
const accum = Array(xAxisLength); | ||
accum.fill(0); | ||
|
||
for (let i = 1; i < data.length; i++) | ||
stackedData.push( | ||
omit(i) ? data[i] : data[i].map((v, i) => (accum[i] += v)), | ||
); | ||
|
||
return [data[0]].concat(stackedData) as AlignedData; | ||
} | ||
|
||
function getStackedBands( | ||
unstackedData: AlignedData, | ||
omit: (i: number) => boolean, | ||
): Band[] { | ||
const bands = []; | ||
|
||
for (let i = 1; i < unstackedData.length; i++) | ||
!omit(i) && | ||
bands.push({ | ||
series: [ | ||
unstackedData.findIndex( | ||
(_series, seriesIdx) => seriesIdx > i && !omit(seriesIdx), | ||
), | ||
i, | ||
] as Band.Bounds, | ||
}); | ||
|
||
return bands.filter(b => b.series[1] > -1); | ||
} | ||
|
||
const { bars } = uPlot.paths; | ||
|
||
export const getBarsBuilder = ( | ||
barWidthFactor: number, // percentage of space allocated to bar in the range [0, 1] | ||
maxWidth: number, | ||
minWidth = 10, | ||
align: 0 | 1 | -1 = 1, // -1 = left aligned, 0 = center, 1 = right aligned | ||
): uPlot.Series.PathBuilder => { | ||
return bars({ size: [barWidthFactor, maxWidth, minWidth], align }); | ||
}; | ||
|
||
export const getStackedBarOpts = ( | ||
unstackedData: AlignedData, | ||
userOptions: Partial<Options>, | ||
xAxisDomain: AxisDomain, | ||
yAxisDomain: AxisDomain, | ||
colourPalette = seriesPalette, | ||
): Options => { | ||
const options = getBarChartOpts( | ||
userOptions, | ||
xAxisDomain, | ||
yAxisDomain, | ||
colourPalette, | ||
); | ||
|
||
options.bands = getStackedBands(unstackedData, () => false); | ||
|
||
options.series.forEach(s => { | ||
s.value = (_u, _v, si, i) => unstackedData[si][i]; | ||
|
||
s.points = s.points || { show: false }; | ||
|
||
// Scan raw unstacked data to return only real points. | ||
s.points.filter = (_u, seriesIdx, show) => { | ||
if (show) { | ||
const pts: number[] = []; | ||
unstackedData[seriesIdx].forEach((v, i) => { | ||
v && pts.push(i); | ||
}); | ||
return pts; | ||
} | ||
}; | ||
}); | ||
|
||
options.cursor = options.cursor || {}; | ||
options.cursor.dataIdx = (_u, seriesIdx, closestIdx, _xValue) => { | ||
return unstackedData[seriesIdx][closestIdx] == null ? null : closestIdx; | ||
}; | ||
|
||
options.hooks = options.hooks || {}; | ||
options.hooks.setSeries = options.hooks.setSeries || []; | ||
options.hooks.setSeries.push(u => { | ||
// Restack on toggle. | ||
const bands = getStackedBands(unstackedData, i => !u.series[i].show); | ||
const data = stack(unstackedData, i => !u.series[i].show); | ||
u.delBand(null); // Clear bands. | ||
bands.forEach(b => u.addBand(b)); | ||
u.setData(data); | ||
}); | ||
|
||
return options; | ||
}; | ||
|
||
export const getBarChartOpts = ( | ||
userOptions: Partial<Options>, | ||
xAxisDomain: AxisDomain, | ||
yAxisDomain: AxisDomain, | ||
colourPalette = seriesPalette, | ||
): Options => { | ||
const { series, ...providedOpts } = userOptions; | ||
const defaultBars = getBarsBuilder(0.9, 80); | ||
|
||
const opts: Options = { | ||
// Default width and height. | ||
width: 947, | ||
height: 300, | ||
ms: 1, // Interpret timestamps in milliseconds. | ||
legend: { | ||
isolate: true, // Isolate series on click. | ||
live: false, | ||
}, | ||
scales: { | ||
x: { | ||
range: () => [xAxisDomain.extent[0], xAxisDomain.extent[1]], | ||
}, | ||
}, | ||
axes: [ | ||
{ | ||
values: (_u, vals) => vals.map(xAxisDomain.tickFormat), | ||
splits: () => xAxisDomain.ticks, | ||
}, | ||
{ | ||
values: (_u, vals) => vals.map(yAxisDomain.tickFormat), | ||
splits: () => [ | ||
yAxisDomain.extent[0], | ||
...yAxisDomain.ticks, | ||
yAxisDomain.extent[1], | ||
], | ||
scale: "yAxis", | ||
}, | ||
], | ||
series: [ | ||
{ | ||
value: (_u, millis) => xAxisDomain.guideFormat(millis), | ||
}, | ||
...series.slice(1).map((s, i) => ({ | ||
fill: colourPalette[i % colourPalette.length], | ||
stroke: colourPalette[i % colourPalette.length], | ||
width: 2, | ||
paths: defaultBars, | ||
points: { show: false }, | ||
scale: "yAxis", | ||
...s, | ||
})), | ||
], | ||
plugins: [barTooltipPlugin()], | ||
}; | ||
|
||
const combinedOpts = merge(opts, providedOpts); | ||
|
||
// Set y-axis label with units. | ||
combinedOpts.axes[1].label += ` ${yAxisDomain.label}`; | ||
|
||
return combinedOpts; | ||
}; |
Oops, something went wrong.