diff --git a/client/app/lib/value-format.js b/client/app/lib/value-format.js
index 650866dcc6..928b28b934 100644
--- a/client/app/lib/value-format.js
+++ b/client/app/lib/value-format.js
@@ -2,6 +2,8 @@ import moment from 'moment/moment';
import numeral from 'numeral';
import _ from 'underscore';
+numeral.options.scalePercentBy100 = false;
+
// eslint-disable-next-line
const urlPattern = /(^|[\s\n]|
)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
diff --git a/client/app/services/query-result.js b/client/app/services/query-result.js
index 4797aa9d95..b2a82446d4 100644
--- a/client/app/services/query-result.js
+++ b/client/app/services/query-result.js
@@ -252,7 +252,7 @@ function QueryResultService($resource, $timeout, $q) {
const series = {};
this.getData().forEach((row) => {
- let point = {};
+ let point = { $raw: row };
let seriesName;
let xValue = 0;
const yValues = {};
diff --git a/client/app/visualizations/chart/chart-editor.html b/client/app/visualizations/chart/chart-editor.html
index 8f9c94e253..5dbe458b4f 100644
--- a/client/app/visualizations/chart/chart-editor.html
+++ b/client/app/visualizations/chart/chart-editor.html
@@ -12,6 +12,9 @@
')
+ .addClass('plotly-chart-tooltip')
+ .appendTo('body');
+
const updateChartDimensions = () => {
if (updateDimensions(layout, plotlyElement, calculateMargins(plotlyElement))) {
Plotly.relayout(plotlyElement, layout);
@@ -64,6 +74,36 @@ const PlotlyChart = () => ({
});
plotlyElement.on('plotly_afterplot', updateChartDimensions);
+
+ plotlyElement.on('plotly_hover', (hoverData) => {
+ const points = prepareTooltipPoints(hoverData.points, data);
+ if (points.length > 0) {
+ const bounds = plotlyElement.getBoundingClientRect();
+ const offsetLeft = bounds.left + window.scrollX;
+ const offsetTop = bounds.top + window.scrollY;
+
+
+ const { left, top } = calculateTooltipPosition(points, scope.options);
+ tooltip
+ .css({
+ left: Math.round(left + offsetLeft) + 'px',
+ top: Math.round(top + offsetTop) + 'px',
+ })
+ .html($sanitize(renderTooltipContents(points, data, scope.options)))
+ .show();
+
+ const tooltipBounds = tooltip[0].getBoundingClientRect();
+ if (tooltipBounds.top < 0) {
+ tooltip
+ .css({
+ top: Math.round(top + offsetTop - tooltipBounds.top) + 'px',
+ });
+ }
+ }
+ });
+ plotlyElement.on('plotly_unhover', () => {
+ tooltip.hide();
+ });
}
update();
@@ -79,6 +119,10 @@ const PlotlyChart = () => ({
}, true);
scope.handleResize = debounce(updateChartDimensions, 50);
+
+ scope.$on('$destroy', () => {
+ tooltip.remove();
+ });
},
});
diff --git a/client/app/visualizations/chart/plotly/plotly.less b/client/app/visualizations/chart/plotly/plotly.less
new file mode 100644
index 0000000000..42a875b40c
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/plotly.less
@@ -0,0 +1,38 @@
+.plotly-chart-tooltip {
+ pointer-events: none;
+ display: none;
+ position: absolute;
+ z-index: 999999;
+ transform: translate(-50%, -100%);
+ margin: -10px 0 0 0;
+
+ background: #fff;
+ border: 1px solid #ccc;
+ color: #333;
+ left: 100px;
+ top: 100px;
+ padding: 10px 15px;
+ border-radius: 5px;
+ box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);
+
+ &:after {
+ content: '';
+ font-size: 1px;
+ display: block;
+ width: 10px;
+ height: 10px;
+ position: absolute;
+ left: 50%;
+ bottom: 0;
+ border: inherit;
+ border-top: 0;
+ border-left: 0;
+ background: inherit;
+ transform-origin: 50% 50%;
+ transform: translate(-50%, 50%) rotate(45deg);
+ }
+
+ > div {
+ white-space: nowrap;
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/utils.js b/client/app/visualizations/chart/plotly/utils.js
index 98b99baff2..9ca4fe6fa6 100644
--- a/client/app/visualizations/chart/plotly/utils.js
+++ b/client/app/visualizations/chart/plotly/utils.js
@@ -1,9 +1,9 @@
import {
isArray, isNumber, isString, isUndefined, contains, min, max, has, find,
- each, values, sortBy, pluck, identity, filter, map,
+ each, values, sortBy, pluck, identity, filter, map, extend, isNull, reduce,
} from 'underscore';
import moment from 'moment';
-import { createFormatter } from '@/lib/value-format';
+import { createFormatter, formatSimpleTemplate } from '@/lib/value-format';
// The following colors will be used if you pick "Automatic" color.
const BaseColors = {
@@ -33,11 +33,45 @@ export const ColorPalette = Object.assign({}, BaseColors, {
'Pink 2': '#C63FA9',
});
-const formatNumber = createFormatter({ displayAs: 'number', numberFormat: '0,0[.]00000' });
-const formatPercent = createFormatter({ displayAs: 'number', numberFormat: '0[.]00' });
-
const ColorPaletteArray = values(BaseColors);
+function defaultFormatSeriesText(item) {
+ let result = item['@@y'];
+ if (item['@@yError'] !== undefined) {
+ result = `${result} \u00B1 ${item['@@yError']}`;
+ }
+ if (item['@@yPercent'] !== undefined) {
+ result = `${item['@@yPercent']} (${result})`;
+ }
+ return result;
+}
+
+function defaultFormatSeriesTextForPie(item) {
+ return item['@@yPercent'];
+}
+
+function colorAsHex(color) {
+ if (isString(color)) {
+ if (/#[0-9a-f]{3}/i.exec(color) || /#[0-9a-f]{6}/i.exec(color)) {
+ return color;
+ }
+ // rgb()
+ let match = /\s*rgb\(([0-9]+),\s*([0-9]+),\s*([0-9]+)\)\s*/i.exec(color);
+ if (match) {
+ return '#' + Number(match[1]).toString(16) + Number(match[2]).toString(16) +
+ Number(match[3]).toString(16);
+ }
+
+ // rgba()
+ match = /\s*rgba\(([0-9]+),\s*([0-9]+),\s*([0-9]+),\s*([0-9]+)\)\s*/i.exec(color);
+ if (match) {
+ return '#' + Number(match[1]).toString(16) + Number(match[2]).toString(16) +
+ Number(match[3]).toString(16);
+ }
+ }
+ return null;
+}
+
function getFontColor(bgcolor) {
let result = '#333333';
if (isString(bgcolor)) {
@@ -69,9 +103,9 @@ function getFontColor(bgcolor) {
return result;
}
-export function normalizeValue(value) {
+export function normalizeValue(value, dateTimeFormat = 'YYYY-MM-DD HH:mm:ss') {
if (moment.isMoment(value)) {
- return value.format('YYYY-MM-DD HH:mm:ss');
+ return value.format(dateTimeFormat);
}
return value;
}
@@ -182,16 +216,46 @@ function preparePieData(seriesList, options) {
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);
+
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 = normalizeValue(row.y, options.dateTimeFormat);
+ return result + y;
+ }, 0);
+ each(serie.data, (row) => {
+ const x = normalizeValue(row.x, options.dateTimeFormat);
+ const y = normalizeValue(row.y, options.dateTimeFormat);
+ sourceData.set(x, {
+ x,
+ y,
+ yPercent: y / seriesTotal * 100,
+ });
+ });
+
return {
values: pluck(serie.data, 'y'),
- labels: map(serie.data, row => (hasX ? row.x : `Slice ${index}`)),
+ labels: map(serie.data, row => (hasX ? normalizeValue(row.x, options.dateTimeFormat) : `Slice ${index}`)),
type: 'pie',
hole: 0.4,
marker: { colors: ColorPaletteArray },
- text: serie.name,
+ hoverinfo: 'none',
+ text: [],
+ textinfo: 'text',
textposition: 'inside',
textfont: { color: '#ffffff' },
name: serie.name,
@@ -199,6 +263,10 @@ function preparePieData(seriesList, options) {
x: [xPosition, xPosition + cellWidth - xPadding],
y: [yPosition, yPosition + cellHeight - yPadding],
},
+ sourceData,
+ formatNumber,
+ formatPercent,
+ formatText,
};
});
}
@@ -206,6 +274,18 @@ function preparePieData(seriesList, options) {
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);
+
return map(seriesList, (series, index) => {
const seriesOptions = options.seriesOptions[series.name] ||
{ type: options.globalSeriesType };
@@ -213,16 +293,16 @@ function prepareChartData(seriesList, options) {
const seriesColor = getSeriesColor(seriesOptions, index);
// Sort by x - `Map` preserves order of items
- const data = sortX ? sortBy(series.data, d => normalizeValue(d.x)) : series.data;
+ const data = sortX ? sortBy(series.data, d => normalizeValue(d.x, options.dateTimeFormat)) : series.data;
const sourceData = new Map();
const xValues = [];
const yValues = [];
const yErrorValues = [];
each(data, (row) => {
- const x = normalizeValue(row.x);
- const y = normalizeValue(row.y);
- const yError = normalizeValue(row.yError);
+ const x = normalizeValue(row.x, options.dateTimeFormat);
+ const y = normalizeValue(row.y, options.dateTimeFormat);
+ const yError = normalizeValue(row.yError, options.dateTimeFormat);
sourceData.set(x, {
x,
y,
@@ -236,7 +316,7 @@ function prepareChartData(seriesList, options) {
const plotlySeries = {
visible: true,
- hoverinfo: 'x+text+name',
+ hoverinfo: 'none',
x: xValues,
y: yValues,
error_y: {
@@ -249,6 +329,9 @@ function prepareChartData(seriesList, options) {
color: getFontColor(seriesColor),
},
sourceData,
+ formatNumber,
+ formatPercent,
+ formatText,
};
if (
@@ -266,7 +349,6 @@ function prepareChartData(seriesList, options) {
};
} else if (seriesOptions.type === 'box') {
plotlySeries.boxpoints = 'outliers';
- plotlySeries.hoverinfo = false;
plotlySeries.marker = {
color: seriesColor,
size: 3,
@@ -383,21 +465,26 @@ export function prepareLayout(element, seriesList, options, data) {
function updateSeriesText(seriesList, options) {
each(seriesList, (series) => {
series.text = [];
- series.x.forEach((x) => {
- let text = null;
+ const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x;
+ xValues.forEach((x) => {
+ const text = {
+ '@@x': x,
+ };
const item = series.sourceData.get(x);
if (item) {
- text = formatNumber(item.y);
+ text['@@y'] = series.formatNumber(item.y);
if (item.yError !== undefined) {
- text = `${text} \u00B1 ${formatNumber(item.yError)}`;
+ text['@@yError'] = series.formatNumber(item.yError);
}
- if (options.series.percentValues) {
- text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`;
+ if (options.series.percentValues || (options.globalSeriesType === 'pie')) {
+ text['@@yPercent'] = series.formatPercent(Math.abs(item.yPercent));
}
+
+ extend(text, item.raw);
}
- series.text.push(text);
+ series.text.push(series.formatText(text));
});
});
return seriesList;
@@ -457,6 +544,7 @@ export function updateData(seriesList, options) {
return seriesList;
}
if (options.globalSeriesType === 'pie') {
+ updateSeriesText(seriesList, options);
return seriesList;
}
@@ -533,3 +621,99 @@ export function updateDimensions(layout, element, margins) {
return changed;
}
+
+export function prepareTooltipPoints(points, seriesList) {
+ const result = {};
+ // keep series order
+ each(seriesList, (series) => {
+ result[series.name] = null;
+ });
+ each(points, (p) => {
+ // different chart types will pass different data in `points` array.
+ // but they all will pass `curveNumber` and `pointNumber`. So we need
+ // to pick all additional data explicitly
+ const s = seriesList[p.curveNumber];
+ p.text = p.text || s.text[p.pointNumber];
+ if (!isUndefined(p.text) && !isNull(p.text)) {
+ result[s.name] = p;
+ }
+ });
+ return filter(result);
+}
+
+export function calculateTooltipPosition(points, options) {
+ if (points.length > 0) {
+ if (options.globalSeriesType === 'pie') {
+ const p = points[0];
+ return {
+ left: p.cxFinal + p.pxmid[0],
+ top: p.cyFinal + p.pxmid[1],
+ };
+ }
+
+ const px = points[0].x;
+ // `sum` only for bars + stacked; for others - `max`
+ // two axes - compute for both separately and choose min y after mapping (!!)
+ const py = { y: 0, y2: 0 };
+ const xaxis = points[0].xaxis;
+ const yaxis = { y: null, y2: null };
+ const reduceSum = (options.globalSeriesType === 'column') && options.series.stacking;
+
+ each(points, (p) => {
+ const a = p.data.yaxis === 'y2' ? 'y2' : 'y';
+ yaxis[a] = p.yaxis;
+ const y = p.y < 0 ? 0 : p.y;
+ if (reduceSum) {
+ py[a] += y;
+ } else {
+ py[a] = Math.max(py[a], y);
+ }
+ });
+
+ const left = xaxis.d2p(px) + xaxis._offset;
+
+ let top = [];
+ if (yaxis.y) {
+ top.push(yaxis.y.d2p(py.y) + yaxis.y._offset);
+ }
+ if (yaxis.y2) {
+ top.push(yaxis.y2.d2p(py.y2) + yaxis.y2._offset);
+ }
+ top = min(top) || 0;
+
+ return { left, top };
+ }
+ return { left: 0, top: 0 };
+}
+
+export function renderTooltipContents(points, seriesList, options) {
+ const data = {};
+ if (points.length > 0) {
+ const series = seriesList[points[0].curveNumber];
+ data['@@x'] = (options.globalSeriesType === 'pie') ? points[0].label : points[0].x;
+ data['@@name'] = series.name;
+ }
+
+ const result = [];
+ if (options.tooltipHeader !== '') {
+ result.push('
' + formatSimpleTemplate(options.tooltipHeader, data) + '
');
+ }
+
+ each(points, (p) => {
+ const series = seriesList[p.curveNumber];
+ const item = series.sourceData.get(data['@@x']);
+ const d = extend({}, (item ? item.raw : null), data, {
+ '@@name': series.name,
+ '@@color': colorAsHex(options.globalSeriesType === 'pie' ? p.color : series.marker.color),
+ '@@label': p.text,
+ });
+
+ result.push('
' + formatSimpleTemplate(options.tooltipLine, d) + '
');
+ });
+
+ if (options.tooltipFooter !== '') {
+ result.push('
' + formatSimpleTemplate(options.tooltipFooter, data) + '
');
+ }
+
+ return result.join('');
+}
diff --git a/client/app/visualizations/table/index.js b/client/app/visualizations/table/index.js
index ed9250a84c..14f3554203 100644
--- a/client/app/visualizations/table/index.js
+++ b/client/app/visualizations/table/index.js
@@ -55,8 +55,8 @@ function getDefaultColumnsOptions(columns) {
function getDefaultFormatOptions(column, clientConfig) {
const dateTimeFormat = {
- date: clientConfig.dateFormat || 'DD/MM/YY',
- datetime: clientConfig.dateTimeFormat || 'DD/MM/YY HH:mm',
+ date: clientConfig.dateFormat || 'DD/MM/YYYY',
+ datetime: clientConfig.dateTimeFormat || 'DD/MM/YYYY HH:mm',
};
const numberFormat = {
integer: clientConfig.integerFormat || '0,0',