IITC plugin: popden

population density layer coloring by 2.5 arcminute cells

// ==UserScript==
// @id             iitc-plugin-popden
// @name           IITC plugin: popden
// @category       -
// @version        0.3
// @description    population density layer coloring by 2.5 arcminute cells
// @include        https://*.ingress.com/intel*
// @include        http://*.ingress.com/intel*
// @match          https://*.ingress.com/intel*
// @match          http://*.ingress.com/intel*
// @grant          none
// @namespace https://greasyfork.org/users/410740
// ==/UserScript==


/*
 * PLUGIN:
 * This plugin overlays population density color coding on your IITC
 * map.  The smallest data unit is arbitrarily called a population
 * "cell".  At maximum zoom (z=10), each cell is 2.5 arc-minutes on each
 * edge; that is 24 cell slices per degree of longitude or of latitude.
 * Cells are aggregated into "tiles" of 24x24 = 576 cells.  At z=10,
 * this is one square degree per tile.  At z=4, the minimum zoom level,
 * it is 24 square degrees per tile.  This aggregation is a performance
 * optimization.  You can zoom further out, but you get the same tiles
 * as at z=4, and performance drops a bit.  Likewise you can zoom in
 * beyond z=10, but performance is already good at this point, and it's
 * already using the smallest available granularity of population data.
 *
 * COLORS/LEGEND:
 * Coloration is done using a color gradient computed with gaussian
 * curves over r, g, b vectors.  The fixed points of the gradient are
 * in the "heatmap" array below.  By default it's [dk gray, blue, red,
 * yellow, white].
 *
 * Worldwide the single-cell maximum population is around 1.5 million.
 * In the United States the maximum cell population is around 460,000.
 * The worldwide mean is 204, and the worldwide median is 0.  In order
 * to keep the color legend meaningful, therefore, there is NO ABSOLUTE
 * DEFINITION of color/population correspondence.  Instead, for each map
 * view, the maximum cell population among all visible cells is used
 * as the baseline, and all other cells are colored according to their
 * percentage of this viewport maximum.  Drag the map or change zoom and
 * you will probably see colors shift accordingly.
 *
 * DATA:
 * Data is loaded on demand over the network, and cached in your
 * browser.  The data files were created by the author of this plugin
 * through a reduction of the standard GPWv3 world population data
 * (see citation/copyright below).  Original source data is simply
 * an array of [8640, 3432] representing population within each 2.5
 * arc-minute cell.  It has been broken out into aggregated tiles based
 * on zoom level (see above), with further per-tile metadata inserted
 * and the whole converted to JSON. The expected format of data is not
 * documented except implicitly by this code.
 */

/*
 * Details concerning the Gridded Population of the World Version 3
 * (GPWv3) Population Grids data:
 *
 * CITATION:
 * Center for International Earth Science Information Network (CIESIN),
 * Columbia University; and Centro Internacional de Agricultura
 * Tropical (CIAT). 2005.  Gridded Population of the World Version 3
 * (GPWv3): Population Grids. Palisades, NY: Socioeconomic Data and
 * Applications Center (SEDAC), Columbia University.  Available at
 * http://sedac.ciesin.columbia.edu/gpw. (date of download).
 *
 * COPYRIGHT:
 * The Trustees of Columbia University in the City of New York and the
 * Centro Internacional de Agricultura Tropical (CIAT) hold the copyright
 * of this dataset.
 */

function wrapper(plugin_info) {
	// ensure plugin framework is there, even if iitc is not yet loaded
	if(typeof window.plugin !== 'function') window.plugin = function() {};


// PLUGIN START

// use own namespace for plugin
window.plugin.popden = function() {};
var popden = window.plugin.popden;

popden.log = function (obj) {
	console.log('popden: ' + obj);
};

// define color gradient
var heatmap = [
	[0.0, [0.25, 0.25, 0.25]],	// dk gray
	[0.25, [0.25, 0.25, 0.75]], // blue
	[0.50, [0.75, 0.25, 0.25]], // red
	[0.75, [0.75, 0.75, 0.25]], // yellow
	[1.00, [1.0, 1.0, 1.0]],	// white
];

// Map data constants
var dataversion = "0.2.2";
var latmin = -58;
var lngmin = -180;
var maxzoom = 10;
var minzoom = 4;

// draw boundaries around population cells?
//var drawborders = false;
var drawborders = true;

function getlocalstorage () {
	try {
		return 'localStorage' in window && window.localStorage || null;
	} catch (e) {
		return null;
	}
}

// load leaflet label plugin
function loadJS(urilist, onload) {
	var scripts = 0;
	function _() {
		scripts--;
	}
	for (var i = 0; i < urilist.length; i++) {
		var script = document.createElement("script");
		scripts++;
		script.type = "text/javascript";
		script.src = urilist[i];
		script.onreadystatechange = _;
		script.onload = _;
		document.getElementsByTagName('head')[0].appendChild(script);
	}
	var timer = setInterval(function () {
		if (scripts === 0) {
			popden.log('loadJS: all scripts loaded');
			clearInterval(timer);
			onload();
		}
	}, 100);
}

function loadstorage() {
	var storage = getlocalstorage();
	if (storage === null)
		return {};
    if (storage['plugin-popden']) {
		var data = JSON.parse(storage['plugin-popden']);
        if (data.version != dataversion) {
            // invalidate cached data
            popden.log('invalidating data: v' + data.version + ' -> v' + dataversion);
            return {version: dataversion};
        }
        return data;
    }
	return {};
}

function savestorage(data) {
	var storage = getlocalstorage();
	if (storage === null)
		return;
	storage['plugin-popden'] = JSON.stringify(data);
	return;
}

var _old_heatmap = [
	[0.0, [0, 0, 0]],
	[0.20, [0, 0, 0.5]],
	[0.40, [0, 0.5, 0]],
	[0.60, [0.5, 0, 0]],
	[0.80, [0.75, 0.75, 0]],
	[0.90, [1.0, 0.75, 0]],
	[1.00, [1.0, 1.0, 1.0]],
];

// computes a multi-stage gaussian color gradient across each color point in map.
// spread is a multiplier on the x axis of the gaussian curve. width is how many
// sampled color points to return.
function gradient(map, spread, width) {
	if (spread === null)
		spread = 1;
	if (width === null)
		width = 100;

	function gaussian(x, a, b, c) {
		d = 0;
		return a * Math.exp(-Math.pow(x - b, 2) / (2 * Math.pow(c, 2))) + d;
	}

	function apply(x, f) {
		var sum = 0;
		for (var i = 0; i < map.length; i++) {
			point = map[i];
			sum += gaussian(x, f(point[1]), point[0] * width,
							width/(spread * map.length));
		}
		return sum;
	}

	function interval(x) {
		r = apply(x, function(point) {return point[0];});
		g = apply(x, function(point) {return point[1];});
		b = apply(x, function(point) {return point[2];});
		return [Math.min(1.0, r), Math.min(1.0, g), Math.min(1.0, b)];
	}

	function rgbtohex(r,g,b) {
		return Number(0x1000000 + r*0x10000 + g*0x100 + b).toString(16).substring(1);
	}

	var colors = [];
	for (var i = 0; i < width; i++) {
		rgb = interval(i);
		rgb = [parseInt(rgb[0] * 255), parseInt(rgb[1] * 255), parseInt(rgb[2] * 255)];
		rgb = '#' + rgbtohex(rgb[0], rgb[1], rgb[2]);
		colors.push(rgb);
	}
	return colors;
}
var colors = gradient(heatmap, 1, 100);

var _setup = function () {
	var plugin = window.plugin.popden;

	plugin.isSelected = false;
	plugin.storage = loadstorage();
	if (!plugin.storage.tiles)
		plugin.storage.tiles = {};

	plugin.baseURL = "https://d2s5zx5h5rs73.cloudfront.net/popden/" + dataversion + "/celldata-z";

	plugin.layergroup = new L.LayerGroup();
	window.addLayerGroup('Population Cells', plugin.layergroup, true);

    $("<style>")
    .prop("type", "text/css")
    .html(".plugin-popden-data {\
             font-size: 13px;\
             color: #000;\
             opacity: 0.7;\
             text-align: center;\
             pointer-events: none;\
          }")
  .appendTo("head");

    // Create a div for population density info, and attach it above the update status area
    plugin.info = $('<div id="popden">');
    $('#updatestatus').prepend(plugin.info);
    plugin.info.html('<b>Population Cell</b>:<br/><b>Population</b>:');

    // #updatestatus isn't really prepared for more inner divs - it expects only #innerstatus.
    // Fix this.
    var css = $('<style type="text/css">');
    css.html('#updatestatus > div { padding: 4px; border-bottom: 1px solid #20A8B1; } #updatestatus {padding: inherit;}');
    css.appendTo("head");

	function update () {
		popden.log("all tiles loaded; updating viewport");
		plugin.layergroup.clearLayers();

		// set viewport max to 0, then calculate vmax across all visible cells
		// this probably gets a bit wonky around the international date line.
		var vmax = 0;
        var vmin = Math.pow(2,31);
		for (var tile = 0; tile < plugin.tiledata.length; tile++) {
			data = plugin.tiledata[tile];
			for (y = 0; y < data.data.length; y++) {
				for (x = 0; x < data.data[0].length; x++) {
					var south = data.bottom + (y * data.incr);
					var west = data.left + (x * data.incr);
					var north = data.bottom + ((y+1) * data.incr);
					var east = data.left + ((x+1) * data.incr);
					// is this cell outside visible area?
					if (east < plugin.west)
						continue;
					if (west > plugin.east)
						continue;
					if (north < plugin.south)
						continue;
					if (south > plugin.north)
						continue;
					// cell must overlap viewport at least partially
					vmax = Math.max(vmax, data.lmax);
					vmin = Math.min(vmin, data.lmin);
				}
			}
		}

		for (var tile = 0; tile < plugin.tiledata.length; tile++) {
			data = plugin.tiledata[tile];
			for (y = 0; y < data.data.length; y++) {
				for (x = 0; x < data.data[0].length; x++) {
					var bounds = [
						[data.bottom + (y * data.incr), data.left + (x * data.incr)],
						[data.bottom + ((y+1) * data.incr), data.left + ((x+1) * data.incr)],
					];

					var popratio = parseInt(100 * ((data.data[y][x]-vmin)/(vmax-vmin)));

					var options = {
						fill: true,
						fillColor: colors[popratio-1],
						fillOpacity: 0.30,
						//color: '#fff',
						//opacity: 0.20,
						color: colors[popratio-1],
						opacity: 0.50,
						weight: 1,
						stroke: drawborders,
					};
					var rect = L.rectangle(bounds, options).addTo(plugin.layergroup);
					var lat = data.bottom + (y * data.incr);
					var lng = data.left + (x * data.incr);
					var latlng = sprintf("%.2f, %.2f", lat, lng);
					//var label = sprintf("Coordinates: %s<br/>Population: %d/%d (%d%%)<br/>", latlng, data.data[y][x], vmax, popratio);
					//rect.bindLabel(label);
                    var center = L.latLng((bounds[0][0] + bounds[1][0])/2, (bounds[0][1] + bounds[1][1])/2);
                    var marker = L.marker(center, {
                        icon: L.divIcon({
                        className: 'plugin-popden-data',
                        iconAnchor: [100,5],
                        iconSize: [200,10],
                        html: data.data[y][x],
                        })
                    });
                    plugin.layergroup.addLayer(marker);
                    var label = sprintf("<b>Population Cell</b>: %s<br/><b>Population</b>: %s (highest %s; %d%%)", latlng, data.data[y][x].toLocaleString(), vmax.toLocaleString(), popratio);
                    var f = (function (label) {
                        rect.on({mouseover: function (e) {
                            $('#popden').html(label);
                        }});
                    })(label);
				}
			}
		}
	}

	function _addtile (data) {
		// called when a tile's data is available
		//popden.log(data);
		plugin.tiledata.push(data);
		plugin.vmax = Math.max(plugin.vmax, data.lmax);

		// if we have all tiles now, update the map
		if (plugin.tiledata.length >= Object.keys(plugin.tilenames).length)
			update();
		else
			popden.log("loaded " + plugin.tiledata.length + "/" + Object.keys(plugin.tilenames).length + " tiles");
	}

	// chunksz is how many 2.5-minute slices are aggregated into one cell
	// at the given zoom level. Because all tiles are 24 cells square, and
	// because 24 x 2.5 min = 1 deg, chunksz is -also- the number of degrees
	// in one tile.
	function chunksz(zoom) {
		if (zoom == 10) return 1;
		if (zoom == 9) return 2;
		if (zoom == 8) return 3;
		if (zoom == 7) return 6;
		if (zoom == 6) return 8;
		if (zoom == 5) return 12;
		if (zoom == 4) return 24;
	}

	function tilename(lat, lng, zoom) {
		if (zoom > maxzoom)
			zoom = maxzoom;
		if (zoom < minzoom)
			zoom = minzoom;
		var chunk = chunksz(zoom);
		lat = Math.floor((lat - latmin) / chunk) * chunk + latmin;
		lng = Math.floor((lng - lngmin) / chunk) * chunk + lngmin;
		return sprintf("%d/%d,%d", zoom, lat, lng);
	}

	function addpoptile (name) {
		if (name in plugin.storage.tiles) {
			popden.log('tile ' + name + ' from storage');
			return _addtile(plugin.storage.tiles[name]);
		}
		//popden.log('retrieving tile ' + name);
		jQuery.ajax({
			url: plugin.baseURL + name + ".json",
			dataType: "json",
			success: function (data) {
				popden.log('tile ' + name + ' from http');
				plugin.storage.tiles[name] = data;
				savestorage(plugin.storage);
				_addtile(data);
			},
		});
	}

	function reposition () {
		popden.log('repositioning');
		plugin.zoom = parseInt(map.getZoom());
		if (plugin.zoom < minzoom)
			plugin.zoom = minzoom;
		if (plugin.zoom > maxzoom)
			plugin.zoom = maxzoom;

		plugin.bounds = window.map.getBounds();
		plugin.tilenames = {};
		plugin.tiledata = [];
		plugin.vmax = 0;  /* max pop in viewport tiles */
		plugin.north = Math.floor(plugin.bounds.getNorth());
		plugin.south = Math.floor(plugin.bounds.getSouth());
		plugin.west = Math.floor(plugin.bounds.getWest());
		plugin.east = Math.floor(plugin.bounds.getEast());
		for (var lat = plugin.south; lat <= plugin.north; lat++) {
			for (var lng = plugin.west; lng <= plugin.east; lng++) {
				var name = tilename(lat, lng, plugin.zoom);
				plugin.tilenames[name] = 1;
			}
		}
		for (var name in plugin.tilenames)
			addpoptile(name);
	}
	window.addHook('mapDataRefreshStart', reposition);

	return;
}


var setup = function() {
	loadJS(['https://cdn.rawgit.com/alexei/sprintf.js/master/dist/sprintf.min.js',
            // Not currently using Leaflet Labels ...
	        //'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/Label.js',
			//'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/BaseMarkerMethods.js',
			//'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/Marker.Label.js',
			//'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/CircleMarker.Label.js',
			//'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/Path.Label.js',
			//'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/Map.Label.js',
			//'https://cdn.rawgit.com/Leaflet/Leaflet.label/master/src/FeatureGroup.Label.js',
           ],
	       _setup);
};

// PLUGIN END


	//add the script info data to the function as a property
	setup.info = plugin_info;

	if (!window.bootPlugins) window.bootPlugins = [];
	window.bootPlugins.push(setup);

	// if IITC has already booted,	immediately run the 'setup' function
	if (window.iitcLoaded && typeof setup === 'function') setup();
}; // wrapper end

// inject code into site context
var script = document.createElement('script');
var info = {};
if (typeof GM_info !== 'undefined' && GM_info && GM_info.script)
	info.script = {
		version: GM_info.script.version,
		name: GM_info.script.name,
		description: GM_info.script.description
	};
	script.appendChild(document.createTextNode('('+ wrapper +')('+JSON.stringify(info)+');'));
(document.body || document.head || document.documentElement).appendChild(script);