// JS import $ from 'jquery'; import throttle from 'lodash.throttle'; import d3 from 'd3'; import nv from 'nvd3'; import mathjs from 'mathjs'; import moment from 'moment'; import d3tip from 'd3-tip'; import { getColorFromScheme } from '../javascripts/modules/colors'; import AnnotationTypes, { applyNativeColumns, } from '../javascripts/modules/AnnotationTypes'; import { customizeToolTip, d3TimeFormatPreset, d3FormatPreset, tryNumify } from '../javascripts/modules/utils'; // CSS import '../node_modules/nvd3/build/nv.d3.min.css'; import './nvd3_vis.css'; import { VIZ_TYPES } from './main'; const minBarWidth = 15; // Limit on how large axes margins can grow as the chart window is resized const maxMarginPad = 30; const animationTime = 1000; const minHeightForBrush = 480; const BREAKPOINTS = { small: 340, }; const addTotalBarValues = function (svg, chart, data, stacked, axisFormat) { const format = d3.format(axisFormat || '.3s'); const countSeriesDisplayed = data.length; const totalStackedValues = stacked && data.length !== 0 ? data[0].values.map(function (bar, iBar) { const bars = data.map(function (series) { return series.values[iBar]; }); return d3.sum(bars, function (d) { return d.y; }); }) : []; const rectsToBeLabeled = svg.selectAll('g.nv-group').filter( function (d, i) { if (!stacked) { return true; } return i === countSeriesDisplayed - 1; }).selectAll('rect'); const groupLabels = svg.select('g.nv-barsWrap').append('g'); rectsToBeLabeled.each( function (d, index) { const rectObj = d3.select(this); if (rectObj.attr('class').includes('positive')) { const transformAttr = rectObj.attr('transform'); const yPos = parseFloat(rectObj.attr('y')); const xPos = parseFloat(rectObj.attr('x')); const rectWidth = parseFloat(rectObj.attr('width')); const t = groupLabels.append('text') .attr('x', xPos) // rough position first, fine tune later .attr('y', yPos - 5) .text(format(stacked ? totalStackedValues[index] : d.y)) .attr('transform', transformAttr) .attr('class', 'bar-chart-label'); const labelWidth = t.node().getBBox().width; t.attr('x', xPos + rectWidth / 2 - labelWidth / 2); // fine tune } }); }; function hideTooltips() { $('.nvtooltip').css({ opacity: 0 }); } function getMaxLabelSize(container, axisClass) { // axis class = .nv-y2 // second y axis on dual line chart // axis class = .nv-x // x axis on time series line chart const labelEls = container.find(`.${axisClass} text`).not('.nv-axislabel'); const labelDimensions = labelEls.map(i => labelEls[i].getComputedTextLength()); return Math.max(...labelDimensions); } /* eslint-disable camelcase */ function formatLabel(column, verbose_map) { let label; if (Array.isArray(column) && column.length) { label = verbose_map[column[0]] || column[0]; if (column.length > 1) { label += ', '; } label += column.slice(1).join(', '); } else { label = verbose_map[column] || column; } return label; } /* eslint-enable camelcase */ function nvd3Vis(slice, payload) { let chart; let colorKey = 'key'; const isExplore = $('#explore-container').length === 1; let data; if (payload.data) { data = payload.data.map(x => ({ ...x, key: formatLabel(x.key, slice.datasource.verbose_map), })); } else { data = []; } slice.container.html(''); slice.clearError(); // Calculates the longest label size for stretching bottom margin function calculateStretchMargins(payloadData) { let stretchMargin = 0; const pixelsPerCharX = 4.5; // approx, depends on font size let maxLabelSize = 10; // accommodate for shorter labels payloadData.data.forEach((d) => { const axisLabels = d.values; for (let i = 0; i < axisLabels.length; i++) { maxLabelSize = Math.max(axisLabels[i].x.toString().length, maxLabelSize); } }); stretchMargin = Math.ceil(pixelsPerCharX * maxLabelSize); return stretchMargin; } let width = slice.width(); const fd = slice.formData; const barchartWidth = function () { let bars; if (fd.bar_stacked) { bars = d3.max(data, function (d) { return d.values.length; }); } else { bars = d3.sum(data, function (d) { return d.values.length; }); } if (bars * minBarWidth > width) { return bars * minBarWidth; } return width; }; const vizType = fd.viz_type; const f = d3.format('.3s'); const reduceXTicks = fd.reduce_x_ticks || false; let stacked = false; let row; const drawGraph = function () { let svg = d3.select(slice.selector).select('svg'); if (svg.empty()) { svg = d3.select(slice.selector).append('svg'); } let height = slice.height(); switch (vizType) { case 'line': if ( fd.show_brush === true || fd.show_brush === 'yes' || (fd.show_brush === 'auto' && height >= minHeightForBrush) ) { chart = nv.models.lineWithFocusChart(); chart.focus.xScale(d3.time.scale.utc()); chart.x2Axis.staggerLabels(false); } else { chart = nv.models.lineChart(); } // To alter the tooltip header // chart.interactiveLayer.tooltip.headerFormatter(function(){return '';}); chart.xScale(d3.time.scale.utc()); chart.interpolate(fd.line_interpolation); chart.xAxis.staggerLabels(false); break; case 'time_pivot': chart = nv.models.lineChart(); chart.xScale(d3.time.scale.utc()); chart.interpolate(fd.line_interpolation); chart.xAxis.staggerLabels(false); break; case 'dual_line': chart = nv.models.multiChart(); chart.interpolate('linear'); break; case 'bar': chart = nv.models.multiBarChart() .showControls(fd.show_controls) .groupSpacing(0.1); if (!reduceXTicks) { width = barchartWidth(); } chart.width(width); chart.xAxis .showMaxMin(false) .staggerLabels(true); stacked = fd.bar_stacked; chart.stacked(stacked); if (fd.show_bar_value) { setTimeout(function () { addTotalBarValues(svg, chart, data, stacked, fd.y_axis_format); }, animationTime); } break; case 'dist_bar': chart = nv.models.multiBarChart() .showControls(fd.show_controls) .reduceXTicks(reduceXTicks) .rotateLabels(45) .groupSpacing(0.1); // Distance between each group of bars. chart.xAxis.showMaxMin(false); stacked = fd.bar_stacked; chart.stacked(stacked); if (fd.order_bars) { data.forEach((d) => { d.values.sort((a, b) => tryNumify(a.x) < tryNumify(b.x) ? -1 : 1); }); } if (fd.show_bar_value) { setTimeout(function () { addTotalBarValues(svg, chart, data, stacked, fd.y_axis_format); }, animationTime); } if (!reduceXTicks) { width = barchartWidth(); } chart.width(width); break; case 'pie': chart = nv.models.pieChart(); colorKey = 'x'; chart.valueFormat(f); if (fd.donut) { chart.donut(true); } chart.labelsOutside(fd.labels_outside); chart.labelThreshold(0.05); // Configure the minimum slice size for labels to show up if (fd.pie_label_type !== 'key_percent' && fd.pie_label_type !== 'key_value') { chart.labelType(fd.pie_label_type); } else if (fd.pie_label_type === 'key_value') { chart.labelType(d => `${d.data.x}: ${d3.format('.3s')(d.data.y)}`); } chart.cornerRadius(true); if (fd.pie_label_type === 'percent' || fd.pie_label_type === 'key_percent') { let total = 0; data.forEach((d) => { total += d.y; }); chart.tooltip.valueFormatter(d => `${((d / total) * 100).toFixed()}%`); if (fd.pie_label_type === 'key_percent') { chart.labelType(d => `${d.data.x}: ${((d.data.y / total) * 100).toFixed()}%`); } } break; case 'column': chart = nv.models.multiBarChart() .reduceXTicks(false) .rotateLabels(45); break; case 'compare': chart = nv.models.cumulativeLineChart(); chart.xScale(d3.time.scale.utc()); chart.useInteractiveGuideline(true); chart.xAxis .showMaxMin(false) .staggerLabels(true); break; case 'bubble': row = (col1, col2) => `${col1}${col2}`; chart = nv.models.scatterChart(); chart.showDistX(true); chart.showDistY(true); chart.tooltip.contentGenerator(function (obj) { const p = obj.point; let s = ''; s += ( `'); s += row(fd.x, f(p.x)); s += row(fd.y, f(p.y)); s += row(fd.size, f(p.size)); s += '
` + `${p[fd.entity]} (${p.group})` + '
'; return s; }); chart.pointRange([5, fd.max_bubble_size ** 2]); chart.pointDomain([0, d3.max(data, d => d3.max(d.values, v => v.size))]); break; case 'area': chart = nv.models.stackedAreaChart(); chart.showControls(fd.show_controls); chart.style(fd.stacked_style); chart.xScale(d3.time.scale.utc()); chart.xAxis.staggerLabels(true); break; case 'box_plot': colorKey = 'label'; chart = nv.models.boxPlotChart(); chart.x(d => d.label); chart.staggerLabels(true); chart.maxBoxWidth(75); // prevent boxes from being incredibly wide break; case 'bullet': chart = nv.models.bulletChart(); break; default: throw new Error('Unrecognized visualization for nvd3' + vizType); } if ('showLegend' in chart && typeof fd.show_legend !== 'undefined') { if (width < BREAKPOINTS.small && vizType !== 'pie') { chart.showLegend(false); } else { chart.showLegend(fd.show_legend); } } if (vizType === 'bullet') { height = Math.min(height, 50); } chart.height(height); slice.container.css('height', height + 'px'); if (chart.forceY && fd.y_axis_bounds && (fd.y_axis_bounds[0] !== null || fd.y_axis_bounds[1] !== null)) { chart.forceY(fd.y_axis_bounds); } if (fd.y_log_scale) { chart.yScale(d3.scale.log()); } if (fd.x_log_scale) { chart.xScale(d3.scale.log()); } const isTimeSeries = [ 'line', 'dual_line', 'area', 'compare', 'bar', 'time_pivot'].indexOf(vizType) >= 0; // if x axis format is a date format, rotate label 90 degrees if (isTimeSeries) { chart.xAxis.rotateLabels(45); } let xAxisFormatter = d3FormatPreset(fd.x_axis_format); if (isTimeSeries) { xAxisFormatter = d3TimeFormatPreset(fd.x_axis_format); } if (chart.x2Axis && chart.x2Axis.tickFormat) { chart.x2Axis.tickFormat(xAxisFormatter); height += 30; } const isXAxisString = ['dist_bar', 'box_plot'].indexOf(vizType) >= 0; if (!isXAxisString && chart.xAxis && chart.xAxis.tickFormat) { chart.xAxis.tickFormat(xAxisFormatter); } const yAxisFormatter = d3FormatPreset(fd.y_axis_format); if (chart.yAxis && chart.yAxis.tickFormat) { chart.yAxis.tickFormat(yAxisFormatter); } if (chart.y2Axis && chart.y2Axis.tickFormat) { chart.y2Axis.tickFormat(yAxisFormatter); } // Set showMaxMin for all axis function setAxisShowMaxMin(axis, showminmax) { if (axis && axis.showMaxMin && showminmax !== undefined) { axis.showMaxMin(showminmax); } } setAxisShowMaxMin(chart.xAxis, fd.x_axis_showminmax); setAxisShowMaxMin(chart.yAxis, fd.y_axis_showminmax); setAxisShowMaxMin(chart.y2Axis, fd.y_axis_showminmax); if (vizType === 'time_pivot') { chart.color((d) => { const c = fd.color_picker; let alpha = 1; if (d.rank > 0) { alpha = d.perc * 0.5; } return `rgba(${c.r}, ${c.g}, ${c.b}, ${alpha})`; }); } else if (vizType !== 'bullet') { chart.color(d => d.color || getColorFromScheme(d[colorKey], fd.color_scheme)); } if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) { chart.useInteractiveGuideline(true); if (vizType === 'line') { // Custom sorted tooltip chart.interactiveLayer.tooltip.contentGenerator((d) => { let tooltip = ''; tooltip += "'; d.series.sort((a, b) => a.value >= b.value ? -1 : 1); d.series.forEach((series) => { tooltip += ( `` + `' + `` + `` + '' ); }); tooltip += '
" + `${xAxisFormatter(d.value)}` + '
` + '
' + '
${series.key}${yAxisFormatter(series.value)}
'; return tooltip; }); } } if (fd.bottom_margin === 'auto') { if (vizType === 'dist_bar') { const stretchMargin = calculateStretchMargins(payload); chart.margin({ bottom: stretchMargin }); } else { chart.margin({ bottom: 50 }); } } else { chart.margin({ bottom: fd.bottom_margin }); } if (vizType === 'dual_line') { const yAxisFormatter1 = d3.format(fd.y_axis_format); const yAxisFormatter2 = d3.format(fd.y_axis_2_format); chart.yAxis1.tickFormat(yAxisFormatter1); chart.yAxis2.tickFormat(yAxisFormatter2); customizeToolTip(chart, xAxisFormatter, [yAxisFormatter1, yAxisFormatter2]); chart.showLegend(width > BREAKPOINTS.small); } svg .datum(data) .transition().duration(500) .attr('height', height) .attr('width', width) .call(chart); if (fd.show_markers) { svg.selectAll('.nv-point') .style('stroke-opacity', 1) .style('fill-opacity', 1); } if (chart.yAxis !== undefined || chart.yAxis2 !== undefined) { // Hack to adjust y axis left margin to accommodate long numbers const containerWidth = slice.container.width(); const marginPad = Math.min(isExplore ? containerWidth * 0.01 : containerWidth * 0.03, maxMarginPad); const maxYAxisLabelWidth = chart.yAxis2 ? getMaxLabelSize(slice.container, 'nv-y1') : getMaxLabelSize(slice.container, 'nv-y'); const maxXAxisLabelHeight = getMaxLabelSize(slice.container, 'nv-x'); chart.margin({ left: maxYAxisLabelWidth + marginPad }); if (fd.y_axis_label && fd.y_axis_label !== '') { chart.margin({ left: maxYAxisLabelWidth + marginPad + 25 }); } // 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 if (isTimeSeries && vizType !== 'bar') { const chartMargins = { bottom: maxXAxisLabelHeight + marginPad, right: maxXAxisLabelHeight + marginPad, }; if (vizType === 'dual_line') { const maxYAxis2LabelWidth = getMaxLabelSize(slice.container, 'nv-y2'); // use y axis width if it's wider than axis width/height if (maxYAxis2LabelWidth > maxXAxisLabelHeight) { chartMargins.right = maxYAxis2LabelWidth + marginPad; } } // apply margins chart.margin(chartMargins); } if (fd.x_axis_label && fd.x_axis_label !== '' && chart.xAxis) { chart.margin({ bottom: maxXAxisLabelHeight + marginPad + 25 }); } if (fd.bottom_margin && fd.bottom_margin !== 'auto') { chart.margin().bottom = fd.bottom_margin; } if (fd.left_margin && fd.left_margin !== 'auto') { chart.margin().left = fd.left_margin; } // Axis labels const margins = chart.margin(); if (fd.x_axis_label && fd.x_axis_label !== '' && chart.xAxis) { let distance = 0; if (margins.bottom && !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(fd.x_axis_label).axisLabelDistance(distance); } if (fd.y_axis_label && fd.y_axis_label !== '' && chart.yAxis) { let distance = 0; if (margins.left && !isNaN(margins.left)) { distance = margins.left - 70; } chart.yAxis.axisLabel(fd.y_axis_label).axisLabelDistance(distance); } const annotationLayers = (slice.formData.annotation_layers || []).filter(x => x.show); if (isTimeSeries && annotationLayers && slice.annotationData) { // Time series annotations add additional data const timeSeriesAnnotations = annotationLayers .filter(a => a.annotationType === AnnotationTypes.TIME_SERIES).reduce((bushel, a) => bushel.concat((slice.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}`, }; })), []); data.push(...timeSeriesAnnotations); } // render chart svg .datum(data) .transition().duration(500) .attr('height', height) .attr('width', width) .call(chart); // on scroll, hide tooltips. throttle to only 4x/second. $(window).scroll(throttle(hideTooltips, 250)); // The below code should be run AFTER rendering because chart is updated in call() if (isTimeSeries && annotationLayers) { // Formula annotations const formulas = annotationLayers.filter(a => a.annotationType === AnnotationTypes.FORMULA) .map(a => ({ ...a, formula: mathjs.parse(a.value) })); let xMax; let xMin; let xScale; if (vizType === VIZ_TYPES.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(); xScale = chart.xScale ? chart.xScale() : d3.scale.linear(); } xScale.clamp(true); if (Array.isArray(formulas) && formulas.length) { const xValues = []; if (vizType === VIZ_TYPES.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.eval({ 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]; const tipFactory = layer => d3tip() .attr('class', 'd3-tip') .direction('n') .offset([-5, 0]) .html((d) => { if (!d) { return ''; } const title = d[layer.titleColumn] && d[layer.titleColumn].length ? d[layer.titleColumn] + ' - ' + layer.name : layer.name; const body = Array.isArray(layer.descriptionColumns) ? layer.descriptionColumns.map(c => d[c]) : Object.values(d); return '
' + title + '

' + '
' + body.join(', ') + '
'; }); if (slice.annotationData) { // Event annotations annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.EVENT && slice.annotationData && slice.annotationData[x.name] )).forEach((config, index) => { const e = applyNativeColumns(config); // Add event annotation layer const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') .attr('class', `nv-event-annotation-layer-${index}`); const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); const tip = tipFactory(e); const records = (slice.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) { 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 chart.focus.dispatch.on('onBrush.event-annotation', function () { 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 annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.INTERVAL && slice.annotationData && slice.annotationData[x.name] )).forEach((config, index) => { const e = applyNativeColumns(config); // Add interval annotation layer const annotations = d3.select(slice.selector).select('.nv-wrap').append('g') .attr('class', `nv-interval-annotation-layer-${index}`); const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); const tip = tipFactory(e); const records = (slice.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) { 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 chart.focus.dispatch.on('onBrush.interval-annotation', function () { 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; }, }); }); }); } } } return chart; }; // hide 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. hideTooltips(); nv.addGraph(drawGraph); } module.exports = nvd3Vis;