diff --git a/pkg/ui/workspaces/cluster-ui/BUILD.bazel b/pkg/ui/workspaces/cluster-ui/BUILD.bazel index 37808e6d810a..93230f70c465 100644 --- a/pkg/ui/workspaces/cluster-ui/BUILD.bazel +++ b/pkg/ui/workspaces/cluster-ui/BUILD.bazel @@ -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", @@ -147,6 +148,7 @@ ts_project( "@npm//redux-saga", "@npm//redux-saga-test-plan", "@npm//reselect", + "@npm//uplot", ], ) diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx new file mode 100644 index 000000000000..070a2027b8b5 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/barGraph.stories.tsx @@ -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 = { + axes: [{}, { label: "values" }], + series: [ + {}, + { + label: "bar 1", + }, + { + label: "bar 2", + }, + { + label: "bar 3", + }, + ], +}; + +storiesOf("BarGraphTimeSeries", module) + .add("with stacked multi-series", () => { + return ( + This is an example stacked bar graph axis unit = count. + } + 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 ( + This is an example bar graph with axis unit = percent. + } + yAxisUnits={AxisUnits.Percentage} + /> + ); + }); diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss new file mode 100644 index 000000000000..da0982dda519 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bargraph.module.scss @@ -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; + } + } +} diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts new file mode 100644 index 000000000000..dc9efd70d3fd --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/bars.ts @@ -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, + 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, + 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; +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/index.tsx b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/index.tsx new file mode 100644 index 000000000000..9f3e974e4bea --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/index.tsx @@ -0,0 +1,99 @@ +// 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, { useEffect, useRef } from "react"; +import classNames from "classnames/bind"; +import { getStackedBarOpts, stack } from "./bars"; +import uPlot, { AlignedData } from "uplot"; +import styles from "./bargraph.module.scss"; +import { Visualization } from "../visualization"; +import { + AxisUnits, + calculateXAxisDomainBarChart, + calculateYAxisDomain, +} from "../utils/domain"; +import { Options } from "uplot"; + +const cx = classNames.bind(styles); + +export type BarGraphTimeSeriesProps = { + alignedData?: AlignedData; + colourPalette?: string[]; // Series colour palette. + preCalcGraphSize?: boolean; + title: string; + tooltip?: React.ReactNode; + uPlotOptions: Partial; + yAxisUnits: AxisUnits; +}; + +// Currently this component only supports stacked multi-series bars. +export const BarGraphTimeSeries: React.FC = ({ + alignedData, + colourPalette, + preCalcGraphSize = true, + title, + tooltip, + uPlotOptions, + yAxisUnits, +}) => { + const graphRef = useRef(null); + const samplingIntervalMillis = alignedData[0].length + ? alignedData[0][1] - alignedData[0][0] + : 0; + + useEffect(() => { + if (!alignedData) return; + + const xAxisDomain = calculateXAxisDomainBarChart( + alignedData[0][0], // startMillis + alignedData[0][alignedData[0].length - 1], // endMillis + samplingIntervalMillis, + ); + + const stackedData = stack(alignedData, () => false); + + const allYDomainPoints: number[] = []; + stackedData.slice(1).forEach(points => allYDomainPoints.push(...points)); + const yAxisDomain = calculateYAxisDomain(yAxisUnits, allYDomainPoints); + + const opts = getStackedBarOpts( + alignedData, + uPlotOptions, + xAxisDomain, + yAxisDomain, + colourPalette, + ); + + const plot = new uPlot(opts, stackedData, graphRef.current); + + return () => { + plot?.destroy(); + }; + }, [ + alignedData, + colourPalette, + uPlotOptions, + yAxisUnits, + samplingIntervalMillis, + ]); + + return ( + +
+
+
+ + ); +}; diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/plugins.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/plugins.ts new file mode 100644 index 000000000000..72845e4c26b1 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/bargraph/plugins.ts @@ -0,0 +1,148 @@ +// 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 uPlot, { Plugin } from "uplot"; +import { formatTimeStamp } from "../utils/domain"; + +// Fallback color for series stroke if one is not defined. +const DEFAULT_STROKE = "#7e89a9"; + +// Generate a series legend within the provided div showing the data points +// relative to the cursor position. +const generateSeriesLegend = (uPlot: uPlot, seriesLegend: HTMLDivElement) => { + // idx is the closest data index to the cursor position. + const { idx } = uPlot.cursor; + + if (idx === undefined || idx === null) { + return; + } + + // remove all previous child nodes + seriesLegend.innerHTML = ""; + + // Generate new child nodes. + uPlot.series.forEach((series: uPlot.Series, index: number) => { + if (index === 0 || series.show === false) { + // Skip the series for x axis or if series is hidden. + return; + } + + // series.stroke can be either a function that returns a canvas stroke + // value, or a function returning a stroke value. + const strokeColor = + typeof series.stroke === "function" + ? series.stroke(uPlot, idx) + : series.stroke; + + const container = document.createElement("div"); + container.style.display = "flex"; + container.style.alignItems = "center"; + + const colorBox = document.createElement("span"); + colorBox.style.height = "12px"; + colorBox.style.width = "12px"; + colorBox.style.background = String(strokeColor || DEFAULT_STROKE); + colorBox.style.display = "inline-block"; + colorBox.style.marginRight = "12px"; + + const label = document.createElement("span"); + label.textContent = series.label || ""; + + const dataValue = uPlot.data[index][idx]; + const value = document.createElement("div"); + value.style.textAlign = "right"; + value.style.flex = "1"; + value.style.fontFamily = "'Source Sans Pro', sans-serif"; + value.textContent = + series.value instanceof Function && dataValue + ? String(series.value(uPlot, dataValue, index, idx)) + : String(dataValue); + + container.appendChild(colorBox); + container.appendChild(label); + container.appendChild(value); + + seriesLegend.appendChild(container); + }); +}; + +// Tooltip legend plugin for bar charts. +export function barTooltipPlugin(): Plugin { + const cursorToolTip = { + tooltip: document.createElement("div"), + timeStamp: document.createElement("div"), + seriesLegend: document.createElement("div"), + }; + + function setCursor(u: uPlot) { + const { tooltip, timeStamp, seriesLegend } = cursorToolTip; + const { left = 0, top = 0 } = u.cursor; + + // get the current timestamp from the x axis and formatting as + // the Tooltip header. + const closestDataPointTimeMillis = u.data[0][u.posToIdx(left)]; + timeStamp.textContent = formatTimeStamp(closestDataPointTimeMillis); + + // Generating the series legend based on current state of µPlot + generateSeriesLegend(u, seriesLegend); + + // set the position of the Tooltip. Adjusting the tooltip away from the + // cursor for readability. + tooltip.style.left = `${left + 20}px`; + tooltip.style.top = `${top - 10}px`; + + if (tooltip.style.display === "none") { + tooltip.style.display = ""; + } + } + + function ready(u: uPlot) { + const plot = u.root.querySelector(".u-over"); + const { tooltip } = cursorToolTip; + + plot?.addEventListener("mouseleave", () => { + tooltip.style.display = "none"; + }); + } + + function init(u: uPlot) { + const plot = u.root.querySelector(".u-over"); + const { tooltip, timeStamp, seriesLegend } = cursorToolTip; + tooltip.style.display = "none"; + tooltip.style.pointerEvents = "none"; + tooltip.style.position = "absolute"; + tooltip.style.padding = "0 16px 16px"; + tooltip.style.minWidth = "230px"; + tooltip.style.background = "#fff"; + tooltip.style.borderRadius = "5px"; + tooltip.style.boxShadow = "0px 7px 13px rgba(71, 88, 114, 0.3)"; + tooltip.style.zIndex = "100"; + tooltip.style.whiteSpace = "nowrap"; + + // Set timeStamp. + timeStamp.textContent = "time"; + timeStamp.style.paddingTop = "12px"; + timeStamp.style.marginBottom = "16px"; + tooltip.appendChild(timeStamp); + + // appending seriesLegend empty. Content will be generated on mousemove. + tooltip.appendChild(seriesLegend); + + plot?.appendChild(tooltip); + } + + return { + hooks: { + init, + ready, + setCursor, + }, + }; +} diff --git a/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts b/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts index 933b7e110c5f..963d09dc8069 100644 --- a/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts +++ b/pkg/ui/workspaces/cluster-ui/src/graphs/utils/domain.ts @@ -340,3 +340,16 @@ export function calculateXAxisDomain( ): AxisDomain { return ComputeTimeAxisDomain([startMillis, endMillis] as Extent); } + +export function calculateXAxisDomainBarChart( + startMillis: number, + endMillis: number, + samplingIntervalMillis: number, +): AxisDomain { + // For bar charts, we want to render past endMillis to fully render the + // last bar. We should extend the x axis to the next sampling interval. + return ComputeTimeAxisDomain([ + startMillis, + endMillis + samplingIntervalMillis, + ] as Extent); +}