github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/views/cluster/util/graphs.ts (about) 1 // Copyright 2018 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 import React from "react"; 12 import _ from "lodash"; 13 import * as nvd3 from "nvd3"; 14 import * as d3 from "d3"; 15 import moment from "moment"; 16 17 import * as protos from "src/js/protos"; 18 import { NanoToMilli } from "src/util/convert"; 19 import { DurationFitScale, BytesFitScale, ComputeByteScale, ComputeDurationScale} from "src/util/format"; 20 21 import { 22 MetricProps, AxisProps, AxisUnits, QueryTimeInfo, 23 } from "src/views/shared/components/metricQuery"; 24 25 type TSResponse = protos.cockroach.ts.tspb.TimeSeriesQueryResponse; 26 27 // Global set of colors for graph series. 28 const seriesPalette = [ 29 "#475872", "#FFCD02", "#F16969", "#4E9FD1", "#49D990", "#D77FBF", "#87326D", "#A3415B", 30 "#B59153", "#C9DB6D", "#203D9B", "#748BF2", "#91C8F2", "#FF9696", "#EF843C", "#DCCD4B", 31 ]; 32 33 // Chart margins to match design. 34 export const CHART_MARGINS: nvd3.Margin = {top: 30, right: 20, bottom: 20, left: 55}; 35 36 // Maximum number of series we will show in the legend. If there are more we hide the legend. 37 const MAX_LEGEND_SERIES: number = 4; 38 39 // The number of ticks to display on a Y axis. 40 const Y_AXIS_TICK_COUNT: number = 3; 41 42 // The number of ticks to display on an X axis. 43 const X_AXIS_TICK_COUNT: number = 10; 44 45 // A tuple of numbers for the minimum and maximum values of an axis. 46 type Extent = [number, number]; 47 48 /** 49 * AxisDomain is a class that describes the domain of a graph axis; this 50 * includes the minimum/maximum extend, tick values, and formatting information 51 * for axis values as displayed in various contexts. 52 */ 53 class AxisDomain { 54 // the values at the ends of the axis. 55 extent: Extent; 56 // numbers at which an intermediate tick should be displayed on the axis. 57 ticks: number[] = [0, 1]; 58 // label returns the label for the axis. 59 label: string = ""; 60 // tickFormat returns a function used to format the tick values for display. 61 tickFormat: (n: number) => string = _.identity; 62 // guideFormat returns a function used to format the axis values in the 63 // chart's interactive guideline. 64 guideFormat: (n: number) => string = _.identity; 65 66 // constructs a new AxisDomain with the given minimum and maximum value, with 67 // ticks placed at intervals of the given increment in between the min and 68 // max. Ticks are always "aligned" to values that are even multiples of 69 // increment. Min and max are also aligned by default - the aligned min will 70 // be <= the provided min, while the aligned max will be >= the provided max. 71 constructor(extent: Extent, increment: number, alignMinMax: boolean = true) { 72 const min = extent[0]; 73 const max = extent[1]; 74 if (alignMinMax) { 75 const alignedMin = min - min % increment; 76 let alignedMax = max; 77 if (max % increment !== 0) { 78 alignedMax = max - max % increment + increment; 79 } 80 this.extent = [alignedMin, alignedMax]; 81 } else { 82 this.extent = extent; 83 } 84 85 this.ticks = []; 86 for (let nextTick = min - min % increment + increment; 87 nextTick < this.extent[1]; 88 nextTick += increment) { 89 this.ticks.push(nextTick); 90 } 91 } 92 } 93 94 const countIncrementTable = [0.1, 0.2, 0.25, 0.3, 0.4, 0.5, 0.6, 0.7, 0.75, 0.8, 0.9, 1.0]; 95 96 // computeNormalizedIncrement computes a human-friendly increment between tick 97 // values on an axis with a range of the given size. The provided size is taken 98 // to be the minimum range needed to display all values present on the axis. 99 // The increment is computed by dividing this minimum range into the correct 100 // number of segments for the supplied tick count, and then increasing this 101 // increment to the nearest human-friendly increment. 102 // 103 // "Human-friendly" increments are taken from the supplied countIncrementTable, 104 // which should include decimal values between 0 and 1. 105 function computeNormalizedIncrement( 106 range: number, incrementTbl: number[] = countIncrementTable, 107 ) { 108 if (range === 0) { 109 throw new Error("cannot compute tick increment with zero range"); 110 } 111 112 let rawIncrement = range / (Y_AXIS_TICK_COUNT + 1); 113 // Compute X such that 0 <= rawIncrement/10^x <= 1 114 let x = 0; 115 while (rawIncrement > 1) { 116 x++; 117 rawIncrement = rawIncrement / 10; 118 } 119 const normalizedIncrementIdx = _.sortedIndex(incrementTbl, rawIncrement); 120 return incrementTbl[normalizedIncrementIdx] * Math.pow(10, x); 121 } 122 123 function computeAxisDomain(extent: Extent, factor: number = 1): AxisDomain { 124 const range = extent[1] - extent[0]; 125 126 // Compute increment on min/max after conversion to the appropriate prefix unit. 127 const increment = computeNormalizedIncrement(range / factor); 128 129 // Create axis domain by multiplying computed increment by prefix factor. 130 const axisDomain = new AxisDomain(extent, increment * factor); 131 132 // If the tick increment is fractional (e.g. 0.2), we display a decimal 133 // point. For non-fractional increments, we display with no decimal points 134 // but with a metric prefix for large numbers (i.e. 1000 will display as "1k") 135 let unitFormat: (v: number) => string; 136 if (Math.floor(increment) !== increment) { 137 unitFormat = d3.format(".1f"); 138 } else { 139 unitFormat = d3.format("s"); 140 } 141 axisDomain.tickFormat = (v: number) => unitFormat(v / factor); 142 143 return axisDomain; 144 } 145 146 function ComputeCountAxisDomain(extent: Extent): AxisDomain { 147 const axisDomain = computeAxisDomain(extent); 148 149 // For numbers larger than 1, the tooltip displays fractional values with 150 // metric multiplicative prefixes (e.g. kilo, mega, giga). For numbers smaller 151 // than 1, we simply display the fractional value without converting to a 152 // fractional metric prefix; this is because the use of fractional metric 153 // prefixes (i.e. milli, micro, nano) have proved confusing to users. 154 const metricFormat = d3.format(".4s"); 155 const decimalFormat = d3.format(".4f"); 156 axisDomain.guideFormat = (n: number) => { 157 if (n < 1) { 158 return decimalFormat(n); 159 } 160 return metricFormat(n); 161 }; 162 163 return axisDomain; 164 } 165 166 function ComputeByteAxisDomain(extent: Extent): AxisDomain { 167 // Compute an appropriate unit for the maximum value to be displayed. 168 const scale = ComputeByteScale(extent[1]); 169 const prefixFactor = scale.value; 170 171 const axisDomain = computeAxisDomain(extent, prefixFactor); 172 173 axisDomain.label = scale.units; 174 175 axisDomain.guideFormat = BytesFitScale(scale.units); 176 return axisDomain; 177 } 178 179 function ComputeDurationAxisDomain(extent: Extent): AxisDomain { 180 const scale = ComputeDurationScale(extent[1]); 181 const prefixFactor = scale.value; 182 183 const axisDomain = computeAxisDomain(extent, prefixFactor); 184 185 axisDomain.label = scale.units; 186 187 axisDomain.guideFormat = DurationFitScale(scale.units); 188 return axisDomain; 189 } 190 191 const percentIncrementTable = [0.25, 0.5, 0.75, 1.0]; 192 193 function ComputePercentageAxisDomain( 194 min: number, max: number, 195 ) { 196 const range = max - min; 197 const increment = computeNormalizedIncrement(range, percentIncrementTable); 198 const axisDomain = new AxisDomain([min, max], increment); 199 axisDomain.label = "percentage"; 200 axisDomain.tickFormat = d3.format(".0%"); 201 axisDomain.guideFormat = d3.format(".2%"); 202 return axisDomain; 203 } 204 205 const timeIncrementDurations = [ 206 moment.duration(1, "m"), 207 moment.duration(5, "m"), 208 moment.duration(10, "m"), 209 moment.duration(15, "m"), 210 moment.duration(30, "m"), 211 moment.duration(1, "h"), 212 moment.duration(2, "h"), 213 moment.duration(3, "h"), 214 moment.duration(6, "h"), 215 moment.duration(12, "h"), 216 moment.duration(24, "h"), 217 moment.duration(1, "week"), 218 ]; 219 const timeIncrements = _.map(timeIncrementDurations, (inc) => inc.asMilliseconds()); 220 221 function ComputeTimeAxisDomain(extent: Extent): AxisDomain { 222 // Compute increment; for time scales, this is taken from a table of allowed 223 // values. 224 let increment = 0; 225 { 226 const rawIncrement = (extent[1] - extent[0]) / (X_AXIS_TICK_COUNT + 1); 227 // Compute X such that 0 <= rawIncrement/10^x <= 1 228 const tbl = timeIncrements; 229 let normalizedIncrementIdx = _.sortedIndex(tbl, rawIncrement); 230 if (normalizedIncrementIdx === tbl.length) { 231 normalizedIncrementIdx--; 232 } 233 increment = tbl[normalizedIncrementIdx]; 234 } 235 236 // Do not normalize min/max for time axis. 237 const axisDomain = new AxisDomain(extent, increment, false); 238 239 axisDomain.label = "time"; 240 241 let tickDateFormatter: (d: Date) => string; 242 if (increment < moment.duration(24, "hours").asMilliseconds()) { 243 tickDateFormatter = d3.time.format.utc("%H:%M"); 244 } else { 245 tickDateFormatter = d3.time.format.utc("%m/%d %H:%M"); 246 } 247 axisDomain.tickFormat = (n: number) => { 248 return tickDateFormatter(new Date(n)); 249 }; 250 251 axisDomain.guideFormat = (num) => { 252 return moment(num).utc().format("HH:mm:ss [<span class=\"legend-subtext\">on</span>] MMM Do, YYYY"); 253 }; 254 return axisDomain; 255 } 256 257 function calculateYAxisDomain(axisUnits: AxisUnits, data: TSResponse): AxisDomain { 258 const resultDatapoints = _.flatMap(data.results, (result) => _.map(result.datapoints, (dp) => dp.value)); 259 // TODO(couchand): Remove these random datapoints when NVD3 is gone. 260 const allDatapoints = resultDatapoints.concat([0, 1]); 261 const yExtent = d3.extent(allDatapoints); 262 263 switch (axisUnits) { 264 case AxisUnits.Bytes: 265 return ComputeByteAxisDomain(yExtent); 266 case AxisUnits.Duration: 267 return ComputeDurationAxisDomain(yExtent); 268 case AxisUnits.Percentage: 269 return ComputePercentageAxisDomain(yExtent[0], yExtent[1]); 270 default: 271 return ComputeCountAxisDomain(yExtent); 272 } 273 } 274 275 function calculateXAxisDomain(timeInfo: QueryTimeInfo): AxisDomain { 276 const xExtent: Extent = [NanoToMilli(timeInfo.start.toNumber()), NanoToMilli(timeInfo.end.toNumber())]; 277 return ComputeTimeAxisDomain(xExtent); 278 } 279 280 type formattedSeries = { 281 values: protos.cockroach.ts.tspb.ITimeSeriesDatapoint[], 282 key: string, 283 area: boolean, 284 fillOpacity: number, 285 }; 286 287 function formatMetricData( 288 metrics: React.ReactElement<MetricProps>[], 289 data: TSResponse, 290 ): formattedSeries[] { 291 const formattedData: formattedSeries[] = []; 292 293 _.each(metrics, (s, idx) => { 294 const result = data.results[idx]; 295 if (result && !_.isEmpty(result.datapoints)) { 296 formattedData.push({ 297 values: result.datapoints, 298 key: s.props.title || s.props.name, 299 area: true, 300 fillOpacity: 0.1, 301 }); 302 } 303 }); 304 305 return formattedData; 306 } 307 308 function filterInvalidDatapoints( 309 formattedData: formattedSeries[], 310 timeInfo: QueryTimeInfo, 311 ): formattedSeries[] { 312 return _.map(formattedData, (datum) => { 313 // Drop any returned points at the beginning that have a lower timestamp 314 // than the explicitly queried domain. This works around a bug in NVD3 315 // which causes the interactive guideline to highlight the wrong points. 316 // https://github.com/novus/nvd3/issues/1913 317 const filteredValues = _.dropWhile(datum.values, (dp) => { 318 return dp.timestamp_nanos.toNumber() < timeInfo.start.toNumber(); 319 }); 320 321 return { 322 ...datum, 323 values: filteredValues, 324 }; 325 }); 326 } 327 328 export function InitLineChart(chart: nvd3.LineChart) { 329 chart 330 .x((d: protos.cockroach.ts.tspb.TimeSeriesDatapoint) => new Date(NanoToMilli(d && d.timestamp_nanos.toNumber()))) 331 .y((d: protos.cockroach.ts.tspb.TimeSeriesDatapoint) => d && d.value) 332 .useInteractiveGuideline(true) 333 .showLegend(true) 334 .showYAxis(true) 335 .color(seriesPalette) 336 .margin(CHART_MARGINS); 337 chart.xAxis 338 .showMaxMin(false); 339 chart.yAxis 340 .showMaxMin(true) 341 .axisLabelDistance(-10); 342 } 343 344 /** 345 * ConfigureLineChart renders the given NVD3 chart with the updated data. 346 */ 347 export function ConfigureLineChart( 348 chart: nvd3.LineChart, 349 svgEl: SVGElement, 350 metrics: React.ReactElement<MetricProps>[], 351 axis: React.ReactElement<AxisProps>, 352 data: TSResponse, 353 timeInfo: QueryTimeInfo, 354 ) { 355 chart.showLegend(metrics.length > 1 && metrics.length <= MAX_LEGEND_SERIES); 356 let formattedData: formattedSeries[]; 357 let xAxisDomain, yAxisDomain: AxisDomain; 358 359 if (data) { 360 const formattedRaw = formatMetricData(metrics, data); 361 formattedData = filterInvalidDatapoints(formattedRaw, timeInfo); 362 363 xAxisDomain = calculateXAxisDomain(timeInfo); 364 yAxisDomain = calculateYAxisDomain(axis.props.units, data); 365 366 chart.yDomain(yAxisDomain.extent); 367 if (axis.props.label && yAxisDomain.label) { 368 chart.yAxis.axisLabel(`${axis.props.label} (${yAxisDomain.label})`); 369 } else if (axis.props.label) { 370 chart.yAxis.axisLabel(axis.props.label); 371 } else { 372 chart.yAxis.axisLabel(yAxisDomain.label); 373 } 374 chart.xDomain(xAxisDomain.extent); 375 376 chart.yAxis.tickFormat(yAxisDomain.tickFormat); 377 chart.interactiveLayer.tooltip.valueFormatter(yAxisDomain.guideFormat); 378 chart.xAxis.tickFormat(xAxisDomain.tickFormat); 379 chart.interactiveLayer.tooltip.headerFormatter(xAxisDomain.guideFormat); 380 381 // always set the tick values to the lowest axis value, the highest axis 382 // value, and one value in between 383 chart.yAxis.tickValues(yAxisDomain.ticks); 384 chart.xAxis.tickValues(xAxisDomain.ticks); 385 } 386 try { 387 d3.select(svgEl) 388 .datum(formattedData) 389 .transition().duration(500) 390 .call(chart); 391 392 // Reduce radius of circles in the legend, if present. This is done through 393 // d3 because it is not exposed as an option by NVD3. 394 d3.select(svgEl).selectAll("circle").attr("r", 3); 395 } catch (e) { 396 console.log("Error rendering graph: ", e); 397 } 398 } 399 400 /** 401 * ConfigureLinkedGuide renders the linked guideline for a chart. 402 */ 403 export function ConfigureLinkedGuideline( 404 chart: nvd3.LineChart, 405 svgEl: SVGElement, 406 axis: React.ReactElement<AxisProps>, 407 data: TSResponse, 408 hoverTime: moment.Moment, 409 ) { 410 if (data) { 411 const xScale = chart.xAxis.scale(); 412 const yScale = chart.yAxis.scale(); 413 const yAxisDomain = calculateYAxisDomain(axis.props.units, data); 414 const yExtent: Extent = data ? [yScale(yAxisDomain.extent[0]), yScale(yAxisDomain.extent[1])] : [0, 1]; 415 updateLinkedGuideline(svgEl, xScale, yExtent, hoverTime); 416 } 417 } 418 419 // updateLinkedGuideline is responsible for maintaining "linked" guidelines on 420 // all other graphs on the page; a "linked" guideline highlights the same X-axis 421 // coordinate on different graphs currently visible on the same page. This 422 // allows the user to visually correlate a single X-axis coordinate across 423 // multiple visible graphs. 424 function updateLinkedGuideline(svgEl: SVGElement, x: d3.scale.Linear<number, number>, yExtent: Extent, hoverTime?: moment.Moment) { 425 // Construct a data array for use by d3; this allows us to use d3's 426 // "enter()/exit()" functions to cleanly add and remove the guideline. 427 const data = !_.isNil(hoverTime) ? [x(hoverTime.valueOf())] : []; 428 429 // Linked guideline will be inserted inside of the "nv-wrap" element of the 430 // nvd3 graph. This element has several translations applied to it by nvd3 431 // which allow us to easily display the linked guideline at the correct 432 // position. 433 const wrapper = d3.select(svgEl).select(".nv-wrap"); 434 if (wrapper.empty()) { 435 // In cases where no data is available for a chart, it will not have 436 // an "nv-wrap" element and thus should not get a linked guideline. 437 return; 438 } 439 440 const container = wrapper.selectAll("g.linked-guideline__container") 441 .data(data); 442 443 // If there is no guideline on the currently hovered graph, data is empty 444 // and this exit statement will remove the linked guideline from this graph 445 // if it is already present. This occurs, for example, when the user moves 446 // the mouse off of a graph. 447 container.exit().remove(); 448 449 // If there is a guideline on the currently hovered graph, this enter 450 // statement will add a linked guideline element to the current graph (if it 451 // does not already exist). 452 container.enter() 453 .append("g") 454 .attr("class", "linked-guideline__container") 455 .append("line") 456 .attr("class", "linked-guideline__line"); 457 458 // Update linked guideline (if present) to match the necessary attributes of 459 // the current guideline. 460 container.select(".linked-guideline__line") 461 .attr("x1", (d) => d) 462 .attr("x2", (d) => d) 463 .attr("y1", () => yExtent[0]) 464 .attr("y2", () => yExtent[1]); 465 }