diff --git a/superset/assets/spec/javascripts/modules/utils_spec.jsx b/superset/assets/spec/javascripts/modules/utils_spec.jsx index f227970ece8b1..10a4fbc0c11ef 100644 --- a/superset/assets/spec/javascripts/modules/utils_spec.jsx +++ b/superset/assets/spec/javascripts/modules/utils_spec.jsx @@ -1,6 +1,5 @@ import { expect, assert } from 'chai'; import { - tryNumify, slugify, formatSelectOptionsForRange, d3format, @@ -11,12 +10,6 @@ import { } from '../../../src/modules/utils'; describe('utils', () => { - it('tryNumify works as expected', () => { - expect(tryNumify(5)).to.equal(5); - expect(tryNumify('5')).to.equal(5); - expect(tryNumify('5.1')).to.equal(5.1); - expect(tryNumify('a string')).to.equal('a string'); - }); it('slugify slugifies', () => { expect(slugify('My Neat Label! ')).to.equal('my-neat-label'); expect(slugify('Some Letters AnD a 5')).to.equal('some-letters-and-a-5'); diff --git a/superset/assets/spec/javascripts/visualizations/nvd3_viz_spec.jsx b/superset/assets/spec/javascripts/visualizations/nvd3/utils_spec.js similarity index 68% rename from superset/assets/spec/javascripts/visualizations/nvd3_viz_spec.jsx rename to superset/assets/spec/javascripts/visualizations/nvd3/utils_spec.js index ded0acc0341ad..d88214e319f6c 100644 --- a/superset/assets/spec/javascripts/visualizations/nvd3_viz_spec.jsx +++ b/superset/assets/spec/javascripts/visualizations/nvd3/utils_spec.js @@ -1,13 +1,13 @@ import { expect } from 'chai'; -import { formatLabel } from '../../../src/visualizations/nvd3_vis'; +import { formatLabel, tryNumify } from '../../../../src/visualizations/nvd3/utils'; -describe('nvd3 viz', () => { +describe('nvd3/utils', () => { const verboseMap = { foo: 'Foo', bar: 'Bar', }; - describe('formatLabel', () => { + describe('formatLabel()', () => { it('formats simple labels', () => { expect(formatLabel('foo')).to.equal('foo'); expect(formatLabel(['foo'])).to.equal('foo'); @@ -24,4 +24,12 @@ describe('nvd3 viz', () => { expect(formatLabel(['foo', 'bar', 'baz', '2 hours offset'], verboseMap)).to.equal('Foo, Bar, baz, 2 hours offset'); }); }); + describe('tryNumify()', () => { + it('tryNumify works as expected', () => { + expect(tryNumify(5)).to.equal(5); + expect(tryNumify('5')).to.equal(5); + expect(tryNumify('5.1')).to.equal(5.1); + expect(tryNumify('a string')).to.equal('a string'); + }); + }); }); diff --git a/superset/assets/src/modules/utils.js b/superset/assets/src/modules/utils.js index c5d4e75450ae9..0694cdcd0ae18 100644 --- a/superset/assets/src/modules/utils.js +++ b/superset/assets/src/modules/utils.js @@ -206,31 +206,6 @@ export function getDatasourceParameter(datasourceId, datasourceType) { return `${datasourceId}__${datasourceType}`; } -export function customizeToolTip(chart, xAxisFormatter, yAxisFormatters) { - chart.useInteractiveGuideline(true); - chart.interactiveLayer.tooltip.contentGenerator(function (d) { - const tooltipTitle = xAxisFormatter(d.value); - let tooltip = ''; - - tooltip += "'; - - d.series.forEach((series, i) => { - const yAxisFormatter = yAxisFormatters[i]; - const value = yAxisFormatter(series.value); - tooltip += "` - + `` - + ``; - }); - - tooltip += '
" - + `${tooltipTitle}` - + '
" - + `
${series.key}${value}
'; - - return tooltip; - }); -} - export function initJQueryAjax() { // Works in conjunction with a Flask-WTF token as described here: // http://flask-wtf.readthedocs.io/en/stable/csrf.html#javascript-requests @@ -246,15 +221,6 @@ export function initJQueryAjax() { } } -export function tryNumify(s) { - // Attempts casting to Number, returns string when failing - const n = Number(s); - if (isNaN(n)) { - return s; - } - return n; -} - export function getParam(name) { /* eslint no-useless-escape: 0 */ const formattedName = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); diff --git a/superset/assets/src/visualizations/index.js b/superset/assets/src/visualizations/index.js index 1f46f5f9f9c89..31feffc2abba4 100644 --- a/superset/assets/src/visualizations/index.js +++ b/superset/assets/src/visualizations/index.js @@ -59,7 +59,7 @@ const loadVis = promise => // deckgl visualizations don't use esModules, fix it? return defaultExport.default || defaultExport; }); -const loadNvd3 = () => loadVis(import(/* webpackChunkName: "nvd3_vis" */ './nvd3_vis.js')); +const loadNvd3 = () => loadVis(import(/* webpackChunkName: "nvd3_vis" */ './nvd3/NVD3Vis.js')); const vizMap = { [VIZ_TYPES.area]: loadNvd3, @@ -87,7 +87,7 @@ const vizMap = { [VIZ_TYPES.iframe]: () => loadVis(import(/* webpackChunkName: "iframe" */ './iframe.js')), [VIZ_TYPES.line]: loadNvd3, [VIZ_TYPES.line_multi]: () => - loadVis(import(/* webpackChunkName: "line_multi" */ './line_multi.js')), + loadVis(import(/* webpackChunkName: "line_multi" */ './nvd3/LineMulti.js')), [VIZ_TYPES.time_pivot]: loadNvd3, [VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './MapBox/MapBox.jsx')), [VIZ_TYPES.markup]: () => loadVis(import(/* webpackChunkName: "markup" */ './markup.js')), diff --git a/superset/assets/src/visualizations/line_multi.js b/superset/assets/src/visualizations/nvd3/LineMulti.js similarity index 95% rename from superset/assets/src/visualizations/line_multi.js rename to superset/assets/src/visualizations/nvd3/LineMulti.js index b8cd1a0785283..7309641433d8d 100644 --- a/superset/assets/src/visualizations/line_multi.js +++ b/superset/assets/src/visualizations/nvd3/LineMulti.js @@ -1,7 +1,6 @@ import d3 from 'd3'; - -import nvd3Vis from './nvd3_vis'; -import { getExploreLongUrl } from '../explore/exploreUtils'; +import nvd3Vis from './NVD3Vis'; +import { getExploreLongUrl } from '../../explore/exploreUtils'; export default function lineMulti(slice, payload) { /* diff --git a/superset/assets/src/visualizations/nvd3_vis.css b/superset/assets/src/visualizations/nvd3/NVD3Vis.css similarity index 100% rename from superset/assets/src/visualizations/nvd3_vis.css rename to superset/assets/src/visualizations/nvd3/NVD3Vis.css diff --git a/superset/assets/src/visualizations/nvd3_vis.js b/superset/assets/src/visualizations/nvd3/NVD3Vis.js similarity index 50% rename from superset/assets/src/visualizations/nvd3_vis.js rename to superset/assets/src/visualizations/nvd3/NVD3Vis.js index 1baf5f576a0c1..73cb7b7cd6632 100644 --- a/superset/assets/src/visualizations/nvd3_vis.js +++ b/superset/assets/src/visualizations/nvd3/NVD3Vis.js @@ -1,32 +1,48 @@ -// JS -import $ from 'jquery'; import throttle from 'lodash.throttle'; import d3 from 'd3'; import nv from 'nvd3'; -import 'nvd3/build/nv.d3.min.css'; import mathjs from 'mathjs'; import moment from 'moment'; -import d3tip from 'd3-tip'; -import dompurify from 'dompurify'; - -import { getColorFromScheme } from '../modules/colors'; -import AnnotationTypes, { - applyNativeColumns, -} from '../modules/AnnotationTypes'; -import { customizeToolTip, d3TimeFormatPreset, d3FormatPreset, tryNumify } from '../modules/utils'; -import { formatDateVerbose } from '../modules/dates'; -import { isTruthy, TIME_SHIFT_PATTERN } from '../utils/common'; -import { t } from '../locales'; - -// CSS -import './nvd3_vis.css'; -import { VIZ_TYPES } from './'; - -const minBarWidth = 15; +import PropTypes from 'prop-types'; +import 'nvd3/build/nv.d3.min.css'; + +import { t } from '../../locales'; +import AnnotationTypes, { applyNativeColumns } from '../../modules/AnnotationTypes'; +import { getScale, getColor } from '../../modules/CategoricalColorNamespace'; +import { formatDateVerbose } from '../../modules/dates'; +import { d3TimeFormatPreset, d3FormatPreset } from '../../modules/utils'; +import { isTruthy } from '../../utils/common'; +import { + computeBarChartWidth, + drawBarValues, + formatLabel, + generateBubbleTooltipContent, + generateMultiLineTooltipContent, + generateRichLineTooltipContent, + getMaxLabelSize, + hideTooltips, + tipFactory, + tryNumify, + setAxisShowMaxMin, + stringifyTimeRange, + wrapTooltip, +} from './utils'; +import { + annotationLayerType, + boxPlotValueType, + bulletDataType, + categoryAndValueXYType, + rgbObjectType, + numericXYType, + numberOrAutoType, + stringOrObjectWithLabelType, +} from './PropTypes'; +import './NVD3Vis.css'; + // Limit on how large axes margins can grow as the chart window is resized -const maxMarginPad = 30; -const animationTime = 1000; -const minHeightForBrush = 480; +const MAX_MARGIN_PAD = 30; +const ANIMATION_TIME = 1000; +const MIN_HEIGHT_FOR_BRUSH = 480; const BREAKPOINTS = { small: 340, @@ -42,163 +58,214 @@ const TIMESERIES_VIZ_TYPES = [ 'time_pivot', ]; -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 textEls = 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 = textEls.node().getBBox().width; - textEls.attr('x', xPos + rectWidth / 2 - labelWidth / 2); // fine tune - } - }); +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, + 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, false, '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', + ]), + 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, }; -function hideTooltips() { - $('.nvtooltip').css({ opacity: 0 }); -} +const NOOP = () => {}; +const formatter = d3.format('.3s'); + +function nvd3Vis(element, props) { + PropTypes.checkPropTypes(propTypes, props, 'prop', 'NVD3Vis'); + + const { + data, + width: maxWidth, + height: maxHeight, + annotationData, + annotationLayers = [], + areaStackedStyle, + baseColor, + bottomMargin, + colorScheme, + comparisonType, + contribution, + entity, + isBarStacked, + isDonut, + isPieLabelOutside, + leftMargin, + lineInterpolation = 'linear', + maxBubbleSize, + onBrushEnd = NOOP, + onError = NOOP, + orderBars, + pieLabelType, + reduceXTicks = false, + showBarValue, + showBrush, + showControls, + showLabels, + showLegend, + showMarkers, + sizeField, + useRichTooltip, + vizType, + xAxisFormat, + xAxisLabel, + xAxisShowMinMax = false, + xField, + xIsLogScale, + xTicksLayout, + yAxisFormat, + yAxis2Format, + yAxisBounds, + yAxisLabel, + yAxisShowMinMax = false, + yField, + yIsLogScale, + } = props; + + const isExplore = document.querySelector('#explorer-container') !== null; + const container = element; + container.innerHTML = ''; -function wrapTooltip(chart, container) { - const tooltipLayer = chart.useInteractiveGuideline && chart.useInteractiveGuideline() ? - chart.interactiveLayer : chart; - const tooltipGeneratorFunc = tooltipLayer.tooltip.contentGenerator(); - tooltipLayer.tooltip.contentGenerator((d) => { - let tooltip = `
`; - tooltip += tooltipGeneratorFunc(d); - tooltip += '
'; - return tooltip; - }); -} - -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() * 0.75); - return Math.ceil(Math.max(...labelDimensions)); -} - -export function formatLabel(input, verboseMap = {}) { - // The input for label may be a string or an array of string - // When using the time shift feature, the label contains a '---' in the array - const verboseLkp = s => verboseMap[s] || s; - let label; - if (Array.isArray(input) && input.length) { - const verboseLabels = input.map(l => TIME_SHIFT_PATTERN.test(l) ? l : verboseLkp(l)); - label = verboseLabels.join(', '); - } else { - label = verboseLkp(input); - } - return label; -} - -export default function nvd3Vis(slice, payload) { let chart; + let width = maxWidth; let colorKey = 'key'; - const isExplore = $('#explore-container').length === 1; - - let data; - if (payload.data) { - if (Array.isArray(payload.data)) { - data = payload.data.map(x => ({ - ...x, key: formatLabel(x.key, slice.datasource.verbose_map), - })); - } else { - data = payload.data; - } - } else { - data = []; - } - slice.container.html(''); - slice.clearError(); - - 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 formatter = d3.format('.3s'); - const reduceXTicks = fd.reduce_x_ticks || false; - let stacked = false; - let row; + function isVizTypes(types) { + return types.indexOf(vizType) >= 0; + } const drawGraph = function () { - let svg = d3.select(slice.selector).select('svg'); + const d3Element = d3.select(element); + let svg = d3Element.select('svg'); if (svg.empty()) { - svg = d3.select(slice.selector).append('svg'); + svg = d3Element.append('svg'); } - let height = slice.height(); - const isTimeSeries = TIMESERIES_VIZ_TYPES.indexOf(vizType) >= 0; + const height = vizType === 'bullet' ? Math.min(maxHeight, 50) : maxHeight; + const isTimeSeries = isVizTypes(TIMESERIES_VIZ_TYPES); // Handling xAxis ticks settings - let xLabelRotation = 0; - let staggerLabels = false; - if (fd.x_ticks_layout === 'auto') { - if (['column', 'dist_bar'].indexOf(vizType) >= 0) { - xLabelRotation = 45; - } - } else if (fd.x_ticks_layout === 'staggered') { - staggerLabels = true; - } else if (fd.x_ticks_layout === '45°') { - if (isTruthy(fd.show_brush)) { - const error = t('You cannot use 45° tick layout along with the time range filter'); - slice.error(error); - return null; - } - xLabelRotation = 45; + 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 showBrush = ( - isTruthy(fd.show_brush) || - (fd.show_brush === 'auto' && height >= minHeightForBrush && fd.x_ticks_layout !== '45°') + + const canShowBrush = ( + isTruthy(showBrush) || + (showBrush === 'auto' && maxHeight >= MIN_HEIGHT_FOR_BRUSH && xTicksLayout !== '45°') ); switch (vizType) { case 'line': - if (showBrush) { + if (canShowBrush) { chart = nv.models.lineWithFocusChart(); if (staggerLabels) { // Give a bit more room to focus area if X axis ticks are staggered @@ -210,69 +277,60 @@ export default function nvd3Vis(slice, payload) { chart = nv.models.lineChart(); } chart.xScale(d3.time.scale.utc()); - chart.interpolate(fd.line_interpolation); + chart.interpolate(lineInterpolation); break; case 'time_pivot': chart = nv.models.lineChart(); chart.xScale(d3.time.scale.utc()); - chart.interpolate(fd.line_interpolation); + chart.interpolate(lineInterpolation); break; case 'dual_line': - chart = nv.models.multiChart(); - chart.interpolate('linear'); - break; - case 'line_multi': chart = nv.models.multiChart(); - chart.interpolate(fd.line_interpolation); + chart.interpolate(lineInterpolation); break; case 'bar': chart = nv.models.multiBarChart() - .showControls(fd.show_controls) - .groupSpacing(0.1); + .showControls(showControls) + .groupSpacing(0.1); + if (showBarValue) { + setTimeout(function () { + drawBarValues(svg, data, isBarStacked, yAxisFormat); + }, ANIMATION_TIME); + } if (!reduceXTicks) { - width = barchartWidth(); + width = computeBarChartWidth(data, isBarStacked, maxWidth); } chart.width(width); - chart.xAxis - .showMaxMin(false); - - stacked = fd.bar_stacked; - chart.stacked(stacked); - - if (fd.show_bar_value) { - setTimeout(function () { - addTotalBarValues(svg, chart, data, stacked, fd.y_axis_format); - }, animationTime); - } + chart.xAxis.showMaxMin(false); + chart.stacked(isBarStacked); break; case 'dist_bar': chart = nv.models.multiBarChart() - .showControls(fd.show_controls) - .reduceXTicks(reduceXTicks) - .groupSpacing(0.1); // Distance between each group of bars. + .showControls(showControls) + .reduceXTicks(reduceXTicks) + .groupSpacing(0.1); // Distance between each group of bars. chart.xAxis.showMaxMin(false); - stacked = fd.bar_stacked; - chart.stacked(stacked); - if (fd.order_bars) { + chart.stacked(isBarStacked); + if (orderBars) { data.forEach((d) => { d.values.sort((a, b) => tryNumify(a.x) < tryNumify(b.x) ? -1 : 1); }); } - if (fd.show_bar_value) { + if (showBarValue) { setTimeout(function () { - addTotalBarValues(svg, chart, data, stacked, fd.y_axis_format); - }, animationTime); + drawBarValues(svg, data, isBarStacked, yAxisFormat); + }, ANIMATION_TIME); } if (!reduceXTicks) { - width = barchartWidth(); + width = computeBarChartWidth(data, isBarStacked, maxWidth); } chart.width(width); break; @@ -281,24 +339,25 @@ export default function nvd3Vis(slice, payload) { chart = nv.models.pieChart(); colorKey = 'x'; chart.valueFormat(formatter); - if (fd.donut) { + if (isDonut) { chart.donut(true); } - chart.showLabels(fd.show_labels); - 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.showLabels(showLabels); + chart.labelsOutside(isPieLabelOutside); + // Configure the minimum slice size for labels to show up + chart.labelThreshold(0.05); + chart.cornerRadius(true); + + if (pieLabelType !== 'key_percent' && pieLabelType !== 'key_value') { + chart.labelType(pieLabelType); + } else if (pieLabelType === '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; }); + if (pieLabelType === 'percent' || pieLabelType === 'key_percent') { + const total = d3.sum(data, d => d.y); chart.tooltip.valueFormatter(d => `${((d / total) * 100).toFixed()}%`); - if (fd.pie_label_type === 'key_percent') { + if (pieLabelType === 'key_percent') { chart.labelType(d => `${d.data.x}: ${((d.data.y / total) * 100).toFixed()}%`); } } @@ -307,7 +366,7 @@ export default function nvd3Vis(slice, payload) { case 'column': chart = nv.models.multiBarChart() - .reduceXTicks(false); + .reduceXTicks(false); break; case 'compare': @@ -318,33 +377,28 @@ export default function nvd3Vis(slice, payload) { 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; - const yAxisFormatter = d3FormatPreset(fd.y_axis_format); - const xAxisFormatter = d3FormatPreset(fd.x_axis_format); - let s = ''; - s += ( - `'); - s += row(fd.x.label || fd.x, xAxisFormatter(p.x)); - s += row(fd.y.label || fd.y, yAxisFormatter(p.y)); - s += row(fd.size.label || fd.size, formatter(p.size)); - s += '
` + - `${p[fd.entity]} (${p.group})` + - '
'; - return s; - }); - chart.pointRange([5, fd.max_bubble_size ** 2]); + chart.tooltip.contentGenerator(d => + generateBubbleTooltipContent({ + point: d.point, + entity, + xField, + yField, + sizeField, + xFormatter: d3FormatPreset(xAxisFormat), + yFormatter: d3FormatPreset(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(fd.show_controls); - chart.style(fd.stacked_style); + chart.showControls(showControls); + chart.style(areaStackedStyle); chart.xScale(d3.time.scale.utc()); break; @@ -363,14 +417,12 @@ export default function nvd3Vis(slice, payload) { throw new Error('Unrecognized visualization for nvd3' + vizType); } - if (isTruthy(fd.show_brush) && isTruthy(fd.send_time_range)) { + if (canShowBrush && onBrushEnd !== NOOP) { chart.focus.dispatch.on('brush', (event) => { - const extent = event.extent; - if (extent.some(d => d.toISOString === undefined)) { - return; + const timeRange = stringifyTimeRange(event.extent); + if (timeRange) { + event.brush.on('brushend', () => { onBrushEnd(timeRange); }); } - const timeRange = extent.map(d => d.toISOString().slice(0, -1)).join(' : '); - event.brush.on('brushend', () => slice.addFilter('__time_range', timeRange, false, true)); }); } @@ -387,47 +439,42 @@ export default function nvd3Vis(slice, payload) { chart.x2Axis.rotateLabels(xLabelRotation); } - if ('showLegend' in chart && typeof fd.show_legend !== 'undefined') { + if ('showLegend' in chart && typeof showLegend !== 'undefined') { if (width < BREAKPOINTS.small && vizType !== 'pie') { chart.showLegend(false); } else { - chart.showLegend(fd.show_legend); + chart.showLegend(showLegend); } } - if (vizType === 'bullet') { - height = Math.min(height, 50); + if (chart.forceY && yAxisBounds && + (yAxisBounds[0] !== null || yAxisBounds[1] !== null)) { + chart.forceY(yAxisBounds); } - - 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) { + if (yIsLogScale) { chart.yScale(d3.scale.log()); } - if (fd.x_log_scale) { + if (xIsLogScale) { chart.xScale(d3.scale.log()); } - let xAxisFormatter = d3FormatPreset(fd.x_axis_format); + let xAxisFormatter = d3FormatPreset(xAxisFormat); if (isTimeSeries) { - xAxisFormatter = d3TimeFormatPreset(fd.x_axis_format); + xAxisFormatter = d3TimeFormatPreset(xAxisFormat); // In tooltips, always use the verbose time format chart.interactiveLayer.tooltip.headerFormatter(formatDateVerbose); } if (chart.x2Axis && chart.x2Axis.tickFormat) { chart.x2Axis.tickFormat(xAxisFormatter); } - const isXAxisString = ['dist_bar', 'box_plot'].indexOf(vizType) >= 0; + const isXAxisString = isVizTypes(['dist_bar', 'box_plot']); if (!isXAxisString && chart.xAxis && chart.xAxis.tickFormat) { chart.xAxis.tickFormat(xAxisFormatter); } - let yAxisFormatter = d3FormatPreset(fd.y_axis_format); + let yAxisFormatter = d3FormatPreset(yAxisFormat); if (chart.yAxis && chart.yAxis.tickFormat) { - if (fd.contribution || fd.comparison_type === 'percentage') { + if (contribution || comparisonType === 'percentage') { // When computing a "Percentage" or "Contribution" selected, we force a percentage format yAxisFormatter = d3.format('.1%'); } @@ -444,93 +491,72 @@ export default function nvd3Vis(slice, payload) { chart.y2Axis.ticks(5); } - // Set showMaxMin for all axis - function setAxisShowMaxMin(axis, showminmax) { - if (axis && axis.showMaxMin && showminmax !== undefined) { - axis.showMaxMin(showminmax); - } - } - - // If these are undefined, they register as truthy - setAxisShowMaxMin(chart.xAxis, fd.x_axis_showminmax || false); - setAxisShowMaxMin(chart.x2Axis, fd.x_axis_showminmax || false); - setAxisShowMaxMin(chart.yAxis, fd.y_axis_showminmax || false); - setAxisShowMaxMin(chart.y2Axis, fd.y_axis_showminmax || false); + setAxisShowMaxMin(chart.xAxis, xAxisShowMinMax); + setAxisShowMaxMin(chart.x2Axis, xAxisShowMinMax); + setAxisShowMaxMin(chart.yAxis, yAxisShowMinMax); + setAxisShowMaxMin(chart.y2Axis, yAxisShowMinMax); 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})`; - }); + 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})`; + }); + } } else if (vizType !== 'bullet') { - chart.color(d => d.color || getColorFromScheme(d[colorKey], fd.color_scheme)); + const colorFn = getScale(colorScheme).toFunction(); + chart.color(d => d.color || colorFn(d[colorKey])); } - if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) { + + if (isVizTypes(['line', 'area']) && useRichTooltip) { chart.useInteractiveGuideline(true); if (vizType === 'line') { - // Custom sorted tooltip - // use a verbose formatter for times - 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 += '
" - + `${formatDateVerbose(d.value)}` - + '
` + - '
' + - '
${dompurify.sanitize(series.key)}${yAxisFormatter(series.value)}
'; - return tooltip; - }); + chart.interactiveLayer.tooltip.contentGenerator(d => + generateRichLineTooltipContent(d, yAxisFormatter)); } } - if (['dual_line', 'line_multi'].indexOf(vizType) >= 0) { - const yAxisFormatter1 = d3.format(fd.y_axis_format); - const yAxisFormatter2 = d3.format(fd.y_axis_2_format); + if (isVizTypes(['dual_line', 'line_multi'])) { + const yAxisFormatter1 = d3.format(yAxisFormat); + const yAxisFormatter2 = d3.format(yAxis2Format); chart.yAxis1.tickFormat(yAxisFormatter1); chart.yAxis2.tickFormat(yAxisFormatter2); const yAxisFormatters = data.map(datum => ( datum.yAxis === 1 ? yAxisFormatter1 : yAxisFormatter2)); - customizeToolTip(chart, xAxisFormatter, yAxisFormatters); + chart.useInteractiveGuideline(true); + chart.interactiveLayer.tooltip.contentGenerator(d => + generateMultiLineTooltipContent(d, xAxisFormatter, yAxisFormatters)); if (vizType === 'dual_line') { chart.showLegend(width > BREAKPOINTS.small); } else { - chart.showLegend(fd.show_legend); + chart.showLegend(showLegend); } } // This is needed for correct chart dimensions if a chart is rendered in a hidden container chart.width(width); chart.height(height); - slice.container.css('height', height + 'px'); + container.style.height = `${height}px`; svg - .datum(data) - .transition().duration(500) - .attr('height', height) - .attr('width', width) - .call(chart); + .datum(data) + .transition().duration(500) + .attr('height', height) + .attr('width', width) + .call(chart); // align yAxis1 and yAxis2 ticks - if (['dual_line', 'line_multi'].indexOf(vizType) >= 0) { + 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); + 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; @@ -551,23 +577,21 @@ export default function nvd3Vis(slice, payload) { } } - if (fd.show_markers) { + if (showMarkers) { svg.selectAll('.nv-point') - .style('stroke-opacity', 1) - .style('fill-opacity', 1); + .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.ceil( - Math.min(isExplore ? containerWidth * 0.01 : containerWidth * 0.03, maxMarginPad), + Math.min(maxWidth * (isExplore ? 0.01 : 0.03), MAX_MARGIN_PAD), ); - const maxYAxisLabelWidth = chart.yAxis2 ? getMaxLabelSize(slice.container, 'nv-y1') - : getMaxLabelSize(slice.container, 'nv-y'); - const maxXAxisLabelHeight = getMaxLabelSize(slice.container, 'nv-x'); + const maxYAxisLabelWidth = getMaxLabelSize(svg, chart.yAxis2 ? 'nv-y1' : 'nv-y'); + const maxXAxisLabelHeight = getMaxLabelSize(svg, 'nv-x'); chart.margin({ left: maxYAxisLabelWidth + marginPad }); - if (fd.y_axis_label && fd.y_axis_label !== '') { + if (yAxisLabel && yAxisLabel !== '') { chart.margin({ left: maxYAxisLabelWidth + marginPad + 25 }); } // Hack to adjust margins to accommodate long axis tick labels. @@ -577,7 +601,7 @@ export default function nvd3Vis(slice, payload) { // - adjust margins based on these measures and render again const margins = chart.margin(); margins.bottom = 28; - if (fd.x_axis_showminmax) { + if (xAxisShowMinMax) { // If x bounds are shown, we need a right margin margins.right = Math.max(20, maxXAxisLabelHeight / 2) + marginPad; } @@ -588,79 +612,81 @@ export default function nvd3Vis(slice, payload) { margins.bottom = 40; } - if (['dual_line', 'line_multi'].indexOf(vizType) >= 0) { - const maxYAxis2LabelWidth = getMaxLabelSize(slice.container, 'nv-y2'); + if (isVizTypes(['dual_line', 'line_multi'])) { + const maxYAxis2LabelWidth = getMaxLabelSize(svg, 'nv-y2'); margins.right = maxYAxis2LabelWidth + marginPad; } - if (fd.bottom_margin && fd.bottom_margin !== 'auto') { - margins.bottom = parseInt(fd.bottom_margin, 10); + if (bottomMargin && bottomMargin !== 'auto') { + margins.bottom = parseInt(bottomMargin, 10); } - if (fd.left_margin && fd.left_margin !== 'auto') { - margins.left = fd.left_margin; + if (leftMargin && leftMargin !== 'auto') { + margins.left = leftMargin; } - if (fd.x_axis_label && fd.x_axis_label !== '' && chart.xAxis) { + if (xAxisLabel && xAxisLabel !== '' && chart.xAxis) { margins.bottom += 25; let distance = 0; - if (margins.bottom && !isNaN(margins.bottom)) { + 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(fd.x_axis_label).axisLabelDistance(distance); + chart.xAxis.axisLabel(xAxisLabel).axisLabelDistance(distance); } - if (fd.y_axis_label && fd.y_axis_label !== '' && chart.yAxis) { + if (yAxisLabel && yAxisLabel !== '' && chart.yAxis) { let distance = 0; - if (margins.left && !isNaN(margins.left)) { + if (margins.left && !Number.isNaN(margins.left)) { distance = margins.left - 70; } - chart.yAxis.axisLabel(fd.y_axis_label).axisLabelDistance(distance); + chart.yAxis.axisLabel(yAxisLabel).axisLabelDistance(distance); } - const annotationLayers = (slice.formData.annotation_layers || []).filter(x => x.show); - if (isTimeSeries && annotationLayers && slice.annotationData) { + if (isTimeSeries && annotationData && annotationLayers.length > 0) { // 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} nv-timeseries-annotation-layer showMarkers${a.showMarkers} hideLine${a.hideLine}`, - }; - })), []); + .filter(layer => layer.show) + .filter(layer => layer.annotationType === AnnotationTypes.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); } // render chart svg - .datum(data) - .transition().duration(500) - .attr('height', height) - .attr('width', width) - .call(chart); + .datum(data) + .transition().duration(500) + .attr('width', width) + .attr('height', height) + .call(chart); // on scroll, hide tooltips. throttle to only 4x/second. - $(window).scroll(throttle(hideTooltips, 250)); + window.addEventListener('scroll', throttle(hideTooltips, 250)); // The below code should be run AFTER rendering because chart is updated in call() - if (isTimeSeries && annotationLayers) { + if (isTimeSeries && annotationLayers.length > 0) { // Formula annotations - const formulas = annotationLayers.filter(a => a.annotationType === AnnotationTypes.FORMULA) + 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) { + 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() @@ -681,9 +707,9 @@ export default function nvd3Vis(slice, payload) { xScale.clamp(true); } - if (Array.isArray(formulas) && formulas.length) { + if (formulas.length > 0) { const xValues = []; - if (vizType === VIZ_TYPES.bar) { + 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) => { @@ -720,37 +746,23 @@ export default function nvd3Vis(slice, payload) { 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) { + if (annotationData) { // Event annotations annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.EVENT && - slice.annotationData && slice.annotationData[x.name] + annotationData && 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') + const annotations = d3.select(element) + .select('.nv-wrap') + .append('g') .attr('class', `nv-event-annotation-layer-${index}`); - const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); + const aColor = e.color || getColor(e.name, colorScheme); const tip = tipFactory(e); - const records = (slice.annotationData[e.name].records || []).map((r) => { + const records = (annotationData[e.name].records || []).map((r) => { const timeValue = new Date(moment.utc(r[e.timeColumn])); return { @@ -798,17 +810,19 @@ export default function nvd3Vis(slice, payload) { // Interval annotations annotationLayers.filter(x => ( x.annotationType === AnnotationTypes.INTERVAL && - slice.annotationData && slice.annotationData[x.name] + annotationData && 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') + const annotations = d3.select(element) + .select('.nv-wrap') + .append('g') .attr('class', `nv-interval-annotation-layer-${index}`); - const aColor = e.color || getColorFromScheme(e.name, fd.color_scheme); + const aColor = e.color || getColor(e.name, colorScheme); const tip = tipFactory(e); - const records = (slice.annotationData[e.name].records || []).map((r) => { + 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 { @@ -875,7 +889,7 @@ export default function nvd3Vis(slice, payload) { } } - wrapTooltip(chart, slice.container); + wrapTooltip(chart, maxWidth); return chart; }; @@ -886,3 +900,117 @@ export default function nvd3Vis(slice, payload) { nv.addGraph(drawGraph); } + +nvd3Vis.propTypes = propTypes; + +function adaptor(slice, payload) { + const { formData, datasource, selector, annotationData } = slice; + const { + annotation_layers: annotationLayers, + bar_stacked: isBarStacked, + bottom_margin: bottomMargin, + color_picker: baseColor, + color_scheme: colorScheme, + comparison_type: comparisonType, + contribution, + donut: isDonut, + entity, + labels_outside: isPieLabelOutside, + left_margin: leftMargin, + line_interpolation: lineInterpolation, + max_bubble_size: maxBubbleSize, + order_bars: orderBars, + pie_label_type: pieLabelType, + reduce_x_ticks: reduceXTicks, + rich_tooltip: useRichTooltip, + send_time_range: hasBrushAction, + show_bar_value: showBarValue, + show_brush: showBrush, + show_controls: showControls, + show_labels: showLabels, + show_legend: showLegend, + show_markers: showMarkers, + size: sizeField, + stacked_style: areaStackedStyle, + viz_type: vizType, + x: xField, + x_axis_format: xAxisFormat, + x_axis_label: xAxisLabel, + x_axis_showminmax: xAxisShowMinMax, + x_log_scale: xIsLogScale, + x_ticks_layout: xTicksLayout, + y: yField, + y_axis_format: yAxisFormat, + y_axis_2_format: yAxis2Format, + y_axis_bounds: yAxisBounds, + y_axis_label: yAxisLabel, + y_axis_showminmax: yAxisShowMinMax, + y_log_scale: yIsLogScale, + } = formData; + + const element = document.querySelector(selector); + + const rawData = payload.data || []; + const data = Array.isArray(rawData) + ? rawData.map(row => ({ + ...row, + key: formatLabel(row.key, datasource.verbose_map), + })) + : rawData; + + const props = { + data, + width: slice.width(), + height: slice.height(), + annotationData, + annotationLayers, + areaStackedStyle, + baseColor, + bottomMargin, + colorScheme, + comparisonType, + contribution, + entity, + isBarStacked, + isDonut, + isPieLabelOutside, + leftMargin, + lineInterpolation, + maxBubbleSize: parseInt(maxBubbleSize, 10), + onBrushEnd: isTruthy(hasBrushAction) ? ((timeRange) => { + slice.addFilter('__time_range', timeRange, false, true); + }) : undefined, + onError(err) { slice.error(err); }, + orderBars, + pieLabelType, + reduceXTicks, + showBarValue, + showBrush, + showControls, + showLabels, + showLegend, + showMarkers, + sizeField, + useRichTooltip, + vizType, + xAxisFormat, + xAxisLabel, + xAxisShowMinMax, + xField, + xIsLogScale, + xTicksLayout, + yAxisFormat, + yAxis2Format, + yAxisBounds, + yAxisLabel, + yAxisShowMinMax, + yField, + yIsLogScale, + }; + + slice.clearError(); + + return nvd3Vis(element, props); +} + +export default adaptor; diff --git a/superset/assets/src/visualizations/nvd3/PropTypes.js b/superset/assets/src/visualizations/nvd3/PropTypes.js new file mode 100644 index 0000000000000..6c3d58daa365d --- /dev/null +++ b/superset/assets/src/visualizations/nvd3/PropTypes.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import { ANNOTATION_TYPES } from '../../modules/AnnotationTypes'; + +export const numberOrAutoType = PropTypes.oneOfType([ + PropTypes.number, + PropTypes.oneOf(['auto']), +]); + +export const stringOrObjectWithLabelType = PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + label: PropTypes.string, + }), +]); + +export const rgbObjectType = PropTypes.shape({ + r: PropTypes.number.isRequired, + g: PropTypes.number.isRequired, + b: PropTypes.number.isRequired, +}); + +export const numericXYType = PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.number, +}); + +export const categoryAndValueXYType = PropTypes.shape({ + x: PropTypes.string, + y: PropTypes.number, +}); + +export const boxPlotValueType = PropTypes.shape({ + Q1: PropTypes.number, + Q2: PropTypes.number, + Q3: PropTypes.number, + outliers: PropTypes.arrayOf(PropTypes.number), + whisker_high: PropTypes.number, + whisker_low: PropTypes.number, +}); + +export const bulletDataType = PropTypes.shape({ + markerLabels: PropTypes.arrayOf(PropTypes.string), + markerLineLabels: PropTypes.arrayOf(PropTypes.string), + markerLines: PropTypes.arrayOf(PropTypes.number), + markers: PropTypes.arrayOf(PropTypes.number), + measures: PropTypes.arrayOf(PropTypes.number), + rangeLabels: PropTypes.arrayOf(PropTypes.string), + ranges: PropTypes.arrayOf(PropTypes.number), +}); + +export const annotationLayerType = PropTypes.shape({ + annotationType: PropTypes.oneOf(Object.keys(ANNOTATION_TYPES)), + color: PropTypes.string, + name: PropTypes.string, + hideLine: PropTypes.bool, + opacity: PropTypes.string, + show: PropTypes.bool, + showMarkers: PropTypes.bool, + sourceType: PropTypes.string, + style: PropTypes.string, + value: PropTypes.string, + width: PropTypes.number, +}); diff --git a/superset/assets/src/visualizations/nvd3/utils.js b/superset/assets/src/visualizations/nvd3/utils.js new file mode 100644 index 0000000000000..47bd499f18a9b --- /dev/null +++ b/superset/assets/src/visualizations/nvd3/utils.js @@ -0,0 +1,206 @@ +import d3 from 'd3'; +import d3tip from 'd3-tip'; +import dompurify from 'dompurify'; +import { formatDateVerbose } from '../../modules/dates'; +import { TIME_SHIFT_PATTERN } from '../../utils/common'; + +export function drawBarValues(svg, 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(series => series.values[iBar]); + return d3.sum(bars, d => d.y); + }) : []; + + const groupLabels = svg.select('g.nv-barsWrap').append('g'); + + svg.selectAll('g.nv-group') + .filter((d, i) => !stacked || i === countSeriesDisplayed - 1) + .selectAll('rect') + .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 textEls = groupLabels.append('text') + .attr('y', yPos - 5) + .text(format(stacked ? totalStackedValues[index] : d.y)) + .attr('transform', transformAttr) + .attr('class', 'bar-chart-label'); + const labelWidth = textEls.node().getBBox().width; + textEls.attr('x', xPos + rectWidth / 2 - labelWidth / 2); // fine tune + } + }); +} + +// Custom sorted tooltip +// use a verbose formatter for times +export function generateRichLineTooltipContent(d, valueFormatter) { + let tooltip = ''; + tooltip += "'; + d.series.sort((a, b) => a.value >= b.value ? -1 : 1); + d.series.forEach((series) => { + tooltip += ( + `` + + `' + + `` + + `` + + '' + ); + }); + tooltip += '
" + + `${formatDateVerbose(d.value)}` + + '
` + + '
' + + '
${dompurify.sanitize(series.key)}${valueFormatter(series.value)}
'; + return tooltip; +} + +export function generateMultiLineTooltipContent(d, xFormatter, yFormatters) { + const tooltipTitle = xFormatter(d.value); + let tooltip = ''; + + tooltip += "'; + + d.series.forEach((series, i) => { + const yFormatter = yFormatters[i]; + tooltip += "` + + `` + + ``; + }); + + tooltip += '
" + + `${tooltipTitle}` + + '
" + + `
${series.key}${yFormatter(series.value)}
'; + + return tooltip; +} + +function getLabel(stringOrObjectWithLabel) { + return stringOrObjectWithLabel.label || stringOrObjectWithLabel; +} + +function createHTMLRow(col1, col2) { + return `${col1}${col2}`; +} + +export function generateBubbleTooltipContent({ + point, + entity, + xField, + yField, + sizeField, + xFormatter, + yFormatter, + sizeFormatter, +}) { + let s = ''; + s += ( + `' + ); + s += createHTMLRow(getLabel(xField), xFormatter(point.x)); + s += createHTMLRow(getLabel(yField), yFormatter(point.y)); + s += createHTMLRow(getLabel(sizeField), sizeFormatter(point.size)); + s += '
` + + `${point[entity]} (${point.group})` + + '
'; + return s; +} + +export function hideTooltips() { + const target = document.querySelector('.nvtooltip'); + if (target) { + target.style.opacity = 0; + } +} + +export function wrapTooltip(chart, maxWidth) { + const tooltipLayer = chart.useInteractiveGuideline && chart.useInteractiveGuideline() ? + chart.interactiveLayer : chart; + const tooltipGeneratorFunc = tooltipLayer.tooltip.contentGenerator(); + tooltipLayer.tooltip.contentGenerator((d) => { + let tooltip = `
`; + tooltip += tooltipGeneratorFunc(d); + tooltip += '
'; + return tooltip; + }); +} + +export function tipFactory(layer) { + return 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(', ') + '
'; + }); +} + +export function getMaxLabelSize(svg, axisClass) { + // axis class = .nv-y2 // second y axis on dual line chart + // axis class = .nv-x // x axis on time series line chart + const tickTexts = svg.selectAll(`.${axisClass} g.tick text`); + if (tickTexts.length > 0) { + const lengths = tickTexts[0].map(text => text.getComputedTextLength()); + return Math.ceil(Math.max(...lengths)); + } + return 0; +} + +export function formatLabel(input, verboseMap = {}) { + // The input for label may be a string or an array of string + // When using the time shift feature, the label contains a '---' in the array + const verboseLookup = s => verboseMap[s] || s; + return (Array.isArray(input) && input.length) + ? input.map(l => TIME_SHIFT_PATTERN.test(l) ? l : verboseLookup(l)) + .join(', ') + : verboseLookup(input); +} + +const MIN_BAR_WIDTH = 15; + +export function computeBarChartWidth(data, stacked, maxWidth) { + const barCount = stacked + ? d3.max(data, d => d.values.length) + : d3.sum(data, d => d.values.length); + + const barWidth = barCount * MIN_BAR_WIDTH; + return Math.max(barWidth, maxWidth); +} + +export function tryNumify(s) { + // Attempts casting to Number, returns string when failing + const n = Number(s); + return Number.isNaN(n) ? s : n; +} + +export function stringifyTimeRange(extent) { + if (extent.some(d => d.toISOString === undefined)) { + return null; + } + return extent.map(d => d.toISOString() + .slice(0, -1)) + .join(' : '); +} + +export function setAxisShowMaxMin(axis, showminmax) { + if (axis && axis.showMaxMin && showminmax !== undefined) { + axis.showMaxMin(showminmax); + } +}