| /* eslint-disable react/sort-prop-types */ |
| /** |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| import { kebabCase, throttle } from 'lodash'; |
| import d3 from 'd3'; |
| import nv from 'nvd3-fork'; |
| import { parse as mathjsParse } from 'mathjs'; |
| import moment from 'moment'; |
| import PropTypes from 'prop-types'; |
| import { |
| t, |
| isDefined, |
| getTimeFormatter, |
| smartDateVerboseFormatter, |
| getNumberFormatter, |
| NumberFormats, |
| CategoricalColorNamespace, |
| } from '@superset-ui/core'; |
| |
| import 'nvd3-fork/build/nv.d3.css'; |
| |
| /* eslint-disable-next-line */ |
| import ANNOTATION_TYPES, { applyNativeColumns } from './vendor/superset/AnnotationTypes'; |
| import isTruthy from './utils/isTruthy'; |
| import { |
| cleanColorInput, |
| computeBarChartWidth, |
| computeYDomain, |
| computeStackedYDomain, |
| drawBarValues, |
| generateBubbleTooltipContent, |
| generateCompareTooltipContent, |
| generateMultiLineTooltipContent, |
| generateRichLineTooltipContent, |
| generateTimePivotTooltip, |
| generateTooltipClassName, |
| generateAreaChartTooltipContent, |
| getMaxLabelSize, |
| getTimeOrNumberFormatter, |
| hideTooltips, |
| tipFactory, |
| tryNumify, |
| removeTooltip, |
| setAxisShowMaxMin, |
| stringifyTimeRange, |
| wrapTooltip, |
| } from './utils'; |
| import { |
| annotationLayerType, |
| boxPlotValueType, |
| bulletDataType, |
| categoryAndValueXYType, |
| rgbObjectType, |
| numericXYType, |
| numberOrAutoType, |
| stringOrObjectWithLabelType, |
| } from './PropTypes'; |
| |
| const NO_DATA_RENDER_DATA = [ |
| { text: 'No data', dy: '-.75em', class: 'header' }, |
| { text: 'Adjust filters or check the Datasource.', dy: '.75em', class: 'body' }, |
| ]; |
| |
| // Override the noData render function to make a prettier UX |
| // Code adapted from https://github.com/novus/nvd3/blob/master/src/utils.js#L653 |
| nv.utils.noData = function noData(chart, container) { |
| const opt = chart.options(); |
| const margin = opt.margin(); |
| const height = nv.utils.availableHeight(null, container, margin); |
| const width = nv.utils.availableWidth(null, container, margin); |
| const x = margin.left + width / 2; |
| const y = margin.top + height / 2; |
| |
| // Remove any previously created chart components |
| container.selectAll('g').remove(); |
| |
| const noDataText = container.selectAll('.nv-noData').data(NO_DATA_RENDER_DATA); |
| |
| noDataText |
| .enter() |
| .append('text') |
| .attr('class', d => `nvd3 nv-noData ${d.class}`) |
| .attr('dy', d => d.dy) |
| .style('text-anchor', 'middle'); |
| |
| noDataText |
| .attr('x', x) |
| .attr('y', y) |
| .text(d => d.text); |
| }; |
| |
| const { getColor, getScale } = CategoricalColorNamespace; |
| |
| // Limit on how large axes margins can grow as the chart window is resized |
| const MAX_MARGIN_PAD = 30; |
| const MIN_HEIGHT_FOR_BRUSH = 480; |
| const MAX_NO_CHARACTERS_IN_LABEL = 40; |
| |
| const BREAKPOINTS = { |
| small: 340, |
| }; |
| |
| const TIMESERIES_VIZ_TYPES = [ |
| 'line', |
| 'dual_line', |
| 'line_multi', |
| 'area', |
| 'compare', |
| 'bar', |
| 'time_pivot', |
| ]; |
| |
| const propTypes = { |
| data: PropTypes.oneOfType([ |
| PropTypes.arrayOf( |
| PropTypes.oneOfType([ |
| // pie |
| categoryAndValueXYType, |
| // dist-bar |
| PropTypes.shape({ |
| key: PropTypes.string, |
| values: PropTypes.arrayOf(categoryAndValueXYType), |
| }), |
| // area, line, compare, bar |
| PropTypes.shape({ |
| key: PropTypes.arrayOf(PropTypes.string), |
| values: PropTypes.arrayOf(numericXYType), |
| }), |
| // dual-line |
| PropTypes.shape({ |
| classed: PropTypes.string, |
| key: PropTypes.string, |
| type: PropTypes.string, |
| values: PropTypes.arrayOf(numericXYType), |
| yAxis: PropTypes.number, |
| }), |
| // box-plot |
| PropTypes.shape({ |
| label: PropTypes.string, |
| values: PropTypes.arrayOf(boxPlotValueType), |
| }), |
| // bubble |
| PropTypes.shape({ |
| key: PropTypes.string, |
| values: PropTypes.arrayOf(PropTypes.object), |
| }), |
| ]), |
| ), |
| // bullet |
| bulletDataType, |
| ]), |
| width: PropTypes.number, |
| height: PropTypes.number, |
| annotationData: PropTypes.object, |
| annotationLayers: PropTypes.arrayOf(annotationLayerType), |
| bottomMargin: numberOrAutoType, |
| colorScheme: PropTypes.string, |
| comparisonType: PropTypes.string, |
| contribution: PropTypes.bool, |
| leftMargin: numberOrAutoType, |
| onError: PropTypes.func, |
| showLegend: PropTypes.bool, |
| showMarkers: PropTypes.bool, |
| useRichTooltip: PropTypes.bool, |
| vizType: PropTypes.oneOf([ |
| 'area', |
| 'bar', |
| 'box_plot', |
| 'bubble', |
| 'bullet', |
| 'compare', |
| 'column', |
| 'dist_bar', |
| 'line', |
| 'line_multi', |
| 'time_pivot', |
| 'pie', |
| 'dual_line', |
| ]), |
| xAxisFormat: PropTypes.string, |
| numberFormat: PropTypes.string, |
| xAxisLabel: PropTypes.string, |
| xAxisShowMinMax: PropTypes.bool, |
| xIsLogScale: PropTypes.bool, |
| xTicksLayout: PropTypes.oneOf(['auto', 'staggered', '45°']), |
| yAxisFormat: PropTypes.string, |
| yAxisBounds: PropTypes.arrayOf(PropTypes.number), |
| yAxisLabel: PropTypes.string, |
| yAxisShowMinMax: PropTypes.bool, |
| yIsLogScale: PropTypes.bool, |
| // 'dist-bar' only |
| orderBars: PropTypes.bool, |
| // 'bar' or 'dist-bar' |
| isBarStacked: PropTypes.bool, |
| showBarValue: PropTypes.bool, |
| // 'bar', 'dist-bar' or 'column' |
| reduceXTicks: PropTypes.bool, |
| // 'bar', 'dist-bar' or 'area' |
| showControls: PropTypes.bool, |
| // 'line' only |
| showBrush: PropTypes.oneOf([true, 'yes', false, 'no', 'auto']), |
| onBrushEnd: PropTypes.func, |
| // 'line-multi' or 'dual-line' |
| yAxis2Format: PropTypes.string, |
| // 'line', 'time-pivot', 'dual-line' or 'line-multi' |
| lineInterpolation: PropTypes.string, |
| // 'pie' only |
| isDonut: PropTypes.bool, |
| isPieLabelOutside: PropTypes.bool, |
| pieLabelType: PropTypes.oneOf([ |
| 'key', |
| 'value', |
| 'percent', |
| 'key_value', |
| 'key_percent', |
| 'key_value_percent', |
| ]), |
| showLabels: PropTypes.bool, |
| // 'area' only |
| areaStackedStyle: PropTypes.string, |
| // 'bubble' only |
| entity: PropTypes.string, |
| maxBubbleSize: PropTypes.number, |
| xField: stringOrObjectWithLabelType, |
| yField: stringOrObjectWithLabelType, |
| sizeField: stringOrObjectWithLabelType, |
| // time-pivot only |
| baseColor: rgbObjectType, |
| }; |
| |
| const NOOP = () => {}; |
| const formatter = getNumberFormatter(); |
| |
| function nvd3Vis(element, props) { |
| const { |
| data, |
| width: maxWidth, |
| height: maxHeight, |
| annotationData, |
| annotationLayers = [], |
| areaStackedStyle, |
| baseColor, |
| bottomMargin, |
| colorScheme, |
| comparisonType, |
| contribution, |
| entity, |
| isBarStacked, |
| isDonut, |
| isPieLabelOutside, |
| leftMargin, |
| lineInterpolation = 'linear', |
| markerLabels, |
| markerLines, |
| markerLineLabels, |
| markers, |
| maxBubbleSize, |
| onBrushEnd = NOOP, |
| onError = NOOP, |
| orderBars, |
| pieLabelType, |
| rangeLabels, |
| ranges, |
| reduceXTicks = false, |
| showBarValue, |
| showBrush, |
| showControls, |
| showLabels, |
| showLegend, |
| showMarkers, |
| sizeField, |
| useRichTooltip, |
| vizType, |
| xAxisFormat, |
| numberFormat, |
| xAxisLabel, |
| xAxisShowMinMax = false, |
| xField, |
| xIsLogScale, |
| xTicksLayout, |
| yAxisFormat, |
| yAxis2Format, |
| yAxisBounds, |
| yAxis2Bounds, |
| yAxisLabel, |
| yAxisShowMinMax = false, |
| yAxis2ShowMinMax = false, |
| yField, |
| yIsLogScale, |
| } = props; |
| |
| const isExplore = document.querySelector('#explorer-container') !== null; |
| const container = element; |
| container.innerHTML = ''; |
| const activeAnnotationLayers = annotationLayers.filter(layer => layer.show); |
| const chartId = |
| container.parentElement && container.parentElement.id !== '' |
| ? container.parentElement.id |
| : null; |
| |
| let chart; |
| let width = maxWidth; |
| let colorKey = 'key'; |
| |
| container.style.width = `${maxWidth}px`; |
| container.style.height = `${maxHeight}px`; |
| |
| function isVizTypes(types) { |
| return types.includes(vizType); |
| } |
| |
| const drawGraph = function drawGraph() { |
| const d3Element = d3.select(element); |
| d3Element.classed('superset-legacy-chart-nvd3', true); |
| d3Element.classed(`superset-legacy-chart-nvd3-${kebabCase(vizType)}`, true); |
| let svg = d3Element.select('svg'); |
| if (svg.empty()) { |
| svg = d3Element.append('svg'); |
| } |
| const height = vizType === 'bullet' ? Math.min(maxHeight, 50) : maxHeight; |
| const isTimeSeries = isVizTypes(TIMESERIES_VIZ_TYPES); |
| |
| // Handling xAxis ticks settings |
| const staggerLabels = xTicksLayout === 'staggered'; |
| const xLabelRotation = |
| (xTicksLayout === 'auto' && isVizTypes(['column', 'dist_bar'])) || xTicksLayout === '45°' |
| ? 45 |
| : 0; |
| if (xLabelRotation === 45 && isTruthy(showBrush)) { |
| onError(t('You cannot use 45° tick layout along with the time range filter')); |
| |
| return null; |
| } |
| |
| const canShowBrush = |
| isTruthy(showBrush) || |
| (showBrush === 'auto' && maxHeight >= MIN_HEIGHT_FOR_BRUSH && xTicksLayout !== '45°'); |
| const numberFormatter = getNumberFormatter(numberFormat); |
| |
| switch (vizType) { |
| case 'line': |
| if (canShowBrush) { |
| chart = nv.models.lineWithFocusChart(); |
| if (staggerLabels) { |
| // Give a bit more room to focus area if X axis ticks are staggered |
| chart.focus.margin({ bottom: 40 }); |
| chart.focusHeight(80); |
| } |
| chart.focus.xScale(d3.time.scale.utc()); |
| } else { |
| chart = nv.models.lineChart(); |
| } |
| chart.xScale(d3.time.scale.utc()); |
| chart.interpolate(lineInterpolation); |
| chart.clipEdge(false); |
| break; |
| |
| case 'time_pivot': |
| chart = nv.models.lineChart(); |
| chart.xScale(d3.time.scale.utc()); |
| chart.interpolate(lineInterpolation); |
| break; |
| |
| case 'dual_line': |
| case 'line_multi': |
| chart = nv.models.multiChart(); |
| chart.interpolate(lineInterpolation); |
| chart.xScale(d3.time.scale.utc()); |
| break; |
| |
| case 'bar': |
| chart = nv.models.multiBarChart().showControls(showControls).groupSpacing(0.1); |
| |
| if (!reduceXTicks) { |
| width = computeBarChartWidth(data, isBarStacked, maxWidth); |
| } |
| chart.width(width); |
| chart.xAxis.showMaxMin(false); |
| chart.stacked(isBarStacked); |
| break; |
| |
| case 'dist_bar': |
| chart = nv.models |
| .multiBarChart() |
| .showControls(showControls) |
| .reduceXTicks(reduceXTicks) |
| .groupSpacing(0.1); // Distance between each group of bars. |
| |
| chart.xAxis.showMaxMin(false); |
| |
| chart.stacked(isBarStacked); |
| if (orderBars) { |
| data.forEach(d => { |
| d.values.sort((a, b) => (tryNumify(a.x) < tryNumify(b.x) ? -1 : 1)); |
| }); |
| } |
| if (!reduceXTicks) { |
| width = computeBarChartWidth(data, isBarStacked, maxWidth); |
| } |
| chart.width(width); |
| break; |
| |
| case 'pie': |
| chart = nv.models.pieChart(); |
| colorKey = 'x'; |
| chart.valueFormat(numberFormatter); |
| if (isDonut) { |
| chart.donut(true); |
| } |
| chart.showLabels(showLabels); |
| chart.labelsOutside(isPieLabelOutside); |
| // Configure the minimum slice size for labels to show up |
| chart.labelThreshold(0.05); |
| chart.cornerRadius(true); |
| |
| if (['key', 'value', 'percent'].includes(pieLabelType)) { |
| chart.labelType(pieLabelType); |
| } else if (pieLabelType === 'key_value') { |
| chart.labelType(d => `${d.data.x}: ${numberFormatter(d.data.y)}`); |
| } else { |
| // pieLabelType in ['key_percent', 'key_value_percent'] |
| const total = d3.sum(data, d => d.y); |
| const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT); |
| if (pieLabelType === 'key_percent') { |
| chart.tooltip.valueFormatter(d => percentFormatter(d)); |
| chart.labelType(d => `${d.data.x}: ${percentFormatter(d.data.y / total)}`); |
| } else { |
| // pieLabelType === 'key_value_percent' |
| chart.tooltip.valueFormatter( |
| d => `${numberFormatter(d)} (${percentFormatter(d / total)})`, |
| ); |
| chart.labelType( |
| d => |
| `${d.data.x}: ${numberFormatter(d.data.y)} (${percentFormatter(d.data.y / total)})`, |
| ); |
| } |
| } |
| // Pie chart does not need top margin |
| chart.margin({ top: 0 }); |
| break; |
| |
| case 'column': |
| chart = nv.models.multiBarChart().reduceXTicks(false); |
| break; |
| |
| case 'compare': |
| chart = nv.models.cumulativeLineChart(); |
| chart.xScale(d3.time.scale.utc()); |
| chart.useInteractiveGuideline(true); |
| chart.xAxis.showMaxMin(false); |
| break; |
| |
| case 'bubble': |
| chart = nv.models.scatterChart(); |
| chart.showDistX(false); |
| chart.showDistY(false); |
| chart.tooltip.contentGenerator(d => |
| generateBubbleTooltipContent({ |
| point: d.point, |
| entity, |
| xField, |
| yField, |
| sizeField, |
| xFormatter: getTimeOrNumberFormatter(xAxisFormat), |
| yFormatter: getTimeOrNumberFormatter(yAxisFormat), |
| sizeFormatter: formatter, |
| }), |
| ); |
| chart.pointRange([5, maxBubbleSize ** 2]); |
| chart.pointDomain([0, d3.max(data, d => d3.max(d.values, v => v.size))]); |
| break; |
| |
| case 'area': |
| chart = nv.models.stackedAreaChart(); |
| chart.showControls(showControls); |
| chart.style(areaStackedStyle); |
| chart.xScale(d3.time.scale.utc()); |
| break; |
| |
| case 'box_plot': |
| colorKey = 'label'; |
| chart = nv.models.boxPlotChart(); |
| chart.x(d => d.label); |
| chart.maxBoxWidth(75); // prevent boxes from being incredibly wide |
| break; |
| |
| case 'bullet': |
| chart = nv.models.bulletChart(); |
| data.rangeLabels = rangeLabels; |
| data.ranges = ranges; |
| data.markerLabels = markerLabels; |
| data.markerLines = markerLines; |
| data.markerLineLabels = markerLineLabels; |
| data.markers = markers; |
| break; |
| |
| default: |
| throw new Error(`Unrecognized visualization for nvd3${vizType}`); |
| } |
| // Assuming the container has padding already other than for top margin |
| chart.margin({ left: 0, bottom: 0 }); |
| |
| if (showBarValue) { |
| drawBarValues(svg, data, isBarStacked, yAxisFormat); |
| chart.dispatch.on('stateChange.drawBarValues', () => { |
| drawBarValues(svg, data, isBarStacked, yAxisFormat); |
| }); |
| } |
| |
| if (canShowBrush && onBrushEnd !== NOOP) { |
| if (chart.focus) { |
| chart.focus.dispatch.on('brush', event => { |
| const timeRange = stringifyTimeRange(event.extent); |
| if (timeRange) { |
| event.brush.on('brushend', () => { |
| onBrushEnd(timeRange); |
| }); |
| } |
| }); |
| } |
| } |
| |
| if (chart.xAxis && chart.xAxis.staggerLabels) { |
| chart.xAxis.staggerLabels(staggerLabels); |
| } |
| if (chart.xAxis && chart.xAxis.rotateLabels) { |
| chart.xAxis.rotateLabels(xLabelRotation); |
| } |
| if (chart.x2Axis && chart.x2Axis.staggerLabels) { |
| chart.x2Axis.staggerLabels(staggerLabels); |
| } |
| if (chart.x2Axis && chart.x2Axis.rotateLabels) { |
| chart.x2Axis.rotateLabels(xLabelRotation); |
| } |
| |
| if ('showLegend' in chart && typeof showLegend !== 'undefined') { |
| if (width < BREAKPOINTS.small && vizType !== 'pie') { |
| chart.showLegend(false); |
| } else { |
| chart.showLegend(showLegend); |
| } |
| } |
| |
| if (yIsLogScale) { |
| chart.yScale(d3.scale.log()); |
| } |
| if (xIsLogScale) { |
| chart.xScale(d3.scale.log()); |
| } |
| |
| let xAxisFormatter; |
| if (isTimeSeries) { |
| xAxisFormatter = getTimeFormatter(xAxisFormat); |
| // In tooltips, always use the verbose time format |
| chart.interactiveLayer.tooltip.headerFormatter(smartDateVerboseFormatter); |
| } else { |
| xAxisFormatter = getTimeOrNumberFormatter(xAxisFormat); |
| } |
| if (chart.x2Axis && chart.x2Axis.tickFormat) { |
| chart.x2Axis.tickFormat(xAxisFormatter); |
| } |
| if (chart.xAxis && chart.xAxis.tickFormat) { |
| const isXAxisString = isVizTypes(['dist_bar', 'box_plot']); |
| if (isXAxisString) { |
| chart.xAxis.tickFormat(d => |
| d.length > MAX_NO_CHARACTERS_IN_LABEL |
| ? `${d.slice(0, Math.max(0, MAX_NO_CHARACTERS_IN_LABEL))}…` |
| : d, |
| ); |
| } else { |
| chart.xAxis.tickFormat(xAxisFormatter); |
| } |
| } |
| |
| let yAxisFormatter = getTimeOrNumberFormatter(yAxisFormat); |
| if (chart.yAxis && chart.yAxis.tickFormat) { |
| if ( |
| (contribution || comparisonType === 'percentage') && |
| (!yAxisFormat || |
| yAxisFormat === NumberFormats.SMART_NUMBER || |
| yAxisFormat === NumberFormats.SMART_NUMBER_SIGNED) |
| ) { |
| // When computing a "Percentage" or "Contribution" selected, |
| // force a percentage format if no custom formatting set |
| yAxisFormatter = getNumberFormatter(NumberFormats.PERCENT_1_POINT); |
| } |
| chart.yAxis.tickFormat(yAxisFormatter); |
| } |
| if (chart.y2Axis && chart.y2Axis.tickFormat) { |
| chart.y2Axis.tickFormat(yAxisFormatter); |
| } |
| |
| if (chart.yAxis) { |
| chart.yAxis.ticks(5); |
| } |
| if (chart.y2Axis) { |
| chart.y2Axis.ticks(5); |
| } |
| |
| // Set showMaxMin for all axis |
| setAxisShowMaxMin(chart.xAxis, xAxisShowMinMax); |
| setAxisShowMaxMin(chart.x2Axis, xAxisShowMinMax); |
| setAxisShowMaxMin(chart.yAxis, yAxisShowMinMax); |
| setAxisShowMaxMin(chart.y2Axis, yAxis2ShowMinMax || yAxisShowMinMax); |
| |
| if (vizType === 'time_pivot') { |
| if (baseColor) { |
| const { r, g, b } = baseColor; |
| chart.color(d => { |
| const alpha = d.rank > 0 ? d.perc * 0.5 : 1; |
| |
| return `rgba(${r}, ${g}, ${b}, ${alpha})`; |
| }); |
| } |
| |
| chart.useInteractiveGuideline(true); |
| chart.interactiveLayer.tooltip.contentGenerator(d => |
| generateTimePivotTooltip(d, xAxisFormatter, yAxisFormatter), |
| ); |
| } else if (vizType !== 'bullet') { |
| const colorFn = getScale(colorScheme); |
| chart.color(d => d.color || colorFn(cleanColorInput(d[colorKey]))); |
| } |
| |
| if (isVizTypes(['line', 'area']) && useRichTooltip) { |
| chart.useInteractiveGuideline(true); |
| if (vizType === 'line') { |
| chart.interactiveLayer.tooltip.contentGenerator(d => |
| generateRichLineTooltipContent(d, smartDateVerboseFormatter, yAxisFormatter), |
| ); |
| } else { |
| // area chart |
| chart.interactiveLayer.tooltip.contentGenerator(d => |
| generateAreaChartTooltipContent(d, smartDateVerboseFormatter, yAxisFormatter, chart), |
| ); |
| } |
| } |
| |
| if (isVizTypes(['compare'])) { |
| chart.interactiveLayer.tooltip.contentGenerator(d => |
| generateCompareTooltipContent(d, yAxisFormatter), |
| ); |
| } |
| |
| if (isVizTypes(['dual_line', 'line_multi'])) { |
| const yAxisFormatter1 = getNumberFormatter(yAxisFormat); |
| const yAxisFormatter2 = getNumberFormatter(yAxis2Format); |
| chart.yAxis1.tickFormat(yAxisFormatter1); |
| chart.yAxis2.tickFormat(yAxisFormatter2); |
| const yAxisFormatters = data.map(datum => |
| datum.yAxis === 1 ? yAxisFormatter1 : yAxisFormatter2, |
| ); |
| chart.useInteractiveGuideline(true); |
| chart.interactiveLayer.tooltip.contentGenerator(d => |
| generateMultiLineTooltipContent(d, xAxisFormatter, yAxisFormatters), |
| ); |
| } |
| // This is needed for correct chart dimensions if a chart is rendered in a hidden container |
| chart.width(width); |
| chart.height(height); |
| |
| svg |
| .datum(data) |
| .transition() |
| .duration(500) |
| .attr('height', height) |
| .attr('width', width) |
| .call(chart); |
| |
| // For log scale, only show 1, 10, 100, 1000, ... |
| if (yIsLogScale) { |
| chart.yAxis.tickFormat(d => (d !== 0 && Math.log10(d) % 1 === 0 ? yAxisFormatter(d) : '')); |
| } |
| |
| if (xLabelRotation > 0) { |
| // shift labels to the left so they look better |
| const xTicks = svg.select('.nv-x.nv-axis > g').selectAll('g'); |
| xTicks.selectAll('text').attr('dx', -6.5); |
| } |
| |
| const applyYAxisBounds = () => { |
| if (chart.yDomain && Array.isArray(yAxisBounds) && yAxisBounds.length === 2) { |
| const [customMin, customMax] = yAxisBounds; |
| const hasCustomMin = isDefined(customMin) && !Number.isNaN(customMin); |
| const hasCustomMax = isDefined(customMax) && !Number.isNaN(customMax); |
| |
| if ((hasCustomMin || hasCustomMax) && vizType === 'area' && chart.style() === 'expand') { |
| // Because there are custom bounds, we need to override them back to 0%-100% since this |
| // is an expanded area chart |
| chart.yDomain([0, 1]); |
| } else if ( |
| (hasCustomMin || hasCustomMax) && |
| vizType === 'area' && |
| chart.style() === 'stream' |
| ) { |
| // Because there are custom bounds, we need to override them back to the domain of the |
| // data since this is a stream area chart |
| chart.yDomain(computeStackedYDomain(data)); |
| } else if (hasCustomMin && hasCustomMax) { |
| // Override the y domain if there's both a custom min and max |
| chart.yDomain([customMin, customMax]); |
| chart.clipEdge(true); |
| } else if (hasCustomMin || hasCustomMax) { |
| // Only one of the bounds has been set, so we need to manually calculate the other one |
| let [trueMin, trueMax] = [0, 1]; |
| |
| // These viz types can be stacked |
| // They correspond to the nvd3 stackedAreaChart and multiBarChart |
| if (vizType === 'area' || (isVizTypes(['bar', 'dist_bar']) && chart.stacked())) { |
| // This is a stacked area chart or a stacked bar chart |
| [trueMin, trueMax] = computeStackedYDomain(data); |
| } else { |
| [trueMin, trueMax] = computeYDomain(data); |
| } |
| |
| const min = hasCustomMin ? customMin : trueMin; |
| const max = hasCustomMax ? customMax : trueMax; |
| chart.yDomain([min, max]); |
| chart.clipEdge(true); |
| } |
| } |
| }; |
| applyYAxisBounds(); |
| |
| // Also reapply on each state change to account for enabled/disabled series |
| if (chart.dispatch && chart.dispatch.stateChange) { |
| chart.dispatch.on('stateChange.applyYAxisBounds', applyYAxisBounds); |
| } |
| |
| // align yAxis1 and yAxis2 ticks |
| if (isVizTypes(['dual_line', 'line_multi'])) { |
| const count = chart.yAxis1.ticks(); |
| const ticks1 = chart.yAxis1.scale().domain(chart.yAxis1.domain()).nice(count).ticks(count); |
| const ticks2 = chart.yAxis2.scale().domain(chart.yAxis2.domain()).nice(count).ticks(count); |
| |
| // match number of ticks in both axes |
| const difference = ticks1.length - ticks2.length; |
| if (ticks1.length > 0 && ticks2.length > 0 && difference !== 0) { |
| const smallest = difference < 0 ? ticks1 : ticks2; |
| const delta = smallest[1] - smallest[0]; |
| for (let i = 0; i < Math.abs(difference); i += 1) { |
| if (i % 2 === 0) { |
| smallest.unshift(smallest[0] - delta); |
| } else { |
| smallest.push(smallest[smallest.length - 1] + delta); |
| } |
| } |
| chart.yDomain1([ticks1[0], ticks1[ticks1.length - 1]]); |
| chart.yDomain2([ticks2[0], ticks2[ticks2.length - 1]]); |
| chart.yAxis1.tickValues(ticks1); |
| chart.yAxis2.tickValues(ticks2); |
| } |
| |
| chart.yDomain1([yAxisBounds[0] || ticks1[0], yAxisBounds[1] || ticks1[ticks1.length - 1]]); |
| chart.yDomain2([yAxis2Bounds[0] || ticks2[0], yAxis2Bounds[1] || ticks2[ticks2.length - 1]]); |
| } |
| |
| if (showMarkers) { |
| svg.selectAll('.nv-point').style('stroke-opacity', 1).style('fill-opacity', 1); |
| |
| // redo on legend toggle; nvd3 calls the callback *before* the line is |
| // drawn, so we need to add a small delay here |
| chart.dispatch.on('stateChange.showMarkers', () => { |
| setTimeout(() => { |
| svg.selectAll('.nv-point').style('stroke-opacity', 1).style('fill-opacity', 1); |
| }, 10); |
| }); |
| } |
| |
| if (chart.yAxis !== undefined || chart.yAxis2 !== undefined) { |
| // Hack to adjust y axis left margin to accommodate long numbers |
| const marginPad = Math.ceil(Math.min(maxWidth * (isExplore ? 0.01 : 0.03), MAX_MARGIN_PAD)); |
| // Hack to adjust margins to accommodate long axis tick labels. |
| // - has to be done only after the chart has been rendered once |
| // - measure the width or height of the labels |
| // ---- (x axis labels are rotated 45 degrees so we use height), |
| // - adjust margins based on these measures and render again |
| const margins = chart.margin(); |
| if (chart.xAxis) { |
| margins.bottom = 28; |
| } |
| const maxYAxisLabelWidth = getMaxLabelSize(svg, chart.yAxis2 ? 'nv-y1' : 'nv-y'); |
| const maxXAxisLabelHeight = getMaxLabelSize(svg, 'nv-x'); |
| margins.left = maxYAxisLabelWidth + marginPad; |
| |
| if (yAxisLabel && yAxisLabel !== '') { |
| margins.left += 25; |
| } |
| if (showBarValue) { |
| // Add more margin to avoid label colliding with legend. |
| margins.top += 24; |
| } |
| if (xAxisShowMinMax) { |
| // If x bounds are shown, we need a right margin |
| margins.right = Math.max(20, maxXAxisLabelHeight / 2) + marginPad; |
| } |
| if (xLabelRotation === 45) { |
| margins.bottom = |
| maxXAxisLabelHeight * Math.sin((Math.PI * xLabelRotation) / 180) + marginPad + 30; |
| margins.right = |
| maxXAxisLabelHeight * Math.cos((Math.PI * xLabelRotation) / 180) + marginPad; |
| } else if (staggerLabels) { |
| margins.bottom = 40; |
| } |
| |
| if (isVizTypes(['dual_line', 'line_multi'])) { |
| const maxYAxis2LabelWidth = getMaxLabelSize(svg, 'nv-y2'); |
| margins.right = maxYAxis2LabelWidth + marginPad; |
| } |
| if (bottomMargin && bottomMargin !== 'auto') { |
| margins.bottom = parseInt(bottomMargin, 10); |
| } |
| if (leftMargin && leftMargin !== 'auto') { |
| margins.left = leftMargin; |
| } |
| |
| if (xAxisLabel && xAxisLabel !== '' && chart.xAxis) { |
| margins.bottom += 25; |
| let distance = 0; |
| if (margins.bottom && !Number.isNaN(margins.bottom)) { |
| distance = margins.bottom - 45; |
| } |
| // nvd3 bug axisLabelDistance is disregarded on xAxis |
| // https://github.com/krispo/angular-nvd3/issues/90 |
| chart.xAxis.axisLabel(xAxisLabel).axisLabelDistance(distance); |
| } |
| |
| if (yAxisLabel && yAxisLabel !== '' && chart.yAxis) { |
| let distance = 0; |
| if (margins.left && !Number.isNaN(margins.left)) { |
| distance = margins.left - 70; |
| } |
| chart.yAxis.axisLabel(yAxisLabel).axisLabelDistance(distance); |
| } |
| if (isTimeSeries && annotationData && activeAnnotationLayers.length > 0) { |
| // Time series annotations add additional data |
| const timeSeriesAnnotations = activeAnnotationLayers |
| .filter(layer => layer.annotationType === ANNOTATION_TYPES.TIME_SERIES) |
| .reduce( |
| (bushel, a) => |
| bushel.concat( |
| (annotationData[a.name] || []).map(series => { |
| if (!series) { |
| return {}; |
| } |
| const key = Array.isArray(series.key) |
| ? `${a.name}, ${series.key.join(', ')}` |
| : `${a.name}, ${series.key}`; |
| |
| return { |
| ...series, |
| key, |
| color: a.color, |
| strokeWidth: a.width, |
| classed: `${a.opacity} ${a.style} nv-timeseries-annotation-layer showMarkers${a.showMarkers} hideLine${a.hideLine}`, |
| }; |
| }), |
| ), |
| [], |
| ); |
| data.push(...timeSeriesAnnotations); |
| } |
| |
| // Uniquely identify tooltips based on chartId so this chart instance only |
| // controls its own tooltips |
| if (chartId) { |
| if (chart && chart.interactiveLayer && chart.interactiveLayer.tooltip) { |
| chart.interactiveLayer.tooltip.classes([generateTooltipClassName(chartId)]); |
| } |
| |
| if (chart && chart.tooltip) { |
| chart.tooltip.classes([generateTooltipClassName(chartId)]); |
| } |
| } |
| |
| // render chart |
| chart.margin(margins); |
| svg |
| .datum(data) |
| .transition() |
| .duration(500) |
| .attr('width', width) |
| .attr('height', height) |
| .call(chart); |
| |
| // On scroll, hide (not remove) tooltips so they can reappear on hover. |
| // Throttle to only 4x/second. |
| window.addEventListener( |
| 'scroll', |
| throttle(() => hideTooltips(false), 250), |
| ); |
| |
| // The below code should be run AFTER rendering because chart is updated in call() |
| if (isTimeSeries && activeAnnotationLayers.length > 0) { |
| // Formula annotations |
| const formulas = activeAnnotationLayers |
| .filter(a => a.annotationType === ANNOTATION_TYPES.FORMULA) |
| .map(a => ({ ...a, formula: mathjsParse(a.value) })); |
| |
| let xMax; |
| let xMin; |
| let xScale; |
| if (vizType === 'bar') { |
| xMin = d3.min(data[0].values, d => d.x); |
| xMax = d3.max(data[0].values, d => d.x); |
| xScale = d3.scale.quantile().domain([xMin, xMax]).range(chart.xAxis.range()); |
| } else { |
| xMin = chart.xAxis.scale().domain()[0].valueOf(); |
| xMax = chart.xAxis.scale().domain()[1].valueOf(); |
| if (chart.xScale) { |
| xScale = chart.xScale(); |
| } else if (chart.xAxis.scale) { |
| xScale = chart.xAxis.scale(); |
| } else { |
| xScale = d3.scale.linear(); |
| } |
| } |
| if (xScale && xScale.clamp) { |
| xScale.clamp(true); |
| } |
| |
| if (formulas.length > 0) { |
| const xValues = []; |
| if (vizType === 'bar') { |
| // For bar-charts we want one data point evaluated for every |
| // data point that will be displayed. |
| const distinct = data.reduce((xVals, d) => { |
| d.values.forEach(x => xVals.add(x.x)); |
| |
| return xVals; |
| }, new Set()); |
| xValues.push(...distinct.values()); |
| xValues.sort(); |
| } else { |
| // For every other time visualization it should be ok, to have a |
| // data points in even intervals. |
| let period = Math.min( |
| ...data.map(d => Math.min(...d.values.slice(1).map((v, i) => v.x - d.values[i].x))), |
| ); |
| const dataPoints = (xMax - xMin) / (period || 1); |
| // make sure that there are enough data points and not too many |
| period = dataPoints < 100 ? (xMax - xMin) / 100 : period; |
| period = dataPoints > 500 ? (xMax - xMin) / 500 : period; |
| xValues.push(xMin); |
| for (let x = xMin; x < xMax; x += period) { |
| xValues.push(x); |
| } |
| xValues.push(xMax); |
| } |
| const formulaData = formulas.map(fo => ({ |
| key: fo.name, |
| values: xValues.map(x => ({ y: fo.formula.evaluate({ x }), x })), |
| color: fo.color, |
| strokeWidth: fo.width, |
| classed: `${fo.opacity} ${fo.style}`, |
| })); |
| data.push(...formulaData); |
| } |
| const xAxis = chart.xAxis1 ? chart.xAxis1 : chart.xAxis; |
| const yAxis = chart.yAxis1 ? chart.yAxis1 : chart.yAxis; |
| const chartWidth = xAxis.scale().range()[1]; |
| const annotationHeight = yAxis.scale().range()[0]; |
| |
| if (annotationData) { |
| // Event annotations |
| activeAnnotationLayers |
| .filter( |
| x => |
| x.annotationType === ANNOTATION_TYPES.EVENT && |
| annotationData && |
| annotationData[x.name], |
| ) |
| .forEach((config, index) => { |
| const e = applyNativeColumns(config); |
| // Add event annotation layer |
| const annotations = d3 |
| .select(element) |
| .select('.nv-wrap') |
| .append('g') |
| .attr('class', `nv-event-annotation-layer-${index}`); |
| const aColor = e.color || getColor(cleanColorInput(e.name), colorScheme); |
| |
| const tip = tipFactory({ |
| ...e, |
| annotationTipClass: `arrow-down nv-event-annotation-layer-${config.sourceType}`, |
| }); |
| const records = (annotationData[e.name].records || []) |
| .map(r => { |
| const timeValue = new Date(moment.utc(r[e.timeColumn])); |
| |
| return { |
| ...r, |
| [e.timeColumn]: timeValue, |
| }; |
| }) |
| .filter(record => !Number.isNaN(record[e.timeColumn].getMilliseconds())); |
| |
| if (records.length > 0) { |
| annotations |
| .selectAll('line') |
| .data(records) |
| .enter() |
| .append('line') |
| .attr({ |
| x1: d => xScale(new Date(d[e.timeColumn])), |
| y1: 0, |
| x2: d => xScale(new Date(d[e.timeColumn])), |
| y2: annotationHeight, |
| }) |
| .attr('class', `${e.opacity} ${e.style}`) |
| .style('stroke', aColor) |
| .style('stroke-width', e.width) |
| .on('mouseover', tip.show) |
| .on('mouseout', tip.hide) |
| .call(tip); |
| } |
| |
| // update annotation positions on brush event |
| if (chart.focus) { |
| chart.focus.dispatch.on('onBrush.event-annotation', () => { |
| annotations |
| .selectAll('line') |
| .data(records) |
| .attr({ |
| x1: d => xScale(new Date(d[e.timeColumn])), |
| y1: 0, |
| x2: d => xScale(new Date(d[e.timeColumn])), |
| y2: annotationHeight, |
| opacity: d => { |
| const x = xScale(new Date(d[e.timeColumn])); |
| |
| return x > 0 && x < chartWidth ? 1 : 0; |
| }, |
| }); |
| }); |
| } |
| }); |
| |
| // Interval annotations |
| activeAnnotationLayers |
| .filter( |
| x => |
| x.annotationType === ANNOTATION_TYPES.INTERVAL && |
| annotationData && |
| annotationData[x.name], |
| ) |
| .forEach((config, index) => { |
| const e = applyNativeColumns(config); |
| // Add interval annotation layer |
| const annotations = d3 |
| .select(element) |
| .select('.nv-wrap') |
| .append('g') |
| .attr('class', `nv-interval-annotation-layer-${index}`); |
| |
| const aColor = e.color || getColor(cleanColorInput(e.name), colorScheme); |
| const tip = tipFactory(e); |
| |
| const records = (annotationData[e.name].records || []) |
| .map(r => { |
| const timeValue = new Date(moment.utc(r[e.timeColumn])); |
| const intervalEndValue = new Date(moment.utc(r[e.intervalEndColumn])); |
| |
| return { |
| ...r, |
| [e.timeColumn]: timeValue, |
| [e.intervalEndColumn]: intervalEndValue, |
| }; |
| }) |
| .filter( |
| record => |
| !Number.isNaN(record[e.timeColumn].getMilliseconds()) && |
| !Number.isNaN(record[e.intervalEndColumn].getMilliseconds()), |
| ); |
| |
| if (records.length > 0) { |
| annotations |
| .selectAll('rect') |
| .data(records) |
| .enter() |
| .append('rect') |
| .attr({ |
| x: d => |
| Math.min( |
| xScale(new Date(d[e.timeColumn])), |
| xScale(new Date(d[e.intervalEndColumn])), |
| ), |
| y: 0, |
| width: d => |
| Math.max( |
| Math.abs( |
| xScale(new Date(d[e.intervalEndColumn])) - |
| xScale(new Date(d[e.timeColumn])), |
| ), |
| 1, |
| ), |
| height: annotationHeight, |
| }) |
| .attr('class', `${e.opacity} ${e.style}`) |
| .style('stroke-width', e.width) |
| .style('stroke', aColor) |
| .style('fill', aColor) |
| .style('fill-opacity', 0.2) |
| .on('mouseover', tip.show) |
| .on('mouseout', tip.hide) |
| .call(tip); |
| } |
| |
| // update annotation positions on brush event |
| if (chart.focus) { |
| chart.focus.dispatch.on('onBrush.interval-annotation', () => { |
| annotations |
| .selectAll('rect') |
| .data(records) |
| .attr({ |
| x: d => xScale(new Date(d[e.timeColumn])), |
| width: d => { |
| const x1 = xScale(new Date(d[e.timeColumn])); |
| const x2 = xScale(new Date(d[e.intervalEndColumn])); |
| |
| return x2 - x1; |
| }, |
| }); |
| }); |
| } |
| }); |
| } |
| |
| // rerender chart appended with annotation layer |
| svg.datum(data).attr('height', height).attr('width', width).call(chart); |
| |
| // Display styles for Time Series Annotations |
| chart.dispatch.on('renderEnd.timeseries-annotation', () => { |
| d3.selectAll('.slice_container .nv-timeseries-annotation-layer.showMarkerstrue .nv-point') |
| .style('stroke-opacity', 1) |
| .style('fill-opacity', 1); |
| d3.selectAll('.slice_container .nv-timeseries-annotation-layer.hideLinetrue').style( |
| 'stroke-width', |
| 0, |
| ); |
| }); |
| } |
| } |
| |
| wrapTooltip(chart); |
| |
| return chart; |
| }; |
| |
| // Remove tooltips before rendering chart, if the chart is being re-rendered sometimes |
| // there are left over tooltips in the dom, |
| // this will clear them before rendering the chart again. |
| if (chartId) { |
| removeTooltip(chartId); |
| } else { |
| hideTooltips(true); |
| } |
| |
| nv.addGraph(drawGraph); |
| } |
| |
| nvd3Vis.displayName = 'NVD3'; |
| nvd3Vis.propTypes = propTypes; |
| export default nvd3Vis; |