From 24bef64fbbb94e729b2ce1723f38ff23c766c9d1 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 15 Aug 2019 11:05:46 +0300 Subject: [PATCH 01/13] getredash/redash#2629 Refactor Chart visualization, add option for handling NULL values (keep/convert to 0.0) --- client/app/lib/value-format.js | 9 - .../visualizations/chart/chart-editor.html | 6 + .../app/visualizations/chart/getChartData.js | 6 +- client/app/visualizations/chart/index.js | 2 + .../chart/plotly/applyLayoutFixes.js | 100 +++ .../app/visualizations/chart/plotly/index.js | 18 +- .../chart/plotly/prepareData.js | 12 + .../chart/plotly/prepareDefaultData.js | 177 ++++ .../chart/plotly/prepareHeatmapData.js | 109 +++ .../chart/plotly/prepareLayout.js | 133 +++ .../chart/plotly/preparePieData.js | 96 ++ .../visualizations/chart/plotly/updateData.js | 211 +++++ .../app/visualizations/chart/plotly/utils.js | 819 +----------------- client/app/visualizations/choropleth/utils.js | 7 +- 14 files changed, 865 insertions(+), 840 deletions(-) create mode 100644 client/app/visualizations/chart/plotly/applyLayoutFixes.js create mode 100644 client/app/visualizations/chart/plotly/prepareData.js create mode 100644 client/app/visualizations/chart/plotly/prepareDefaultData.js create mode 100644 client/app/visualizations/chart/plotly/prepareHeatmapData.js create mode 100644 client/app/visualizations/chart/plotly/prepareLayout.js create mode 100644 client/app/visualizations/chart/plotly/preparePieData.js create mode 100644 client/app/visualizations/chart/plotly/updateData.js diff --git a/client/app/lib/value-format.js b/client/app/lib/value-format.js index 4d73741109..263e148cda 100644 --- a/client/app/lib/value-format.js +++ b/client/app/lib/value-format.js @@ -64,15 +64,6 @@ export function createNumberFormatter(format) { return value => toString(value); } -export function createFormatter(column) { - switch (column.displayAs) { - case 'number': return createNumberFormatter(column.numberFormat); - case 'boolean': return createBooleanFormatter(column.booleanValues); - case 'datetime': return createDateTimeFormatter(column.dateTimeFormat); - default: return createTextFormatter(column.allowHTML && column.highlightLinks); - } -} - export function formatSimpleTemplate(str, data) { if (!isString(str)) { return ''; diff --git a/client/app/visualizations/chart/chart-editor.html b/client/app/visualizations/chart/chart-editor.html index a0f43a795c..f782912051 100644 --- a/client/app/visualizations/chart/chart-editor.html +++ b/client/app/visualizations/chart/chart-editor.html @@ -168,6 +168,12 @@ Normalize values to percentage + +
+ +
diff --git a/client/app/visualizations/chart/getChartData.js b/client/app/visualizations/chart/getChartData.js index 8e8f1ebd39..d9255c70c4 100644 --- a/client/app/visualizations/chart/getChartData.js +++ b/client/app/visualizations/chart/getChartData.js @@ -26,12 +26,11 @@ export default function getChartData(data, options) { let sizeValue = null; let zValue = null; - forOwn(row, (v, definition) => { + forOwn(row, (value, definition) => { definition = '' + definition; const definitionParts = definition.split('::') || definition.split('__'); const name = definitionParts[0]; const type = mappings ? mappings[definition] : definitionParts[1]; - let value = v; if (type === 'unused') { return; @@ -42,9 +41,6 @@ export default function getChartData(data, options) { point[type] = value; } if (type === 'y') { - if (value == null) { - value = 0; - } yValues[name] = value; point[type] = value; } diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index f6857717b9..f4da59f3c9 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -27,6 +27,8 @@ const DEFAULT_OPTIONS = { percentFormat: '0[.]00%', // dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }}) + + missingValuesAsZero: true, }; function initEditorForm(options, columns) { diff --git a/client/app/visualizations/chart/plotly/applyLayoutFixes.js b/client/app/visualizations/chart/plotly/applyLayoutFixes.js new file mode 100644 index 0000000000..56b3fa7d5e --- /dev/null +++ b/client/app/visualizations/chart/plotly/applyLayoutFixes.js @@ -0,0 +1,100 @@ +import { find, pick, reduce } from 'lodash'; + +function fixLegendContainer(plotlyElement) { + const legend = plotlyElement.querySelector('.legend'); + if (legend) { + let node = legend.parentNode; + while (node) { + if (node.tagName.toLowerCase() === 'svg') { + node.style.overflow = 'visible'; + break; + } + node = node.parentNode; + } + } +} + +export default function applyLayoutFixes(plotlyElement, layout, updatePlot) { + // update layout size to plot container + layout.width = Math.floor(plotlyElement.offsetWidth); + layout.height = Math.floor(plotlyElement.offsetHeight); + + const transformName = find([ + 'transform', + 'WebkitTransform', + 'MozTransform', + 'MsTransform', + 'OTransform', + ], prop => prop in plotlyElement.style); + + if (layout.width <= 600) { + // change legend orientation to horizontal; plotly has a bug with this + // legend alignment - it does not preserve enough space under the plot; + // so we'll hack this: update plot (it will re-render legend), compute + // legend height, reduce plot size by legend height (but not less than + // half of plot container's height - legend will have max height equal to + // plot height), re-render plot again and offset legend to the space under + // the plot. + layout.legend = { + orientation: 'h', + // locate legend inside of plot area - otherwise plotly will preserve + // some amount of space under the plot; also this will limit legend height + // to plot's height + y: 0, + x: 0, + xanchor: 'left', + yanchor: 'bottom', + }; + + // set `overflow: visible` to svg containing legend because later we will + // position legend outside of it + fixLegendContainer(plotlyElement); + + updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])).then(() => { + const legend = plotlyElement.querySelector('.legend'); // eslint-disable-line no-shadow + if (legend) { + // compute real height of legend - items may be split into few columnns, + // also scrollbar may be shown + const bounds = reduce(legend.querySelectorAll('.traces'), (result, node) => { + const b = node.getBoundingClientRect(); + result = result || b; + return { + top: Math.min(result.top, b.top), + bottom: Math.max(result.bottom, b.bottom), + }; + }, null); + // here we have two values: + // 1. height of plot container excluding height of legend items; + // it may be any value between 0 and plot container's height; + // 2. half of plot containers height. Legend cannot be larger than + // plot; if legend is too large, plotly will reduce it's height and + // show a scrollbar; in this case, height of plot === height of legend, + // so we can split container's height half by half between them. + layout.height = Math.floor(Math.max( + layout.height / 2, + layout.height - (bounds.bottom - bounds.top), + )); + // offset the legend + legend.style[transformName] = 'translate(0, ' + layout.height + 'px)'; + updatePlot(plotlyElement, pick(layout, ['height'])); + } + }); + } else { + layout.legend = { + orientation: 'v', + // vertical legend will be rendered properly, so just place it to the right + // side of plot + y: 1, + x: 1, + xanchor: 'left', + yanchor: 'top', + }; + + const legend = plotlyElement.querySelector('.legend'); + if (legend) { + legend.style[transformName] = null; + } + + updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])); + } +} diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js index 4d0a91d4d0..c0e151f915 100644 --- a/client/app/visualizations/chart/plotly/index.js +++ b/client/app/visualizations/chart/plotly/index.js @@ -7,13 +7,12 @@ import histogram from 'plotly.js/lib/histogram'; import box from 'plotly.js/lib/box'; import heatmap from 'plotly.js/lib/heatmap'; -import { - prepareData, - prepareLayout, - updateData, - updateLayout, - normalizeValue, -} from './utils'; +import { normalizeValue } from './utils'; + +import prepareData from './prepareData'; +import prepareLayout from './prepareLayout'; +import updateData from './updateData'; +import applyLayoutFixes from './applyLayoutFixes'; Plotly.register([bar, pie, histogram, box, heatmap]); Plotly.setPlotConfig({ @@ -41,12 +40,11 @@ const PlotlyChart = () => ({ } data = prepareData(scope.series, scope.options); - updateData(data, scope.options); layout = prepareLayout(plotlyElement, scope.series, scope.options, data); // It will auto-purge previous graph Plotly.newPlot(plotlyElement, data, layout, plotlyOptions).then(() => { - updateLayout(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); + applyLayoutFixes(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); }); plotlyElement.on('plotly_restyle', (updates) => { @@ -72,7 +70,7 @@ const PlotlyChart = () => ({ }, true); scope.handleResize = debounce(() => { - updateLayout(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); + applyLayoutFixes(plotlyElement, layout, (e, u) => Plotly.relayout(e, u)); }, 50); }, }); diff --git a/client/app/visualizations/chart/plotly/prepareData.js b/client/app/visualizations/chart/plotly/prepareData.js new file mode 100644 index 0000000000..e8819d9986 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareData.js @@ -0,0 +1,12 @@ +import preparePieData from './preparePieData'; +import prepareHeatmapData from './prepareHeatmapData'; +import prepareDefaultData from './prepareDefaultData'; +import updateData from './updateData'; + +export default function prepareData(seriesList, options) { + switch (options.globalSeriesType) { + case 'pie': return updateData(preparePieData(seriesList, options), options); + case 'heatmap': return updateData(prepareHeatmapData(seriesList, options, options)); + default: return updateData(prepareDefaultData(seriesList, options), options); + } +} diff --git a/client/app/visualizations/chart/plotly/prepareDefaultData.js b/client/app/visualizations/chart/plotly/prepareDefaultData.js new file mode 100644 index 0000000000..aeedae3ba3 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareDefaultData.js @@ -0,0 +1,177 @@ +import { isNil, each, includes, isString, map, sortBy } from 'lodash'; +import { cleanNumber, normalizeValue, getSeriesAxis } from './utils'; +import { ColorPaletteArray } from '@/visualizations/ColorPalette'; + +function getSeriesColor(seriesOptions, seriesIndex) { + return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length]; +} + +function getFontColor(backgroundColor) { + let result = '#333333'; + if (isString(backgroundColor)) { + let matches = /#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(backgroundColor); + let r; + let g; + let b; + if (matches) { + r = parseInt(matches[1], 16); + g = parseInt(matches[2], 16); + b = parseInt(matches[3], 16); + } else { + matches = /#?([0-9a-f])([0-9a-f])([0-9a-f])/i.exec(backgroundColor); + if (matches) { + r = parseInt(matches[1] + matches[1], 16); + g = parseInt(matches[2] + matches[2], 16); + b = parseInt(matches[3] + matches[3], 16); + } else { + return result; + } + } + + const lightness = r * 0.299 + g * 0.587 + b * 0.114; + if (lightness < 170) { + result = '#ffffff'; + } + } + + return result; +} + +function getHoverInfoPattern(options) { + const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); + const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); + let result = 'text'; + if (!hasX) result += '+x'; + if (!hasName) result += '+name'; + return result; +} + +function prepareBarSeries(series, options) { + series.type = 'bar'; + if (options.showDataLabels) { + series.textposition = 'inside'; + } + return series; +} + +function prepareLineSeries(series, options) { + series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); + return series; +} + +function prepareAreaSeries(series, options) { + series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); + series.fill = options.series.stacking ? 'tonexty' : 'tozeroy'; + return series; +} + +function prepareScatterSeries(series, options) { + series.type = 'scatter'; + series.mode = 'markers' + (options.showDataLabels ? '+text' : ''); + return series; +} + +function prepareBubbleSeries(series, options, { seriesColor, data }) { + series.mode = 'markers'; + series.marker = { + color: seriesColor, + size: map(data, i => i.size), + }; + return series; +} + +function prepareBoxSeries(series, options, { seriesColor }) { + series.type = 'box'; + series.mode = 'markers'; + + series.boxpoints = 'outliers'; + series.hoverinfo = false; + series.marker = { + color: seriesColor, + size: 3, + }; + if (options.showpoints) { + series.boxpoints = 'all'; + series.jitter = 0.3; + series.pointpos = -1.8; + } + return series; +} + +function prepareSeries(series, options, additionalOptions) { + const { hoverInfoPattern, index } = additionalOptions; + + const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; + const seriesColor = getSeriesColor(seriesOptions, index); + const seriesYAxis = getSeriesAxis(series, options); + + // Sort by x - `Map` preserves order of items + const data = options.sortX ? sortBy(series.data, d => normalizeValue(d.x, options.xAxis.type)) : series.data; + + // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size; + // for other types `y` is always number + const cleanYValue = includes(['bubble', 'scatter'], seriesOptions.type) ? normalizeValue : (v) => { + v = cleanNumber(v); + return (options.missingValuesAsZero && isNil(v)) ? 0.0 : v; + }; + + const sourceData = new Map(); + const xValues = []; + const yValues = []; + const yErrorValues = []; + each(data, (row) => { + const x = normalizeValue(row.x, options.xAxis.type); // number/datetime/category + const y = cleanYValue(row.y, seriesYAxis === 'y2' ? options.yAxis[1].type : options.yAxis[0].type); // depends on series type! + const yError = cleanNumber(row.yError); // always number + const size = cleanNumber(row.size); // always number + sourceData.set(x, { + x, + y, + yError, + size, + yPercent: null, // will be updated later + row, + }); + xValues.push(x); + yValues.push(y); + yErrorValues.push(yError); + }); + + const plotlySeries = { + visible: true, + hoverinfo: hoverInfoPattern, + x: xValues, + y: yValues, + error_y: { + array: yErrorValues, + color: seriesColor, + }, + name: seriesOptions.name || series.name, + marker: { color: seriesColor }, + insidetextfont: { + color: getFontColor(seriesColor), + }, + yaxis: seriesYAxis, + sourceData, + }; + + additionalOptions = { ...additionalOptions, seriesColor, data }; + + switch (seriesOptions.type) { + case 'column': return prepareBarSeries(plotlySeries, options, additionalOptions); + case 'line': return prepareLineSeries(plotlySeries, options, additionalOptions); + case 'area': return prepareAreaSeries(plotlySeries, options, additionalOptions); + case 'scatter': return prepareScatterSeries(plotlySeries, options, additionalOptions); + case 'bubble': return prepareBubbleSeries(plotlySeries, options, additionalOptions); + case 'box': return prepareBoxSeries(plotlySeries, options, additionalOptions); + default: return plotlySeries; + } +} + +export default function prepareDefaultData(seriesList, options) { + const additionalOptions = { + hoverInfoPattern: getHoverInfoPattern(options), + }; + + return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index })); +} diff --git a/client/app/visualizations/chart/plotly/prepareHeatmapData.js b/client/app/visualizations/chart/plotly/prepareHeatmapData.js new file mode 100644 index 0000000000..f86e907c5d --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareHeatmapData.js @@ -0,0 +1,109 @@ +import { map, max, uniq, sortBy, flatten } from 'lodash'; +import { createNumberFormatter } from '@/lib/value-format'; + +const defaultColorScheme = [ + [0, '#356aff'], + [0.14, '#4a7aff'], + [0.28, '#5d87ff'], + [0.42, '#7398ff'], + [0.56, '#fb8c8c'], + [0.71, '#ec6463'], + [0.86, '#ec4949'], + [1, '#e92827'], +]; + +function prepareSeries(series, options, additionalOptions) { + const { colorScheme, formatNumber } = additionalOptions; + + const plotlySeries = { + x: [], + y: [], + z: [], + type: 'heatmap', + name: '', + colorscale: colorScheme, + }; + + plotlySeries.x = uniq(map(series.data, v => v.x)); + plotlySeries.y = uniq(map(series.data, v => v.y)); + + if (options.sortX) { + plotlySeries.x = sortBy(plotlySeries.x); + } + + if (options.sortY) { + plotlySeries.y = sortBy(plotlySeries.y); + } + + if (options.reverseX) { + plotlySeries.x.reverse(); + } + + if (options.reverseY) { + plotlySeries.y.reverse(); + } + + const zMax = max(map(series.data, 'zVal')); + + // Use text trace instead of default annotation for better performance + const dataLabels = { + x: [], + y: [], + mode: 'text', + hoverinfo: 'skip', + showlegend: false, + text: [], + textfont: { + color: [], + }, + }; + + for (let i = 0; i < plotlySeries.y.length; i += 1) { + const item = []; + for (let j = 0; j < plotlySeries.x.length; j += 1) { + const datum = find( + series.data, + { x: plotlySeries.x[j], y: plotlySeries.y[i] }, + ); + + const zValue = datum && datum.zVal || 0; + item.push(zValue); + + if (isFinite(zMax) && options.showDataLabels) { + dataLabels.x.push(plotlySeries.x[j]); + dataLabels.y.push(plotlySeries.y[i]); + dataLabels.text.push(formatNumber(zValue)); + if (options.colorScheme && options.colorScheme === 'Custom...') { + dataLabels.textfont.color.push('white'); + } else { + dataLabels.textfont.color.push((zValue / zMax) < 0.25 ? 'white' : 'black'); + } + } + } + plotlySeries.z.push(item); + } + + if (isFinite(zMax) && options.showDataLabels) { + return [plotlySeries, dataLabels]; + } + return [plotlySeries]; +} + +export default function prepareHeatmapData(seriesList, options) { + let colorScheme = []; + + if (!options.colorScheme) { + colorScheme = defaultColorScheme; + } else if (options.colorScheme === 'Custom...') { + colorScheme = [[0, options.heatMinColor], [1, options.heatMaxColor]]; + } else { + colorScheme = options.colorScheme; + } + + const additionalOptions = { + colorScheme, + formatNumber: createNumberFormatter(options.numberFormat), + }; + + return flatten(map(seriesList, series => prepareSeries(series, options, additionalOptions))); +} diff --git a/client/app/visualizations/chart/plotly/prepareLayout.js b/client/app/visualizations/chart/plotly/prepareLayout.js new file mode 100644 index 0000000000..6b0719fa0a --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareLayout.js @@ -0,0 +1,133 @@ +import { filter, has, isArray, isNumber, isObject, isUndefined, map, max, min } from 'lodash'; +import { getSeriesAxis } from './utils'; +import { getPieDimensions } from './preparePieData'; + +function getAxisTitle(axis) { + return isObject(axis.title) ? axis.title.text : null; +} + +function getAxisScaleType(axis) { + switch (axis.type) { + case 'datetime': return 'date'; + case 'logarithmic': return 'log'; + default: return axis.type; + } +} + +function calculateAxisRange(seriesList, minValue, maxValue) { + if (!isNumber(minValue)) { + minValue = Math.min(0, min(map(seriesList, series => min(series.y)))); + } + if (!isNumber(maxValue)) { + maxValue = max(map(seriesList, series => max(series.y))); + } + return [minValue, maxValue]; +} + +function prepareXAxis(axisOptions, additionalOptions) { + axisOptions = axisOptions || {}; + const axis = { + title: getAxisTitle(axisOptions), + type: getAxisScaleType(axisOptions), + automargin: true, + }; + + if (additionalOptions.sortX && axis.type === 'category') { + if (additionalOptions.reverseX) { + axis.categoryorder = 'category descending'; + } else { + axis.categoryorder = 'category ascending'; + } + } + + if (!isUndefined(axisOptions.labels)) { + axis.showticklabels = axisOptions.labels.enabled; + } + + return axis; +} + +function prepareYAxis(axisOptions, additionalOptions, data) { + axisOptions = axisOptions || {}; + + const axis = { + title: getAxisTitle(axisOptions), + type: getAxisScaleType(axisOptions), + automargin: true, + }; + + if (isNumber(axisOptions.rangeMin) || isNumber(axisOptions.rangeMax)) { + axis.range = calculateAxisRange(data, axisOptions.rangeMin, axisOptions.rangeMax); + } + + return axis; +} + +function preparePieLayout(layout, seriesList, options) { + const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); + + const { cellsInRow, cellWidth, cellHeight, xPadding } = getPieDimensions(seriesList); + + if (hasName) { + layout.annotations = []; + } else { + layout.annotations = filter(map(seriesList, (series, index) => { + const xPosition = (index % cellsInRow) * cellWidth; + const yPosition = Math.floor(index / cellsInRow) * cellHeight; + return { + x: xPosition + ((cellWidth - xPadding) / 2), + y: yPosition + cellHeight - 0.015, + xanchor: 'center', + yanchor: 'top', + text: (options.seriesOptions[series.name] || {}).name || series.name, + showarrow: false, + }; + })); + } + + return layout; +} + +function prepareDefaultLayout(layout, seriesList, options, data) { + const hasY2 = !!find(seriesList, series => getSeriesAxis(series, options) === 'y2'); + + layout.xaxis = prepareXAxis(options.xAxis, options); + + if (isArray(options.yAxis)) { + layout.yaxis = prepareYAxis(options.yAxis[0], options, data.filter(s => s.yaxis !== 'y2')); + if (hasY2) { + layout.yaxis2 = prepareYAxis(options.yAxis[1], options, data.filter(s => s.yaxis === 'y2')); + layout.yaxis2.overlaying = 'y'; + layout.yaxis2.side = 'right'; + } + } + + if (options.series.stacking) { + layout.barmode = 'relative'; + } + + return layout; +} + +function prepareBoxLayout(layout, seriesList, options, data) { + layout = prepareDefaultLayout(layout, seriesList, options, data); + layout.boxmode = 'group'; + layout.boxgroupgap = 0.50; + return layout; +} + +export default function prepareLayout(element, seriesList, options, data) { + const layout = { + margin: { l: 10, r: 10, b: 10, t: 25, pad: 4 }, + width: Math.floor(element.offsetWidth), + height: Math.floor(element.offsetHeight), + autosize: true, + showlegend: has(options, 'legend') ? options.legend.enabled : true, + }; + + switch (options.globalSeriesType) { + case 'pie': return preparePieLayout(layout, seriesList, options, data); + case 'box': return prepareBoxLayout(layout, seriesList, options, data); + default: return prepareDefaultLayout(layout, seriesList, options, data); + } +} diff --git a/client/app/visualizations/chart/plotly/preparePieData.js b/client/app/visualizations/chart/plotly/preparePieData.js new file mode 100644 index 0000000000..b8ac696993 --- /dev/null +++ b/client/app/visualizations/chart/plotly/preparePieData.js @@ -0,0 +1,96 @@ +import { each, includes, isString, map, reduce } from 'lodash'; +import d3 from 'd3'; +import { ColorPaletteArray } from '@/visualizations/ColorPalette'; + +import { cleanNumber, normalizeValue } from './utils'; + +export function getPieDimensions(series) { + const rows = series.length > 2 ? 2 : 1; + const cellsInRow = Math.ceil(series.length / rows); + const cellWidth = 1 / cellsInRow; + const cellHeight = 1 / rows; + const xPadding = 0.02; + const yPadding = 0.1; + + return { rows, cellsInRow, cellWidth, cellHeight, xPadding, yPadding }; +} + +function getPieHoverInfoPattern(options) { + const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); + let result = 'text'; + if (!hasX) result += '+label'; + return result; +} + +function prepareSeries(series, options, additionalOptions) { + const { + cellWidth, cellHeight, xPadding, yPadding, cellsInRow, hasX, + index, hoverInfoPattern, getValueColor, + } = additionalOptions; + + const xPosition = (index % cellsInRow) * cellWidth; + const yPosition = Math.floor(index / cellsInRow) * cellHeight; + + const labels = []; + const values = []; + const sourceData = new Map(); + const seriesTotal = reduce(series.data, (result, row) => { + const y = cleanNumber(row.y); + return result + Math.abs(y); + }, 0); + each(series.data, (row) => { + const x = hasX ? normalizeValue(row.x, options.xAxis.type) : `Slice ${index}`; + const y = cleanNumber(row.y); + labels.push(x); + values.push(y); + sourceData.set(x, { + x, + y, + yPercent: y / seriesTotal * 100, + row, + }); + }); + + return { + visible: true, + values, + labels, + type: 'pie', + hole: 0.4, + marker: { + colors: map(series.data, row => getValueColor(row.x)), + }, + hoverinfo: hoverInfoPattern, + text: [], + textinfo: options.showDataLabels ? 'percent' : 'none', + textposition: 'inside', + textfont: { color: '#ffffff' }, + name: series.name, + direction: options.direction.type, + domain: { + x: [xPosition, xPosition + cellWidth - xPadding], + y: [yPosition, yPosition + cellHeight - yPadding], + }, + sourceData, + }; +} + +export default function preparePieData(seriesList, options) { + // we will use this to assign colors for values that have no explicitly set color + const getDefaultColor = d3.scale.ordinal().domain([]).range(ColorPaletteArray); + const valuesColors = {}; + each(options.valuesOptions, (item, key) => { + if (isString(item.color) && (item.color !== '')) { + valuesColors[key] = item.color; + } + }); + + const additionalOptions = { + ...getPieDimensions(seriesList), + hasX: includes(options.columnMapping, 'x'), + hoverInfoPattern: getPieHoverInfoPattern(options), + getValueColor: v => valuesColors[v] || getDefaultColor(v), + }; + + return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index })); +} diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js new file mode 100644 index 0000000000..c221af2b3c --- /dev/null +++ b/client/app/visualizations/chart/plotly/updateData.js @@ -0,0 +1,211 @@ +import { each, extend, filter, identity, includes, map, sortBy, get } from 'lodash'; +import { createNumberFormatter, formatSimpleTemplate } from '@/lib/value-format'; +import { normalizeValue } from './utils'; + +function defaultFormatSeriesText(item) { + let result = item['@@y']; + if (item['@@yError'] !== undefined) { + result = `${result} \u00B1 ${item['@@yError']}`; + } + if (item['@@yPercent'] !== undefined) { + result = `${item['@@yPercent']} (${result})`; + } + if (item['@@size'] !== undefined) { + result = `${result}: ${item['@@size']}`; + } + return result; +} + +function defaultFormatSeriesTextForPie(item) { + return item['@@yPercent'] + ' (' + item['@@y'] + ')'; +} + +function createTextFormatter(options) { + if (options.textFormat === '') { + return options.globalSeriesType === 'pie' ? defaultFormatSeriesTextForPie : defaultFormatSeriesText; + } + return item => formatSimpleTemplate(options.textFormat, item); +} + +function formatValue(value, axis, options) { + let axisType = null; + switch (axis) { + case 'x': axisType = get(options, 'xAxis.type', null); break; + case 'y': axisType = get(options, 'yAxis[0].type', null); break; + case 'y2': axisType = get(options, 'yAxis[1].type', null); break; + // no default + } + return normalizeValue(value, axisType, options.dateTimeFormat); +} + +function updateSeriesText(seriesList, options) { + const formatNumber = createNumberFormatter(options.numberFormat); + const formatPercent = createNumberFormatter(options.percentFormat); + const formatText = createTextFormatter(options); + + each(seriesList, (series) => { + const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; + + series.text = []; + series.hover = []; + const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x; + xValues.forEach((x) => { + const text = { + '@@name': series.name, + }; + const item = series.sourceData.get(x); + if (item) { + const yValueIsAny = includes(['bubble', 'scatter'], seriesOptions.type); + + text['@@x'] = formatValue(item.row.x, 'x', options); + text['@@y'] = yValueIsAny ? formatValue(item.row.y, series.yaxis, options) : formatNumber(item.y); + if (item.yError !== undefined) { + text['@@yError'] = formatNumber(item.yError); + } + if (item.size !== undefined) { + text['@@size'] = formatNumber(item.size); + } + + if (options.series.percentValues || (options.globalSeriesType === 'pie')) { + text['@@yPercent'] = formatPercent(Math.abs(item.yPercent)); + } + + extend(text, item.row.$raw); + } + + series.text.push(formatText(text)); + }); + }); +} + +function updatePercentValues(seriesList, options) { + if (options.series.percentValues) { + // Some series may not have corresponding x-values; + // do calculations for each x only for series that do have that x + const sumOfCorrespondingPoints = new Map(); + each(seriesList, (series) => { + series.sourceData.forEach((item) => { + const sum = sumOfCorrespondingPoints.get(item.x) || 0; + sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y)); + }); + }); + + each(seriesList, (series) => { + const yValues = []; + + series.sourceData.forEach((item) => { + const sum = sumOfCorrespondingPoints.get(item.x); + item.yPercent = item.y / sum * 100; + yValues.push(item.yPercent); + }); + + series.y = yValues; + }); + } +} + +function getUnifiedXAxisValues(seriesList, sorted) { + const set = new Set(); + each(seriesList, (series) => { + // `Map.forEach` will walk items in insertion order + series.sourceData.forEach((item) => { + set.add(item.x); + }); + }); + + const result = []; + // `Set.forEach` will walk items in insertion order + set.forEach((item) => { + result.push(item); + }); + + return sorted ? sortBy(result, identity) : result; +} + +function updateUnifiedXAxisValues(seriesList, options, defaultY) { + const unifiedX = getUnifiedXAxisValues(seriesList, options.sortX); + defaultY = defaultY === undefined ? null : defaultY; + each(seriesList, (series) => { + series.x = []; + series.y = []; + series.error_y.array = []; + each(unifiedX, (x) => { + series.x.push(x); + const item = series.sourceData.get(x); + if (item) { + series.y.push(options.series.percentValues ? item.yPercent : item.y); + series.error_y.array.push(item.yError); + } else { + series.y.push(defaultY); + series.error_y.array.push(null); + } + }); + }); +} + +function updatePieData(seriesList, options) { + updateSeriesText(seriesList, options); +} + +function updateLineAreaData(seriesList, options) { + // Apply "percent values" modification + updatePercentValues(seriesList, options); + if (options.series.stacking) { + updateUnifiedXAxisValues(seriesList, options, 0); + + // Calculate cumulative value for each x tick + let prevSeries = null; + each(seriesList, (series) => { + if (prevSeries) { + series.y = map(series.y, (y, i) => prevSeries.y[i] + y); + } + prevSeries = series; + }); + } else { + const useUnifiedXAxis = options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); + if (useUnifiedXAxis) { + updateUnifiedXAxisValues(seriesList, options); + } + } + + // Finally - update text labels + updateSeriesText(seriesList, options); +} + +function updateDefaultData(seriesList, options) { + // Apply "percent values" modification + updatePercentValues(seriesList, options); + + if (!options.series.stacking) { + const useUnifiedXAxis = options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); + if (useUnifiedXAxis) { + updateUnifiedXAxisValues(seriesList, options); + } + } + + // Finally - update text labels + updateSeriesText(seriesList, options); +} + +export default function updateData(seriesList, options) { + // Use only visible series + const visibleSeriesList = filter(seriesList, s => s.visible === true); + + if (visibleSeriesList.length > 0) { + switch (options.globalSeriesType) { + case 'pie': + updatePieData(visibleSeriesList, options); + break; + case 'line': + case 'area': + updateLineAreaData(visibleSeriesList, options); + break; + case 'heatmap': + break; + default: + updateDefaultData(visibleSeriesList, options); + break; + } + } + return seriesList; +} diff --git a/client/app/visualizations/chart/plotly/utils.js b/client/app/visualizations/chart/plotly/utils.js index 0c02734baf..006339c46f 100644 --- a/client/app/visualizations/chart/plotly/utils.js +++ b/client/app/visualizations/chart/plotly/utils.js @@ -1,80 +1,17 @@ -import { - isArray, isNumber, isString, isUndefined, includes, min, max, has, find, - each, values, sortBy, identity, filter, map, extend, reduce, pick, flatten, uniq, -} from 'lodash'; +import { isUndefined } from 'lodash'; import moment from 'moment'; -import d3 from 'd3'; import plotlyCleanNumber from 'plotly.js/src/lib/clean_number'; -import { createFormatter, formatSimpleTemplate } from '@/lib/value-format'; -import { ColorPaletteArray } from '@/visualizations/ColorPalette'; -function cleanNumber(value) { - return isUndefined(value) ? value : (plotlyCleanNumber(value) || 0.0); +export function cleanNumber(value) { + return isUndefined(value) ? value : plotlyCleanNumber(value); } -function defaultFormatSeriesText(item) { - let result = item['@@y']; - if (item['@@yError'] !== undefined) { - result = `${result} \u00B1 ${item['@@yError']}`; +export function getSeriesAxis(series, options) { + const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; + if ((seriesOptions.yAxis === 1) && (!options.series.stacking || (seriesOptions.type === 'line'))) { + return 'y2'; } - if (item['@@yPercent'] !== undefined) { - result = `${item['@@yPercent']} (${result})`; - } - if (item['@@size'] !== undefined) { - result = `${result}: ${item['@@size']}`; - } - return result; -} - -function defaultFormatSeriesTextForPie(item) { - return item['@@yPercent'] + ' (' + item['@@y'] + ')'; -} - -function getFontColor(bgcolor) { - let result = '#333333'; - if (isString(bgcolor)) { - let matches = /#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(bgcolor); - let r; - let g; - let b; - if (matches) { - r = parseInt(matches[1], 16); - g = parseInt(matches[2], 16); - b = parseInt(matches[3], 16); - } else { - matches = /#?([0-9a-f])([0-9a-f])([0-9a-f])/i.exec(bgcolor); - if (matches) { - r = parseInt(matches[1] + matches[1], 16); - g = parseInt(matches[2] + matches[2], 16); - b = parseInt(matches[3] + matches[3], 16); - } else { - return result; - } - } - - const lightness = r * 0.299 + g * 0.587 + b * 0.114; - if (lightness < 170) { - result = '#ffffff'; - } - } - - return result; -} - -function getPieHoverInfoPattern(options) { - const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); - let result = 'text'; - if (!hasX) result += '+label'; - return result; -} - -function getHoverInfoPattern(options) { - const hasX = /{{\s*@@x\s*}}/.test(options.textFormat); - const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); - let result = 'text'; - if (!hasX) result += '+x'; - if (!hasName) result += '+name'; - return result; + return 'y'; } export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH:mm:ss') { @@ -86,743 +23,3 @@ export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH: } return value; } - -function naturalSort($a, $b) { - if ($a === $b) { - return 0; - } else if ($a < $b) { - return -1; - } - return 1; -} - -function calculateAxisRange(seriesList, minValue, maxValue) { - if (!isNumber(minValue)) { - minValue = Math.min(0, min(map(seriesList, series => min(series.y)))); - } - if (!isNumber(maxValue)) { - maxValue = max(map(seriesList, series => max(series.y))); - } - return [minValue, maxValue]; -} - -function getScaleType(scale) { - if (scale === 'datetime') { - return 'date'; - } - if (scale === 'logarithmic') { - return 'log'; - } - return scale; -} - -function getSeriesColor(seriesOptions, seriesIndex) { - return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length]; -} - -function getTitle(axis) { - if (!isUndefined(axis) && !isUndefined(axis.title)) { - return axis.title.text; - } - return null; -} - -function setType(series, type, options) { - switch (type) { - case 'column': - series.type = 'bar'; - if (options.showDataLabels) { - series.textposition = 'inside'; - } - break; - case 'line': - series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); - break; - case 'area': - series.mode = 'lines' + (options.showDataLabels ? '+text' : ''); - series.fill = options.series.stacking === null ? 'tozeroy' : 'tonexty'; - break; - case 'scatter': - series.type = 'scatter'; - series.mode = 'markers' + (options.showDataLabels ? '+text' : ''); - break; - case 'bubble': - series.mode = 'markers'; - break; - case 'box': - series.type = 'box'; - series.mode = 'markers'; - break; - default: - break; - } -} - -function calculateDimensions(series, options) { - const rows = series.length > 2 ? 2 : 1; - const cellsInRow = Math.ceil(series.length / rows); - const cellWidth = 1 / cellsInRow; - const cellHeight = 1 / rows; - const xPadding = 0.02; - const yPadding = 0.1; - - const hasX = includes(values(options.columnMapping), 'x'); - const hasY2 = !!find(series, (serie) => { - const seriesOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType }; - return (seriesOptions.yAxis === 1) && ( - (options.series.stacking === null) || (seriesOptions.type === 'line') - ); - }); - - return { - rows, cellsInRow, cellWidth, cellHeight, xPadding, yPadding, hasX, hasY2, - }; -} - -function getUnifiedXAxisValues(seriesList, sorted) { - const set = new Set(); - each(seriesList, (series) => { - // `Map.forEach` will walk items in insertion order - series.sourceData.forEach((item) => { - set.add(item.x); - }); - }); - - const result = []; - // `Set.forEach` will walk items in insertion order - set.forEach((item) => { - result.push(item); - }); - - return sorted ? sortBy(result, identity) : result; -} - -function preparePieData(seriesList, options) { - const { - cellWidth, cellHeight, xPadding, yPadding, cellsInRow, hasX, - } = calculateDimensions(seriesList, options); - - const formatNumber = createFormatter({ - displayAs: 'number', - numberFormat: options.numberFormat, - }); - const formatPercent = createFormatter({ - displayAs: 'number', - numberFormat: options.percentFormat, - }); - const formatText = options.textFormat === '' - ? defaultFormatSeriesTextForPie : - item => formatSimpleTemplate(options.textFormat, item); - - const hoverinfo = getPieHoverInfoPattern(options); - - // we will use this to assign colors for values that have not explicitly set color - const getDefaultColor = d3.scale.ordinal().domain([]).range(ColorPaletteArray); - const valuesColors = {}; - each(options.valuesOptions, (item, key) => { - if (isString(item.color) && (item.color !== '')) { - valuesColors[key] = item.color; - } - }); - - return map(seriesList, (serie, index) => { - const xPosition = (index % cellsInRow) * cellWidth; - const yPosition = Math.floor(index / cellsInRow) * cellHeight; - - const sourceData = new Map(); - const seriesTotal = reduce(serie.data, (result, row) => { - const y = cleanNumber(row.y); - return result + Math.abs(y); - }, 0); - each(serie.data, (row) => { - const x = normalizeValue(row.x); - const y = cleanNumber(row.y); - sourceData.set(x, { - x, - y, - yPercent: y / seriesTotal * 100, - raw: extend({}, row.$raw, { - // use custom display format - see also `updateSeriesText` - '@@x': normalizeValue(row.x, options.xAxis.type, options.dateTimeFormat), - }), - }); - }); - - return { - values: map(serie.data, i => i.y), - labels: map(serie.data, row => (hasX ? normalizeValue(row.x) : `Slice ${index}`)), - type: 'pie', - hole: 0.4, - marker: { - colors: map(serie.data, row => valuesColors[row.x] || getDefaultColor(row.x)), - }, - hoverinfo, - text: [], - textinfo: options.showDataLabels ? 'percent' : 'none', - textposition: 'inside', - textfont: { color: '#ffffff' }, - name: serie.name, - direction: options.direction.type, - domain: { - x: [xPosition, xPosition + cellWidth - xPadding], - y: [yPosition, yPosition + cellHeight - yPadding], - }, - sourceData, - formatNumber, - formatPercent, - formatText, - }; - }); -} - -function prepareHeatmapData(seriesList, options) { - const defaultColorScheme = [ - [0, '#356aff'], - [0.14, '#4a7aff'], - [0.28, '#5d87ff'], - [0.42, '#7398ff'], - [0.56, '#fb8c8c'], - [0.71, '#ec6463'], - [0.86, '#ec4949'], - [1, '#e92827'], - ]; - - const formatNumber = createFormatter({ - displayAs: 'number', - numberFormat: options.numberFormat, - }); - - let colorScheme = []; - - if (!options.colorScheme) { - colorScheme = defaultColorScheme; - } else if (options.colorScheme === 'Custom...') { - colorScheme = [[0, options.heatMinColor], [1, options.heatMaxColor]]; - } else { - colorScheme = options.colorScheme; - } - - return map(seriesList, (series) => { - const plotlySeries = { - x: [], - y: [], - z: [], - type: 'heatmap', - name: '', - colorscale: colorScheme, - }; - - plotlySeries.x = uniq(map(series.data, 'x')); - plotlySeries.y = uniq(map(series.data, 'y')); - - if (options.sortX) { - plotlySeries.x.sort(naturalSort); - } - - if (options.sortY) { - plotlySeries.y.sort(naturalSort); - } - - if (options.reverseX) { - plotlySeries.x.reverse(); - } - - if (options.reverseY) { - plotlySeries.y.reverse(); - } - - const zMax = max(map(series.data, 'zVal')); - - // Use text trace instead of default annotation for better performance - const dataLabels = { - x: [], - y: [], - mode: 'text', - hoverinfo: 'skip', - showlegend: false, - text: [], - textfont: { - color: [], - }, - }; - - for (let i = 0; i < plotlySeries.y.length; i += 1) { - const item = []; - for (let j = 0; j < plotlySeries.x.length; j += 1) { - const datum = find( - series.data, - { x: plotlySeries.x[j], y: plotlySeries.y[i] }, - ); - - const zValue = datum ? datum.zVal : 0; - item.push(zValue); - - if (isFinite(zMax) && options.showDataLabels) { - dataLabels.x.push(plotlySeries.x[j]); - dataLabels.y.push(plotlySeries.y[i]); - dataLabels.text.push(formatNumber(zValue)); - if (options.colorScheme && options.colorScheme === 'Custom...') { - dataLabels.textfont.color.push('white'); - } else { - dataLabels.textfont.color.push((zValue / zMax) < 0.25 ? 'white' : 'black'); - } - } - } - plotlySeries.z.push(item); - } - - if (isFinite(zMax) && options.showDataLabels) { - return [plotlySeries, dataLabels]; - } - return [plotlySeries]; - }); -} - -function prepareChartData(seriesList, options) { - const sortX = (options.sortX === true) || (options.sortX === undefined); - - const formatNumber = createFormatter({ - displayAs: 'number', - numberFormat: options.numberFormat, - }); - const formatPercent = createFormatter({ - displayAs: 'number', - numberFormat: options.percentFormat, - }); - const formatText = options.textFormat === '' - ? defaultFormatSeriesText : - item => formatSimpleTemplate(options.textFormat, item); - - const hoverinfo = getHoverInfoPattern(options); - - return map(seriesList, (series, index) => { - const seriesOptions = options.seriesOptions[series.name] || - { type: options.globalSeriesType }; - - const seriesColor = getSeriesColor(seriesOptions, index); - - // Sort by x - `Map` preserves order of items - const data = sortX ? sortBy(series.data, d => normalizeValue(d.x, options.xAxis.type)) : series.data; - - // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size; - // for other types `y` is always number - const cleanYValue = includes(['bubble', 'scatter'], seriesOptions.type) ? normalizeValue : cleanNumber; - - const sourceData = new Map(); - const xValues = []; - const yValues = []; - const yErrorValues = []; - each(data, (row) => { - const x = normalizeValue(row.x, options.xAxis.type); // number/datetime/category - const y = cleanYValue(row.y, options.yAxis[0].type); // depends on series type! - const yError = cleanNumber(row.yError); // always number - const size = cleanNumber(row.size); // always number - sourceData.set(x, { - x, - y, - yError, - size, - yPercent: null, // will be updated later - raw: extend({}, row.$raw, { - // use custom display format - see also `updateSeriesText` - '@@x': normalizeValue(row.x, options.xAxis.type, options.dateTimeFormat), - }), - }); - xValues.push(x); - yValues.push(y); - yErrorValues.push(yError); - }); - - const plotlySeries = { - visible: true, - hoverinfo, - x: xValues, - y: yValues, - error_y: { - array: yErrorValues, - color: seriesColor, - }, - name: seriesOptions.name || series.name, - marker: { color: seriesColor }, - insidetextfont: { - color: getFontColor(seriesColor), - }, - sourceData, - formatNumber, - formatPercent, - formatText, - }; - - if ( - (seriesOptions.yAxis === 1) && - ((options.series.stacking === null) || (seriesOptions.type === 'line')) - ) { - plotlySeries.yaxis = 'y2'; - } - - setType(plotlySeries, seriesOptions.type, options); - - if (seriesOptions.type === 'bubble') { - plotlySeries.marker = { - color: seriesColor, - size: map(data, i => i.size), - }; - } else if (seriesOptions.type === 'box') { - plotlySeries.boxpoints = 'outliers'; - plotlySeries.hoverinfo = false; - plotlySeries.marker = { - color: seriesColor, - size: 3, - }; - if (options.showpoints) { - plotlySeries.boxpoints = 'all'; - plotlySeries.jitter = 0.3; - plotlySeries.pointpos = -1.8; - } - } - - return plotlySeries; - }); -} - -export function prepareData(seriesList, options) { - if (options.globalSeriesType === 'pie') { - return preparePieData(seriesList, options); - } - if (options.globalSeriesType === 'heatmap') { - return flatten(prepareHeatmapData(seriesList, options)); - } - return prepareChartData(seriesList, options); -} - -export function prepareLayout(element, seriesList, options, data) { - const { - cellsInRow, cellWidth, cellHeight, xPadding, hasY2, - } = calculateDimensions(seriesList, options); - - const result = { - margin: { - l: 10, - r: 10, - b: 10, - t: 25, - pad: 4, - }, - width: Math.floor(element.offsetWidth), - height: Math.floor(element.offsetHeight), - autosize: true, - showlegend: has(options, 'legend') ? options.legend.enabled : true, - }; - - if (options.globalSeriesType === 'pie') { - const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); - - if (hasName) { - result.annotations = []; - } else { - result.annotations = filter(map(seriesList, (series, index) => { - const xPosition = (index % cellsInRow) * cellWidth; - const yPosition = Math.floor(index / cellsInRow) * cellHeight; - return { - x: xPosition + ((cellWidth - xPadding) / 2), - y: yPosition + cellHeight - 0.015, - xanchor: 'center', - yanchor: 'top', - text: (options.seriesOptions[series.name] || {}).name || series.name, - showarrow: false, - }; - })); - } - } else { - if (options.globalSeriesType === 'box') { - result.boxmode = 'group'; - result.boxgroupgap = 0.50; - } - - result.xaxis = { - title: getTitle(options.xAxis), - type: getScaleType(options.xAxis.type), - automargin: true, - }; - - if (options.sortX && result.xaxis.type === 'category') { - if (options.reverseX) { - result.xaxis.categoryorder = 'category descending'; - } else { - result.xaxis.categoryorder = 'category ascending'; - } - } - - if (!isUndefined(options.xAxis.labels)) { - result.xaxis.showticklabels = options.xAxis.labels.enabled; - } - - if (isArray(options.yAxis)) { - result.yaxis = { - title: getTitle(options.yAxis[0]), - type: getScaleType(options.yAxis[0].type), - automargin: true, - }; - - if (isNumber(options.yAxis[0].rangeMin) || isNumber(options.yAxis[0].rangeMax)) { - result.yaxis.range = calculateAxisRange( - data.filter(s => !s.yaxis !== 'y2'), - options.yAxis[0].rangeMin, - options.yAxis[0].rangeMax, - ); - } - } - - if (hasY2 && !isUndefined(options.yAxis)) { - result.yaxis2 = { - title: getTitle(options.yAxis[1]), - type: getScaleType(options.yAxis[1].type), - overlaying: 'y', - side: 'right', - automargin: true, - }; - - if (isNumber(options.yAxis[1].rangeMin) || isNumber(options.yAxis[1].rangeMax)) { - result.yaxis2.range = calculateAxisRange( - data.filter(s => s.yaxis === 'y2'), - options.yAxis[1].rangeMin, - options.yAxis[1].rangeMax, - ); - } - } - - if (options.series.stacking) { - result.barmode = 'relative'; - } - } - - return result; -} - -function updateSeriesText(seriesList, options) { - each(seriesList, (series) => { - const seriesOptions = options.seriesOptions[series.name] || - { type: options.globalSeriesType }; - - series.text = []; - series.hover = []; - const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x; - xValues.forEach((x) => { - const text = { - '@@name': series.name, - // '@@x' is already in `item.$raw` - }; - const item = series.sourceData.get(x); - if (item) { - text['@@y'] = includes(['bubble', 'scatter'], seriesOptions.type) ? item.y : series.formatNumber(item.y); - if (item.yError !== undefined) { - text['@@yError'] = series.formatNumber(item.yError); - } - if (item.size !== undefined) { - text['@@size'] = series.formatNumber(item.size); - } - - if (options.series.percentValues || (options.globalSeriesType === 'pie')) { - text['@@yPercent'] = series.formatPercent(Math.abs(item.yPercent)); - } - - extend(text, item.raw); - } - - series.text.push(series.formatText(text)); - }); - }); - return seriesList; -} - -function updatePercentValues(seriesList, options) { - if (options.series.percentValues && (seriesList.length > 0)) { - // Some series may not have corresponding x-values; - // do calculations for each x only for series that do have that x - const sumOfCorrespondingPoints = new Map(); - each(seriesList, (series) => { - series.sourceData.forEach((item) => { - const sum = sumOfCorrespondingPoints.get(item.x) || 0; - sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y)); - }); - }); - - each(seriesList, (series) => { - const yValues = []; - - series.sourceData.forEach((item) => { - const sum = sumOfCorrespondingPoints.get(item.x); - item.yPercent = Math.sign(item.y) * Math.abs(item.y) / sum * 100; - yValues.push(item.yPercent); - }); - - series.y = yValues; - }); - } - - return seriesList; -} - -function updateUnifiedXAxisValues(seriesList, options, sorted, defaultY) { - const unifiedX = getUnifiedXAxisValues(seriesList, sorted); - defaultY = defaultY === undefined ? null : defaultY; - each(seriesList, (series) => { - series.x = []; - series.y = []; - series.error_y.array = []; - each(unifiedX, (x) => { - series.x.push(x); - const item = series.sourceData.get(x); - if (item) { - series.y.push(options.series.percentValues ? item.yPercent : item.y); - series.error_y.array.push(item.yError); - } else { - series.y.push(defaultY); - series.error_y.array.push(null); - } - }); - }); -} - -export function updateData(seriesList, options) { - if (seriesList.length === 0) { - return seriesList; - } - if (options.globalSeriesType === 'pie') { - updateSeriesText(seriesList, options); - return seriesList; - } - if (options.globalSeriesType === 'heatmap') { - return seriesList; - } - - // Use only visible series - seriesList = filter(seriesList, s => s.visible === true); - - // Apply "percent values" modification - updatePercentValues(seriesList, options); - - const sortX = (options.sortX === true) || (options.sortX === undefined); - - if (options.series.stacking) { - if (['line', 'area'].indexOf(options.globalSeriesType) >= 0) { - updateUnifiedXAxisValues(seriesList, options, sortX, 0); - - // Calculate cumulative value for each x tick - let prevSeries = null; - each(seriesList, (series) => { - if (prevSeries) { - series.y = map(series.y, (y, i) => prevSeries.y[i] + y); - } - prevSeries = series; - }); - } - } else { - const useUnifiedXAxis = sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); - if (useUnifiedXAxis) { - updateUnifiedXAxisValues(seriesList, options, sortX); - } - } - - // Finally - update text labels - updateSeriesText(seriesList, options); -} - -function fixLegendContainer(plotlyElement) { - const legend = plotlyElement.querySelector('.legend'); - if (legend) { - let node = legend.parentNode; - while (node) { - if (node.tagName.toLowerCase() === 'svg') { - node.style.overflow = 'visible'; - break; - } - node = node.parentNode; - } - } -} - -export function updateLayout(plotlyElement, layout, updatePlot) { - // update layout size to plot container - layout.width = Math.floor(plotlyElement.offsetWidth); - layout.height = Math.floor(plotlyElement.offsetHeight); - - const transformName = find([ - 'transform', - 'WebkitTransform', - 'MozTransform', - 'MsTransform', - 'OTransform', - ], prop => prop in plotlyElement.style); - - if (layout.width <= 600) { - // change legend orientation to horizontal; plotly has a bug with this - // legend alignment - it does not preserve enough space under the plot; - // so we'll hack this: update plot (it will re-render legend), compute - // legend height, reduce plot size by legend height (but not less than - // half of plot container's height - legend will have max height equal to - // plot height), re-render plot again and offset legend to the space under - // the plot. - layout.legend = { - orientation: 'h', - // locate legend inside of plot area - otherwise plotly will preserve - // some amount of space under the plot; also this will limit legend height - // to plot's height - y: 0, - x: 0, - xanchor: 'left', - yanchor: 'bottom', - }; - - // set `overflow: visible` to svg containing legend because later we will - // position legend outside of it - fixLegendContainer(plotlyElement); - - updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])).then(() => { - const legend = plotlyElement.querySelector('.legend'); // eslint-disable-line no-shadow - if (legend) { - // compute real height of legend - items may be split into few columnns, - // also scrollbar may be shown - const bounds = reduce(legend.querySelectorAll('.traces'), (result, node) => { - const b = node.getBoundingClientRect(); - result = result || b; - return { - top: Math.min(result.top, b.top), - bottom: Math.max(result.bottom, b.bottom), - }; - }, null); - // here we have two values: - // 1. height of plot container excluding height of legend items; - // it may be any value between 0 and plot container's height; - // 2. half of plot containers height. Legend cannot be larger than - // plot; if legend is too large, plotly will reduce it's height and - // show a scrollbar; in this case, height of plot === height of legend, - // so we can split container's height half by half between them. - layout.height = Math.floor(Math.max( - layout.height / 2, - layout.height - (bounds.bottom - bounds.top), - )); - // offset the legend - legend.style[transformName] = 'translate(0, ' + layout.height + 'px)'; - updatePlot(plotlyElement, pick(layout, ['height'])); - } - }); - } else { - layout.legend = { - orientation: 'v', - // vertical legend will be rendered properly, so just place it to the right - // side of plot - y: 1, - x: 1, - xanchor: 'left', - yanchor: 'top', - }; - - const legend = plotlyElement.querySelector('.legend'); - if (legend) { - legend.style[transformName] = null; - } - - updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])); - } -} diff --git a/client/app/visualizations/choropleth/utils.js b/client/app/visualizations/choropleth/utils.js index 15b3e80544..4b65171e59 100644 --- a/client/app/visualizations/choropleth/utils.js +++ b/client/app/visualizations/choropleth/utils.js @@ -1,6 +1,6 @@ import chroma from 'chroma-js'; import _ from 'lodash'; -import { createFormatter } from '@/lib/value-format'; +import { createNumberFormatter as createFormatter } from '@/lib/value-format'; export const AdditionalColors = { White: '#ffffff', @@ -13,10 +13,7 @@ export function darkenColor(color) { } export function createNumberFormatter(format, placeholder) { - const formatter = createFormatter({ - displayAs: 'number', - numberFormat: format, - }); + const formatter = createFormatter(format); return (value) => { if (_.isNumber(value) && isFinite(value)) { return formatter(value); From f7f4604467d9fd29763c17d9d637337ccac503ac Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 15 Aug 2019 14:19:57 +0300 Subject: [PATCH 02/13] Handle null values in line/area stacking code; some cleanup --- .../chart/plotly/prepareHeatmapData.js | 2 +- .../chart/plotly/prepareLayout.js | 17 +++----- .../visualizations/chart/plotly/updateData.js | 43 ++++++++++--------- 3 files changed, 29 insertions(+), 33 deletions(-) diff --git a/client/app/visualizations/chart/plotly/prepareHeatmapData.js b/client/app/visualizations/chart/plotly/prepareHeatmapData.js index f86e907c5d..f5b8a29f9f 100644 --- a/client/app/visualizations/chart/plotly/prepareHeatmapData.js +++ b/client/app/visualizations/chart/plotly/prepareHeatmapData.js @@ -43,7 +43,7 @@ function prepareSeries(series, options, additionalOptions) { plotlySeries.y.reverse(); } - const zMax = max(map(series.data, 'zVal')); + const zMax = max(map(series.data, d => d.zVal)); // Use text trace instead of default annotation for better performance const dataLabels = { diff --git a/client/app/visualizations/chart/plotly/prepareLayout.js b/client/app/visualizations/chart/plotly/prepareLayout.js index 6b0719fa0a..f4379db1bc 100644 --- a/client/app/visualizations/chart/plotly/prepareLayout.js +++ b/client/app/visualizations/chart/plotly/prepareLayout.js @@ -1,4 +1,4 @@ -import { filter, has, isArray, isNumber, isObject, isUndefined, map, max, min } from 'lodash'; +import { filter, has, isNumber, isObject, isUndefined, map, max, min } from 'lodash'; import { getSeriesAxis } from './utils'; import { getPieDimensions } from './preparePieData'; @@ -25,7 +25,6 @@ function calculateAxisRange(seriesList, minValue, maxValue) { } function prepareXAxis(axisOptions, additionalOptions) { - axisOptions = axisOptions || {}; const axis = { title: getAxisTitle(axisOptions), type: getAxisScaleType(axisOptions), @@ -48,8 +47,6 @@ function prepareXAxis(axisOptions, additionalOptions) { } function prepareYAxis(axisOptions, additionalOptions, data) { - axisOptions = axisOptions || {}; - const axis = { title: getAxisTitle(axisOptions), type: getAxisScaleType(axisOptions), @@ -93,13 +90,11 @@ function prepareDefaultLayout(layout, seriesList, options, data) { layout.xaxis = prepareXAxis(options.xAxis, options); - if (isArray(options.yAxis)) { - layout.yaxis = prepareYAxis(options.yAxis[0], options, data.filter(s => s.yaxis !== 'y2')); - if (hasY2) { - layout.yaxis2 = prepareYAxis(options.yAxis[1], options, data.filter(s => s.yaxis === 'y2')); - layout.yaxis2.overlaying = 'y'; - layout.yaxis2.side = 'right'; - } + layout.yaxis = prepareYAxis(options.yAxis[0], options, data.filter(s => s.yaxis !== 'y2')); + if (hasY2) { + layout.yaxis2 = prepareYAxis(options.yAxis[1], options, data.filter(s => s.yaxis === 'y2')); + layout.yaxis2.overlaying = 'y'; + layout.yaxis2.side = 'right'; } if (options.series.stacking) { diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js index c221af2b3c..03b4e75c28 100644 --- a/client/app/visualizations/chart/plotly/updateData.js +++ b/client/app/visualizations/chart/plotly/updateData.js @@ -1,7 +1,11 @@ -import { each, extend, filter, identity, includes, map, sortBy, get } from 'lodash'; +import { isNil, each, extend, filter, identity, includes, map, sortBy } from 'lodash'; import { createNumberFormatter, formatSimpleTemplate } from '@/lib/value-format'; import { normalizeValue } from './utils'; +function shouldUseUnifiedXAxis(options) { + return options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); +} + function defaultFormatSeriesText(item) { let result = item['@@y']; if (item['@@yError'] !== undefined) { @@ -30,9 +34,9 @@ function createTextFormatter(options) { function formatValue(value, axis, options) { let axisType = null; switch (axis) { - case 'x': axisType = get(options, 'xAxis.type', null); break; - case 'y': axisType = get(options, 'yAxis[0].type', null); break; - case 'y2': axisType = get(options, 'yAxis[1].type', null); break; + case 'x': axisType = options.xAxis.type; break; + case 'y': axisType = options.yAxis[0].type; break; + case 'y2': axisType = options.yAxis[1].type; break; // no default } return normalizeValue(value, axisType, options.dateTimeFormat); @@ -113,18 +117,13 @@ function getUnifiedXAxisValues(seriesList, sorted) { }); }); - const result = []; - // `Set.forEach` will walk items in insertion order - set.forEach((item) => { - result.push(item); - }); - + const result = [...set]; return sorted ? sortBy(result, identity) : result; } function updateUnifiedXAxisValues(seriesList, options, defaultY) { const unifiedX = getUnifiedXAxisValues(seriesList, options.sortX); - defaultY = defaultY === undefined ? null : defaultY; + defaultY = options.missingValuesAsZero ? 0.0 : null; each(seriesList, (series) => { series.x = []; series.y = []; @@ -151,19 +150,22 @@ function updateLineAreaData(seriesList, options) { // Apply "percent values" modification updatePercentValues(seriesList, options); if (options.series.stacking) { - updateUnifiedXAxisValues(seriesList, options, 0); + updateUnifiedXAxisValues(seriesList, options); // Calculate cumulative value for each x tick - let prevSeries = null; + const cumulativeValues = {}; each(seriesList, (series) => { - if (prevSeries) { - series.y = map(series.y, (y, i) => prevSeries.y[i] + y); - } - prevSeries = series; + series.y = map(series.y, (y, i) => { + if (isNil(y) && !options.missingValuesAsZero) { + return null; + } + const stackedY = y + (cumulativeValues[i] || 0.0); + cumulativeValues[i] = stackedY; + return stackedY; + }); }); } else { - const useUnifiedXAxis = options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); - if (useUnifiedXAxis) { + if (shouldUseUnifiedXAxis(options)) { updateUnifiedXAxisValues(seriesList, options); } } @@ -177,8 +179,7 @@ function updateDefaultData(seriesList, options) { updatePercentValues(seriesList, options); if (!options.series.stacking) { - const useUnifiedXAxis = options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box'); - if (useUnifiedXAxis) { + if (shouldUseUnifiedXAxis(options)) { updateUnifiedXAxisValues(seriesList, options); } } From 8300b87d10c44a8b0fdf300f27003da7ac49f377 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 15 Aug 2019 14:43:23 +0300 Subject: [PATCH 03/13] Handle edge case: line/area stacking when last value of one of series is missing --- .../visualizations/chart/plotly/updateData.js | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js index 03b4e75c28..c6dae43773 100644 --- a/client/app/visualizations/chart/plotly/updateData.js +++ b/client/app/visualizations/chart/plotly/updateData.js @@ -47,6 +47,8 @@ function updateSeriesText(seriesList, options) { const formatPercent = createNumberFormatter(options.percentFormat); const formatText = createTextFormatter(options); + const defaultY = options.missingValuesAsZero ? 0.0 : null; + each(seriesList, (series) => { const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType }; @@ -57,26 +59,30 @@ function updateSeriesText(seriesList, options) { const text = { '@@name': series.name, }; - const item = series.sourceData.get(x); - if (item) { - const yValueIsAny = includes(['bubble', 'scatter'], seriesOptions.type); - - text['@@x'] = formatValue(item.row.x, 'x', options); - text['@@y'] = yValueIsAny ? formatValue(item.row.y, series.yaxis, options) : formatNumber(item.y); - if (item.yError !== undefined) { - text['@@yError'] = formatNumber(item.yError); - } - if (item.size !== undefined) { - text['@@size'] = formatNumber(item.size); - } - - if (options.series.percentValues || (options.globalSeriesType === 'pie')) { - text['@@yPercent'] = formatPercent(Math.abs(item.yPercent)); - } + const item = series.sourceData.get(x) || { x, y: defaultY, row: { x, y: defaultY } }; + + const yValueIsAny = includes(['bubble', 'scatter'], seriesOptions.type); + + // for `formatValue` we have to use original value of `x` and `y`: `item.x`/`item.y` contains value + // already processed with `normalizeValue`, and if they were `moment` instances - they are formatted + // using default (ISO) date/time format. Here we need to use custom date/time format, so we pass original value + // to `formatValue` which will call `normalizeValue` again, but this time with different date/time format + // (if needed) + text['@@x'] = formatValue(item.row.x, 'x', options); + text['@@y'] = yValueIsAny ? formatValue(item.row.y, series.yaxis, options) : formatNumber(item.y); + if (item.yError !== undefined) { + text['@@yError'] = formatNumber(item.yError); + } + if (item.size !== undefined) { + text['@@size'] = formatNumber(item.size); + } - extend(text, item.row.$raw); + if (options.series.percentValues || (options.globalSeriesType === 'pie')) { + text['@@yPercent'] = formatPercent(Math.abs(item.yPercent)); } + extend(text, item.row.$raw); + series.text.push(formatText(text)); }); }); @@ -121,9 +127,9 @@ function getUnifiedXAxisValues(seriesList, sorted) { return sorted ? sortBy(result, identity) : result; } -function updateUnifiedXAxisValues(seriesList, options, defaultY) { +function updateUnifiedXAxisValues(seriesList, options) { const unifiedX = getUnifiedXAxisValues(seriesList, options.sortX); - defaultY = options.missingValuesAsZero ? 0.0 : null; + const defaultY = options.missingValuesAsZero ? 0.0 : null; each(seriesList, (series) => { series.x = []; series.y = []; From 094ca7b37767d460367e63a613db84b8ba839499 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 15 Aug 2019 15:27:10 +0300 Subject: [PATCH 04/13] Mjnor update to line/area stacking code --- client/app/visualizations/chart/plotly/updateData.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js index c6dae43773..af734ef647 100644 --- a/client/app/visualizations/chart/plotly/updateData.js +++ b/client/app/visualizations/chart/plotly/updateData.js @@ -165,8 +165,9 @@ function updateLineAreaData(seriesList, options) { if (isNil(y) && !options.missingValuesAsZero) { return null; } - const stackedY = y + (cumulativeValues[i] || 0.0); - cumulativeValues[i] = stackedY; + const x = series.x[i]; + const stackedY = y + (cumulativeValues[x] || 0.0); + cumulativeValues[x] = stackedY; return stackedY; }); }); From acee9f9ba1d4368b79b35ead2891107391c6e817 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Thu, 15 Aug 2019 15:38:09 +0300 Subject: [PATCH 05/13] Fix line/area normalize to percents feature --- client/app/visualizations/chart/plotly/updateData.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js index af734ef647..b91f043ebe 100644 --- a/client/app/visualizations/chart/plotly/updateData.js +++ b/client/app/visualizations/chart/plotly/updateData.js @@ -96,7 +96,7 @@ function updatePercentValues(seriesList, options) { each(seriesList, (series) => { series.sourceData.forEach((item) => { const sum = sumOfCorrespondingPoints.get(item.x) || 0; - sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y)); + sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y || 0.0)); }); }); @@ -104,8 +104,12 @@ function updatePercentValues(seriesList, options) { const yValues = []; series.sourceData.forEach((item) => { - const sum = sumOfCorrespondingPoints.get(item.x); - item.yPercent = item.y / sum * 100; + if (isNil(item.y) && !options.missingValuesAsZero) { + item.yPercent = null; + } else { + const sum = sumOfCorrespondingPoints.get(item.x); + item.yPercent = item.y / sum * 100; + } yValues.push(item.yPercent); }); From f6fa1a52b52c0f4e33c0c7abb490f8d316ccc877 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Tue, 20 Aug 2019 10:51:54 +0300 Subject: [PATCH 06/13] Unit tests --- .../getChartData/multiple-series-grouped.json | 41 +++++++++++++++++ .../multiple-series-multiple-y.json | 42 ++++++++++++++++++ .../getChartData/multiple-series-sorted.json | 44 +++++++++++++++++++ .../fixtures/getChartData/single-series.json | 33 ++++++++++++++ .../visualizations/chart/getChartData.test.js | 22 ++++++++++ 5 files changed, 182 insertions(+) create mode 100644 client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json create mode 100644 client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json create mode 100644 client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json create mode 100644 client/app/visualizations/chart/fixtures/getChartData/single-series.json create mode 100644 client/app/visualizations/chart/getChartData.test.js diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json new file mode 100644 index 0000000000..47e92b906b --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json @@ -0,0 +1,41 @@ +{ + "name": "Multiple series: groupped", + "input": { + "data": [ + { "a": 42, "b": 10, "g": "first" }, + { "a": 62, "b": 73, "g": "first" }, + { "a": 21, "b": 82, "g": "second" }, + { "a": 85, "b": 50, "g": "first" }, + { "a": 95, "b": 32, "g": "second" } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y", + "g": "series" + }, + "seriesOptions": {} + } + }, + "output": { + "data": [ + { + "name": "first", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "g": "first" } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73, "g": "first" } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50, "g": "first" } } + ] + }, + { + "name": "second", + "type": "column", + "data": [ + { "x": 21, "y": 82, "$raw": { "a": 21, "b": 82, "g": "second" } }, + { "x": 95, "y": 32, "$raw": { "a": 95, "b": 32, "g": "second" } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json new file mode 100644 index 0000000000..e40b0c6c5c --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json @@ -0,0 +1,42 @@ +{ + "name": "Multiple series: multipple Y mappings", + "input": { + "data": [ + { "a": 42, "b": 10, "c": 41, "d": 92 }, + { "a": 62, "b": 73 }, + { "a": 21, "b": null, "c": 33 }, + { "a": 85, "b": 50 }, + { "a": 95 } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y", + "c": "y" + }, + "seriesOptions": {} + } + }, + "output": { + "data": [ + { + "name": "b", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73 } }, + { "x": 21, "y": null, "$raw": { "a": 21, "b": null, "c": 33 } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50 } } + ] + }, + { + "name": "c", + "type": "column", + "data": [ + { "x": 42, "y": 41, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } }, + { "x": 21, "y": 33, "$raw": { "a": 21, "b": null, "c": 33 } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json new file mode 100644 index 0000000000..df939927b4 --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json @@ -0,0 +1,44 @@ +{ + "name": "Multiple series: groupped", + "input": { + "data": [ + { "a": 42, "b": 10, "g": "first" }, + { "a": 62, "b": 73, "g": "first" }, + { "a": 21, "b": 82, "g": "second" }, + { "a": 85, "b": 50, "g": "first" }, + { "a": 95, "b": 32, "g": "second" } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y", + "g": "series" + }, + "seriesOptions": { + "first": { "zIndex": 2 }, + "second": { "zIndex": 1 } + } + } + }, + "output": { + "data": [ + { + "name": "second", + "type": "column", + "data": [ + { "x": 21, "y": 82, "$raw": { "a": 21, "b": 82, "g": "second" } }, + { "x": 95, "y": 32, "$raw": { "a": 95, "b": 32, "g": "second" } } + ] + }, + { + "name": "first", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "g": "first" } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73, "g": "first" } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50, "g": "first" } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/fixtures/getChartData/single-series.json b/client/app/visualizations/chart/fixtures/getChartData/single-series.json new file mode 100644 index 0000000000..2902e52a20 --- /dev/null +++ b/client/app/visualizations/chart/fixtures/getChartData/single-series.json @@ -0,0 +1,33 @@ +{ + "name": "Single series", + "input": { + "data": [ + { "a": 42, "b": 10, "c": 41, "d": 92 }, + { "a": 62, "b": 73 }, + { "a": 21, "b": null }, + { "a": 85, "b": 50 }, + { "a": 95 } + ], + "options": { + "columnMapping": { + "a": "x", + "b": "y" + }, + "seriesOptions": {} + } + }, + "output": { + "data": [ + { + "name": "b", + "type": "column", + "data": [ + { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } }, + { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73 } }, + { "x": 21, "y": null, "$raw": { "a": 21, "b": null } }, + { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50 } } + ] + } + ] + } +} diff --git a/client/app/visualizations/chart/getChartData.test.js b/client/app/visualizations/chart/getChartData.test.js new file mode 100644 index 0000000000..a8ae371085 --- /dev/null +++ b/client/app/visualizations/chart/getChartData.test.js @@ -0,0 +1,22 @@ +import fs from 'fs'; +import path from 'path'; +import getChartData from './getChartData'; + +function loadFixtures(directoryName) { + directoryName = path.join(__dirname, directoryName); + const fileNames = fs.readdirSync(directoryName); + return fileNames.map((fileName) => { + const str = fs.readFileSync(path.join(directoryName, fileName)); + return JSON.parse(str); + }); +} + +describe('Visualizations - getChartData', () => { + const fixtures = loadFixtures('fixtures/getChartData'); + fixtures.forEach(({ name, input, output }) => { + test(name, () => { + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + }); +}); From 1fea46269eec5c1159cf106ad862d841ea6c5ca9 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 11:57:29 +0300 Subject: [PATCH 07/13] Refine tests; add tests for prepareLayout function --- .../getChartData/multiple-series-grouped.json | 1 - .../multiple-series-multiple-y.json | 1 - .../getChartData/multiple-series-sorted.json | 1 - .../fixtures/getChartData/single-series.json | 1 - .../visualizations/chart/getChartData.test.js | 42 +++++++----- .../prepareLayout/box-single-axis.json | 38 +++++++++++ .../prepareLayout/box-with-second-axis.json | 46 +++++++++++++ .../prepareLayout/default-single-axis.json | 36 +++++++++++ .../default-with-second-axis.json | 44 +++++++++++++ .../prepareLayout/default-with-stacking.json | 38 +++++++++++ .../prepareLayout/default-without-legend.json | 37 +++++++++++ .../prepareLayout/pie-multiple-series.json | 48 ++++++++++++++ .../pie-without-annotations.json | 21 ++++++ .../plotly/fixtures/prepareLayout/pie.json | 30 +++++++++ .../app/visualizations/chart/plotly/index.js | 2 +- .../chart/plotly/prepareData.test.js | 31 +++++++++ .../chart/plotly/prepareLayout.js | 32 +++++----- .../chart/plotly/prepareLayout.test.js | 64 +++++++++++++++++++ 18 files changed, 476 insertions(+), 37 deletions(-) create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json create mode 100644 client/app/visualizations/chart/plotly/prepareData.test.js create mode 100644 client/app/visualizations/chart/plotly/prepareLayout.test.js diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json index 47e92b906b..7fd5acd9c0 100644 --- a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json @@ -1,5 +1,4 @@ { - "name": "Multiple series: groupped", "input": { "data": [ { "a": 42, "b": 10, "g": "first" }, diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json index e40b0c6c5c..df4fa93629 100644 --- a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json @@ -1,5 +1,4 @@ { - "name": "Multiple series: multipple Y mappings", "input": { "data": [ { "a": 42, "b": 10, "c": 41, "d": 92 }, diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json index df939927b4..65f7c05cdc 100644 --- a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json +++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json @@ -1,5 +1,4 @@ { - "name": "Multiple series: groupped", "input": { "data": [ { "a": 42, "b": 10, "g": "first" }, diff --git a/client/app/visualizations/chart/fixtures/getChartData/single-series.json b/client/app/visualizations/chart/fixtures/getChartData/single-series.json index 2902e52a20..748ef9a921 100644 --- a/client/app/visualizations/chart/fixtures/getChartData/single-series.json +++ b/client/app/visualizations/chart/fixtures/getChartData/single-series.json @@ -1,5 +1,4 @@ { - "name": "Single series", "input": { "data": [ { "a": 42, "b": 10, "c": 41, "d": 92 }, diff --git a/client/app/visualizations/chart/getChartData.test.js b/client/app/visualizations/chart/getChartData.test.js index a8ae371085..c198b7c889 100644 --- a/client/app/visualizations/chart/getChartData.test.js +++ b/client/app/visualizations/chart/getChartData.test.js @@ -1,22 +1,32 @@ -import fs from 'fs'; -import path from 'path'; +/* eslint-disable global-require, import/no-unresolved */ import getChartData from './getChartData'; -function loadFixtures(directoryName) { - directoryName = path.join(__dirname, directoryName); - const fileNames = fs.readdirSync(directoryName); - return fileNames.map((fileName) => { - const str = fs.readFileSync(path.join(directoryName, fileName)); - return JSON.parse(str); - }); -} +describe('Visualizations', () => { + describe('Chart', () => { + describe('getChartData', () => { + test('Single series', () => { + const { input, output } = require('./fixtures/getChartData/single-series'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + + test('Multiple series: multipple Y mappings', () => { + const { input, output } = require('./fixtures/getChartData/multiple-series-multiple-y'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); + + test('Multiple series: groupped', () => { + const { input, output } = require('./fixtures/getChartData/multiple-series-grouped'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); -describe('Visualizations - getChartData', () => { - const fixtures = loadFixtures('fixtures/getChartData'); - fixtures.forEach(({ name, input, output }) => { - test(name, () => { - const data = getChartData(input.data, input.options); - expect(data).toEqual(output.data); + test('Multiple series: sorted', () => { + const { input, output } = require('./fixtures/getChartData/multiple-series-sorted'); + const data = getChartData(input.data, input.options); + expect(data).toEqual(output.data); + }); }); }); }); diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json new file mode 100644 index 0000000000..2ca978f540 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json @@ -0,0 +1,38 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "boxmode": "group", + "boxgroupgap": 0.50, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json new file mode 100644 index 0000000000..db513dd4a3 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json @@ -0,0 +1,46 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" }, + { "name": "b", "yaxis": "y2" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "boxmode": "group", + "boxgroupgap": 0.50, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + }, + "yaxis2": { + "automargin": true, + "title": null, + "type": "linear", + "overlaying": "y", + "side": "right" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json new file mode 100644 index 0000000000..f80aa28be2 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json @@ -0,0 +1,36 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json new file mode 100644 index 0000000000..2d83b6a12e --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json @@ -0,0 +1,44 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" }, + { "name": "b", "yaxis": "y2" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + }, + "yaxis2": { + "automargin": true, + "title": null, + "type": "linear", + "overlaying": "y", + "side": "right" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json new file mode 100644 index 0000000000..7dfd7c1e10 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json @@ -0,0 +1,38 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "legend": { "enabled": false }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": false, + "barmode": "relative", + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json new file mode 100644 index 0000000000..93747d5c03 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json @@ -0,0 +1,37 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "legend": { "enabled": false }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": false, + "xaxis": { + "automargin": true, + "showticklabels": true, + "title": null, + "type": "-" + }, + "yaxis": { + "automargin": true, + "title": null, + "type": "linear" + } + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json new file mode 100644 index 0000000000..ef935b4b12 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json @@ -0,0 +1,48 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "textFormat": "" + }, + "series": [ + { "name": "a" }, + { "name": "b" }, + { "name": "c" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "annotations": [ + { + "x": 0.24, + "y": 0.485, + "xanchor": "center", + "yanchor": "top", + "text": "a", + "showarrow": false + }, + { + "x": 0.74, + "y": 0.485, + "xanchor": "center", + "yanchor": "top", + "text": "b", + "showarrow": false + }, + { + "x": 0.24, + "y": 0.985, + "xanchor": "center", + "yanchor": "top", + "text": "c", + "showarrow": false + } + ] + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json new file mode 100644 index 0000000000..c306a7dfc4 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json @@ -0,0 +1,21 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "textFormat": "{{ @@name }}" + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "annotations": [] + } + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json new file mode 100644 index 0000000000..be6ec72584 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json @@ -0,0 +1,30 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "textFormat": "" + }, + "series": [ + { "name": "a" } + ] + }, + "output": { + "layout": { + "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 }, + "width": 400, + "height": 300, + "autosize": true, + "showlegend": true, + "annotations": [ + { + "x": 0.49, + "y": 0.985, + "xanchor": "center", + "yanchor": "top", + "text": "a", + "showarrow": false + } + ] + } + } +} diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js index c0e151f915..19d6f87e13 100644 --- a/client/app/visualizations/chart/plotly/index.js +++ b/client/app/visualizations/chart/plotly/index.js @@ -40,7 +40,7 @@ const PlotlyChart = () => ({ } data = prepareData(scope.series, scope.options); - layout = prepareLayout(plotlyElement, scope.series, scope.options, data); + layout = prepareLayout(plotlyElement, scope.options, data); // It will auto-purge previous graph Plotly.newPlot(plotlyElement, data, layout, plotlyOptions).then(() => { diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js new file mode 100644 index 0000000000..6ee927b383 --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -0,0 +1,31 @@ +/* eslint-disable global-require, import/no-unresolved */ +// import prepareData from './prepareData'; + +describe('Visualizations', () => { + describe('Chart', () => { + describe('prepareData', () => { + test('Pie: single series', () => { + // Stub + }); + test('Pie: multiple series', () => { + // Stub + }); + + test('Heatmap', () => { + // Stub + }); + + test('Default: single series', () => { + // Stub + }); + + test('Default: multiple series of single type', () => { + // Stub + }); + + test('Default: multiple series of all types', () => { + // Stub + }); + }); + }); +}); diff --git a/client/app/visualizations/chart/plotly/prepareLayout.js b/client/app/visualizations/chart/plotly/prepareLayout.js index f4379db1bc..b028eecbb8 100644 --- a/client/app/visualizations/chart/plotly/prepareLayout.js +++ b/client/app/visualizations/chart/plotly/prepareLayout.js @@ -1,5 +1,4 @@ import { filter, has, isNumber, isObject, isUndefined, map, max, min } from 'lodash'; -import { getSeriesAxis } from './utils'; import { getPieDimensions } from './preparePieData'; function getAxisTitle(axis) { @@ -60,15 +59,15 @@ function prepareYAxis(axisOptions, additionalOptions, data) { return axis; } -function preparePieLayout(layout, seriesList, options) { +function preparePieLayout(layout, options, data) { const hasName = /{{\s*@@name\s*}}/.test(options.textFormat); - const { cellsInRow, cellWidth, cellHeight, xPadding } = getPieDimensions(seriesList); + const { cellsInRow, cellWidth, cellHeight, xPadding } = getPieDimensions(data); if (hasName) { layout.annotations = []; } else { - layout.annotations = filter(map(seriesList, (series, index) => { + layout.annotations = filter(map(data, (series, index) => { const xPosition = (index % cellsInRow) * cellWidth; const yPosition = Math.floor(index / cellsInRow) * cellHeight; return { @@ -76,7 +75,7 @@ function preparePieLayout(layout, seriesList, options) { y: yPosition + cellHeight - 0.015, xanchor: 'center', yanchor: 'top', - text: (options.seriesOptions[series.name] || {}).name || series.name, + text: series.name, showarrow: false, }; })); @@ -85,14 +84,15 @@ function preparePieLayout(layout, seriesList, options) { return layout; } -function prepareDefaultLayout(layout, seriesList, options, data) { - const hasY2 = !!find(seriesList, series => getSeriesAxis(series, options) === 'y2'); +function prepareDefaultLayout(layout, options, data) { + const ySeries = data.filter(s => s.yaxis !== 'y2'); + const y2Series = data.filter(s => s.yaxis === 'y2'); layout.xaxis = prepareXAxis(options.xAxis, options); - layout.yaxis = prepareYAxis(options.yAxis[0], options, data.filter(s => s.yaxis !== 'y2')); - if (hasY2) { - layout.yaxis2 = prepareYAxis(options.yAxis[1], options, data.filter(s => s.yaxis === 'y2')); + layout.yaxis = prepareYAxis(options.yAxis[0], options, ySeries); + if (y2Series.length > 0) { + layout.yaxis2 = prepareYAxis(options.yAxis[1], options, y2Series); layout.yaxis2.overlaying = 'y'; layout.yaxis2.side = 'right'; } @@ -104,14 +104,14 @@ function prepareDefaultLayout(layout, seriesList, options, data) { return layout; } -function prepareBoxLayout(layout, seriesList, options, data) { - layout = prepareDefaultLayout(layout, seriesList, options, data); +function prepareBoxLayout(layout, options, data) { + layout = prepareDefaultLayout(layout, options, data); layout.boxmode = 'group'; layout.boxgroupgap = 0.50; return layout; } -export default function prepareLayout(element, seriesList, options, data) { +export default function prepareLayout(element, options, data) { const layout = { margin: { l: 10, r: 10, b: 10, t: 25, pad: 4 }, width: Math.floor(element.offsetWidth), @@ -121,8 +121,8 @@ export default function prepareLayout(element, seriesList, options, data) { }; switch (options.globalSeriesType) { - case 'pie': return preparePieLayout(layout, seriesList, options, data); - case 'box': return prepareBoxLayout(layout, seriesList, options, data); - default: return prepareDefaultLayout(layout, seriesList, options, data); + case 'pie': return preparePieLayout(layout, options, data); + case 'box': return prepareBoxLayout(layout, options, data); + default: return prepareDefaultLayout(layout, options, data); } } diff --git a/client/app/visualizations/chart/plotly/prepareLayout.test.js b/client/app/visualizations/chart/plotly/prepareLayout.test.js new file mode 100644 index 0000000000..6af330cbcd --- /dev/null +++ b/client/app/visualizations/chart/plotly/prepareLayout.test.js @@ -0,0 +1,64 @@ +/* eslint-disable global-require, import/no-unresolved */ +import prepareLayout from './prepareLayout'; + +const fakeElement = { offsetWidth: 400, offsetHeight: 300 }; + +describe('Visualizations', () => { + describe('Chart', () => { + describe('prepareLayout', () => { + test('Pie', () => { + const { input, output } = require('./fixtures/prepareLayout/pie'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Pie without annotations', () => { + const { input, output } = require('./fixtures/prepareLayout/pie-without-annotations'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Pie with multiple series', () => { + const { input, output } = require('./fixtures/prepareLayout/pie-multiple-series'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Box with single Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/box-single-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Box with second Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/box-with-second-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default with single Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/default-single-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default with second Y axis', () => { + const { input, output } = require('./fixtures/prepareLayout/default-with-second-axis'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default without legend', () => { + const { input, output } = require('./fixtures/prepareLayout/default-without-legend'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + + test('Default with stacking', () => { + const { input, output } = require('./fixtures/prepareLayout/default-with-stacking'); + const layout = prepareLayout(fakeElement, input.options, input.series); + expect(layout).toEqual(output.layout); + }); + }); + }); +}); From 93c0adebca1bab937571ef4f026375befbcd4201 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 12:53:25 +0300 Subject: [PATCH 08/13] Tests for prepareData (heatmap) function --- .../fixtures/prepareData/heatmap/default.json | 33 ++++++++++++ .../prepareData/heatmap/reversed.json | 35 +++++++++++++ .../prepareData/heatmap/sorted-reversed.json | 37 ++++++++++++++ .../fixtures/prepareData/heatmap/sorted.json | 35 +++++++++++++ .../prepareData/heatmap/with-labels.json | 44 ++++++++++++++++ .../plotly/fixtures/prepareData/template.json | 9 ++++ .../chart/plotly/prepareData.test.js | 51 +++++++++++-------- .../chart/plotly/prepareHeatmapData.js | 2 +- 8 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/template.json diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json new file mode 100644 index 0000000000..4006b3d12e --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json @@ -0,0 +1,33 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [12, 11], + "y": [21, 22], + "z": [[3, 1], [4, 2]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json new file mode 100644 index 0000000000..ff1e16e0f5 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json @@ -0,0 +1,35 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false, + "reverseX": true, + "reverseY": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [11, 12], + "y": [22, 21], + "z": [[2, 4], [1, 3]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json new file mode 100644 index 0000000000..ac8d69f694 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json @@ -0,0 +1,37 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false, + "sortX": true, + "sortY": true, + "reverseX": true, + "reverseY": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [12, 11], + "y": [22, 21], + "z": [[4, 2], [3, 1]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json new file mode 100644 index 0000000000..3073a32916 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json @@ -0,0 +1,35 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": false, + "sortX": true, + "sortY": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [11, 12], + "y": [21, 22], + "z": [[1, 3], [2, 4]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json new file mode 100644 index 0000000000..87a2583541 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json @@ -0,0 +1,44 @@ +{ + "input": { + "options": { + "globalSeriesType": "heatmap", + "colorScheme": "Bluered", + "seriesOptions": {}, + "showDataLabels": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": 12, "y": 21, "zVal": 3 }, + { "x": 11, "y": 22, "zVal": 2 }, + { "x": 11, "y": 21, "zVal": 1 }, + { "x": 12, "y": 22, "zVal": 4 } + ] + } + ] + }, + "output": { + "series": [ + { + "x": [12, 11], + "y": [21, 22], + "z": [[3, 1], [4, 2]], + "type": "heatmap", + "name": "", + "colorscale": "Bluered" + }, + { + "x": [12, 11, 12, 11], + "y": [21, 21, 22, 22], + "mode": "text", + "hoverinfo": "skip", + "showlegend": false, + "text": ["3", "1", "4", "2"], + "textfont": { + "color": ["black", "black", "black", "black"] + } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/template.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/template.json new file mode 100644 index 0000000000..c53082342b --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/template.json @@ -0,0 +1,9 @@ +{ + "input": { + "options": {}, + "data": [] + }, + "output": { + "series": [] + } +} diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js index 6ee927b383..7667e04fc6 100644 --- a/client/app/visualizations/chart/plotly/prepareData.test.js +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -1,30 +1,41 @@ /* eslint-disable global-require, import/no-unresolved */ -// import prepareData from './prepareData'; +import prepareData from './prepareData'; describe('Visualizations', () => { describe('Chart', () => { describe('prepareData', () => { - test('Pie: single series', () => { - // Stub - }); - test('Pie: multiple series', () => { - // Stub - }); - - test('Heatmap', () => { - // Stub - }); - - test('Default: single series', () => { - // Stub - }); - - test('Default: multiple series of single type', () => { - // Stub + test.skip('Template', () => { + const { input, output } = require('./fixtures/prepareLayout/box-with-second-axis'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); }); - test('Default: multiple series of all types', () => { - // Stub + describe('Heatmap', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/default'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('sorted', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/sorted'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('reversed', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/reversed'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('sorted & reversed', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/sorted'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); + test('with labels', () => { + const { input, output } = require('./fixtures/prepareData/heatmap/with-labels'); + const series = prepareData(input.data, input.options); + expect(series).toEqual(output.series); + }); }); }); }); diff --git a/client/app/visualizations/chart/plotly/prepareHeatmapData.js b/client/app/visualizations/chart/plotly/prepareHeatmapData.js index f5b8a29f9f..0f3448c669 100644 --- a/client/app/visualizations/chart/plotly/prepareHeatmapData.js +++ b/client/app/visualizations/chart/plotly/prepareHeatmapData.js @@ -1,4 +1,4 @@ -import { map, max, uniq, sortBy, flatten } from 'lodash'; +import { map, max, uniq, sortBy, flatten, find } from 'lodash'; import { createNumberFormatter } from '@/lib/value-format'; const defaultColorScheme = [ From 82d224caaf053e5a3ea08fa70dd80d7997d1e79d Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 13:57:25 +0300 Subject: [PATCH 09/13] Tests for prepareData (pie) function --- .../visualizations/chart/getChartData.test.js | 4 +- .../prepareData/pie/custom-tooltip.json | 57 +++++++++++++++++++ .../fixtures/prepareData/pie/default.json | 57 +++++++++++++++++++ .../prepareData/pie/without-labels.json | 57 +++++++++++++++++++ .../fixtures/prepareData/pie/without-x.json | 53 +++++++++++++++++ .../chart/plotly/prepareData.test.js | 34 ++++++++++- 6 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json diff --git a/client/app/visualizations/chart/getChartData.test.js b/client/app/visualizations/chart/getChartData.test.js index c198b7c889..5d1239f6d4 100644 --- a/client/app/visualizations/chart/getChartData.test.js +++ b/client/app/visualizations/chart/getChartData.test.js @@ -10,13 +10,13 @@ describe('Visualizations', () => { expect(data).toEqual(output.data); }); - test('Multiple series: multipple Y mappings', () => { + test('Multiple series: multiple Y mappings', () => { const { input, output } = require('./fixtures/getChartData/multiple-series-multiple-y'); const data = getChartData(input.data, input.options); expect(data).toEqual(output.data); }); - test('Multiple series: groupped', () => { + test('Multiple series: grouped', () => { const { input, output } = require('./fixtures/getChartData/multiple-series-grouped'); const data = getChartData(input.data, input.options); expect(data).toEqual(output.data); diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json new file mode 100644 index 0000000000..2b8be824d1 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "{{ @@name }}: {{ @@yPercent }} ({{ @@y }})", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "columnMapping": { + "x": "x", + "y": "y" + } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["a1", "a2", "a3", "a4"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["a: 5% (10)", "a: 30% (60)", "a: 50% (100)", "a: 15% (30)"], + "textinfo": "percent", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json new file mode 100644 index 0000000000..ffabb1db31 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "columnMapping": { + "x": "x", + "y": "y" + } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["a1", "a2", "a3", "a4"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["5% (10)", "30% (60)", "50% (100)", "15% (30)"], + "textinfo": "percent", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json new file mode 100644 index 0000000000..8ef0d06136 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": false, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "columnMapping": { + "x": "x", + "y": "y" + } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["a1", "a2", "a3", "a4"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["5% (10)", "30% (60)", "50% (100)", "15% (30)"], + "textinfo": "none", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json new file mode 100644 index 0000000000..a5c69b24a7 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json @@ -0,0 +1,53 @@ +{ + "input": { + "options": { + "globalSeriesType": "pie", + "seriesOptions": {}, + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "a1", "y": 10 }, + { "x": "a2", "y": 60 }, + { "x": "a3", "y": 100 }, + { "x": "a4", "y": 30 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "values": [10, 60, 100, 30], + "labels": ["Slice 0", "Slice 0", "Slice 0", "Slice 0"], + "type": "pie", + "hole": 0.4, + "marker": { + "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"] + }, + "hoverinfo": "text+label", + "hover": [], + "text": ["15% (30)", "15% (30)", "15% (30)", "15% (30)"], + "textinfo": "percent", + "textposition": "inside", + "textfont": { "color": "#ffffff" }, + "name": "a", + "direction": "counterclockwise", + "domain": { "x": [0, 0.98], "y": [0, 0.9] } + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js index 7667e04fc6..21a130bcf4 100644 --- a/client/app/visualizations/chart/plotly/prepareData.test.js +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -1,16 +1,20 @@ /* eslint-disable global-require, import/no-unresolved */ import prepareData from './prepareData'; +function cleanSeries(series) { + return series.map(({ sourceData, ...rest }) => rest); +} + describe('Visualizations', () => { describe('Chart', () => { describe('prepareData', () => { test.skip('Template', () => { - const { input, output } = require('./fixtures/prepareLayout/box-with-second-axis'); + const { input, output } = require('./fixtures/prepareData/template'); const series = prepareData(input.data, input.options); expect(series).toEqual(output.series); }); - describe('Heatmap', () => { + describe('heatmap', () => { test('default', () => { const { input, output } = require('./fixtures/prepareData/heatmap/default'); const series = prepareData(input.data, input.options); @@ -37,6 +41,32 @@ describe('Visualizations', () => { expect(series).toEqual(output.series); }); }); + + describe('pie', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/pie/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('without X mapped', () => { + const { input, output } = require('./fixtures/prepareData/pie/without-x'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('without labels', () => { + const { input, output } = require('./fixtures/prepareData/pie/without-labels'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('custom tooltip', () => { + const { input, output } = require('./fixtures/prepareData/pie/custom-tooltip'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); }); }); }); From de9642a8123710acf9b06c9bbf01b899ea33b584 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 16:36:55 +0300 Subject: [PATCH 10/13] Tests for prepareData (bar, line, area) function --- .../fixtures/prepareData/bar/default.json | 56 +++++++++++++ .../fixtures/prepareData/bar/normalized.json | 81 +++++++++++++++++++ .../fixtures/prepareData/bar/stacked.json | 81 +++++++++++++++++++ .../prepareData/line-area/default.json | 55 +++++++++++++ .../line-area/keep-missing-values.json | 77 ++++++++++++++++++ .../line-area/missing-values-0.json | 77 ++++++++++++++++++ .../line-area/normalized-stacked.json | 79 ++++++++++++++++++ .../prepareData/line-area/normalized.json | 79 ++++++++++++++++++ .../prepareData/line-area/stacked.json | 79 ++++++++++++++++++ .../chart/plotly/prepareData.test.js | 58 +++++++++++++ 10 files changed, 722 insertions(+) create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json new file mode 100644 index 0000000000..8a31fe2c83 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json @@ -0,0 +1,56 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "column", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "type": "bar", + "name": "a", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "textposition": "inside", + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json new file mode 100644 index 0000000000..3a29cdf376 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json @@ -0,0 +1,81 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true }, "percentValues": true }, + "seriesOptions": { + "a": { "type": "column", "color": "red" }, + "b": { "type": "column", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 40, "yError": 0 }, + { "x": "x2", "y": 30, "yError": 0 }, + { "x": "x3", "y": 20, "yError": 0 }, + { "x": "x4", "y": 10, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "type": "bar", + "name": "a", + "x": ["x1", "x2", "x3", "x4"], + "y": [20, 40, 60, 80], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], + "textposition": "inside", + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "type": "bar", + "name": "b", + "x": ["x1", "x2", "x3", "x4"], + "y": [80, 60, 40, 20], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], + "textposition": "inside", + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json new file mode 100644 index 0000000000..cb54f92407 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json @@ -0,0 +1,81 @@ +{ + "input": { + "options": { + "globalSeriesType": "column", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "column", "color": "red" }, + "b": { "type": "column", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 1, "yError": 0 }, + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x3", "y": 3, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "type": "bar", + "name": "a", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "textposition": "inside", + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "type": "bar", + "name": "b", + "x": ["x1", "x2", "x3", "x4"], + "y": [1, 2, 3, 4], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"], + "textposition": "inside", + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json new file mode 100644 index 0000000000..ca3f540979 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json @@ -0,0 +1,55 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json new file mode 100644 index 0000000000..108be880c8 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json @@ -0,0 +1,77 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": false + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [null, 22, null, 44], + "error_y": { "array": [null, 0, null, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["", "2 ± 0", "", "4 ± 0"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json new file mode 100644 index 0000000000..23e6a15df2 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json @@ -0,0 +1,77 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 22, 30, 44], + "error_y": { "array": [null, 0, null, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["0", "2 ± 0", "0", "4 ± 0"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json new file mode 100644 index 0000000000..a5b25b6e79 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json @@ -0,0 +1,79 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true }, "percentValues": true }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 40, "yError": 0 }, + { "x": "x2", "y": 30, "yError": 0 }, + { "x": "x3", "y": 20, "yError": 0 }, + { "x": "x4", "y": 10, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [20, 40, 60, 80], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [100, 100, 100, 100], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json new file mode 100644 index 0000000000..c016e392d4 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json @@ -0,0 +1,79 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true }, "percentValues": true }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 40, "yError": 0 }, + { "x": "x2", "y": 30, "yError": 0 }, + { "x": "x3", "y": 20, "yError": 0 }, + { "x": "x4", "y": 10, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [20, 40, 60, 80], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [80, 60, 40, 20], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json new file mode 100644 index 0000000000..bcb7a5157b --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json @@ -0,0 +1,79 @@ +{ + "input": { + "options": { + "globalSeriesType": "line", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "line", "color": "red" }, + "b": { "type": "line", "color": "blue" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + }, + { + "name": "b", + "data": [ + { "x": "x1", "y": 1, "yError": 0 }, + { "x": "x2", "y": 2, "yError": 0 }, + { "x": "x3", "y": 3, "yError": 0 }, + { "x": "x4", "y": 4, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + }, + { + "visible": true, + "name": "b", + "mode": "lines+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [11, 22, 33, 44], + "error_y": { "array": [0, 0, 0, 0], "color": "blue" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"], + "marker": { "color": "blue" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js index 21a130bcf4..41569e0362 100644 --- a/client/app/visualizations/chart/plotly/prepareData.test.js +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -67,6 +67,64 @@ describe('Visualizations', () => { expect(series).toEqual(output.series); }); }); + + describe('bar (column)', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/bar/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('stacked', () => { + const { input, output } = require('./fixtures/prepareData/bar/stacked'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('normalized values', () => { + const { input, output } = require('./fixtures/prepareData/bar/normalized'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('lines & area', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/line-area/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('stacked', () => { + const { input, output } = require('./fixtures/prepareData/line-area/stacked'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('normalized values', () => { + const { input, output } = require('./fixtures/prepareData/line-area/normalized'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('stacked & normalized values', () => { + const { input, output } = require('./fixtures/prepareData/line-area/normalized-stacked'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('keep missing values', () => { + const { input, output } = require('./fixtures/prepareData/line-area/keep-missing-values'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('convert missing values to 0', () => { + const { input, output } = require('./fixtures/prepareData/line-area/missing-values-0'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); }); }); }); From b3e53cb55610f49729a4cbc81cd7e5042301ed3a Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 17:26:52 +0300 Subject: [PATCH 11/13] Tests for prepareData (scatter, bubble) function --- .../fixtures/prepareData/bubble/default.json | 55 ++++++++++++++++++ .../fixtures/prepareData/scatter/default.json | 56 +++++++++++++++++++ .../prepareData/scatter/without-labels.json | 56 +++++++++++++++++++ .../chart/plotly/prepareData.test.js | 32 +++++++++-- 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json new file mode 100644 index 0000000000..10e9b45505 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json @@ -0,0 +1,55 @@ +{ + "input": { + "options": { + "globalSeriesType": "bubble", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "bubble", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0, "size": 51 }, + { "x": "x2", "y": 20, "yError": 0, "size": 52 }, + { "x": "x3", "y": 30, "yError": 0, "size": 53 }, + { "x": "x4", "y": 40, "yError": 0, "size": 54 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "mode": "markers", + "marker": { "color": "red", "size": [51, 52, 53, 54] }, + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0: 51", "20 ± 0: 52", "30 ± 0: 53", "40 ± 0: 54"], + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json new file mode 100644 index 0000000000..5daed94941 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json @@ -0,0 +1,56 @@ +{ + "input": { + "options": { + "globalSeriesType": "scatter", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "scatter", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "scatter", + "mode": "markers+text", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json new file mode 100644 index 0000000000..9267346196 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json @@ -0,0 +1,56 @@ +{ + "input": { + "options": { + "globalSeriesType": "scatter", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": false, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "scatter", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "scatter", + "mode": "markers", + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hoverinfo": "text+x+name", + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "marker": { "color": "red" }, + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js index 41569e0362..f8f222bc4e 100644 --- a/client/app/visualizations/chart/plotly/prepareData.test.js +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -8,12 +8,6 @@ function cleanSeries(series) { describe('Visualizations', () => { describe('Chart', () => { describe('prepareData', () => { - test.skip('Template', () => { - const { input, output } = require('./fixtures/prepareData/template'); - const series = prepareData(input.data, input.options); - expect(series).toEqual(output.series); - }); - describe('heatmap', () => { test('default', () => { const { input, output } = require('./fixtures/prepareData/heatmap/default'); @@ -125,6 +119,32 @@ describe('Visualizations', () => { expect(series).toEqual(output.series); }); }); + + describe('scatter', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/scatter/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + + test('without labels', () => { + const { input, output } = require('./fixtures/prepareData/scatter/without-labels'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('bubble', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/bubble/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + }); + + describe('box', () => { + + }); }); }); }); From d76845e1805173444c47ad0576de7ccd7c0f67ba Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 17:32:06 +0300 Subject: [PATCH 12/13] Tests for prepareData (box) function --- .../fixtures/prepareData/box/default.json | 57 ++++++++++++++++++ .../fixtures/prepareData/box/with-points.json | 60 +++++++++++++++++++ .../chart/plotly/prepareData.test.js | 10 ++++ 3 files changed, 127 insertions(+) create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json create mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json new file mode 100644 index 0000000000..5a5ba12dbe --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json @@ -0,0 +1,57 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "box", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "box", + "mode": "markers", + "boxpoints": "outliers", + "hoverinfo": false, + "marker": { "color": "red", "size": 3 }, + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json new file mode 100644 index 0000000000..710cf6bd11 --- /dev/null +++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json @@ -0,0 +1,60 @@ +{ + "input": { + "options": { + "globalSeriesType": "box", + "numberFormat": "0,0[.]00000", + "percentFormat": "0[.]00%", + "textFormat": "", + "showDataLabels": true, + "direction": { "type": "counterclockwise" }, + "xAxis": { "type": "-", "labels": { "enabled": true } }, + "yAxis": [ + { "type": "linear" }, + { "type": "linear", "opposite": true } + ], + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "seriesOptions": { + "a": { "type": "box", "color": "red" } + }, + "columnMapping": { + "x": "x", + "y1": "y" + }, + "missingValuesAsZero": true, + "showpoints": true + }, + "data": [ + { + "name": "a", + "data": [ + { "x": "x1", "y": 10, "yError": 0 }, + { "x": "x2", "y": 20, "yError": 0 }, + { "x": "x3", "y": 30, "yError": 0 }, + { "x": "x4", "y": 40, "yError": 0 } + ] + } + ] + }, + "output": { + "series": [ + { + "visible": true, + "name": "a", + "type": "box", + "mode": "markers", + "boxpoints": "all", + "jitter": 0.3, + "pointpos": -1.8, + "hoverinfo": false, + "marker": { "color": "red", "size": 3 }, + "x": ["x1", "x2", "x3", "x4"], + "y": [10, 20, 30, 40], + "error_y": { "array": [0, 0, 0, 0], "color": "red" }, + "hover": [], + "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"], + "insidetextfont": { "color": "#333333" }, + "yaxis": "y" + } + ] + } +} diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js index f8f222bc4e..3aaf54d8c1 100644 --- a/client/app/visualizations/chart/plotly/prepareData.test.js +++ b/client/app/visualizations/chart/plotly/prepareData.test.js @@ -143,7 +143,17 @@ describe('Visualizations', () => { }); describe('box', () => { + test('default', () => { + const { input, output } = require('./fixtures/prepareData/box/default'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); + test('with points', () => { + const { input, output } = require('./fixtures/prepareData/box/with-points'); + const series = cleanSeries(prepareData(input.data, input.options)); + expect(series).toEqual(output.series); + }); }); }); }); From 960454b89343255e60e9784069574fb16e5468c2 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Wed, 28 Aug 2019 17:47:18 +0300 Subject: [PATCH 13/13] Remove unused file --- .../chart/plotly/fixtures/prepareData/template.json | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 client/app/visualizations/chart/plotly/fixtures/prepareData/template.json diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/template.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/template.json deleted file mode 100644 index c53082342b..0000000000 --- a/client/app/visualizations/chart/plotly/fixtures/prepareData/template.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "input": { - "options": {}, - "data": [] - }, - "output": { - "series": [] - } -}