/*!
* Matomo - free/libre analytics platform
*
* Real time visitors map
* Using Kartograph.js http://kartograph.org/
*
* @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
(function () {
var UIControl = require('piwik/UI').UIControl;
var RealtimeMap = window.UserCountryMap.RealtimeMap = function (element) {
UIControl.call(this, element);
this._init();
this.run();
};
RealtimeMap.initElements = function () {
UIControl.initElements(this, '.RealTimeMap');
};
$.extend(RealtimeMap.prototype, UIControl.prototype, {
_init: function () {
var $element = this.$element;
this.config = JSON.parse($element.attr('data-config'));
// If the map is loaded from the menu, do a few tweaks to clean up the display
if ($element.attr('data-standalone') == 1) {
this._initStandaloneMap();
}
// handle widgetry
if ($('#dashboardWidgetsArea').length) {
var $widgetContent = $element.closest('.widgetContent');
var self = this;
$widgetContent.on('widget:maximise', function () {
self.resize();
}).on('widget:minimise', function () {
self.resize();
});
}
// set unique ID for kartograph map div
this.uniqueId = 'RealTimeMap_map-' + this._controlId;
$('.RealTimeMap_map', $element).attr('id', this.uniqueId);
// create the map
this.map = $K.map('#' + this.uniqueId);
$element.focus();
},
_initStandaloneMap: function () {
window.CoreHome.Matomo.postEvent('hidePeriodSelector');
$('.realTimeMap_overlay').css('top', '0px');
$('.realTimeMap_datetime').css('top', '20px');
},
run: function () {
var self = this,
config = self.config,
_ = config._,
map = self.map,
maxVisits = config.maxVisits || 100,
changeVisitAlpha = typeof config.changeVisitAlpha === 'undefined' ? true : config.changeVisitAlpha,
removeOldVisits = typeof config.removeOldVisits === 'undefined' ? true : config.removeOldVisits,
doNotRefreshVisits = typeof config.doNotRefreshVisits === 'undefined' ? false : config.doNotRefreshVisits,
enableAnimation = typeof config.enableAnimation === 'undefined' ? true : config.enableAnimation,
forceNowValue = typeof config.forceNowValue === 'undefined' ? false : +config.forceNowValue,
lastTimestamp = -1,
lastVisits = [],
visitSymbols,
tokenAuth = '' + config.reqParams.token_auth,
oldest,
isFullscreenWidget = $('.widget').parent().get(0) == document.body,
now,
nextReqTimer,
symbolFadeInTimer = [],
colorMode = 'default',
currentMap = 'world',
yesterday = false,
userHasZoomed = false,
colorManager = piwik.ColorManager,
colors = colorManager.getColors('realtime-map', ['white-bg', 'white-fill', 'black-bg', 'black-fill', 'visit-stroke',
'website-referrer-color', 'direct-referrer-color', 'search-referrer-color',
'live-widget-highlight', 'live-widget-unhighlight', 'symbol-animate-fill', 'region-stroke-color']),
currentTheme = 'white',
colorTheme = {
white: {
bg: colors['white-bg'],
fill: colors['white-fill']
},
black: {
bg: colors['black-bg'],
fill: colors['black-fill']
}
},
visitStrokeColor = colors['visit-stroke'],
referrerColorWebsite = colors['referrer-color-website'],
referrerColorDirect = colors['referrer-color-direct'],
referrerColorSearch = colors['referrer-color-search'],
liveWidgetHighlightColor = colors['live-widget-highlight'],
liveWidgetUnhighlightColor = colors['live-widget-unhighlight'],
symbolAnimateFill = colors['symbol-animate-fill']
;
self.widget = $('#widgetRealTimeMaprealtimeMap').parent();
var preset = self.widget.dashboardWidget('getWidgetObject').parameters;
if (preset) {
currentTheme = preset.colorTheme;
colorMode = preset.colorMode;
currentMap = preset.lastMap;
}
/*
* returns the parameters for API calls, extended from
* self.reqParams which is set in template
*/
function _reportParams(firstRun) {
return $.extend(config.reqParams, {
module: 'API',
method: 'Live.getLastVisitsDetails',
filter_limit: maxVisits,
showColumns: ['latitude', 'longitude', 'actions', 'lastActionTimestamp',
'visitLocalTime', 'city', 'country', 'countryCode', 'referrerType', 'referrerName',
'referrerTypeName', 'browserIcon', 'operatingSystemIcon', 'deviceType', 'deviceModel',
'countryFlag', 'idVisit', 'actionDetails', 'continentCode',
'actions', 'searches', 'goalConversions', 'visitorId', 'userId'].join(','),
minTimestamp: firstRun ? 0 : lastTimestamp
});
}
/*
* wrapper around jQuery.ajax, moves token_auth parameter
* to POST data while keeping other parameters as GET
*/
function ajax(params) {
delete params['token_auth'];
return $.ajax({
url: 'index.php?' + $.param(params),
dataType: 'json',
data: { token_auth: tokenAuth, force_api_session: broadcast.isWidgetizeRequestWithoutSession() ? 0 : 1 },
type: 'POST'
});
}
/*
* updateMap is called by renderCountryMap() and renderWorldMap()
*/
function _updateMap(svgUrl, callback) {
if (svgUrl === undefined) return;
map.loadMap(config.svgBasePath + svgUrl, function () {
map.clear();
self.resize();
callback();
$('.ui-tooltip').remove(); // remove all existing tooltips
}, { padding: -3});
}
/*
* to ensure that onResize is not called a hundred times
* while resizing the browser window, this functions
* makes sure to only call onResize at the end
*/
function onResizeLazy() {
clearTimeout(self._resizeTimer);
self._resizeTimer = setTimeout(self.resize.bind(self), 300);
}
/*
* returns value betwddn 0..1, where 1 means that the
* visit is fresh, and 0 means the visit is almost gone
* from the map
*/
function age(r) {
var nowSecs = Math.floor(now);
var o = (r.lastActionTimestamp - oldest) / (nowSecs - oldest);
return Math.min(1, Math.max(0, o));
}
function relativeTime(ds) {
var val = function (val) { return '' + Math.round(val) + ''; };
return (ds < 90 ? _.seconds_ago.replace('%s', val(ds))
: ds < 5400 ? _.minutes_ago.replace('%s', val(ds / 60))
: ds < 129600 ? _.hours_ago.replace('%s', val(ds / 3600))
: _.days_ago.replace('%s', val(ds / 86400)));
}
/*
* returns the content of the tooltip displayed for each
* visitor on the map
*/
function visitTooltip(r) {
var ds = new Date().getTime() / 1000 - r.lastActionTimestamp,
ad = r.actionDetails,
ico = function (src) { return '
'; };
return '
' + (r.city ? $('').text(r.city).html() + ' / ' : '') + $('').text(r.country).html() + '
' +
// icons
ico(r.countryFlag) + ico(r.browserIcon) + ico(r.operatingSystemIcon) + '
' +
// device type, model, brand
r.deviceType + ' (' + r.deviceModel + ')
' +
// User ID
(r.userId ? _pk_translate('General_UserId') + ': ' + $('').text(r.userId).html() + '
' : '') +
// last action
(ad && ad.length && ad[ad.length - 1].pageTitle ? '' + $('').text(ad[ad.length - 1].pageTitle).html() + '
' : '') +
// time of visit
'' + relativeTime(ds) + '
' +
// either from or direct
(r.referrerType == "direct" ? r.referrerTypeName :
_.from + ': ' + $('').text(r.referrerName).html()) + '
' +
// local time
'' + _.local_time + ': ' + r.visitLocalTime + '
' +
// goals, if available
(self.config.siteHasGoals && r.goalConversions ? '' + _.goal_conversions.replace('%s', '' + r.goalConversions + '') +
(r.searches > 0 ? ', ' + _.searches.replace('%s', r.searches) : '') + '
' : '') +
// actions and searches
'' + _.actions.replace('%s', '' + r.actions + '') +
(r.searches > 0 ? ', ' + _.searches.replace('%s', '' + r.searches + '') : '') + '';
}
/*
* the radius of the symbol depends on the lastActionTimestamp
*/
function visitRadius(r) {
return Math.pow(age(r), 4) * (self.maxRad - self.minRad) + self.minRad;
}
/*
* defines the color of the map symbols.
* depends on colorMode, which is set to 'default'
* unless you type Shift+Alt+C
*/
function visitColor(r) {
var col,
engaged = self.config.siteHasGoals ? r.goalConversions > 0 : r.actions > 4;
if (colorMode == 'referrerType') {
col = ({
website: referrerColorWebsite,
direct: referrerColorDirect,
search: referrerColorSearch
})[r.referrerType];
}
// defu
else col = chroma.hsl(
42 * age(r), // hue
Math.sqrt(age(r)), // saturation
(engaged ? 0.65 : 0.5) - (1 - age(r)) * 0.45 // lightness
);
return col;
}
/*
* attributes of the map symbols
*/
function visitSymbolAttrs(r) {
var result = {
fill: visitColor(r).hex(),
stroke: visitStrokeColor,
'stroke-width': 1 * age(r),
r: visitRadius(r),
cursor: 'pointer'
};
if (changeVisitAlpha) {
result['fill-opacity'] = Math.pow(age(r), 2) * 0.8 + 0.2;
result['stroke-opacity'] = Math.pow(age(r), 1.7) * 0.8 + 0.2;
}
return result;
}
/*
* eventually highlights the row in LiveVisitors widget
* that corresponds to a visit on the map
*/
function highlightVisit(r) {
$('#visitsLive').find('li#' + r.idVisit + ' .datetime')
.css('background', liveWidgetHighlightColor);
}
/*
* removes the highlight after the mouse left
* the visit marker on the map
*/
function unhighlightVisit(r) {
$('#visitsLive').find('li#' + r.idVisit + ' .datetime')
.css({ background: liveWidgetUnhighlightColor });
}
/*
* create a nice popping animation for appearing
* visit symbols.
*/
function animateSymbol(s) {
// create a white outline and explode it
var c = map.paper.circle().attr(s.path.attrs);
c.insertBefore(s.path);
c.attr({ fill: false });
c.animate({ r: c.attrs.r * 3, 'stroke-width': 7, opacity: 0 }, 2500,
'linear', function () { c.remove(); });
// ..and pop the bubble itself
var col = s.path.attrs.fill,
rad = s.path.attrs.r;
s.path.show();
s.path.attr({ fill: symbolAnimateFill, r: 0.1, opacity: 1 });
s.path.animate({ fill: col, r: rad }, 700, 'bounce');
}
// default click behavior. if a visit is clicked, the visitor profile is launched,
// otherwise zoom in or out.
// TODO: visitor profile launching logic should probably be contained in
// visitorProfile.js. not sure how to do that, though...
this.$element.on('mapClick', function (e, visit, mapPath) {
var VisitorProfileControl = require('piwik/UI').VisitorProfileControl;
if (visit
&& piwik.visitorProfileEnabled
&& VisitorProfileControl
&& !self.$element.closest('.visitor-profile').length
) {
VisitorProfileControl.showPopover(visit.visitorId);
} else {
var cont = UserCountryMap.cont2cont[mapPath.data.continentCode];
if (cont && cont != currentMap) {
updateMap(cont);
}
}
});
/*
* this function requests new data from Live.getLastVisitsDetails
* and updates the symbols on the map. Then, it sets a timeout
* to call itself after the refresh time set by Matomo
*
* If firstRun is true, the SymbolGroup is initialized
*/
function refreshVisits(firstRun) {
if (lastTimestamp != -1
&& doNotRefreshVisits
&& !firstRun
) {
return;
}
/*
* this is called after new visit reports came in
*/
function gotNewReport(report) {
// if the map has been destroyed, do nothing
if (!self.map || !self.$element.length || !$.contains(document, self.$element[0])) {
return;
}
// successful request, so set timeout for next API call
nextReqTimer = setTimeout(refreshVisits, config.liveRefreshAfterMs);
// hide loading indicator
$('.realTimeMap_overlay img').hide();
$('.realTimeMap_overlay .loading_data').hide();
// store current timestamp
now = forceNowValue || (new Date().getTime() / 1000);
if (firstRun) { // if we run this the first time, we initialiize the map symbols
visitSymbols = map.addSymbols({
data: [],
type: $K.Bubble,
/*title: function(d) {
return visitRadius(d) > 15 && d.actions > 1 ? d.actions : '';
},
labelattrs: {
fill: '#fff',
'font-weight': 'bold',
'font-size': 11,
stroke: false,
cursor: 'pointer'
},*/
sortBy: function (r) { return r.lastActionTimestamp; },
radius: visitRadius,
location: function (r) { return [r.longitude, r.latitude]; },
attrs: visitSymbolAttrs,
tooltip: visitTooltip,
mouseenter: highlightVisit,
mouseleave: unhighlightVisit,
click: function (visit, mapPath, evt) {
evt.stopPropagation();
self.$element.trigger('mapClick', [visit, mapPath]);
}
});
// clear existing report
lastVisits = [];
}
if (report.length) {
// filter results without location
report = $.grep(report, function (r) {
return r.latitude !== null;
});
if (firstRun) {
// show warning if no visits w/ latitude
$('#realTimeMapNoVisitsInfo').toggle(!report.length);
}
}
// check whether we got any geolocated visits left
if (!report.length) {
if (firstRun) {
// show no visits message only if the first request did not return any data
$('.realTimeMap_overlay .showing_visits_of').hide();
$('.realTimeMap_overlay .no_data').show();
}
return;
} else {
$('.realTimeMap_overlay .showing_visits_of').show();
$('.realTimeMap_overlay .no_data').hide();
if (yesterday === false) {
yesterday = report[0].lastActionTimestamp - 24 * 60 * 60;
}
lastVisits = [].concat(report).concat(lastVisits).slice(0, maxVisits);
oldest = Math.max(lastVisits[lastVisits.length - 1].lastActionTimestamp, yesterday);
// let's try a different strategy
// remove symbols that are too old
var _removed = 0;
if (removeOldVisits) {
visitSymbols.remove(function (r) {
if (r.lastActionTimestamp < oldest) _removed++;
return r.lastActionTimestamp < oldest;
});
}
// update symbols that remain
visitSymbols.update({
radius: function (d) { return visitSymbolAttrs(d).r; },
attrs: visitSymbolAttrs
}, true);
// add new symbols
var newSymbols = [];
$.each(report, function (i, r) {
newSymbols.push(visitSymbols.add(r));
});
visitSymbols.layout().render();
if (enableAnimation) {
$.each(newSymbols, function (i, s) {
if (i > 10) return false;
s.path.hide(); // hide new symbol at first
var t = setTimeout(function () { animateSymbol(s); },
1000 * (s.data.lastActionTimestamp - now) + config.liveRefreshAfterMs);
symbolFadeInTimer.push(t);
});
}
lastTimestamp = report[0].lastActionTimestamp;
// show
var dur = lastTimestamp - oldest, d;
if (dur < 60) d = dur + ' ' + _.seconds;
else if (dur < 3600) d = Math.ceil(dur / 60) + ' ' + _.minutes;
else d = Math.ceil(dur / 3600) + ' ' + _.hours;
$('.realTimeMap_timeSpan').html(d);
if (!userHasZoomed) {
// we only apply auto zoom when user has not zoomed manually
var usedContinents = [];
var usedCountries = [];
var aSymbol;
for (var z = 0; z < visitSymbols.symbols.length; z++) {
aSymbol = visitSymbols.symbols[z];
if (aSymbol && aSymbol.data) {
if (aSymbol.data.continentCode && -1 === usedContinents.indexOf(aSymbol.data.continentCode)) {
usedContinents.push(aSymbol.data.continentCode);
}
if (aSymbol.data.countryCode && -1 === usedCountries.indexOf(aSymbol.data.countryCode)) {
usedCountries.push(aSymbol.data.countryCode);
}
}
}
if (usedCountries.length === 1 && usedCountries[0] && usedCountries[0] !== 'unk') {
updateMap(UserCountryMap.ISO2toISO3[usedCountries[0].toUpperCase()], false);
} else if (usedContinents.length === 1 && usedContinents[0] && usedContinents[0] !== 'unk') {
updateMap(UserCountryMap.cont2cont[usedContinents[0]], false);
}
}
}
firstRun = false;
}
if (firstRun && lastVisits.length) {
// zoom changed, use cached report data
gotNewReport(lastVisits.slice());
} else if (Visibility.hidden()) {
nextReqTimer = setTimeout(refreshVisits, config.liveRefreshAfterMs);
} else {
// request API for new data
$('.realTimeMap_overlay img').show();
ajax(_reportParams(firstRun)).done(gotNewReport);
}
}
/*
* Set up the base map after loading of the SVG. Adds a single layer
* that shows countries in gray with white outlines. Also this is where
* the zoom behaviour is initialized.
*/
function initMap() {
$('#widgetRealTimeMapliveMap .loadingPiwik, .RealTimeMap .loadingPiwik').hide();
map.addLayer(currentMap.length == 3 ? 'context' : 'countries', {
styles: {
fill: colorTheme[currentTheme].fill,
stroke: colorTheme[currentTheme].bg,
'stroke-width': 0.2
},
click: function (d, p, evt) {
evt.stopPropagation();
userHasZoomed = true;
if (currentMap.length == 2){ // zoom to country
updateMap(d.iso);
} else if (currentMap != 'world') { // zoom out if zoomed in
updateMap('world');
} else { // or zoom to continent view otherwise
updateMap(UserCountryMap.ISO3toCONT[d.iso]);
}
},
title: function (d) {
// return the country name for educational purpose
return d.name;
}
});
if (currentMap.length == 3){
map.addLayer('regions', {
styles: {
stroke: colors['region-stroke-color']
}
});
}
refreshVisits(true);
}
function storeSettings() {
self.widget.dashboardWidget('setParameters', {
lastMap: currentMap, theme: colorTheme, colorMode: colorMode
});
}
/*
* updates the map view (after changing the zoom)
* clears all existing timeouts
*/
function updateMap(_map, _storeSettings) {
if ('undefined' === typeof _storeSettings) {
_storeSettings = true;
}
if (_map && currentMap === _map && _map !== 'world') {
return;
}
clearTimeout(nextReqTimer);
$.each(symbolFadeInTimer, function (i, t) {
clearTimeout(t);
});
symbolFadeInTimer = [];
try {
map.removeSymbols();
} catch (e) {}
currentMap = _map;
_updateMap(currentMap + '.svg', initMap);
if (_storeSettings) {
storeSettings();
}
}
updateMap(location.hash && (location.hash == '#world' || location.hash.match(/^#[A-Z]{2,3}$/)) ? location.hash.slice(1) : 'world'); // TODO: restore last state
// clicking on map background zooms out
$('.RealTimeMap_map', this.$element).off('click').click(function () {
if (currentMap != 'world') {
userHasZoomed = true;
updateMap('world');
}
});
// secret gimmick shortcuts
this.$element.on('keydown', function (evt) {
// shift+alt+C changes color mode
if (evt.shiftKey && evt.altKey && evt.keyCode == 67) {
colorMode = ({
'default': 'referrerType',
referrerType: 'default'
})[colorMode];
storeSettings();
}
function switchTheme() {
self.$element.css({ background: colorTheme[currentTheme].bg });
if (isFullscreenWidget) {
$('body').css({ background: colorTheme[currentTheme].bg });
$('.widget').css({ 'border-width': 1 });
}
map.getLayer('countries')
.style('fill', colorTheme[currentTheme].fill)
.style('stroke', colorTheme[currentTheme].bg);
storeSettings();
}
// shift+alt+B: switch to black background
if (evt.shiftKey && evt.altKey && evt.keyCode == 66) {
currentTheme = 'black';
switchTheme();
}
// shift+alt+W: return to white background
if (evt.shiftKey && evt.altKey && evt.keyCode == 87) {
currentTheme = 'white';
switchTheme();
}
});
// make sure the map adapts to the widget size
$(window).on('resize.' + this.uniqueId, onResizeLazy);
// setup automatic tooltip updates
this._tooltipUpdateInterval = setInterval(function () {
$('.qtip .rel-time').each(function (i, el) {
el = $(el);
var ds = new Date().getTime() / 1000 - el.data('actiontime');
el.html(relativeTime(ds));
});
var d = new Date(), datetime = d.toTimeString().slice(0, 8);
$('.realTimeMap_datetime').html(datetime);
}, 1000);
},
/*
* resizes the map to widget dimensions
*/
resize: function () {
var ratio, w, h, map = this.map;
ratio = map.viewAB.width / map.viewAB.height;
w = map.container.width();
h = Math.min(w / ratio, $(window).height() - 30);
var radScale = Math.pow((h * ratio * h) / 130000, 0.3);
this.maxRad = 10 * radScale;
this.minRad = 4 * radScale;
map.container.height(h - 2);
map.resize(w, h);
if (map.symbolGroups && map.symbolGroups.length > 0) {
map.symbolGroups[0].update();
}
if (w < 355) $('.UserCountryMap .tableIcon span').hide();
else $('.UserCountryMap .tableIcon span').show();
},
_destroy: function () {
UIControl.prototype._destroy.call(this);
if (this._tooltipUpdateInterval) {
clearInterval(this._tooltipUpdateInterval);
}
$(window).off('resize.' + this.uniqueId);
this.map.clear();
$(this.map.container).html('');
delete this.map;
}
});
}());