Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

release-22.1: bar chart component and improvements #82887

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkg/ui/workspaces/cluster-ui/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"rules": {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-namespace": "off"
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
}
}
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
134 changes: 134 additions & 0 deletions pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// 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, 10000], 20),
genValuesInRange([0, 100000], 20),
];
const mockDataSingle: AlignedData = [[1654115121], [0], [1], [2]];
const mockDataDuration: AlignedData = [
generateTimestampsMillis(1546300800, 20),
genValuesInRange([0, 1e7], 20),
genValuesInRange([0, 1e7], 20),
genValuesInRange([0, 1e7], 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}
/>
);
})
.add("with single stacked multi-series", () => {
return (
<BarGraphTimeSeries
title="Example one Stacked - Count"
alignedData={mockDataSingle}
uPlotOptions={mockOpts}
tooltip={
<div>This is an example stacked bar graph axis unit = Count.</div>
}
yAxisUnits={AxisUnits.Count}
/>
);
})
.add("with duration stacked multi-series", () => {
return (
<BarGraphTimeSeries
title="Example one Stacked - Duration"
alignedData={mockDataDuration}
uPlotOptions={mockOpts}
tooltip={
<div>This is an example stacked bar graph axis unit = Duration.</div>
}
yAxisUnits={AxisUnits.Duration}
/>
);
})
.add("with bytes stacked multi-series", () => {
return (
<BarGraphTimeSeries
title="Example one Stacked - Bytes"
alignedData={mockDataDuration}
uPlotOptions={mockOpts}
tooltip={
<div>This is an example stacked bar graph axis unit = Bytes.</div>
}
yAxisUnits={AxisUnits.Bytes}
/>
);
});
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: 1;
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,
yyAxisUnits: AxisUnits,
colourPalette = seriesPalette,
): Options => {
const options = getBarChartOpts(
userOptions,
xAxisDomain,
yAxisDomain,
yyAxisUnits,
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,
yAxisUnits: AxisUnits,
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(yAxisUnits)],
};

const combinedOpts = merge(opts, providedOpts);

// Set y-axis label with units.
combinedOpts.axes[1].label += ` ${yAxisDomain.label}`;

return combinedOpts;
};
Loading