mirror of
https://github.com/getredash/redash.git
synced 2026-03-22 10:00:17 -04:00
815 lines
23 KiB
JavaScript
815 lines
23 KiB
JavaScript
import {
|
|
isArray, isNumber, isString, isUndefined, includes, min, max, has, find,
|
|
each, values, sortBy, identity, filter, map, extend, reduce, pick, flatten, uniq,
|
|
} 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);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH:mm:ss') {
|
|
if (axisType === 'datetime' && moment.utc(value).isValid()) {
|
|
value = moment.utc(value);
|
|
}
|
|
if (moment.isMoment(value)) {
|
|
return value.format(dateTimeFormat);
|
|
}
|
|
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);
|
|
|
|
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),
|
|
));
|
|
updatePlot(plotlyElement, pick(layout, ['height']));
|
|
// offset the legend
|
|
legend.setAttribute('transform', 'translate(0, ' + 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',
|
|
};
|
|
updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend']));
|
|
}
|
|
}
|