Skip to content

Commit

Permalink
cluster-ui: create bar graph for time series data
Browse files Browse the repository at this point in the history
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
xinhaoz authored and maryliag committed Jun 10, 2022
1 parent 386914b commit 8cbf561
Show file tree
Hide file tree
Showing 7 changed files with 569 additions and 0 deletions.
2 changes: 2 additions & 0 deletions pkg/ui/workspaces/cluster-ui/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ DEPENDENCIES = [
"@npm//source-map-loader",
"@npm//style-loader",
"@npm//ts-jest",
"@npm//uplot",
"@npm//url-loader",
"@npm//webpack",
"@npm//webpack-cli",
Expand Down Expand Up @@ -147,6 +148,7 @@ ts_project(
"@npm//redux-saga",
"@npm//redux-saga-test-plan",
"@npm//reselect",
"@npm//uplot",
],
)

Expand Down
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}
/>
);
});
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 pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts
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;
};
Loading

0 comments on commit 8cbf561

Please sign in to comment.