/**
 *    ______      ___
 *   / ____/___  /   | _____________  __________
 *  / / __/ __ \/ /| |/ ___/ ___/ _ \/ ___/ ___/
 * / /_/ / /_/ / ___ / /__/ /__/  __(__  |__  )
 * \____/\____/_/  |_\___/\___/\___/____/____/
 *
 * The MIT License (MIT)
 * Copyright (c) 2009-2018 Gerardo Orellana <hello @ goaccess.io>
 */

'use strict';

const MAP_FULLSCREEN_CLASS = 'map-fullscreen';
const MAP_FULLSCREEN_BODY_CLASS = 'map-fullscreen-open';
const MAP_FULLSCREEN_MIN_HEIGHT = 400;
const MAP_FULLSCREEN_HORIZONTAL_MARGIN = 16;
const MAP_LEGEND_HEIGHT = 30;

// Update the map control to describe the action it will perform.
function setMapFullscreenControlState(control, expanded) {
	var label = expanded ? 'Collapse map' : 'Expand map';
	var icon = control.select('i');

	control
		.attr('aria-label', label)
		.attr('aria-pressed', expanded ? 'true' : 'false')
		.attr('title', label);
	icon
		.classed('fa-arrows-alt', !expanded)
		.classed('fa-window-restore', expanded);
}

// Remove fullscreen state and controls from a map container.
function clearMapFullscreen(selection) {
	if (!selection || selection.empty()) return;

	selection
		.classed(MAP_FULLSCREEN_CLASS, false)
		.on('.map-fullscreen', null)
		.selectAll('.map-fullscreen-toggle')
		.remove();
	d3.select('body').classed(MAP_FULLSCREEN_BODY_CLASS,
		!d3.select('.' + MAP_FULLSCREEN_CLASS).empty());
}

// Let Escape collapse the currently expanded map.
d3.select(document).on('keydown.map-fullscreen', function(event) {
	if (event.key !== 'Escape') return;

	var control = d3.select('.' + MAP_FULLSCREEN_CLASS + ' .map-fullscreen-toggle');
	if (!control.empty()) control.node().click();
});

// This is faster than calculating the exact length of each label.
// e.g., getComputedTextLength(), slice()...
function truncate(text, width) {
	text.each(function () {
		var parent = this.parentNode, $d3parent = d3.select(parent);
		var gw = $d3parent.node().getBBox();
		var x = (Math.min(gw.width, width) / 2) * -1;
		// adjust wrapper <svg> width
		if ('svg' == parent.nodeName) {
			$d3parent.attr('width', width).attr('x', x);
		}
		// wrap <text> within an svg
		else {
			$d3parent.insert('svg', function () {
				return this;
			}.bind(this))
			.attr('class', 'wrap-text')
			.attr('width', width)
			.attr('x', x)
			.append(function () {
				return this;
			}.bind(this));
		}
	});
}

function WorldMap(selection) {
	const maxLat = 84;
	let path = null;
	let projection = null;
	let tlast = [0, 0];
	let slast = null;
	let opts = {};
	let metric = 'hits';
	let margin = {
		top: 20,
		right: 50,
		bottom: 40,
		left: 50
	};
	let normalLeftMargin = margin.left;
	let normalRightMargin = margin.right;
	let width = 760;
	let height = 170;
	let normalHeight = height;
	let renderedHeight = 0;
	let renderedProjectionType = null;
	let renderedWidth = 0;
	// default value; will be set externally
	let projectionType = 'mercator';
	let initialScale;
	let panelName = null;
	let cityIndex = null;
	let citySizeScale = null;

	function innerW() {
		return width - margin.left - margin.right;
	}

	function innerH() {
		return height - margin.top - margin.bottom;
	}

	function getStyleSize(value) {
		return parseFloat(value) || 0;
	}

	function getContentSize(selectionElem) {
		var style = window.getComputedStyle(selectionElem);
		var horizontalPadding = getStyleSize(style.paddingLeft) + getStyleSize(style.paddingRight);
		var verticalPadding = getStyleSize(style.paddingTop) + getStyleSize(style.paddingBottom);

		return {
			width: selectionElem.clientWidth - horizontalPadding,
			height: selectionElem.clientHeight - verticalPadding
		};
	}

	function syncDimensions(selectionElem) {
		var expanded = selectionElem.classList.contains(MAP_FULLSCREEN_CLASS);
		var size = getContentSize(selectionElem);

		margin.left = expanded ? MAP_FULLSCREEN_HORIZONTAL_MARGIN : normalLeftMargin;
		margin.right = expanded ? MAP_FULLSCREEN_HORIZONTAL_MARGIN : normalRightMargin;
		width = size.width;
		height = expanded
			? Math.max(MAP_FULLSCREEN_MIN_HEIGHT, size.height - MAP_LEGEND_HEIGHT)
			: normalHeight;
	}

	function dimensionsChanged() {
		return renderedWidth !== width || renderedHeight !== height ||
			renderedProjectionType !== projectionType;
	}

	function resetMap(selection) {
		selection.selectAll('svg.map, svg.legend-svg').remove();
		tlast = [0, 0];
		slast = null;
	}

	function addFullscreenControl(selection) {
		var control = selection.select('.map-fullscreen-toggle');

		if (control.empty()) {
			control = selection.append('button')
				.attr('class', 'btn btn-default btn-sm map-fullscreen-toggle')
				.attr('type', 'button');
			control.append('i')
				.attr('class', 'fa fa-arrows-alt')
				.attr('aria-hidden', 'true');
		}

		setMapFullscreenControlState(control, selection.classed(MAP_FULLSCREEN_CLASS));
		control.on('click.map-fullscreen', function(event) {
			var expanded = !selection.classed(MAP_FULLSCREEN_CLASS);

			event.preventDefault();
			selection.classed(MAP_FULLSCREEN_CLASS, expanded);
			d3.select('body').classed(MAP_FULLSCREEN_BODY_CLASS, expanded);
			setMapFullscreenControlState(control, expanded);
			resetMap(selection);
			selection.call(chart);
		});
	}

	function buildCityIndex() {
		if (cityIndex) return;
		var cityData = window.cities10m;
		if (!cityData) return;
		cityIndex = {};
		var features = topojson.feature(cityData, cityData.objects.cities).features;
		features.forEach(function(f) {
			var p = f.properties;
			var iso = (p.ISO_A2 || '').toUpperCase();
			var asciiKey = iso + '|' + (p.NAMEASCII || '').toLowerCase().trim();
			cityIndex[asciiKey] = f;
			var nameKey = iso + '|' + (p.NAME || '').toLowerCase().trim();
			if (nameKey !== asciiKey)
				cityIndex[nameKey] = f;
		});
	}

	function collectCities(country, code) {
		var out = [];
		if (!country.items) return out;
		country.items.forEach(function(city) {
			out.push({
				data: city.data,
				countryCode: code,
				hits: city.hits.count,
				visitors: city.visitors.count,
				bytes: city.bytes.count
			});
		});
		return out;
	}

	function isCityVisible(coords) {
		if (projectionType !== 'orthographic') return true;
		var rotate = projection.rotate();
		var center = [-rotate[0], -rotate[1]];
		return d3.geoDistance(coords, center) < Math.PI / 2;
	}

	function getMapRenderState() {
		if (!panelName) return null;

		var fullData = (GoAccess.getPanelData(panelName) || {}).data || [];
		var expanded = GoAccess.Util.getProp(GoAccess.AppState, panelName + '.expanded') || {};

		var allCountries = [];
		var highlightCodes = new Set();
		var expandedCountryCodes = new Set();
		var cities = [];

		var expandedContinents = new Set();

		// pass 1: detect expanded continents and collect countries
		fullData.forEach(function(continent) {
			if (!continent.items) return;

			var continentKey = GoAccess.Util.hashCode(continent.data);
			if (expanded[continentKey]) {
				expandedContinents.add(continentKey);
			}

			continent.items.forEach(function(country) {
				var code = country.data.split(' ')[0];
				allCountries.push({
					data: country.data,
					hits: country.hits.count,
					visitors: country.visitors.count,
					bytes: country.bytes.count,
					region: continent.data
				});
			});
		});

		var hasExpandedContinent = expandedContinents.size > 0;

		// pass 2: resolve each continent independently
		fullData.forEach(function(continent) {
			if (!continent.items) return;

			var continentKey = GoAccess.Util.hashCode(continent.data);
			var isContinentExpanded = expandedContinents.has(continentKey);

			// if nothing expanded, treat all continents as expanded
			if (!hasExpandedContinent) {
				isContinentExpanded = true;
			}

			if (!isContinentExpanded) return;

			// detect expanded countries inside this continent
			var localExpandedCountries = new Set();

			continent.items.forEach(function(country) {
				var code = country.data.split(' ')[0];
				var countryKey = GoAccess.Util.hashCode(country.data);
				if (expanded[countryKey]) {
					localExpandedCountries.add(code);
					expandedCountryCodes.add(code);
				}
			});

			var hasLocalExpanded = localExpandedCountries.size > 0;

			continent.items.forEach(function(country) {
				var code = country.data.split(' ')[0];

				// highlight logic
				if (hasLocalExpanded) {
					if (localExpandedCountries.has(code)) {
						highlightCodes.add(code);
					}
				} else {
					highlightCodes.add(code);
				}

				// city logic
				if (hasLocalExpanded) {
					if (localExpandedCountries.has(code)) {
						cities = cities.concat(collectCities(country, code));
					}
				} else {
					cities = cities.concat(collectCities(country, code));
				}
			});
		});

		// nothing expanded at all -> show everything
		if (!hasExpandedContinent) {
			highlightCodes = null;
			cities = [];

			fullData.forEach(function(continent) {
				if (!continent.items) return;
				continent.items.forEach(function(country) {
					var code = country.data.split(' ')[0];
					cities = cities.concat(collectCities(country, code));
				});
			});
		}

		return {
			allCountries: allCountries,
			highlightCodes: highlightCodes,
			expandedCountryCodes: expandedCountryCodes.size
				? expandedCountryCodes
				: null,
			cities: cities
		};
	}

	function formatTooltip(data) {
		const d = {
			...data
		};
		let out = {};
		out[0] = GoAccess.Util.fmtValue(d['data'], 'str');
		out[1] = metric === 'bytes' ? GoAccess.Util.fmtValue(d['bytes'], 'bytes') : d3.format(',')(d['hits']);
		if (metric === 'hits') out[2] = d3.format(',')(d['visitors']);
		const template = d3.select('#tpl-chart-tooltip')
			.html();
		return Hogan.compile(template)
			.render({
				'data': out
			});
	}

	function mouseover(event, selection, data) {
		const tooltip = selection.select('.chart-tooltip-wrap');
		tooltip.html(formatTooltip(data))
			.style('left', `${d3.pointer(event)[0] + 10}px`)
			.style('top', `${d3.pointer(event)[1] + 10}px`)
			.style('display', 'block');
	}

	function mouseout(selection) {
		const tooltip = selection.select('.chart-tooltip-wrap');
		tooltip.style('display', 'none');
	}

	function drawLegend(selection, colorScale) {
		const legendHeight = 10;
		const legendPadding = 10;
		let svg = selection.select('.legend-svg');
		if (svg.empty()) {
			svg = selection.append('svg')
				.attr('class', 'legend-svg')
				.attr('width', width)
				.attr('height', legendHeight + 2 * legendPadding);
		}
		let legend = svg.select('.legend');
		if (legend.empty()) {
			legend = svg.append('g')
				.attr('class', 'legend')
				.attr('transform', `translate(${margin.left}, ${legendPadding})`);
		}
		const legendData = colorScale.quantiles();
		const legendRects = legend.selectAll('rect')
			.data(legendData);
		legendRects.enter()
			.append('rect')
			.merge(legendRects)
			.attr('x', (d, i) => (i * (innerW())) / legendData.length)
			.attr('y', 0)
			.attr('width', innerW() / legendData.length)
			.attr('height', legendHeight)
			.style('fill', d => colorScale(d));
		legendRects.exit()
			.remove();
		const legendTexts = legend.selectAll('text')
			.data(legendData);
		legendTexts.enter()
			.append('text')
			.merge(legendTexts)
			.attr('x', (d, i) => (i * (innerW())) / legendData.length)
			.attr('y', legendHeight + legendPadding)
			.style('font-size', '10px')
			.attr('text-anchor', 'middle')
			.text(d => metric === 'bytes' ? GoAccess.Util.fmtValue(d, 'bytes') : d3.format(',')(d));
		legendTexts.exit()
			.remove();
	}

	function updateSphere(svg, g) {
		if (projectionType === 'orthographic') {
			let sphere = g.selectAll('.sphere')
				.data([{
					type: 'Sphere'
				}]);
			// Insert as first child to be behind countries
			let sphereEnter = sphere.enter()
				.insert('path', ':first-child')
				.attr('class', 'sphere')
				.attr('d', path)
				.attr('fill', '#DDEEFF') /* Light blue for ocean */
				.attr('opacity', 0);
			sphere = sphereEnter.merge(sphere);
			sphere.transition()
				.duration(500)
				.attr('opacity', 1);
		} else {
			// Remove sphere when not in orthographic projection
			g.selectAll('.sphere')
				.transition()
				.duration(500)
				.attr('opacity', 0)
				.remove();
		}
	}

	function updateMap(selection, svg, data, countries, countryNameToGeoJson) {
		buildCityIndex();

		var state = getMapRenderState();
		var activeData;
		if (state) {
			activeData = state.allCountries;
		} else {
			// Fallback: flatten data the old way when panel is not set
			activeData = data.reduce(function(acc, region) {
				if (!region.items) acc.push(region);
				else region.items.forEach(function(item) {
					acc.push({
						data: item.data,
						hits: item.hits.count !== undefined ? item.hits.count : item.hits,
						visitors: item.visitors.count !== undefined ? item.visitors.count : item.visitors,
						bytes: item.bytes.count !== undefined ? item.bytes.count : item.bytes,
						region: region.data
					});
				});
				return acc;
			}, []);
			state = { allCountries: activeData, highlightCodes: null, expandedCountryCodes: null, cities: [] };
		}

		path = d3.geoPath()
			.projection(projection);

		const colorScale = d3.scaleQuantile()
			.domain(activeData.map(d => d[metric]))
			.range(['#eafff1', '#a7e3d7', '#6cc5c0', '#44a2b1', '#246e96']);

		if (activeData.length)
			drawLegend(selection, colorScale);

		// Create a mapping from country code to data
		const dataByName = {};
		activeData.forEach(d => {
			const k = d.data.split(' ')[0];
			dataByName[k] = d;
		});

		// Add graticule (grid lines)
		if (projectionType !== 'mercator') {
			const graticule = d3.geoGraticule();
			let grid = svg.select('g')
				.selectAll('.graticule')
				.data([graticule()]);
			let gridEnter = grid.enter()
				.append('path')
				.attr('class', 'graticule')
				.attr('d', path)
				.attr('fill', 'none')
				.attr('stroke', '#ccc')
				.attr('stroke-width', 0.5)
				.attr('stroke-dasharray', '3,3')
				.attr('opacity', 0);
			grid = gridEnter.merge(grid);
			grid.transition()
				.duration(500)
				.attr('opacity', 0.4);
		}

		let country = svg.select('g')
			.selectAll('.country')
			.data(countries);
		let countryEnter = country.enter()
			.append('path')
			.attr('class', 'country')
			.attr('d', path)
			.attr('opacity', 0);
		country = countryEnter.merge(country)
			.on('mouseover', function(event, d) {
				const countryData = dataByName[d.id];
				if (countryData) mouseover(event, selection, countryData);
			})
			.on('mouseout', function() {
				mouseout(selection);
			});
		country.transition()
			.duration(500)
			.style('fill', function(d) {
				const countryData = dataByName[d.id];
				if (!countryData) return '#cccccc54';
				if (state.highlightCodes) {
					return state.highlightCodes.has(d.id)
						? colorScale(countryData[metric])
						: '#cccccc30';
				}
				return colorScale(countryData[metric]);
			})
			.attr('opacity', 1);
		country.exit()
			.transition()
			.duration(500)
			.attr('opacity', 0)
			.remove();

		// Update city markers
		updateCities(selection, svg, state, colorScale);
	}

	function updateCities(selection, svg, state, colorScale) {
		var g = svg.select('g');
		var cityMarkers = [];

		if (state.cities.length && cityIndex) {
			state.cities.forEach(function(c) {
				var key = c.countryCode.toUpperCase() + '|' + c.data.toLowerCase().trim();
				var feature = cityIndex[key];
				if (feature) {
					cityMarkers.push({
						feature: feature,
						data: c.data,
						hits: c.hits,
						visitors: c.visitors,
						bytes: c.bytes
					});
				}
			});
		}

		var sizeExtent = d3.extent(cityMarkers, function(d) { return d[metric]; });
		if (!sizeExtent[0] && !sizeExtent[1]) sizeExtent = [0, 1];
		var sizeScale = d3.scaleSqrt()
			.domain(sizeExtent)
			.range([3, 10])
			.clamp(true);
		var strokeScale = d3.scaleSqrt()
			.domain(sizeExtent)
			.range([1, 2.5])
			.clamp(true);

		citySizeScale = sizeScale;

		var circles = g.selectAll('.city-marker')
			.data(cityMarkers, function(d) { return d.data + '|' + d.feature.properties.ISO_A2; });

		circles.exit()
			.transition().duration(300)
			.attr('r', 0)
			.remove();

		var circlesEnter = circles.enter()
			.append('circle')
			.attr('class', 'city-marker')
			.attr('r', 0)
			.style('fill', '#ff6b6b')
			.style('stroke', '#fff')
			.style('cursor', 'pointer')
			.on('mouseover', function(event, d) {
				mouseover(event, selection, d);
			})
			.on('mouseout', function() {
				mouseout(selection);
			});

		circlesEnter.merge(circles)
			.transition().duration(500)
			.attr('cx', function(d) {
				if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
				var coords = projection(d.feature.geometry.coordinates);
				return coords ? coords[0] : -9999;
			})
			.attr('cy', function(d) {
				if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
				var coords = projection(d.feature.geometry.coordinates);
				return coords ? coords[1] : -9999;
			})
			.attr('r', function(d) {
				if (!isCityVisible(d.feature.geometry.coordinates)) return 0;
				var coords = projection(d.feature.geometry.coordinates);
				return coords ? sizeScale(d[metric]) : 0;
			})
			.style('fill', '#ff6b6b')
			.style('stroke', '#fff')
			.style('stroke-width', function(d) {
				return strokeScale(d[metric]) + 'px';
			});
	}

	function setBounds(projection, maxLat) {
		// Top-left corner of the ~viewable~ lat
		const [yaw] = projection.rotate();
		const xymax = projection([-yaw + 180 - 1e-6, -maxLat]);
		const xymin = projection([-yaw - 180 + 1e-6, maxLat]);
		return [xymin, xymax];
	}

	function clampVertical(projection, maxLat, height) {
		const b = setBounds(projection, maxLat);
		// top-left
		const [xMin, yMin] = b[0];
		// bottom-right
		const [xMax, yMax] = b[1];
		const t = projection.translate();
		// If the top (yMin) is now below 0, shift upward.
		if (yMin > 0)
			t[1] -= yMin;
		// If the bottom (yMax) is now above the container height, shift downward.
		else if (yMax < height)
			t[1] += (height - yMax);
		projection.translate(t);
	}

	function baseScale() {
		// If the effective width is small, use a fixed mobile scale; otherwise use the computed scale.
		return innerW() < 400 ? 150 : innerW() / (2 * Math.PI);
	}

	function setProjection(type) {
		if (type === 'mercator') {
			const bScale = baseScale();
			projection = d3.geoMercator()
				.center([0, 15])
				.scale(bScale)
				.translate([innerW() / 2, height / 1.5]);
		} else if (type === 'orthographic') {
			const globeScale = Math.min(innerW(), height) / 1.5;
			projection = d3.geoOrthographic()
				.scale(globeScale)
				.translate([innerW() / 2, height / 2])
				.rotate([0, 0, 0]);
		}
		initialScale = projection.scale();
		path = d3.geoPath().projection(projection);
	}

	function zoomed(event, projection, path, scaleExtent, g) {
		const scale = event.transform.k;
		if (projectionType === 'mercator') {
			const containerCenter = [innerW() / 2, height / 2];
			let anchor = containerCenter;
			if (Math.abs(scale - slast) > 1e-3) {
				const geoCoord = projection.invert(anchor);
				// Use baseScale() here instead of innerW()/(2*Math.PI)
				projection.scale(scale * baseScale());
				const newPixel = projection(geoCoord);
				const dxAnchor = anchor[0] - newPixel[0];
				const dyAnchor = anchor[1] - newPixel[1];
				const t = projection.translate();
				projection.translate([t[0] + dxAnchor, t[1] + dyAnchor]);
				clampVertical(projection, maxLat, height);
			} else {
				const newX = event.transform.x;
				const newY = event.transform.y;
				const dx = newX - tlast[0];
				const dy = newY - tlast[1];
				let [longitude] = projection.rotate();
				longitude += 360 * (dx / width) * (scaleExtent[0] / scale);
				projection.rotate([longitude, 0, 0]);
				const t = projection.translate();
				t[1] += dy;
				projection.translate(t);
				clampVertical(projection, maxLat, height);
			}
		}
		else if (projectionType === 'orthographic') {
			// Use the zoom transform's k to compute a new scale,
			// clamping to a maximum factor (here, 2x the initialScale).
			let newScale = scale * initialScale;
			const maxOrthoScale = initialScale * 2;
			if (newScale > maxOrthoScale) newScale = maxOrthoScale;
			if (newScale < initialScale) newScale = initialScale;
			projection.scale(newScale);

			// Update rotation using the difference between the current and previous transform values.
			const dx = event.transform.x - tlast[0];
			const dy = event.transform.y - tlast[1];
			let [longitude, latitude] = projection.rotate();

			// we could reduce sensitivity for smoother rotation
			const sensitivity = 0.2;
			longitude += dx * sensitivity;
			latitude -= dy * sensitivity;
			latitude = Math.max(-90, Math.min(90, latitude));
			projection.rotate([longitude, latitude, 0]);
		}
		g.selectAll('path')
			.attr('d', path);
		g.selectAll('.city-marker')
			.attr('cx', function(d) {
				if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
				var coords = projection(d.feature.geometry.coordinates);
				return coords ? coords[0] : -9999;
			})
			.attr('cy', function(d) {
				if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
				var coords = projection(d.feature.geometry.coordinates);
				return coords ? coords[1] : -9999;
			})
			.attr('r', function(d) {
				if (!isCityVisible(d.feature.geometry.coordinates)) return 0;
				return citySizeScale ? citySizeScale(d[metric]) : 0;
			});
		tlast = [event.transform.x, event.transform.y];
		slast = scale;
	}

	function createSVG(selectionElem) {
		const svg = d3.select(selectionElem)
			.append('svg')
			.attr('class', 'map')
			.attr('style', 'display:block; margin:auto;')
			.attr('width', innerW())
			.attr('height', height)
			.lower();
		const g = svg.append('g')
			.attr('transform', 'translate(0,0)');

		// Use the externally provided projectionType
		setProjection(projectionType);
		// Compute scale extent based on projection type.
		let scaleExtent;
		if (projectionType === 'mercator') {
			// For cellphones (narrow screens), use a fixed scale and limit zoom-out to the initial state.
			if (innerW() < 400) {
				// Here 1 means the initial zoom level (no zoom-out), 6 is arbitrary for max zoom in.
				scaleExtent = [1, 6];
			} else {
				// Otherwise, calculate the base scale from the bounds.
				const bounds = setBounds(projection, maxLat);
				const s = innerW() / (bounds[1][0] - bounds[0][0]);
				scaleExtent = [s, 6 * s];
			}
		} else if (projectionType === 'orthographic') {
			// For orthographic, let d3.zoom use a relative scale factor.
			// The identity transform (k = 1) corresponds to the initialScale,
			// and we allow zooming from 1x to 2x.
			scaleExtent = [1, 2];
		}
		const zoom = d3.zoom()
			.scaleExtent(scaleExtent)
			.on('zoom', event => {
				zoomed(event, projection, path, scaleExtent, g);
			});
		svg.call(zoom);
		return {
			svg,
			g
		};
	}

	function chart(selectionData) {
		selectionData.each(function(data) {
			var chartSelection = d3.select(this);
			const worldData = window.countries110m;
			const countries = topojson.feature(worldData, worldData.objects.countries)
				.features; /* Build a mapping from country names to GeoJSON if needed */
			const countryNameToGeoJson = {};
			countries.forEach(country => {
				countryNameToGeoJson[country.properties.name] = country;
			});
			syncDimensions(this);
			if (dimensionsChanged()) resetMap(chartSelection);
			addFullscreenControl(chartSelection);

			let svgObj = chartSelection
				.select('svg.map');
			let g;
			if (svgObj.empty()) {
				const created = createSVG(this);
				svgObj = created.svg;
				g = created.g;
			} else {
				/* If the SVG already exists, select its group */
				g = svgObj.select('g');
			}
			updateMap(chartSelection, svgObj, data, countries, countryNameToGeoJson); /* Update sphere in case the projection is orthographic */
			updateSphere(svgObj, g);
			renderedWidth = width;
			renderedHeight = height;
			renderedProjectionType = projectionType;
		});
	}
	// Getter-setter for metric
	chart.metric = function(_) {
		if (!arguments.length) return metric;
		metric = _;
		return chart;
	};
	// Getter-setter for opts
	chart.opts = function(_) {
		if (!arguments.length) return opts;
		opts = _;
		return chart;
	};
	// Getter-setter for width
	chart.width = function(_) {
		if (!arguments.length) return width;
		width = _;
		return chart;
	};
	// Getter-setter for height
	chart.height = function(_) {
		if (!arguments.length) return height;
		height = _;
		normalHeight = _;
		return chart;
	};
	// Getter-setter for projectionType
	chart.projectionType = function(_) {
		if (!arguments.length) return projectionType;
		projectionType = _;
		return chart;
	};
	// Getter-setter for panel name
	chart.panel = function(_) {
		if (!arguments.length) return panelName;
		panelName = _;
		return chart;
	};
	chart.isWorldMap = true;

	return chart;
}

function AreaChart(dualYaxis) {
	var opts = {};
	var margin = {
			top    : 20,
			right  : 50,
			bottom : 40,
			left   : 50,
		},
		height = 170,
		nTicks = 10,
		padding = 10,
		width = 760;
	var labels = { x: 'Unnamed', y0: 'Unnamed', y1: 'Unnamed' };
	var format = { x: null, y0: null, y1: null};

	var xValue = function (d) {
			return d[0];
		},
		yValue0 = function (d) {
			return d[1];
		},
		yValue1 = function (d) {
			return d[2];
		};

	var xScale = d3.scaleBand();
	var yScale0 = d3.scaleLinear().nice();
	var yScale1 = d3.scaleLinear().nice();

	var xAxis = d3.axisBottom(xScale)
		.tickFormat(function(d) {
			if (format.x)
				return GoAccess.Util.fmtValue(d, format.x);
			return d;
		});

	var yAxis0 = d3.axisLeft(yScale0)
		.tickFormat(function(d) {
			return d3.format('.2s')(d);
		});

	var yAxis1 = d3.axisRight(yScale1)
		.tickFormat(function(d) {
			if (format.y1)
				return GoAccess.Util.fmtValue(d, format.y1);
			return d3.format('.2s')(d);
		});

	var xGrid = d3.axisBottom(xScale);
	var yGrid = d3.axisLeft(yScale0);

	var area0 = d3.area()
		.curve(d3.curveMonotoneX)
		.x(X)
		.y0(height)
		.y1(Y0);

	var area1 = d3.area()
		.curve(d3.curveMonotoneX)
		.x(X)
		.y0(Y1)
		.y1(height);

	var line0 = d3.line()
		.curve(d3.curveMonotoneX)
		.x(X)
		.y(Y0);

	var line1 = d3.line()
		.curve(d3.curveMonotoneX)
		.x(X)
		.y(Y1);

	// The x-accessor for the path generator; xScale xValue.
	function X(d) {
		return (xScale(d[0]) + xScale.bandwidth() / 2);
	}

	// The x-accessor for the path generator; yScale0 yValue0.
	function Y0(d) {
		return yScale0(d[1]);
	}

	// The x-accessor for the path generator; yScale0 yValue0.
	function Y1(d) {
		return yScale1(d[2]);
	}

	function innerW() {
		return width - margin.left - margin.right;
	}

	function innerH() {
		return height - margin.top - margin.bottom;
	}

	function getXTicks(data) {
		const domain = xScale.domain();
		if (data.length < nTicks)
			return domain;

		return d3.range(0, nTicks).map(function(i) {
			const index = Math.floor(i * (domain.length - 1) / (nTicks - 1));
			if (index >= 0 && index < domain.length)
				return domain[index];
			return null;
		});
	}

	function getYTicks(scale) {
		var domain = scale.domain();
		return d3.range(domain[0], domain[1], Math.ceil(domain[1] / nTicks));
	}

	// Convert data to standard representation greedily;
	// this is needed for nondeterministic accessors.
	function mapData(data) {
		var _datum = function (d, i) {
			var datum = [xValue.call(data, d, i), yValue0.call(data, d, i)];
			dualYaxis && datum.push(yValue1.call(data, d, i));
			return datum;
		};
		return data.map(function (d, i) {
			return _datum(d, i);
		});
	}

	function updateScales(data) {
		// Update the x-scale.
		xScale.domain(data.map(function (d) {
			return d[0];
		}))
		.range([0, innerW()]);

		// Update the y-scale.
		yScale0.domain([0, d3.max(data, function (d) {
			return d[1];
		})])
		.range([innerH(), 0]);

		// Update the y-scale.
		dualYaxis && yScale1.domain([0, d3.max(data, function (d) {
			return d[2];
		})])
		.range([innerH(), 0]);
	}

	function toggleOpacity(ele, op) {
		d3.select(ele.parentNode).selectAll('.' + (ele.getAttribute('data-yaxis') == 'y0' ? 'y1' : 'y0')).attr('style', op);
	}

	function setLegendLabels(svg) {
		// Legend Color
		var rect = svg.selectAll('rect.legend.y0').data([null]);

		var rectEnter = rect.enter()
			.append('rect')
			.attr('class', 'legend y0')
			.attr('data-yaxis', 'y0')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 15));

		rectEnter.merge(rect)
			.attr('x', (width / 2) - 100);

		// Legend Labels
		var text = svg.selectAll('text.legend.y0').data([null]);

		var textEnter = text.enter()
			.append('text')
			.attr('class', 'legend y0')
			.attr('data-yaxis', 'y0')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 6));

		textEnter.merge(text)
			.attr('x', (width / 2) - 85)
			.text(labels.y0);

		if (!dualYaxis)
			return;

		// Legend Labels
		rect = svg.selectAll('rect.legend.y1').data([null]);

		var rectEnter = rect.enter()
			.append('rect')
			.attr('class', 'legend y1')
			.attr('data-yaxis', 'y1')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 15));

		rectEnter.merge(rect)
			.attr('x', (width / 2));

		// Legend Labels
		text = svg.selectAll('text.legend.y1').data([null]);

		var textEnter = text.enter()
			.append('text')
			.attr('class', 'legend y1')
			.attr('data-yaxis', 'y1')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 6));

		textEnter.merge(text)
			.attr('x', (width / 2) + 15)
			.text(labels.y1);
	}

	function setAxisLabels(svg) {
		// Labels
		svg.selectAll('text.axis-label.y0')
			.data([null])
			.enter()
			.append('text')
			.attr('class', 'axis-label y0')
			.attr('y', 10)
			.attr('x', 53)
			.text(labels.y0);

		if (!dualYaxis) return;

		// Labels
		var tEnter = svg.selectAll('text.axis-label.y1')
			.data([null])
			.enter()
			.append('text')
			.attr('class', 'axis-label y1')
			.attr('y', 10)
			.text(labels.y1);

		dualYaxis && tEnter.attr('x', width - 25);
	}

	function createSkeleton(svg) {
		const g = svg.append('g');

		// Lines
		g.append('g')
			.attr('class', 'line line0 y0');
		dualYaxis && g.append('g')
			.attr('class', 'line line1 y1');

		// Areas
		g.append('g')
			.attr('class', 'area area0 y0');
		dualYaxis && g.append('g')
			.attr('class', 'area area1 y1');

		// Points
		g.append('g')
			.attr('class', 'points y0');
		dualYaxis && g.append('g')
			.attr('class', 'points y1');

		// Grid
		g.append('g')
			.attr('class', 'x grid');
		g.append('g')
			.attr('class', 'y grid');

		// Axis
		g.append('g')
			.attr('class', 'x axis');
		g.append('g')
			.attr('class', 'y0 axis');
		dualYaxis && g.append('g')
			.attr('class', 'y1 axis');

		// Rects
		g.append('g')
			.attr('class', 'rects');

		setAxisLabels(svg);
		setLegendLabels(svg);

		// Mouseover line
		g.append('line')
			.attr('y2', innerH())
			.attr('y1', 0)
			.attr('class', 'indicator');
	}

	function pathLen(d) {
		return d.node().getTotalLength();
	}

	function addLine(g, data, line, cName) {
		// Update the line path.
		var path = g.select('g.' + cName).selectAll('path.' + cName).data([data]);

		// enter
		var pathEnter = path.enter()
			.append('svg:path')
			.attr('d', line)
			.attr('class', cName)
			.attr('stroke-dasharray', function(d) {
				var pl = pathLen(d3.select(this));
				return pl + ' ' + pl;
			})
			.attr('stroke-dashoffset', function(d) {
				return pathLen(d3.select(this));
			});

		// update
		pathEnter.merge(path)
			.attr('d', line)
			.transition()
			.attr('stroke-dasharray', function(d) {
			var pl = pathLen(d3.select(this));
				return pl + ' ' + pl;
			})
			.duration(2000)
			.attr('stroke-dashoffset', 0);

		// remove elements
		path.exit().remove();

	}

	function addArea(g, data, cb, cName) {
		// Update the area path.
		var area = g.select('g.' + cName).selectAll('path.' + cName)
			.data([data]);

		var areaEnter = area.enter()
			.append('svg:path')
			.attr('class', cName);

		areaEnter.merge(area)
			.attr('d', cb);

		// remove elements
		area.exit().remove();
	}

	// Update the area path and lines.
	function addAreaLines(g, data) {
		// Update the area path.
		addArea(g, data, area0.y0(yScale0.range()[0]), 'area0');
		// Update the line path.
		addLine(g, data, line0, 'line0');
		// Update the area path.
		addArea(g, data, area1.y1(yScale1.range()[0]), 'area1');
		// Update the line path.
		addLine(g, data, line1, 'line1');
	}

	// Update chart points
	function addPoints(g, data) {
		var radius = data.length > 100 ? 1 : 2.5;

		var points = g.select('g.points.y0').selectAll('circle.point').data(data);

		var pointsEnter = points.enter()
			.append('svg:circle')
			.attr('r', radius)
			.attr('class', 'point');

		pointsEnter.merge(points)
			.attr('cx', function(d) {
				return (xScale(d[0]) + xScale.bandwidth() / 2);
			})
			.attr('cy', function(d) {
				return yScale0(d[1]);
			});

		// remove elements
		points.exit().remove();

		if (!dualYaxis)
			return;

		points = g.select('g.points.y1').selectAll('circle.point').data(data);

		pointsEnter = points.enter()
			.append('svg:circle')
			.attr('r', radius)
			.attr('class', 'point');

		pointsEnter.merge(points)
			.attr('cx', function(d) {
				return (xScale(d[0]) + xScale.bandwidth() / 2);
			})
			.attr('cy', function(d) {
				return yScale1(d[2]);
			});

		// remove elements
		points.exit().remove();
	}

	function addAxis(g, data) {
		var xTicks = getXTicks(data);
		var tickDistance = xTicks.length > 1 ? (xScale(xTicks[1]) - xScale(xTicks[0])) : innerW();
		var labelW = tickDistance - padding;

		// Update the x-axis.
		g.select('.x.axis')
			.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
			.call(xAxis.tickValues(xTicks))
			.selectAll(".tick text")
			.call(truncate, labelW > 0 ? labelW : innerW());

		// Update the y0-axis.
		g.select('.y0.axis')
			.call(yAxis0.tickValues(getYTicks(yScale0)));

		if (!dualYaxis)
			return;

		// Update the y1-axis.
		g.select('.y1.axis')
			.attr('transform', 'translate(' + innerW() + ', 0)')
			.call(yAxis1.tickValues(getYTicks(yScale1)));
	}

	// Update the X-Y grid.
	function addGrid(g, data) {
		g.select('.x.grid')
			.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
			.call(xGrid
				.tickValues(getXTicks(data))
				.tickSize(-innerH(), 0, 0)
				.tickSizeOuter(0)
				.tickFormat('')
			);

		g.select('.y.grid')
			.call(yGrid
				.tickValues(getYTicks(yScale0))
				.tickSize(-innerW(), 0)
				.tickSizeOuter(0)
				.tickFormat('')
			);
	}

	function formatTooltip(data) {
		var d = data.slice(0);

		d[0] = (format.x) ? GoAccess.Util.fmtValue(d[0], format.x) : d[0];
		d[1] = (format.y0) ? GoAccess.Util.fmtValue(d[1], format.y0) : d3.format(',')(d[1]);
		dualYaxis && (d[2] = (format.y1) ? GoAccess.Util.fmtValue(d[2], format.y1) : d3.format(',')(d[2]));

		var template = d3.select('#tpl-chart-tooltip').html();
		return Hogan.compile(template).render({
			'data': d
		});
	}

	function mouseover(event, selection, data) {
		var tooltip = selection.select('.chart-tooltip-wrap');
		tooltip.html(formatTooltip(data))
			.style('left', X(data) + 'px')
			.style('top',  (d3.pointer(event)[1] + 10) + 'px')
			.style('display', 'block');

		selection.select('line.indicator')
			.style('display', 'block')
			.attr('transform', 'translate(' + X(data) + ',' + 0 + ')');
	}

	function mouseout(selection, g) {
		var tooltip = selection.select('.chart-tooltip-wrap');
		tooltip.style('display', 'none');

		g.select('line.indicator').style('display', 'none');
	}

	function addRects(selection, g, data) {
		var w = (innerW() / data.length);

		var rects = g.select('g.rects').selectAll('rect').data(data);

		var rectsEnter = rects.enter()
			.append('svg:rect')
			.attr('height', innerH())
			.attr('class', 'point');

		rectsEnter.merge(rects)
			.attr('width', w)
			.attr('x', function(d, i) {
				return (w * i);
			})
			.attr('y', 0)
			.on('mousemove', function(event) {
				mouseover(event, selection, d3.select(this).datum());
			})
			.on('mouseleave', function(event) {
				mouseout(selection, g);
			});

		// remove elements
		rects.exit().remove();
	}

	function chart(selection) {
		selection.each(function (data) {
			// normalize data
			data = mapData(data);
			// updates X-Y scales
			updateScales(data);

			// select the SVG element, if it exists
			let svg = d3.select(this).select('svg');

			// if the SVG element doesn't exist, create it
			if (svg.empty()) {
				svg = d3.select(this).append('svg').attr('width', width).attr('height', height);
				createSkeleton(svg);
			}

			// Update the inner dimensions.
			var g = svg.select('g')
				.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

			// Add grid
			addGrid(g, data);
			// Add chart lines and areas
			addAreaLines(g, data);
			// Add chart points
			addPoints(g, data);
			// Add axis
			addAxis(g, data);
			// Add rects
			addRects(selection, g, data);
		});
	}

	chart.opts = function (_) {
		if (!arguments.length) return opts;
		opts = _;
		return chart;
	};

	chart.format = function (_) {
		if (!arguments.length) return format;
		format = _;
		return chart;
	};

	chart.labels = function (_) {
		if (!arguments.length) return labels;
		labels = _;
		return chart;
	};

	chart.margin = function (_) {
		if (!arguments.length) return margin;
		margin = _;
		return chart;
	};

	chart.width = function (_) {
		if (!arguments.length) return width;
		width = _;
		return chart;
	};

	chart.height = function (_) {
		if (!arguments.length) return height;
		height = _;
		return chart;
	};

	chart.x = function (_) {
		if (!arguments.length) return xValue;
		xValue = _;
		return chart;
	};

	chart.y0 = function (_) {
		if (!arguments.length) return yValue0;
		yValue0 = _;
		return chart;
	};

	chart.y1 = function (_) {
		if (!arguments.length) return yValue1;
		yValue1 = _;
		return chart;
	};

	return chart;
}

function BarChart(dualYaxis) {
	var opts = {};
	var margin = {
			top    : 20,
			right  : 50,
			bottom : 40,
			left   : 50,
		},
		height = 170,
		nTicks = 10,
		padding = 10,
		width = 760;
	var labels = { x: 'Unnamed', y0: 'Unnamed', y1: 'Unnamed' };
	var format = { x: null, y0: null, y1: null};

	var xValue = function (d) {
			return d[0];
		},
		yValue0 = function (d) {
			return d[1];
		},
		yValue1 = function (d) {
			return d[2];
		};

	var xScale = d3.scaleBand()
		.paddingInner(0.1)
		.paddingOuter(0.1);
	var yScale0 = d3.scaleLinear().nice();
	var yScale1 = d3.scaleLinear().nice();

	var xAxis = d3.axisBottom(xScale)
		.tickFormat(function (d) {
			if (format.x)
				return GoAccess.Util.fmtValue(d, format.x);
			return d;
		});

	var yAxis0 = d3.axisLeft(yScale0)
		.tickFormat(function (d) {
			return d3.format('.2s')(d);
		});

	var yAxis1 = d3.axisRight(yScale1)
		.tickFormat(function (d) {
			if (format.y1)
				return GoAccess.Util.fmtValue(d, format.y1);
			return d3.format('.2s')(d);
		});

	var xGrid = d3.axisBottom(xScale);
	var yGrid = d3.axisLeft(yScale0);

	function innerW() {
		return width - margin.left - margin.right;
	}

	function innerH() {
		return height - margin.top - margin.bottom;
	}

	function getXTicks(data) {
		const domain = xScale.domain();
		if (data.length < nTicks)
			return domain;

		return d3.range(0, nTicks).map(function(i) {
			const index = Math.floor(i * (domain.length - 1) / (nTicks - 1));
			if (index >= 0 && index < domain.length)
				return domain[index];
			return null;
		});
	}

	function getYTicks(scale) {
		var domain = scale.domain();
		return d3.range(domain[0], domain[1], Math.ceil(domain[1] / nTicks));
	}

	// The x-accessor for the path generator; xScale - xValue.
	function X(d) {
		return (xScale(d[0]) + xScale.bandwidth() / 2);
	}

	// Convert data to standard representation greedily;
	// this is needed for nondeterministic accessors.
	function mapData(data) {
		var _datum = function (d, i) {
			var datum = [xValue.call(data, d, i), yValue0.call(data, d, i)];
			dualYaxis && datum.push(yValue1.call(data, d, i));
			return datum;
		};
		return data.map(function (d, i) {
			return _datum(d, i);
		});
	}

	function updateScales(data) {
		// Update the x-scale.
		xScale.domain(data.map(function (d) {
			return d[0];
		}))
		.range([0, innerW()]);

		// Update the y-scale.
		yScale0.domain([0, d3.max(data, function (d) {
			return d[1];
		})])
		.range([innerH(), 0]);

		// Update the y-scale.
		// If all values are [0, 0]. This can cause issues when drawing the
		// chart because all values passed to the scale will be mapped to the
		// same value in the range, thus + 0.1 e.g., Not Found visitors.
		dualYaxis && yScale1.domain([0, d3.max(data, function (d) {
			return d[2];
		}) + 0.1])
		.range([innerH(), 0]);
	}

	function toggleOpacity(ele, op) {
		d3.select(ele.parentNode).selectAll('.' + (ele.getAttribute('data-yaxis') == 'y0' ? 'y1' : 'y0')).attr('style', op);
	}

	function setLegendLabels(svg) {
		// Legend Color
		var rect = svg.selectAll('rect.legend.y0').data([null]);

		var rectEnter = rect.enter()
			.append('rect')
			.attr('class', 'legend y0')
			.attr('data-yaxis', 'y0')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 15));

		rectEnter.merge(rect)
			.attr('x', (width / 2) - 100);

		// Legend Labels
		var text = svg.selectAll('text.legend.y0').data([null]);

		var textEnter = text.enter()
			.append('text')
			.attr('class', 'legend y0')
			.attr('data-yaxis', 'y0')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 6));

		textEnter.merge(text)
			.attr('x', (width / 2) - 85)
			.text(labels.y0);

		if (!dualYaxis)
			return;

		// Legend Labels
		rect = svg.selectAll('rect.legend.y1').data([null]);

		var rectEnter = rect.enter()
			.append('rect')
			.attr('class', 'legend y1')
			.attr('data-yaxis', 'y1')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 15));

		rectEnter.merge(rect)
			.attr('x', (width / 2));

		// Legend Labels
		text = svg.selectAll('text.legend.y1').data([null]);

		var textEnter = text.enter()
			.append('text')
			.attr('class', 'legend y1')
			.attr('data-yaxis', 'y1')
			.on('mousemove', function(d, i) {
				toggleOpacity(this, 'opacity:0.1');
			})
			.on('mouseleave', function(d, i) {
				toggleOpacity(this, null);
			})
			.attr('y', (height - 6));

		textEnter.merge(text)
			.attr('x', (width / 2) + 15)
			.text(labels.y1);
	}

	function setAxisLabels(svg) {
		// Labels
		svg.selectAll('text.axis-label.y0')
			.data([null])
			.enter()
			.append('text')
			.attr('class', 'axis-label y0')
			.attr('y', 10)
			.attr('x', 53)
			.text(labels.y0);

		if (!dualYaxis) return;

		// Labels
		var tEnter = svg.selectAll('text.axis-label.y1')
			.data([null])
			.enter()
			.append('text')
			.attr('class', 'axis-label y1')
			.attr('y', 10)
			.text(labels.y1);

		dualYaxis && tEnter.attr('x', width - 25);
	}

	function createSkeleton(svg) {
		const g = svg.append('g');

		// Grid
		g.append('g')
			.attr('class', 'x grid');
		g.append('g')
			.attr('class', 'y grid');

		// Axis
		g.append('g')
			.attr('class', 'x axis');
		g.append('g')
			.attr('class', 'y0 axis');
		dualYaxis && g.append('g')
			.attr('class', 'y1 axis');

		// Bars
		g.append('g')
			.attr('class', 'bars y0');
		dualYaxis && g.append('g')
			.attr('class', 'bars y1');

		// Rects
		g.append('g')
			.attr('class', 'rects');

		setAxisLabels(svg);
		setLegendLabels(svg);

		// Mouseover line
		g.append('line')
			.attr('y2', innerH())
			.attr('y1', 0)
			.attr('class', 'indicator');
	}

	// Update the area path and lines.
	function addBars(g, data) {
		var bars = g.select('g.bars.y0').selectAll('rect.bar').data(data);

		// enter
		var enter = bars.enter()
			.append('svg:rect')
			.attr('class', 'bar')
			.attr('height', 0)
			.attr('width', function (d, i) { return xScale.bandwidth() / 2; })
			.attr('x', function (d, i) { return xScale(d[0]); })
			.attr('y', function (d, i) { return innerH(); });

		// update
		bars.merge(enter)
			.attr('width', xScale.bandwidth() / 2)
			.attr('x', function (d) { return xScale(d[0]); })
			.transition()
			.delay(function (d, i) { return i / data.length * 1000; })
			.duration(500)
			.attr('height', function (d, i) { return innerH() - yScale0(d[1]); })
			.attr('y', function (d, i) { return yScale0(d[1]); });
		// remove elements
		bars.exit().remove();

		if (!dualYaxis)
			return;

		bars = g.select('g.bars.y1').selectAll('rect.bar').data(data);
		// enter
		enter = bars.enter()
			.append('svg:rect')
			.attr('class', 'bar')
			.attr('height', 0)
			.attr('width', function (d, i) { return xScale.bandwidth() / 2; })
			.attr('x', function (d) { return (xScale(d[0]) + xScale.bandwidth() / 2); })
			.attr('y', function (d, i) { return innerH(); });
		// update
		bars.merge(enter)
			.attr('width', xScale.bandwidth() / 2)
			.attr('x', function (d) { return (xScale(d[0]) + xScale.bandwidth() / 2); })
			.transition()
			.delay(function (d, i) { return i / data.length * 1000; })
			.duration(500)
			.attr('height', function (d, i) { return innerH() - yScale1(d[2]); })
			.attr('y', function (d, i) { return yScale1(d[2]); });
		// remove elements
		bars.exit().remove();
	}

	function addAxis(g, data) {
		var xTicks = getXTicks(data);
		var tickDistance = xTicks.length > 1 ? (xScale(xTicks[1]) - xScale(xTicks[0])) : innerW();
		var labelW = tickDistance - padding;

		// Update the x-axis.
		g.select('.x.axis')
			.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
			.call(xAxis.tickValues(xTicks))
			.selectAll(".tick text")
			.call(truncate, labelW > 0 ? labelW : innerW());

		// Update the y0-axis.
		g.select('.y0.axis')
			.call(yAxis0.tickValues(getYTicks(yScale0)));

		if (!dualYaxis)
			return;

		// Update the y1-axis.
		g.select('.y1.axis')
			.attr('transform', 'translate(' + innerW() + ', 0)')
			.call(yAxis1.tickValues(getYTicks(yScale1)));
	}

	// Update the X-Y grid.
	function addGrid(g, data) {
		g.select('.x.grid')
			.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
			.call(xGrid
				.tickValues(getXTicks(data))
				.tickSize(-innerH(), 0, 0)
				.tickSizeOuter(0)
				.tickFormat('')
			);

		g.select('.y.grid')
			.call(yGrid
				.tickValues(getYTicks(yScale0))
				.tickSize(-innerW(), 0)
				.tickSizeOuter(0)
				.tickFormat('')
			);
	}

	function formatTooltip(data) {
		var d = data.slice(0);

		d[0] = (format.x) ? GoAccess.Util.fmtValue(d[0], format.x) : d[0];
		d[1] = (format.y0) ? GoAccess.Util.fmtValue(d[1], format.y0) : d3.format(',')(d[1]);
		dualYaxis && (d[2] = (format.y1) ? GoAccess.Util.fmtValue(d[2], format.y1) : d3.format(',')(d[2]));

		var template = d3.select('#tpl-chart-tooltip').html();
		return Hogan.compile(template).render({
			'data': d
		});
	}

	function mouseover(event, selection, data) {
		var tooltip = selection.select('.chart-tooltip-wrap');
		tooltip.html(formatTooltip(data))
			.style('left', X(data) + 'px')
			.style('top',  (d3.pointer(event)[1] + 10) + 'px')
			.style('display', 'block');

		selection.select('line.indicator')
			.style('display', 'block')
			.attr('transform', 'translate(' + X(data) + ',' + 0 + ')');
	}

	function mouseout(selection, g) {
		var tooltip = selection.select('.chart-tooltip-wrap');
		tooltip.style('display', 'none');

		g.select('line.indicator').style('display', 'none');
	}

	function addRects(selection, g, data) {
		var w = (innerW() / data.length);

		var rects = g.select('g.rects').selectAll('rect').data(data);

		var rectsEnter = rects.enter()
			.append('svg:rect')
			.attr('height', innerH())
			.attr('class', 'point');

		rectsEnter.merge(rects)
			.attr('width', w)
			.attr('x', function(d, i) {
				return (w * i);
			})
			.attr('y', 0)
			.on('mousemove', function(event) {
				mouseover(event, selection, d3.select(this).datum());
			})
			.on('mouseleave', function(event) {
				mouseout(selection, g);
			});

		// remove elements
		rects.exit().remove();
	}

	function chart(selection) {
		selection.each(function (data) {
			// normalize data
			data = mapData(data);
			// updates X-Y scales
			updateScales(data);

			// select the SVG element, if it exists
			let svg = d3.select(this).select('svg');

			// if the SVG element doesn't exist, create it
			if (svg.empty()) {
				svg = d3.select(this).append('svg').attr('width', width).attr('height', height);
				createSkeleton(svg);
			}

			// Update the inner dimensions.
			var g = svg.select('g')
				.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

			// Add grid
			addGrid(g, data);
			// Add axis
			addAxis(g, data);
			// Add chart lines and areas
			addBars(g, data);
			// Add rects
			addRects(selection, g, data);
		});
	}

	chart.opts = function (_) {
		if (!arguments.length) return opts;
		opts = _;
		return chart;
	};

	chart.format = function (_) {
		if (!arguments.length) return format;
		format = _;
		return chart;
	};

	chart.labels = function (_) {
		if (!arguments.length) return labels;
		labels = _;
		return chart;
	};

	chart.width = function (_) {
		if (!arguments.length) return width;
		width = _;
		return chart;
	};

	chart.height = function (_) {
		if (!arguments.length) return height;
		height = _;
		return chart;
	};

	chart.x = function (_) {
		if (!arguments.length) return xValue;
		xValue = _;
		return chart;
	};

	chart.y0 = function (_) {
		if (!arguments.length) return yValue0;
		yValue0 = _;
		return chart;
	};

	chart.y1 = function (_) {
		if (!arguments.length) return yValue1;
		yValue1 = _;
		return chart;
	};

	return chart;
}
