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 @@
  • Series
  • +
  • + Formatting +
  • @@ -135,24 +138,26 @@
    -
    - - -
    +
    +
    + + +
    -
    - -
    +
    + +
    -
    - +
    + +
    @@ -267,4 +272,69 @@

    {{$index == 0 ? 'Left' : 'Right'}} Y Axis

    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + + +
    +
    + + +
    + +
    + +
    +
    diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js index 4b6e7f8bae..e6f337144b 100644 --- a/client/app/visualizations/chart/index.js +++ b/client/app/visualizations/chart/index.js @@ -1,10 +1,35 @@ import { - some, extend, has, partial, intersection, without, contains, isUndefined, + some, extend, defaults, has, partial, intersection, without, contains, isUndefined, sortBy, each, pluck, keys, difference, } from 'underscore'; import template from './chart.html'; import editorTemplate from './chart-editor.html'; +const DEFAULT_OPTIONS = { + globalSeriesType: 'column', + sortX: true, + legend: { enabled: true }, + yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }], + xAxis: { type: 'datetime', labels: { enabled: true } }, + error_y: { type: 'data', visible: true }, + series: { stacking: null, error_y: { type: 'data', visible: true } }, + seriesOptions: {}, + columnMapping: {}, + + numberFormat: '0,0[.]00000', + percentFormat: '0[.]00%', + dateTimeFormat: 'DD/MM/YYYY HH:mm', + textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }}) + tooltipHeader: '{{ @@x }}', + tooltipLine: '{{ @@name }}: {{ @@label }})', + tooltipFooter: '', + + defaultColumns: 3, + defaultRows: 8, + minColumns: 1, + minRows: 5, +}; + function ChartRenderer() { return { restrict: 'E', @@ -33,7 +58,7 @@ function ChartRenderer() { function reloadChart() { reloadData(); - $scope.plotlyOptions = $scope.options; + $scope.plotlyOptions = extend({}, DEFAULT_OPTIONS, $scope.options); } $scope.$watch('options', reloadChart, true); @@ -238,6 +263,27 @@ function ChartEditor(ColorPalette, clientConfig) { } }); } + + scope.$watch('options', () => { + if (scope.options) { + // For existing visualization - set default options + defaults(scope.options, DEFAULT_OPTIONS); + } + }); + + scope.templateHint = ` +
    Use special names to access additional properties:
    +
    {{ @@x }} x-value;
    +
    {{ @@name }} series name;
    +
    Tooltip lines only:
    +
    {{ @@color }} color of current point;
    +
    {{ @@y }} y-value;
    +
    {{ @@yPercent }} relative y-value;
    +
    {{ @@yError }} y deviation;
    +
    {{ @@label }} formatted "Data label".
    +
    Also, all query result columns can be referenced using + {{ column_name }} syntax.
    + `; }, }; } @@ -257,28 +303,12 @@ export default function init(ngModule) { const renderTemplate = ''; const editTemplate = ''; - const defaultOptions = { - globalSeriesType: 'column', - sortX: true, - legend: { enabled: true }, - yAxis: [{ type: 'linear' }, { type: 'linear', opposite: true }], - xAxis: { type: 'datetime', labels: { enabled: true } }, - error_y: { type: 'data', visible: true }, - series: { stacking: null, error_y: { type: 'data', visible: true } }, - seriesOptions: {}, - columnMapping: {}, - defaultColumns: 3, - defaultRows: 8, - minColumns: 1, - minRows: 5, - }; - VisualizationProvider.registerVisualization({ type: 'CHART', name: 'Chart', renderTemplate, editorTemplate: editTemplate, - defaultOptions, + defaultOptions: DEFAULT_OPTIONS, }); }); } diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js index f88432b565..abbfe40370 100644 --- a/client/app/visualizations/chart/plotly/index.js +++ b/client/app/visualizations/chart/plotly/index.js @@ -1,4 +1,5 @@ import { each, debounce, isArray, isObject } from 'underscore'; +import $ from 'jquery'; import Plotly from 'plotly.js/lib/core'; import bar from 'plotly.js/lib/bar'; @@ -6,6 +7,8 @@ import pie from 'plotly.js/lib/pie'; import histogram from 'plotly.js/lib/histogram'; import box from 'plotly.js/lib/box'; +import './plotly.less'; + import { ColorPalette, prepareData, @@ -14,14 +17,17 @@ import { updateDimensions, updateData, normalizeValue, + prepareTooltipPoints, + calculateTooltipPosition, + renderTooltipContents, } from './utils'; Plotly.register([bar, pie, histogram, box]); Plotly.setPlotConfig({ - modeBarButtonsToRemove: ['sendDataToCloud'], + modeBarButtonsToRemove: ['sendDataToCloud', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleSpikelines'], }); -const PlotlyChart = () => ({ +const PlotlyChart = $sanitize => ({ restrict: 'E', template: '
    ', scope: { @@ -34,6 +40,10 @@ const PlotlyChart = () => ({ let layout = {}; let data = []; + const tooltip = $('
    ') + .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',