/**
* Matomo - free/libre analytics platform
*
* DataTable UI class for JqplotGraph.
*
* @link http://www.jqplot.com
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
function rowEvolutionGetMetricNameFromRow(tr)
{
return $(tr).find('td [data-name]').text().trim();
}
function getOrCreateLegendFooter($dataTable)
{
var $legendFooter = $dataTable.find('.jqplot-legend-footer');
if ($legendFooter.length) {
return $legendFooter;
}
$legendFooter = $('
');
var $dataTableFeatures = $dataTable.find('.dataTableFeatures');
var $footerNavigation = $dataTableFeatures.find('.dataTableFooterNavigation');
if ($footerNavigation.length) {
$legendFooter.insertBefore($footerNavigation);
} else if ($dataTableFeatures.length) {
$dataTableFeatures.append($legendFooter);
} else {
$dataTable.append($legendFooter);
}
return $legendFooter;
}
// The legend footer stacks the legend above the "Choose metrics" picker once the report is
// narrower than this. We can't use a CSS container query here — Matomo's LESS compiler
// (less.php) can't parse @container — so we measure the footer width and toggle the
// `is-narrow` class the stylesheet targets. This tracks the report's own width, so it also
// kicks in when a report is shown as a narrow dashboard widget rather than full screen.
var FOOTER_STACK_MAX_WIDTH = 321;
function updateLegendFooterStacking($legendFooter)
{
if (!$legendFooter || !$legendFooter.length) {
return;
}
// 0 when the footer isn't laid out yet (e.g. display:none); skip so we don't flip-flop
var width = $legendFooter[0].clientWidth;
if (width > 0) {
$legendFooter.toggleClass('is-narrow', width < FOOTER_STACK_MAX_WIDTH);
}
}
var MAX_FOOTER_LEGEND_ROWS = 2;
var FOOTER_LEGEND_ROW_TOLERANCE = 1;
var FOOTER_LEGEND_EXPORT_GRAPH_GAP = 12;
function resetLegendItems($legendItems)
{
$legendItems
.removeClass('jqplot-legend-item-hidden jqplot-legend-item-overflow')
.each(function () {
var $label = $(this).find('.jqplot-legend-label');
var originalLabel = $label.attr('data-original-label');
if (typeof originalLabel !== 'undefined') {
$label.text(originalLabel);
}
});
}
function getLegendRows($legendItems)
{
var rowTops = [];
var rows = [];
$legendItems.each(function () {
var $item = $(this);
var itemTop;
var rowIndex = -1;
if ($item.hasClass('jqplot-legend-item-hidden')) {
return;
}
itemTop = Math.round($item.position().top);
for (var i = 0; i < rowTops.length; i++) {
if (Math.abs(rowTops[i] - itemTop) <= FOOTER_LEGEND_ROW_TOLERANCE) {
rowIndex = i;
break;
}
}
if (rowIndex === -1) {
rowTops.push(itemTop);
rows.push([]);
rowIndex = rows.length - 1;
}
rows[rowIndex].push(this);
});
return rows;
}
function limitLegendRows($legendContainer, maxRows)
{
var $legendItems = $legendContainer.children('.jqplot-legend-item');
var rows;
var overflowSource;
var hideItems = false;
resetLegendItems($legendItems);
if (!maxRows || maxRows < 1) {
return;
}
rows = getLegendRows($legendItems);
// Left-align the legend (via CSS) once it wraps past a single row; a single row stays centered.
$legendContainer.closest('.jqplot-legend-footer')
.toggleClass('is-multi-row', rows.length >= 2);
if (rows.length <= maxRows) {
return;
}
// Reserve the last visible legend slot for the overflow marker so the
// ellipsis stays within the allowed rows instead of landing on a hidden row.
overflowSource = rows[maxRows - 1] && rows[maxRows - 1][rows[maxRows - 1].length - 1];
if (!overflowSource) {
return;
}
$(overflowSource)
.addClass('jqplot-legend-item-overflow')
.find('.jqplot-legend-label')
.text('…');
$legendItems.each(function () {
if (this === overflowSource) {
hideItems = true;
return;
}
if (hideItems) {
$(this).addClass('jqplot-legend-item-hidden');
}
});
}
function getLegendLabelTextForExport(ctx, labelElement, originalLabel, maxWidth)
{
var labelText = labelElement.textContent || '';
if (!originalLabel || maxWidth <= 0 || labelElement.scrollWidth <= labelElement.clientWidth) {
return labelText;
}
var ellipsis = '…';
var ellipsisWidth = ctx.measureText(ellipsis).width;
if (ellipsisWidth >= maxWidth) {
return ellipsis;
}
var low = 0;
var high = originalLabel.length;
var bestFit = '';
while (low <= high) {
var middle = Math.floor((low + high) / 2);
var candidate = originalLabel.slice(0, middle) + ellipsis;
if (ctx.measureText(candidate).width <= maxWidth) {
bestFit = candidate;
low = middle + 1;
} else {
high = middle - 1;
}
}
return bestFit || ellipsis;
}
// Re-apply the footer legend row limit for a given dataTable. The "Choose metrics"
// button shares the footer row with the legend items, so the legend's available
// width is only final once the picker has been placed and its Vue button mounted.
// This is called again after the picker is rendered (and on replot/resize) so the
// items wrap against the real width and stay within MAX_FOOTER_LEGEND_ROWS.
function applyFooterLegendRowLimit($dataTable)
{
var $legendContainer = $dataTable.find('.jqplot-legend-footer.has-legend .jqplot-legend-items');
if ($legendContainer.length) {
limitLegendRows($legendContainer, MAX_FOOTER_LEGEND_ROWS);
}
}
(function ($, require) {
var exports = require('piwik/UI'),
DataTable = exports.DataTable,
dataTablePrototype = DataTable.prototype,
getLabelFontFamily = function () {
if (!window.piwik.jqplotLabelFont) {
window.piwik.jqplotLabelFont = $('').hide().appendTo('body').css('font-family');
}
return window.piwik.jqplotLabelFont || 'Arial';
}
;
exports.getLabelFontFamily = getLabelFontFamily;
function getPlotLinesSeriesColorNames() {
var seriesColorNames = [];
for (var seriesIndex = 0; seriesIndex < 8; seriesIndex++) {
seriesColorNames.push('series' + seriesIndex);
}
for (var shade = 1; shade <= 3; shade++) {
for (var shadedSeriesIndex = 0; shadedSeriesIndex < 8; shadedSeriesIndex++) {
seriesColorNames.push('series' + shadedSeriesIndex + '-shade' + shade);
}
}
return seriesColorNames;
}
/**
* DataTable UI class for jqPlot graph datatable visualizations.
*
* @constructor
*/
exports.JqplotGraphDataTable = function (element) {
DataTable.call(this, element);
};
$.extend(exports.JqplotGraphDataTable.prototype, dataTablePrototype, {
/**
* Initializes this class.
*/
init: function () {
dataTablePrototype.init.call(this);
var graphElement = $('.piwik-graph', this.$element);
if (!graphElement.length) {
return;
}
this._lang = {
noData: _pk_translate('General_NoDataForGraph'),
exportTitle: _pk_translate('General_ExportAsImage'),
exportText: _pk_translate('General_SaveImageOnYourComputer'),
metricsToPlot: _pk_translate('General_MetricsToPlot'),
metricToPlot: _pk_translate('General_MetricToPlot'),
recordsToPlot: _pk_translate('General_RecordsToPlot'),
incompletePeriod: _pk_translate('General_IncompletePeriod'),
invalidatedPeriod: _pk_translate('General_InvalidatedPeriod')
};
// set a unique ID for the graph element (required by jqPlot)
this.targetDivId = this.workingDivId + 'Chart';
graphElement.attr('id', this.targetDivId);
try {
var graphData = JSON.parse(graphElement.attr('data-data'));
} catch (e) {
console.error('JSON.parse Error: "' + e + "\" in:\n" + graphElement.attr('data-data'));
return;
}
this.data = graphData.data;
this._setJqplotParameters(graphData.params);
this._setDataStates(graphData.dataStates);
if (this.props.display_percentage_in_tooltip) {
this._setTooltipPercentages();
}
this._bindEvents();
// add external series toggle if it should be added
if (this.props.external_series_toggle) {
this.addExternalSeriesToggle(
window[this.props.external_series_toggle], // get the function w/ string name
this.props.external_series_toggle_show_all == 1
);
}
// render the graph (setTimeout is required, otherwise the graph will not
// render initially)
var self = this;
setTimeout(function () { self.render(); }, 1);
},
_setDataStates: function (dataStates) {
this.jqplotParams.dataStates = [];
if (Array.isArray(dataStates)) {
this.jqplotParams.dataStates = dataStates;
}
},
_setJqplotParameters: function (params) {
defaultParams = {
grid: {
borderWidth: 0,
shadow: false
},
title: {
show: false
},
axesDefaults: {
pad: 1.0,
tickRenderer: $.jqplot.CanvasAxisTickRenderer,
tickOptions: {
showMark: false,
fontSize: '11px',
fontFamily: getLabelFontFamily()
},
rendererOptions: {
drawBaseline: false
}
},
axes: {
yaxis: {
tickOptions: {
formatString: '%s',
formatter: $.jqplot.NumberFormatter
}
},
}
};
this.jqplotParams = $.extend(true, {}, defaultParams, params);
for (var i = 2; typeof this.jqplotParams.axes['y' + i + 'axis'] != 'undefined'; i++) {
this.jqplotParams.axes['y' + i + 'axis'].tickOptions = $.extend(true, {}, {
formatString: '%s',
formatter: $.jqplot.NumberFormatter
}, this.jqplotParams.axes['y' + i + 'axis'].tickOptions);
}
this._setColors();
},
_setTooltipPercentages: function () {
this.tooltip = {percentages: []};
for (var seriesIdx = 0; seriesIdx != this.data.length; ++seriesIdx) {
var series = this.data[seriesIdx];
var sum = 0;
$.each(series, function(index, value) {
if ($.isArray(value) && value[1]) {
sum = sum + value[1];
} else if (!$.isArray(value)) {
sum = sum + value;
}
});
var percentages = this.tooltip.percentages[seriesIdx] = [];
for (var valueIdx = 0; valueIdx != series.length; ++valueIdx) {
var value = series[valueIdx];
if ($.isArray(value) && value[1]) {
value = value[1];
}
percentages[valueIdx] = sum > 0 ? Math.round(100 * value / sum) : 0;
}
}
},
_bindEvents: function () {
var self = this;
var target = $('#' + this.targetDivId);
// tooltip show/hide
target.on('jqplotDataHighlight', function (e, seriesIndex, valueIndex) {
self._showDataPointTooltip(this, seriesIndex, valueIndex);
})
.on('jqplotDataUnhighlight', function () {
self._destroyDataPointTooltip($(this));
});
// handle window resize
this._plotWidth = target.innerWidth();
target.on('resizeGraph', function () { // TODO: shouldn't be a triggerable event.
self._resizeGraph();
});
// export as image
target.on('piwikExportAsImage', function () {
self.exportAsImage(target, self._lang);
});
// manage resources
target.on('piwikDestroyPlot', function () {
if (self._resizeListener) {
$(window).off('resize', self._resizeListener);
}
self._plot.destroy();
for (var i = 0; i < $.jqplot.visiblePlots.length; i++) {
if ($.jqplot.visiblePlots[i] === self) {
$.jqplot.visiblePlots[i] = null;
}
}
});
this.$element.closest('.widgetContent').on('widget:resize', function () {
self._resizeGraph();
});
this._themeModeChangeListener = function () {
self.refreshTheme();
};
window.addEventListener('themeModeChange', this._themeModeChangeListener);
},
_resizeGraph: function () {
var width = $('#' + this.targetDivId).innerWidth();
if (width > 0 && Math.abs(this._plotWidth - width) >= 5) {
this._plotWidth = width;
this.render();
}
},
_setWindowResizeListener: function () {
var self = this;
var timeout = false;
this._resizeListener = function () {
if (timeout) {
window.clearTimeout(timeout);
}
timeout = window.setTimeout(function () { $('#' + self.targetDivId).trigger('resizeGraph'); }, 300);
};
$(window).on('resize', this._resizeListener);
},
_destroyDataPointTooltip: function ($element) {
if ($element.is( ":data('ui-tooltip')" )) {
$element.tooltip('destroy');
}
},
_showDataPointTooltip: function (element, seriesIndex, valueIndex) {
// empty
},
changeSeries: function (columns, rows) {
this.showLoading();
columns = columns || [];
if (typeof columns == 'string') {
columns = columns.split(',');
}
rows = rows || [];
if (typeof rows == 'string') {
rows = rows.split(',');
}
var dataTable = $('#' + this.workingDivId).data('uiControlObject');
dataTable.param.columns = columns.join(',');
dataTable.param.rows = rows.join(',');
delete dataTable.param.filter_limit;
delete dataTable.param.totalRows;
if (dataTable.param.filter_sort_column != 'label') {
dataTable.param.filter_sort_column = columns[0];
}
dataTable.param.disable_generic_filters = '0';
dataTable.reloadAjaxDataTable(false);
},
destroyPlot: function () {
var target = $('#' + this.targetDivId);
var dataTable = target.closest('.dataTable');
var legendFooter = dataTable.find('.jqplot-legend-footer');
// tear down the outgoing picker before its DOM is removed, so it can't leak
if (this._plot && this._plot.plugins && this._plot.plugins.seriesPicker) {
this._plot.plugins.seriesPicker.destroy();
}
target.trigger('piwikDestroyPlot');
if (legendFooter.length) {
legendFooter
.removeClass('has-legend')
.find('.jqplot-legend-items')
.empty();
}
if (target.data('oldHeight') > 0) {
// handle replot after empty report
target.height(target.data('oldHeight'));
target.data('oldHeight', 0);
target.innerHTML = '';
}
},
_destroy: function () {
if (this._themeModeChangeListener) {
window.removeEventListener('themeModeChange', this._themeModeChangeListener);
}
if (this._plot) {
this.destroyPlot();
}
dataTablePrototype._destroy.call(this);
},
showLoading: function () {
var target = $('#' + this.targetDivId);
var loading = $(document.createElement('div')).addClass('jqplot-loading');
loading.append('');
loading.css({
width: target.innerWidth() + 'px',
height: target.innerHeight() + 'px',
backgroundColor: (this.jqplotParams.grid && this.jqplotParams.grid.background) || '',
opacity: 0
});
target.prepend(loading);
loading.css({opacity: .7});
},
/**
* This method sums up total width of all tick according to currently
* set font-family, font-size and font-weight. It is achieved by
* creating span elements with ticks and adding their width.
* Rendered ticks have to be visible to get their real width. But it
* is too fast for user to notice it. If total ticks width is bigger
* than container width then half of ticks is being cut out and their
* width is tested again. Until their total width is smaller than chart
* div. There is a failsafe so check will be performed no more than 20
* times, which is I think more than enough. Each tick have its own
* gutter, by default width of 5 px from each side so they are more
* readable.
*
* @param $targetDiv
* @private
*/
_checkTicksWidth: function($targetDiv){
if(typeof this.jqplotParams.axes.xaxis.ticksOriginal === 'undefined' || this.jqplotParams.axes.xaxis.ticksOriginal === {}){
this.jqplotParams.axes.xaxis.ticksOriginal = this.jqplotParams.axes.xaxis.ticks.slice();
}
var ticks = this.jqplotParams.axes.xaxis.ticks = this.jqplotParams.axes.xaxis.ticksOriginal.slice();
var divWidth = $targetDiv.width();
var tickOptions = $.extend(true, {}, this.jqplotParams.axesDefaults.tickOptions, this.jqplotParams.axes.xaxis.tickOptions);
var gutter = tickOptions.gutter || 5;
var sumWidthOfTicks = Number.MAX_VALUE;
var $labelTestChamber = {};
var tick = "";
var $body = $("body");
var maxRunsFailsafe = 20;
var ticksCount = 0;
var key = 0;
while(sumWidthOfTicks > divWidth && maxRunsFailsafe > 0) {
sumWidthOfTicks = 0;
for (key = 0; key < ticks.length; key++) {
tick = ticks[key];
if (tick !== " " && tick !== "") {
$labelTestChamber = $("", {
style: 'font-size: ' + (tickOptions.fontSize || '11px') + '; font-family: ' + (tickOptions.fontFamily || 'Arial, Helvetica, sans-serif') + ';' + (tickOptions.fontWeight || 'normal') + ';' + 'clear: both; float: none;',
text: tick
}).appendTo($body);
sumWidthOfTicks += ($labelTestChamber.width() + gutter*2);
$labelTestChamber.remove();
}
}
ticksCount = 0;
if (sumWidthOfTicks > divWidth) {
for (key = 0; key < ticks.length; key++) {
tick = ticks[key];
if (tick !== " " && tick !== "") {
if (ticksCount % 2 == 1) {
ticks[key] = " ";
}
ticksCount++;
}
}
}
maxRunsFailsafe--;
}
},
/** Generic render function */
render: function () {
if (this.data.length == 0) { // sanity check
return;
}
var targetDivId = this.workingDivId + 'Chart';
var lang = this._lang;
var dataTableDiv = $('#' + this.workingDivId);
// if the plot has already been rendered, get rid of the existing plot
var target = $('#' + targetDivId);
if (target.find('canvas').length > 0) {
this.destroyPlot();
}
// handle replot
// this has be bound before the check for an empty graph.
// otherwise clicking on sparklines won't work anymore after an empty
// report has been displayed.
var self = this;
// before drawing a jqplot chart, check if all labels ticks will fit
// into it
if( this.param.viewDataTable === "graphBar"
|| this.param.viewDataTable === "graphVerticalBar"
|| this.param.viewDataTable === "graphEvolution" ) {
self._checkTicksWidth(target);
}
// create jqplot chart
try {
var plot = self._plot = $.jqplot(targetDivId, this.data, this.jqplotParams);
} catch (e) {
// this is thrown when refreshing piwik in the browser
if (e != "No plot target specified") {
throw e;
}
}
self._setWindowResizeListener();
var self = this;
// TODO: this code destroys plots when a page is switched. there must be a better way of managing memory.
if (typeof $.jqplot.visiblePlots == 'undefined') {
$.jqplot.visiblePlots = [];
window.CoreHome.Matomo.on('matomoPageChange', function () {
for (var i = 0; i < $.jqplot.visiblePlots.length; i++) {
if ($.jqplot.visiblePlots[i] == null) {
continue;
}
$.jqplot.visiblePlots[i].destroyPlot();
}
$.jqplot.visiblePlots = [];
});
}
if (typeof plot != 'undefined') {
$.jqplot.visiblePlots.push(self);
}
},
/** Export the chart as an image */
exportAsImage: function (container, lang) {
var pixelRatio = window.devicePixelRatio || 1;
var dataTable = container.closest('.dataTable');
var legendFooter = dataTable.find('.jqplot-legend-footer.has-legend');
var hasFooterLegend = legendFooter.length > 0;
var legendHeight = hasFooterLegend ? legendFooter[0].getBoundingClientRect().height : 0;
var legendGraphGap = hasFooterLegend ? FOOTER_LEGEND_EXPORT_GRAPH_GAP : 0;
var exportCanvas = document.createElement('canvas');
var exportWidth = container.outerWidth();
var dataTableWidth = dataTable.innerWidth();
if (dataTableWidth) {
exportWidth = Math.max(exportWidth, dataTableWidth);
}
exportCanvas.width = Math.round(exportWidth * pixelRatio);
exportCanvas.height = Math.round((container.height() + legendGraphGap + legendHeight) * pixelRatio);
if (!exportCanvas.getContext) {
alert("Sorry, not supported in your browser. Please upgrade your browser :)");
return;
}
var exportCtx = exportCanvas.getContext('2d');
exportCtx.fillStyle = (this.jqplotParams.grid && this.jqplotParams.grid.background) || '#ffffff';
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
var canvases = container.find('canvas');
for (var i = 0; i < canvases.length; i++) {
var canvas = canvases.eq(i);
var position = canvas.position();
var parent = canvas.parent();
if (parent.hasClass('jqplot-axis')) {
var addPosition = parent.position();
position.left += addPosition.left;
position.top += addPosition.top + parseInt(parent.css('marginTop'), 10);
}
exportCtx.drawImage(canvas[0], Math.round(position.left * pixelRatio), Math.round(position.top * pixelRatio));
}
if (hasFooterLegend) {
this.drawLegendForExport(
exportCtx,
legendFooter,
exportWidth,
container.height() + legendGraphGap,
pixelRatio
);
}
var exported = exportCanvas.toDataURL("image/png");
var img = document.createElement('img');
img.src = exported;
img = $(img).css({
width: Math.round(exportCanvas.width / pixelRatio) + 'px',
height: Math.round(exportCanvas.height / pixelRatio) + 'px'
});
var popover = $(document.createElement('div'));
popover.append(''
+ lang.exportText + '
').append($(img));
popover.dialog({
title: lang.exportTitle,
modal: true,
width: 'auto',
resizable: false,
autoOpen: true,
open: function (event, ui) {
$('.ui-widget-overlay').on('click.popover', function () {
popover.dialog('close');
});
},
close: function (event, ui) {
$(this).dialog("destroy").remove();
}
});
},
// Draw the footer legend onto the export canvas by replaying its rendered DOM
// geometry. This preserves browser-resolved item positioning, wrapping,
// centering and hidden overflow state without maintaining a second legend
// layout implementation for export. Text is redrawn onto canvas, so the
// export follows the rendered label bounds but does not reproduce CSS text
// overflow behaviour pixel-for-pixel.
drawLegendForExport: function (ctx, legendFooter, exportWidth, topOffset, pixelRatio) {
var footerRect = legendFooter[0].getBoundingClientRect();
var $items = legendFooter.find('.jqplot-legend-item').filter(function () {
var $item = $(this);
return !$item.hasClass('jqplot-legend-item-hidden') && $item.css('display') !== 'none';
});
if (!$items.length) {
return;
}
// Center the legend on the rendered bounds of its items, not the whole
// footer: the "Choose metrics" button shares the footer row but is an
// interactive control that is not drawn into the exported image, so it
// must not pull the legend off-centre.
var contentLeft = Infinity;
var contentRight = -Infinity;
$items.each(function () {
var rect = this.getBoundingClientRect();
contentLeft = Math.min(contentLeft, rect.left);
contentRight = Math.max(contentRight, rect.right);
});
var offsetX = Math.max(0, (exportWidth - (contentRight - contentLeft)) / 2);
$items.each(function () {
var $item = $(this);
var $swatch = $item.find('.jqplot-legend-swatch');
var $label = $item.find('.jqplot-legend-label');
var labelElement = $label[0];
var originalLabel = $label.attr('data-original-label') || $label.text();
var labelText = $label.text();
if (!$swatch.length || !$label.length || !labelText) {
return;
}
// swatch: a circle, positioned and coloured from its rendered box
var swatchRect = $swatch[0].getBoundingClientRect();
ctx.save();
ctx.fillStyle = $swatch.css('background-color');
ctx.beginPath();
ctx.arc(
(offsetX + (swatchRect.left - contentLeft) + swatchRect.width / 2) * pixelRatio,
(topOffset + (swatchRect.top - footerRect.top) + swatchRect.height / 2) * pixelRatio,
(Math.min(swatchRect.width, swatchRect.height) / 2) * pixelRatio,
0,
Math.PI * 2
);
ctx.fill();
ctx.restore();
// label: drawn at its rendered box and clipped to it, so any CSS
// truncation of a long label is preserved in the exported image
var labelRect = $label[0].getBoundingClientRect();
var labelLeft = offsetX + (labelRect.left - contentLeft);
var labelTop = topOffset + (labelRect.top - footerRect.top);
var fontSize = parseFloat($label.css('font-size')) || 12;
ctx.save();
ctx.beginPath();
ctx.rect(
labelLeft * pixelRatio,
labelTop * pixelRatio,
labelRect.width * pixelRatio,
labelRect.height * pixelRatio
);
ctx.clip();
ctx.font = ($label.css('font-weight') || '400') + ' '
+ Math.round(fontSize * pixelRatio) + 'px '
+ ($label.css('font-family') || require('piwik/UI').getLabelFontFamily());
ctx.fillStyle = $label.css('color') || '#666666';
ctx.textBaseline = 'middle';
labelText = getLegendLabelTextForExport(
ctx,
labelElement,
originalLabel,
labelRect.width * pixelRatio
);
ctx.fillText(
labelText,
labelLeft * pixelRatio,
(labelTop + labelRect.height / 2) * pixelRatio
);
ctx.restore();
});
},
// ------------------------------------------------------------
// HELPER METHODS
// ------------------------------------------------------------
/** Generate ticks in y direction */
setYTicks: function () {
// default axis
this.setYTicksForAxis('yaxis', this.jqplotParams.axes.yaxis);
// other axes: y2axis, y3axis...
for (var i = 2; typeof this.jqplotParams.axes['y' + i + 'axis'] != 'undefined'; i++) {
this.setYTicksForAxis('y' + i + 'axis', this.jqplotParams.axes['y' + i + 'axis']);
}
},
setYTicksForAxis: function (axisName, axis) {
// calculate maximum x value of all data sets
var maxCrossDataSets = 0;
for (var i = 0; i < this.data.length; i++) {
if (this.jqplotParams.series[i].yaxis == axisName) {
var maxValue = Math.max.apply(Math, this.data[i]);
if (maxValue > maxCrossDataSets) {
maxCrossDataSets = maxValue;
}
maxCrossDataSets = parseFloat(maxCrossDataSets);
}
}
// add little padding on top
maxCrossDataSets += Math.max(1, Math.round(maxCrossDataSets * .03));
// round to the nearest multiple of ten
if (maxCrossDataSets > 15) {
maxCrossDataSets = maxCrossDataSets + 10 - maxCrossDataSets % 10;
}
if (maxCrossDataSets == 0) {
maxCrossDataSets = 1;
}
// make sure percent axes don't go above 100%
if (
axis.tickOptions
&& axis.tickOptions.formatString
&& axis.tickOptions.formatString.endsWith('%')
&& maxCrossDataSets > 100
) {
maxCrossDataSets = 100;
}
// calculate y-values for ticks
var ticks = [];
var numberOfTicks = 2;
var tickDistance = Math.ceil(maxCrossDataSets / numberOfTicks);
for (var i = 0; i <= numberOfTicks; i++) {
ticks.push(i * tickDistance);
}
axis.ticks = ticks;
},
/** Get a formatted y values (with unit) */
formatY: function (value, seriesIndex) {
var floatVal = parseFloat(value);
var intVal = parseInt(value, 10);
if (Math.abs(floatVal - intVal) >= 0.005) {
value = Math.round(floatVal * 100) / 100;
} else if (parseFloat(intVal) == floatVal) {
value = intVal;
} else {
value = floatVal;
}
var axisId = this.jqplotParams.series[seriesIndex].yaxis;
var formatString = this.jqplotParams.axes[axisId].tickOptions.formatString;
return $.jqplot.NumberFormatter(formatString, value);
},
/**
* Add an external series toggle.
* As opposed to addSeriesPicker, the external series toggle can only show/hide
* series that are already loaded.
*
* @param seriesPickerClass a subclass of JQPlotExternalSeriesToggle
* @param initiallyShowAll
*/
addExternalSeriesToggle: function (seriesPickerClass, initiallyShowAll) {
new seriesPickerClass(this.targetDivId, this, initiallyShowAll);
if (!initiallyShowAll) {
var initialMetrics = 0;
var $rowEvolution = $('#'+this.targetDivId).closest('.rowevolution');
var newData = [];
var newSeries = [];
if ($rowEvolution.data('initialMetrics')) {
initialMetrics = $rowEvolution.data('initialMetrics');
if (Array.isArray(initialMetrics)) {
for (var j = 0; j < initialMetrics.length; j++) {
// find index of series and data
for (var k = 0; k < this.jqplotParams.series.length; k++) {
if (this.jqplotParams.series[k]
&& this.jqplotParams.series[k].label
&& this.jqplotParams.series[k].label === initialMetrics[j]) {
newData.push(this.data[k]);
newSeries.push(this.jqplotParams.series[k]);
break;
}
}
}
}
}
if (newData.length) {
// restore original selection
this.data = newData;
this.jqplotParams.series = newSeries;
} else {
// initially, show only the first series
this.data = [this.data[0]];
this.jqplotParams.series = [this.jqplotParams.series[0]];
}
this.setYTicks();
}
},
/**
* Sets the colors used to render this graph.
*/
_setColors: function () {
var colorManager = piwik.ColorManager;
var viewDataTable = $('#' + this.workingDivId).data('uiControlObject').param['viewDataTable'];
var graphType = '';
if (viewDataTable == 'graphEvolution' || viewDataTable == 'graphStackedBarEvolution') {
graphType = 'evolution';
} else if (viewDataTable == 'graphPie') {
graphType = 'pie';
} else if (viewDataTable == 'graphVerticalBar') {
graphType = 'bar';
}
var namespace = graphType + '-graph-colors';
this._setSeriesColors(namespace);
this.jqplotParams.grid.background = colorManager.getColor(namespace, 'grid-background');
this.jqplotParams.grid.borderColor = colorManager.getColor(namespace, 'grid-border');
this.tickColor = colorManager.getColor(namespace, 'ticks');
// Evolution and bar gridlines use a lighter tick color.
if (graphType === 'evolution' || graphType === 'bar') {
var TICK_OPACITY = 0.5;
var tickRgb = colorManager.getRgb(this.tickColor);
this.tickColor = 'rgba(' + tickRgb[0] + ', ' + tickRgb[1] + ', '
+ tickRgb[2] + ', ' + TICK_OPACITY + ')';
// Keep gridlines and tick marks the same color.
this.jqplotParams.grid.gridLineColor = this.tickColor;
}
this.singleMetricColor = colorManager.getColor(namespace, 'single-metric-label');
if (this.jqplotParams.pieLegend) {
this.jqplotParams.pieLegend.labelColor = this.singleMetricColor;
}
if (this.jqplotParams.canvasLegend
&& this.jqplotParams.canvasLegend.singleMetric
) {
this.jqplotParams.canvasLegend.singleMetricColor = this.singleMetricColor;
}
},
_setSeriesColors: function (namespace) {
var colorManager = piwik.ColorManager,
seriesColorNames;
var comparisonService = window.CoreHome.ComparisonsStoreInstance;
if (comparisonService.isComparing() && typeof this.jqplotParams.series[0].seriesIndex !== 'undefined') {
// Use the shared comparison palette when comparing rows.
namespace = 'comparison-series-color';
seriesColorNames = [];
this.jqplotParams.series.forEach(function (s) {
var seriesColorName = comparisonService.getSeriesColorName(s.seriesIndex, s.metricIndex);
seriesColorNames.push(seriesColorName);
});
} else if (namespace === 'evolution-graph-colors'
|| namespace === 'bar-graph-colors'
|| namespace === 'pie-graph-colors') {
seriesColorNames = getPlotLinesSeriesColorNames();
} else {
seriesColorNames = ['series0', 'series1', 'series2', 'series3', 'series4', 'series5',
'series6', 'series7', 'series8', 'series9', 'series10'];
}
this.jqplotParams.seriesColors = colorManager.getColors(namespace, seriesColorNames, true);
},
refreshTheme: function () {
if (!this.data || !this.data.length || !this.$element
|| !$.contains(document.documentElement, this.$element[0])) {
return;
}
this._setColors();
this.render();
}
});
DataTable.registerFooterIconHandler('graphPie', DataTable.switchToGraph);
DataTable.registerFooterIconHandler('graphVerticalBar', DataTable.switchToGraph);
DataTable.registerFooterIconHandler('graphEvolution', DataTable.switchToGraph);
})(jQuery, require);
// ----------------------------------------------------------------
// EXTERNAL SERIES TOGGLE
// Use external dom elements and their events to show/hide series
// ----------------------------------------------------------------
function JQPlotExternalSeriesToggle(targetDivId, jqplotObject, initiallyShowAll) {
this.init(targetDivId, originalConfig, initiallyShowAll);
}
JQPlotExternalSeriesToggle.prototype = {
init: function (targetDivId, jqplotObject, initiallyShowAll) {
this.targetDivId = targetDivId;
this.jqplotObject = jqplotObject;
this.originalData = jqplotObject.data;
this.originalSeries = jqplotObject.jqplotParams.series;
this.originalAxes = jqplotObject.jqplotParams.axes;
this.originalParams = jqplotObject.jqplotParams;
this.originalSeriesColors = jqplotObject.jqplotParams.seriesColors;
this.initiallyShowAll = initiallyShowAll;
this.activated = [];
this.target = $('#' + targetDivId);
this.attachEvents();
},
// can be overridden
attachEvents: function () {},
// show a single series
showSeries: function (i) {
this.activated = [i];
this.replot();
},
// toggle a series (make plotting multiple series possible)
toggleSeries: function (i) {
if (this.activated.indexOf(i) > -1) {
// need to remove the metric
if (this.activated.length > 1) {
// prevent removing the only visible metric
this.activated.splice(this.activated.indexOf(i), 1);
}
} else {
this.activated.push(i);
}
this.replot();
},
replot: function () {
this.beforeReplot();
// build new config and replot
var usedAxes = [];
var config = {data: this.originalData, params: this.originalParams};
config.data = [];
config.params.series = [];
config.params.axes = {xaxis: this.originalAxes.xaxis};
config.params.seriesColors = [];
for (var j = 0; j < this.activated.length; j++) {
// find index of series and data
for (var k = 0; k < this.originalSeries.length; k++) {
if (this.originalSeries[k]
&& this.originalSeries[k].label
&& (
this.originalSeries[k].label === this.activated[j]
|| piwikHelper.htmlDecode(this.originalSeries[k].label) === this.activated[j]
)
) {
config.data.push(this.originalData[k]);
config.params.seriesColors.push(this.originalSeriesColors[k]);
config.params.series.push($.extend(true, {}, this.originalSeries[k]));
// build array of used axes
var axis = this.originalSeries[k].yaxis;
if ($.inArray(axis, usedAxes) == -1) {
usedAxes.push(axis);
}
break;
}
}
}
// build new axes config
var replaceAxes = {};
for (j = 0; j < usedAxes.length; j++) {
var originalAxisName = usedAxes[j];
var newAxisName = (j == 0 ? 'yaxis' : 'y' + (j + 1) + 'axis');
replaceAxes[originalAxisName] = newAxisName;
config.params.axes[newAxisName] = this.originalAxes[originalAxisName];
}
// replace axis names in series config
for (j = 0; j < config.params.series.length; j++) {
var series = config.params.series[j];
series.yaxis = replaceAxes[series.yaxis];
}
this.jqplotObject.data = config.data;
this.jqplotObject.jqplotParams = config.params;
this.jqplotObject.setYTicks();
this.jqplotObject.render();
},
// can be overridden
beforeReplot: function () {}
};
// ROW EVOLUTION SERIES TOGGLE
function RowEvolutionSeriesToggle(targetDivId, jqplotData, initiallyShowAll) {
this.init(targetDivId, jqplotData, initiallyShowAll);
}
RowEvolutionSeriesToggle.prototype = JQPlotExternalSeriesToggle.prototype;
RowEvolutionSeriesToggle.prototype.attachEvents = function () {
var self = this;
var $rowEvolution = this.target.closest('.rowevolution');
this.seriesPickers = $rowEvolution.find('table.metrics tr');
var initialMetrics = [];
if ($rowEvolution.data('initialMetrics')) {
initialMetrics = [];
var savedMetrics = $rowEvolution.data('initialMetrics');
var existingMetricsInSeries = [];
var m = 0;
for (m = 0; m < this.originalSeries.length; m++) {
existingMetricsInSeries.push(this.originalSeries[m].label);
}
for (m = 0; m < savedMetrics.length; m++) {
if (existingMetricsInSeries.indexOf(savedMetrics[m]) > -1) {
// only if it exists... for example unique visitors etc might not be available for some metrics,
// then we need to make sure to highlight the default first metric for example
initialMetrics.push(savedMetrics[m]);
}
}
}
this.seriesPickers.each(function (i) {
var el = $(this);
el.off('click').on('click', function (e) {
var metricName = rowEvolutionGetMetricNameFromRow(this);
// we are storing this info on the element as the series picker and the jqplot object gets recreated whenever
// we change a period so we cannot persist the selection there.
if (e.shiftKey) {
self.toggleSeries(metricName);
document.getSelection().removeAllRanges(); // make sure chrome doesn't select text
} else {
self.showSeries(metricName);
}
$rowEvolution.data('initialMetrics', self.activated);
return false;
});
var label = rowEvolutionGetMetricNameFromRow(el);
var metricExists = false;
for (var k = 0; k < self.originalSeries.length; k++) {
if (self.originalSeries[k] && labelMatches(self.originalSeries[k].label, label)) {
metricExists = true;
}
}
if (!metricExists) {
el.hide();
} else if (
(initialMetrics.length === 0 && i == 0)
|| (initialMetrics.length > 0 && initialMetrics.indexOf(label) > -1)
|| self.initiallyShowAll) {
// show the active series
// if initiallyShowAll, all are active; otherwise only the first one
if (!el.hasClass('hiddenByDefault')) {
el.show();
}
el.find('td').css('opacity', '');
self.activated.push(rowEvolutionGetMetricNameFromRow(el));
} else {
if (!el.hasClass('hiddenByDefault')) {
el.show();
}
// fade out the others
el.find('td').css('opacity', .5);
}
// the API outputs the label double encoded when it shouldn't. so when looking for a matching label we have
// to check if one is double encoded.
function labelMatches(lhs, rhs) {
return lhs === rhs || piwikHelper.htmlDecode(lhs) === rhs || lhs === piwikHelper.htmlDecode(rhs);
}
});
};
RowEvolutionSeriesToggle.prototype.beforeReplot = function () {
var self = this;
// fade out if not activated
this.seriesPickers.find('td').css('opacity', .5);
this.seriesPickers.each(function (i) {
var name = rowEvolutionGetMetricNameFromRow(this);
if (self.activated.indexOf(name) > -1) {
$(this).find('td').css('opacity', 1);
}
});
};
// ------------------------------------------------------------
// PIWIK NUMBERFORMATTER PLUGIN FOR JQPLOT
// ------------------------------------------------------------
(function($){
$.jqplot.NumberFormatter = function (format, value) {
if (!$.isNumeric(value)) {
return format.replace(/%s/, value);
}
return format.replace(/%s/, NumberFormatter.formatNumber(value));
}
})(jQuery);
// ------------------------------------------------------------
// PIWIK TICKS PLUGIN FOR JQPLOT
// Handle ticks the piwik way...
// ------------------------------------------------------------
(function ($) {
$.jqplot.PiwikTicks = function (options) {
// canvas for the grid
this.piwikTicksCanvas = null;
// canvas for the highlight
this.piwikHighlightCanvas = null;
// renderer used to draw the marker of the highlighted point
this.markerRenderer = new $.jqplot.MarkerRenderer({
shadow: false
});
// the x tick the mouse is over
this.currentXTick = false;
// show the highlight around markers
this.showHighlight = false;
// show the grid
this.showGrid = false;
// show the ticks
this.showTicks = false;
$.extend(true, this, options);
};
$.jqplot.PiwikTicks.init = function (target, data, opts) {
// add plugin as an attribute to the plot
var options = opts || {};
this.plugins.piwikTicks = new $.jqplot.PiwikTicks(options.piwikTicks);
if (typeof $.jqplot.PiwikTicks.init.eventsBound == 'undefined') {
$.jqplot.PiwikTicks.init.eventsBound = true;
$.jqplot.eventListenerHooks.push(['jqplotMouseMove', handleMouseMove]);
$.jqplot.eventListenerHooks.push(['jqplotMouseLeave', handleMouseLeave]);
}
};
// draw the grid
// called with context of plot
$.jqplot.PiwikTicks.postDraw = function () {
var c = this.plugins.piwikTicks;
// highligh canvas
if (c.showHighlight) {
c.piwikHighlightCanvas = new $.jqplot.GenericCanvas();
this.eventCanvas._elem.before(c.piwikHighlightCanvas.createElement(
this._gridPadding, 'jqplot-piwik-highlight-canvas', this._plotDimensions, this));
c.piwikHighlightCanvas.setContext();
}
// grid canvas
if (c.showTicks) {
var dimensions = this._plotDimensions;
dimensions.height += 6;
c.piwikTicksCanvas = new $.jqplot.GenericCanvas();
this.series[0].shadowCanvas._elem.before(c.piwikTicksCanvas.createElement(
this._gridPadding, 'jqplot-piwik-ticks-canvas', dimensions, this));
c.piwikTicksCanvas.setContext();
var ctx = c.piwikTicksCanvas._ctx;
var ticks = this.data[0];
var totalWidth = ctx.canvas.width;
var tickWidth = totalWidth / ticks.length;
var xaxisLabels = this.axes.xaxis.ticks;
for (var i = 0; i < ticks.length; i++) {
var pos = Math.round(i * tickWidth + tickWidth / 2);
var full = xaxisLabels[i] && xaxisLabels[i] != ' ';
drawLine(ctx, pos, full, c.showGrid, c.tickColor);
}
}
};
$.jqplot.preInitHooks.push($.jqplot.PiwikTicks.init);
$.jqplot.postDrawHooks.push($.jqplot.PiwikTicks.postDraw);
// draw a 1px line
function drawLine(ctx, x, full, showGrid, color) {
ctx.save();
ctx.strokeStyle = color;
ctx.beginPath();
ctx.lineWidth = 2;
var top = 0;
if ((full && !showGrid) || !full) {
top = ctx.canvas.height - 5;
}
ctx.moveTo(x, top);
ctx.lineTo(x, full ? ctx.canvas.height : ctx.canvas.height - 2);
ctx.stroke();
// canvas renders line slightly too large
ctx.clearRect(x, 0, x + 1, ctx.canvas.height);
ctx.restore();
}
// trigger the event jqplotPiwikTickOver when the mouse enters
// and new tick. this is used for tooltips.
function handleMouseMove(ev, gridpos, datapos, neighbor, plot) {
var c = plot.plugins.piwikTicks;
var tick = Math.floor(datapos.xaxis + 0.5) - 1;
if (tick !== c.currentXTick) {
c.currentXTick = tick;
plot.target.trigger('jqplotPiwikTickOver', [tick]);
highlight(plot, tick);
}
}
function handleMouseLeave(ev, gridpos, datapos, neighbor, plot) {
unHighlight(plot);
plot.plugins.piwikTicks.currentXTick = false;
}
// highlight a marker
function highlight(plot, tick) {
var c = plot.plugins.piwikTicks;
if (!c.showHighlight) {
return;
}
unHighlight(plot);
for (var i = 0; i < plot.series.length; i++) {
var series = plot.series[i];
var seriesMarkerRenderer = series.markerRenderer;
c.markerRenderer.style = seriesMarkerRenderer.style;
c.markerRenderer.size = 8;
var rgba = $.jqplot.getColorComponents(seriesMarkerRenderer.color);
var newrgb = [rgba[0], rgba[1], rgba[2]];
var alpha = rgba[3];
c.markerRenderer.color = 'rgba(' + newrgb[0] + ',' + newrgb[1] + ',' + newrgb[2] + ',' + alpha + ')';
c.markerRenderer.init();
var position = series.gridData[tick];
if (typeof position !== 'undefined') {
c.markerRenderer.draw(position[0], position[1], c.piwikHighlightCanvas._ctx);
}
}
}
function unHighlight(plot) {
var canvas = plot.plugins.piwikTicks.piwikHighlightCanvas;
if (canvas !== null) {
var ctx = canvas._ctx;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
}
})(jQuery);
// ------------------------------------------------------------
// LEGEND PLUGIN FOR JQPLOT
// Render legend below the graph
// ------------------------------------------------------------
(function ($) {
$.jqplot.CanvasLegendRenderer = function (options) {
// is it a legend for a single metric only (pie chart)?
this.singleMetric = false;
// render the legend?
this.show = false;
$.extend(true, this, options);
};
$.jqplot.CanvasLegendRenderer.init = function (target, data, opts) {
// add plugin as an attribute to the plot
var options = opts || {};
this.plugins.canvasLegend = new $.jqplot.CanvasLegendRenderer(options.canvasLegend);
if (this.plugins.canvasLegend.show) {
options.gridPadding = $.extend({}, options.gridPadding, {
top: Math.max((options.gridPadding && options.gridPadding.top) || 0, 21)
});
}
};
// render the legend
$.jqplot.CanvasLegendRenderer.postDraw = function () {
var plot = this;
var legend = plot.plugins.canvasLegend;
if (!legend.show) {
return;
}
var $target = $(plot.targetId);
var $dataTable = $target.closest('.dataTable');
var $legendFooter = getOrCreateLegendFooter($dataTable);
var $legendContainer = $legendFooter.find('.jqplot-legend-items');
if (!$legendContainer.length) {
return;
}
$legendFooter.removeClass('has-legend');
$legendContainer.empty();
var series = plot.legend && plot.legend._series ? plot.legend._series : [];
for (var i = 0; i < series.length; i++) {
var s = series[i];
var label = '';
if (legend.labels && legend.labels[i]) {
label = legend.labels[i];
} else if (typeof s.label !== 'undefined' && s.label !== null) {
label = s.label.toString();
}
if (!label) {
continue;
}
var color = s.color;
if (legend.singleMetric && legend.singleMetricColor) {
color = legend.singleMetricColor;
}
$('', {'class': 'jqplot-legend-item'})
.append(
$('', {'class': 'jqplot-legend-swatch'}).css('background-color', color),
$('', {
'class': 'jqplot-legend-label',
'data-original-label': label
}).text(label)
)
.appendTo($legendContainer);
}
if ($legendContainer.children().length) {
$legendFooter.addClass('has-legend');
limitLegendRows($legendContainer, MAX_FOOTER_LEGEND_ROWS);
}
updateLegendFooterStacking($legendFooter);
};
$.jqplot.preInitHooks.push($.jqplot.CanvasLegendRenderer.init);
$.jqplot.postDrawHooks.push($.jqplot.CanvasLegendRenderer.postDraw);
})(jQuery);
// ------------------------------------------------------------
// SERIES PICKER
// ------------------------------------------------------------
(function ($, require) {
$.jqplot.preInitHooks.push(function (target, data, options) {
// create the series picker
var dataTable = $('#' + target).closest('.dataTable').data('uiControlObject');
if (!dataTable) { // if we're not dealing w/ a DataTable visualization, don't add the series picker
return;
}
var SeriesPicker = require('piwik/DataTableVisualizations/Widgets').SeriesPicker;
var seriesPicker = new SeriesPicker(dataTable);
// render the "Choose metrics" button variant and place it in the legend
// footer instead of above the chart
seriesPicker.useChooseMetricsButton = true;
// handle placeSeriesPicker event
var plot = this;
$(seriesPicker).bind('placeSeriesPicker', function () {
var $dataTable = $(plot.targetId).closest('.dataTable');
var $legendFooter = getOrCreateLegendFooter($dataTable);
var $pickerSlot = $legendFooter.find('.jqplot-legend-picker');
if (!$pickerSlot.length) {
$pickerSlot = $('').prependTo($legendFooter);
}
// replace any previously rendered picker so redraws don't stack duplicates
$pickerSlot.empty().append(this.domElem);
$legendFooter.addClass('has-picker');
updateLegendFooterStacking($legendFooter);
});
// handle seriesPicked event
$(seriesPicker).bind('seriesPicked', function (e, columns, rows) {
dataTable.changeSeries(columns, rows);
});
this.plugins.seriesPicker = seriesPicker;
});
$.jqplot.postDrawHooks.push(function () {
this.plugins.seriesPicker.init();
// The legend footer renders before this hook, so its row limit was computed
// without the "Choose metrics" button. Now that the button is placed and
// mounted (and has taken its share of the footer width), recompute the limit
// so the legend items wrap against the real available width.
applyFooterLegendRowLimit($(this.targetId).closest('.dataTable'));
});
})(jQuery, require);
// ------------------------------------------------------------
// PIE CHART LEGEND PLUGIN FOR JQPLOT
// Render legend inside the pie graph
// ------------------------------------------------------------
(function ($) {
$.jqplot.PieLegend = function (options) {
// canvas for the legend
this.pieLegendCanvas = null;
// render the legend?
this.show = false;
$.extend(true, this, options);
};
$.jqplot.PieLegend.init = function (target, data, opts) {
// add plugin as an attribute to the plot
var options = opts || {};
this.plugins.pieLegend = new $.jqplot.PieLegend(options.pieLegend);
};
// render the legend
$.jqplot.PieLegend.postDraw = function () {
var plot = this;
var legend = plot.plugins.pieLegend;
if (!legend.show) {
return;
}
var series = plot.series[0];
var angles = series._sliceAngles;
var radius = series._diameter / 2;
var center = series._center;
var colors = this.seriesColors;
// concentric line angles
var lineAngles = [];
for (var i = 0; i < angles.length; i++) {
lineAngles.push((angles[i][0] + angles[i][1]) / 2 + Math.PI / 2);
}
// labels
var labels = [];
var data = series._plotData;
for (i = 0; i < data.length; i++) {
labels.push(data[i][0]);
}
// initialize legend canvas
legend.pieLegendCanvas = new $.jqplot.GenericCanvas();
plot.series[0].canvas._elem.before(legend.pieLegendCanvas.createElement(
plot._gridPadding, 'jqplot-pie-legend-canvas', plot._plotDimensions, plot));
legend.pieLegendCanvas.setContext();
var ctx = legend.pieLegendCanvas._ctx;
ctx.save();
ctx.font = '11px ' + require('piwik/UI').getLabelFontFamily()
// render labels
var height = legend.pieLegendCanvas._elem.height();
var x1, x2, y1, y2, lastY2 = false, right, lastRight = false;
for (i = 0; i < labels.length; i++) {
var label = labels[i];
ctx.strokeStyle = colors[i % colors.length];
ctx.lineCap = 'round';
ctx.lineWidth = 1;
// concentric line
x1 = center[0] + Math.sin(lineAngles[i]) * (radius);
y1 = center[1] - Math.cos(lineAngles[i]) * (radius);
x2 = center[0] + Math.sin(lineAngles[i]) * (radius + 7);
y2 = center[1] - Math.cos(lineAngles[i]) * (radius + 7);
right = x2 > center[0];
// move close labels
if (lastY2 !== false && lastRight == right && (
(right && y2 - lastY2 < 13) ||
(!right && lastY2 - y2 < 13))) {
if (x1 > center[0]) {
// move down if the label is in the right half of the graph
y2 = lastY2 + 13;
} else {
// move up if in left halt
y2 = lastY2 - 13;
}
}
if (y2 < 4 || y2 + 4 > height) {
continue;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.closePath();
ctx.stroke();
// horizontal line
ctx.beginPath();
ctx.moveTo(x2, y2);
if (right) {
ctx.lineTo(x2 + 5, y2);
} else {
ctx.lineTo(x2 - 5, y2);
}
ctx.closePath();
ctx.stroke();
lastY2 = y2;
lastRight = right;
// text
if (right) {
var x = x2 + 9;
} else {
var x = x2 - 9 - ctx.measureText(label).width;
}
ctx.fillStyle = legend.labelColor;
ctx.fillText(label, x, y2 + 3);
}
ctx.restore();
};
$.jqplot.preInitHooks.push($.jqplot.PieLegend.init);
$.jqplot.postDrawHooks.push($.jqplot.PieLegend.postDraw);
})(jQuery, require);
// ------------------------------------------------------------
// MATOMO INCOMPLETE DATA INDICATOR FOR JQPLOT
// Show a dashed line to the last point to indicate incomplete
// ------------------------------------------------------------
(function ($) {
$.jqplot.LineRenderer.prototype.draw = function(ctx, gd, options, plot) {
var i;
// get a copy of the options, so we don't modify the original object.
var opts = $.extend(true, {}, options);
var shadow = (opts.shadow != undefined) ? opts.shadow : this.shadow;
var showLine = (opts.showLine != undefined) ? opts.showLine : this.showLine;
var fill = (opts.fill != undefined) ? opts.fill : this.fill;
var fillAndStroke = (opts.fillAndStroke != undefined) ? opts.fillAndStroke : this.fillAndStroke;
var xmin, ymin, xmax, ymax;
// Only change in this overridden method, to pass the option to the renderers
if (plot.options.hasOwnProperty('dataStates')) {
opts.dataStates = plot.options.dataStates;
}
if (!Array.isArray(opts.dataStates)) {
opts.dataStates = [];
}
ctx.save();
if (gd.length) {
if (showLine) {
// if we fill, we'll have to add points to close the curve.
if (fill) {
if (this.fillToZero) {
// have to break line up into shapes at axis crossings
var negativeColor = this.negativeColor;
if (! this.useNegativeColors) {
negativeColor = opts.fillStyle;
}
var isnegative = false;
var posfs = opts.fillStyle;
// if stoking line as well as filling, get a copy of line data.
if (fillAndStroke) {
var fasgd = gd.slice(0);
}
// if not stacked, fill down to axis
if (this.index == 0 || !this._stack) {
var tempgd = [];
var pd = (this.renderer.smooth) ? this.renderer._smoothedPlotData : this._plotData;
this._areaPoints = [];
var pyzero = this._yaxis.series_u2p(this.fillToValue);
var pxzero = this._xaxis.series_u2p(this.fillToValue);
opts.closePath = true;
if (this.fillAxis == 'y') {
tempgd.push([gd[0][0], pyzero]);
this._areaPoints.push([gd[0][0], pyzero]);
for (var i=0; i0; i--) {
gd.push(prev[i-1]);
// this._areaPoints.push(prev[i-1]);
}
if (shadow) {
this.renderer.shadowRenderer.draw(ctx, gd, opts);
}
this._areaPoints = gd;
this.renderer.shapeRenderer.draw(ctx, gd, opts);
}
}
/////////////////////////
// Not filled to zero
////////////////////////
else {
// if stoking line as well as filling, get a copy of line data.
if (fillAndStroke) {
var fasgd = gd.slice(0);
}
// if not stacked, fill down to axis
if (this.index == 0 || !this._stack) {
// var gridymin = this._yaxis.series_u2p(this._yaxis.min) - this.gridBorderWidth / 2;
var gridymin = ctx.canvas.height;
// IE doesn't return new length on unshift
gd.unshift([gd[0][0], gridymin]);
var len = gd.length;
gd.push([gd[len - 1][0], gridymin]);
}
// if stacked, fill to line below
else {
var prev = this._prevGridData;
for (var i=prev.length; i>0; i--) {
gd.push(prev[i-1]);
}
}
this._areaPoints = gd;
if (shadow) {
this.renderer.shadowRenderer.draw(ctx, gd, opts);
}
this.renderer.shapeRenderer.draw(ctx, gd, opts);
}
if (fillAndStroke) {
var fasopts = $.extend(true, {}, opts, {fill:false, closePath:false});
this.renderer.shapeRenderer.draw(ctx, fasgd, fasopts);
// now draw the markers
if (this.markerRenderer.show) {
if (this.renderer.smooth) {
fasgd = this.gridData;
}
for (i=0; i p[0] || xmin == null) {
xmin = p[0];
}
if (ymax < p[1] || ymax == null) {
ymax = p[1];
}
if (xmax < p[0] || xmax == null) {
xmax = p[0];
}
if (ymin > p[1] || ymin == null) {
ymin = p[1];
}
}
if (this.type === 'line' && this.renderer.bands.show) {
ymax = this._yaxis.series_u2p(this.renderer.bands._min);
ymin = this._yaxis.series_u2p(this.renderer.bands._max);
}
this._boundingBox = [[xmin, ymax], [xmax, ymin]];
// now draw the markers
if (this.markerRenderer.show && !fill) {
if (this.renderer.smooth) {
gd = this.gridData;
}
for (i = 0; i < gd.length; i++) {
if (gd[i][0] === null || gd[i][1] === null) {
continue;
}
const markerOptions = opts.markerOptions || {};
markerOptions.isIncomplete = opts.dataStates[i] && opts.dataStates[i] !== 'complete';
markerOptions.incompleteFillColor = plot.grid.background;
this.markerRenderer.draw(gd[i][0], gd[i][1], ctx, markerOptions);
}
}
}
ctx.restore();
};
$.jqplot.ShapeRenderer.prototype.draw = function(ctx, points, options) {
ctx.save();
var opts = (options != null) ? options : {};
var fill = (opts.fill != null) ? opts.fill : this.fill;
var closePath = (opts.closePath != null) ? opts.closePath : this.closePath;
var fillRect = (opts.fillRect != null) ? opts.fillRect : this.fillRect;
var strokeRect = (opts.strokeRect != null) ? opts.strokeRect : this.strokeRect;
var clearRect = (opts.clearRect != null) ? opts.clearRect : this.clearRect;
var isarc = (opts.isarc != null) ? opts.isarc : this.isarc;
var linePattern = (opts.linePattern != null) ? opts.linePattern : this.linePattern;
var ctxPattern = $.jqplot.LinePattern(ctx, linePattern);
ctx.lineWidth = opts.lineWidth || this.lineWidth;
ctx.lineJoin = opts.lineJoin || this.lineJoin;
ctx.lineCap = opts.lineCap || this.lineCap;
ctx.strokeStyle = (opts.strokeStyle || opts.color) || this.strokeStyle;
ctx.fillStyle = opts.fillStyle || this.fillStyle;
ctx.beginPath();
let dataStates = [];
if (!closePath && !fill && Array.isArray(opts.dataStates)) {
// only do the incomplete visualization for line charts
dataStates = opts.dataStates;
}
if (isarc) {
ctx.arc(points[0], points[1], points[2], points[3], points[4], true);
if (closePath) {
ctx.closePath();
}
if (fill) {
ctx.fill();
}
else {
ctx.stroke();
}
if (opts.isIncomplete && opts.incompleteFillColor) {
// graph lines reach into the point
// render inner point filled with background color to avoid showing them
ctx.beginPath();
ctx.arc(points[0], points[1], points[2] / 8, points[3], points[4], true);
ctx.strokeStyle = opts.incompleteFillColor;
ctx.stroke();
ctx.closePath();
}
ctx.restore();
return;
}
else if (clearRect) {
ctx.clearRect(points[0], points[1], points[2], points[3]);
ctx.restore();
return;
}
else if (fillRect || strokeRect) {
if (fillRect) {
ctx.fillRect(points[0], points[1], points[2], points[3]);
}
if (strokeRect) {
ctx.strokeRect(points[0], points[1], points[2], points[3]);
ctx.restore();
return;
}
}
if (!points || !points.length) {
return;
}
let move = true;
for (let i = 0; i < points.length; i++) {
// skip to the first non-null point and move to it.
if (null === points[i][0] && null === points[i][1]) {
continue;
}
if (move) {
move = false;
ctxPattern.moveTo(points[i][0], points[i][1]);
continue;
}
// draw line to current point or skip if incomplete data point
if (dataStates[i] && 'complete' !== dataStates[i]) {
ctxPattern.moveTo(points[i][0], points[i][1]);
} else {
ctxPattern.lineTo(points[i][0], points[i][1]);
}
}
if (closePath) {
ctxPattern.closePath();
}
if (fill) {
ctx.fill();
} else {
ctx.stroke();
}
// draw dashed lines for incomplete data points
ctx.beginPath();
ctx.setLineDash([3, 3]);
move = true;
for (let i = 0; i < points.length; i++) {
// skip to the first non-null point and move to it.
if (points[i][0] === null && points[i][1] === null) {
continue;
}
if (move) {
move = false;
ctxPattern.moveTo(points[i][0], points[i][1]);
continue;
}
// draw dashed line to current point or skip if not incomplete data point
if (!dataStates[i] || 'complete' === dataStates[i]) {
ctxPattern.moveTo(points[i][0], points[i][1]);
} else {
ctxPattern.lineTo(points[i][0], points[i][1]);
}
}
ctx.stroke();
ctx.closePath();
ctx.restore();
};
// Only overriding this method to prevent drawing the shadow for the last line segment
$.jqplot.ShadowRenderer.prototype.draw = function(ctx, points, options) {
ctx.save();
var opts = (options != null) ? options : {};
var fill = (opts.fill != null) ? opts.fill : this.fill;
var fillRect = (opts.fillRect != null) ? opts.fillRect : this.fillRect;
var closePath = (opts.closePath != null) ? opts.closePath : this.closePath;
var offset = (opts.offset != null) ? opts.offset : this.offset;
var alpha = (opts.alpha != null) ? opts.alpha : this.alpha;
var depth = (opts.depth != null) ? opts.depth : this.depth;
var isarc = (opts.isarc != null) ? opts.isarc : this.isarc;
var linePattern = (opts.linePattern != null) ? opts.linePattern : this.linePattern;
ctx.lineWidth = (opts.lineWidth != null) ? opts.lineWidth : this.lineWidth;
ctx.lineJoin = (opts.lineJoin != null) ? opts.lineJoin : this.lineJoin;
ctx.lineCap = (opts.lineCap != null) ? opts.lineCap : this.lineCap;
ctx.strokeStyle = opts.strokeStyle || this.strokeStyle || 'rgba(0,0,0,'+alpha+')';
ctx.fillStyle = opts.fillStyle || this.fillStyle || 'rgba(0,0,0,'+alpha+')';
let dataStates = [];
if (!closePath && !fill && Array.isArray(opts.dataStates)) {
// only do the incomplete visualization for line charts
dataStates = opts.dataStates;
}
for (let j= 0; j < depth; j++) {
const ctxPattern = $.jqplot.LinePattern(ctx, linePattern);
ctx.translate(Math.cos(this.angle*Math.PI/180)*offset, Math.sin(this.angle*Math.PI/180)*offset);
ctxPattern.beginPath();
if (isarc) {
ctx.arc(points[0], points[1], points[2], points[3], points[4], true);
}
else if (fillRect) {
ctx.fillRect(points[0], points[1], points[2], points[3]);
}
else if (points && points.length) {
let move = true;
for (let i = 0; i < points.length; i++) {
// skip to the first non-null point and move to it.
if (points[i][0] === null && points[i][1] === null) {
continue;
}
if (move) {
move = false;
ctxPattern.moveTo(points[i][0], points[i][1]);
continue;
}
// draw shadow line to current point or skip if incomplete data point
if (dataStates[i] && 'complete' !== dataStates[i]) {
ctxPattern.moveTo(points[i][0], points[i][1]);
} else {
ctxPattern.lineTo(points[i][0], points[i][1]);
}
}
}
if (closePath) {
ctxPattern.closePath();
}
if (fill) {
ctx.fill();
}
else {
ctx.stroke();
}
}
ctx.restore();
};
})(jQuery);