MediaWiki:Gadget-maps.js

From the AARoads Wiki: Read about the road before you go
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/* jslint esversion: 6 */
/**
 * Embeds a MapLibre GL map into any wiki page that asks for one.
 * 
 * To embed a map, add a <div> with the class `maplibre-map`. Use data
 * attributes to specify the map’s parameters:
 * 
 * - `data-width`: Width of the map. `full` fits available space.
 * - `data-height`: Height of the map.
 * - `data-layer`: Vector style or raster tileset ID; see configuration below.
 * - `data-lat`: Initial center latitude.
 * - `data-lon`: Initial center longitude.
 * - `data-zoom`: Initial vector zoom level (one less than raster).
 * - `data-bearing`: Initial bearing in degrees counterclockwise from north.
 * - `data-pitch`: Initial pitch in degrees away from the plane of the screen.
 * - `data-date`: ISO 8601-1 date (for OpenHistoricalMap layers).
 * - `data-commons`: Page name of Wikimedia Commons map data to load as an
 *		overlay (including .map extension, excluding Data: namespace).
 *		Deprecated in favor of `maplibre-map-geojson` child elements.
 * - `data-mlat`: Marker latitude. Deprecated in favor of `maplibre-map-marker`
 *		child elements.
 * - `data-mlon`: Marker longitude. Deprecated in favor of `maplibre-map-marker`
 *		child elements.
 * - `data-navigation-position`: Corner in which the navigation controls appear,
 *		or `none` to hide the navigation controls.
 * - `data-full-screen-position`: Corner in which the full screen control
 *		appears, or `none` to hide the full screen control.
 * - `data-attribution-position`: Corner in which the attribution control
 *		appears.
 * - `title`: Overwrites the "title" property on all features not overwritten at the layer level
 * - `description`: Overwrites the "description" property on all features not overwritten at the layer level
 * - `stroke-color`: Overwrites the "stroke" property on all features not overwritten at the layer level
 * - `stroke-width`: Overwrites the "title" property on all features not overwritten at the layer level
 * - `stroke-opacity`: Overwrites the "stroke-opacity" property on all features not overwritten at the layer level
 * - `fill`: Overwrites the "fill" property on all features not overwritten at the layer level
 * - `fill-opacity`: Overwrites the "fill-opacity" property on all features not overwritten at the layer level
 * - `title-key`: If `title` is defined as a JSON object, the value of each key-value pair there overwrites the "title" property if that key matches the value of the key defined in this parameter
 * - `description-key`: See `title-key`
 * - `stroke-color-key`: See `title-key`
 * - `stroke-width-key`: See `title-key`
 * - `stroke-opacity-key`: See `title-key`
 * - `fill-key`: See `title-key`
 * - `fill-opacity-key`: See `title-key`
 * 
 * To overlay a marker, add a <span> with the class `maplibre-map-marker` as a
 * child of the `maplibre-map` <div>. Use data attributes to specify the
 * marker’s parameters:
 * 
 * - `data-lat`: Marker latitude.
 * - `data-lon`: Marker longitude.
 * 
 * To overlay GeoJSON data, add a <span> with the class `maplibre-map-geojson`
 * as a child of the `maplibre-map` <div>. Use data attributes to specify the
 * GeoJSON overlay’s parameters:
 * 
 * - `data-commons`: Page name of Wikimedia Commons map data to load as an
 *		overlay (including .map extension, excluding Data: namespace).
 * - `title`: Overwrites the "title" property with this value
 * - `description`: Overwrites the "description" property with this value
 * - `stroke-color`: Overwrites the "stroke" property with this value
 * - `stroke-width`: Overwrites the "title" property with this value
 * - `stroke-opacity`: Overwrites the "stroke-opacity" property with this value
 * - `fill`: Overwrites the "fill" property with this value
 * - `fill-opacity`: Overwrites the "fill-opacity" property with this value
 * - `title-key`: If `title` is defined as a JSON object, the value of each key-value pair there overwrites the "title" property if that key matches the value of the key defined in this parameter
 * - `description-key`: See `title-key`
 * - `stroke-color-key`: See `title-key`
 * - `stroke-width-key`: See `title-key`
 * - `stroke-opacity-key`: See `title-key`
 * - `fill-key`: See `title-key`
 * - `fill-opacity-key`: See `title-key`

 * To embed a scrubber that compares two maps, wrap the two `maplibre-map`
 * <div>s in a <div> with the class `maplibre-comparison`. Use data attributes
 * to specify the comparison’s parameters:
 * 
 * - `data-width`: Width of the comparison. `full` fits available space.
 * - `data-height`: Height of the comparison.
 */

// Configuration
mw.config.set("ext.gadget.maps.scriptServer", "//wiki.aaroads.com");
mw.config.set("ext.gadget.maps.layers", $.extend(mw.config.get("ext.gadget.maps.layers"), function () {
	var osmAttribution = "Map data © <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap contributors</a>";
	var ohmAttribution = "<a href='https://www.openhistoricalmap.org/copyright'>OpenHistoricalMap contributors</a>";
	return {
		americana: {
			style: "https://zelonewolf.github.io/openstreetmap-americana/style.json",
			afterLoad: ["installShields"],
		},
		usrd: {
			style: "https://1ec5.github.io/openstreetmap-americana/style.json",
			afterLoad: ["installShields"],
		},
		aaroads: {
			style: "https://aaroads-wiki.github.io/openstreetmap-americana/style.json",
			afterLoad: ["installShields"],
		},
		carto: {
			tileset: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
			name: "OpenStreetMap Carto",
			attribution: osmAttribution,
		},
		cyclosm: {
			tileset: "https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
			name: "CyclOSM",
			attribution: osmAttribution + ", map style <a href='https://github.com/cyclosm/cyclosm-cartocss-style/releases'>CyclOSM v0.6</a>",
		},
		historic: {
			style: "https://openhistoricalmap.github.io/map-styles/ohm_timeslider_tegola/tegola-ohm.json",
			afterLoad: ["filterByDate"],
			attribution: ohmAttribution,
		},
		humanitarian: {
			tileset: "https://tile-a.openstreetmap.fr/hot/{z}/{x}/{y}.png",
			name: "Humanitarian",
			attribution: osmAttribution + ", map style <a href='https://hotosm.org/'>Humanitarian OpenStreetMap Team</a>, hosted by <a href='https://openstreetmap.fr/'>OpenStreetMap France</a>",
		},
		"japanese scroll": {
			style: "https://openhistoricalmap.github.io/map-styles/japanese_scroll/ohm-japanese-scroll-map.json",
			afterLoad: ["filterByDate"],
			attribution: ohmAttribution,
		},
		woodblock: {
			style: "https://openhistoricalmap.github.io/map-styles/woodblock/woodblock.json",
			afterLoad: ["filterByDate"],
			attribution: ohmAttribution,
		},
	};
}()));

$(function () {
	var containers = $(".maplibre-map");
	if (containers.length === 0) return;
	
	var scriptServer = mw.config.get("ext.gadget.maps.scriptServer");
	mw.loader.load(scriptServer + mw.util.getUrl("MediaWiki:Gadget-maplibre.css", { action: "raw", ctype: "text/css" }), "text/css");
	mw.loader.getScript(scriptServer + mw.util.getUrl("MediaWiki:Gadget-maplibre.js", { action: "raw", ctype: "text/javascript" })).then(function () {
		var totalTitles = [];
		var dataOut = {};
		containers.each(function () {
			var titles = $(this).find(".maplibre-map-geojson").map(function () {
				var fileName = $(this).data("commons");
				if (fileName.endsWith(".map")) {
					return "Data:" + fileName;
				}
			}).get();
			
			var legacyFileName = $(this).data("commons");
			if (legacyFileName && legacyFileName.endsWith(".map")) {
				titles.push("Data:" + legacyFileName);
			}
			
			if (titles.length === 0) return;
			$(this).data("commonsTitles", titles);
			var concatArr = totalTitles.concat(titles);
			totalTitles = concatArr.filter(function (item, idx) {
				return concatArr.indexOf(item) === idx;
			});
		});
		
		$.getJSON("https://commons.wikimedia.org/w/api.php", {
			"action": "query",
			"format": "json",
			"formatversion": 2,
			"titles": totalTitles.join("|"),
			"prop": "revisions",
			"rvprop": "content",
			"rvslots": "main",
			// Set the Access-Control-Allow-Origin header, since no user-specific data is needed anyways.
			"origin": "*",
		}, function (data) {
			dataOut = data;
		}).done (function() {
			containers.each(function () {
				populateContainer(this, dataOut);
			});
			
			var comparisons = $(".maplibre-comparison");
			if (comparisons.length === 0) return;
			
			mw.loader.load(scriptServer + mw.util.getUrl("MediaWiki:Gadget-maplibre-gl-compare.css", { action: "raw", ctype: "text/css" }), "text/css");
			mw.loader.getScript(scriptServer + mw.util.getUrl("MediaWiki:Gadget-maplibre-gl-compare.js", { action: "raw", ctype: "text/javascript" })).then(function () {
				comparisons.each(function () {
					populateComparison(this);
				});
			});
		})
		.fail(function(xhr, status, error) {
		    console.error( "Request for commons data failed. ");
	    });
	});
	
	/**
	 * Populates an HTML element with an interactive map.
	 * 
	 * @param container The HTML element to populate.
	 * @param data The queried commons data of all containers
	 */
	function populateContainer(container, data) {
		var layerFunctions = {
			filterByDate: filterByDate,
			installShields: installShields,
		};
		var layers = mw.config.get("ext.gadget.maps.layers");
		
		var width = $(container).data("width");
		if (width === "full") {
			width = "100%";
		}
		$(container)
			.css("width", width)
			.css("height", $(container).data("height"));
		
		var layerID = $(container).data("layer");
		var layer = layers[layerID] || layers.aaroads;
		var style = layer.style || wrapTileset(layer);
		
		var mapOptions = {
			container: container,
			style: style,
			center: [$(container).data("lon") || 0, $(container).data("lat") || 0],
			zoom: $(container).data("zoom") || 0,
			bearing: $(container).data("bearing") || 0,
			pitch: $(container).data("pitch") || 0,
			attributionControl: false, // will add a custom control below
		};
		
		// Some vector styles need attribution to be overwritten.
		if (layer.attribution && !layer.tileset) {
			mapOptions.customAttribution = layer.attribution;
		}
		
		var map = new maplibregl.Map(mapOptions);
		container.map = map;
		
		var navigationPosition = $(container).data("navigation-position") || "top-left";
		if (navigationPosition !== "none") {
			map.addControl(new maplibregl.NavigationControl(), navigationPosition);
		}
		
		var fullScreenPosition = $(container).data("full-screen-position") || "top-left";
		if (fullScreenPosition !== "none") {
			map.addControl(new maplibregl.FullscreenControl(), fullScreenPosition);
		}
		
		var attributionPosition = $(container).data("attribution-position") || "bottom-right";
		var attributionControl = new maplibregl.AttributionControl({
			customAttribution: mapOptions.customAttribution,
			compact: false
		});
		map.addControl(attributionControl, attributionPosition);
		
		var attributionElement = attributionControl._innerContainer;
		$(attributionElement).css("overflow", "hidden");
		$(attributionElement).css("text-overflow", "ellipsis");
		$(attributionElement).css("max-height", "20px");
		$(attributionElement).hover(
			function() {
			    $(this).css("max-height", "");
			}, function() {
			    $(this).css("max-height", "20px");
			}
		);
		
		var totalBbox = new maplibregl.LngLatBounds();
		// Add legacy deprecated top-level marker.
		var markerLatitude = $(container).data("mlat");
		var markerLongitude = $(container).data("mlon");
		if (markerLatitude || markerLongitude) {
			new maplibregl.Marker()
				.setLngLat([markerLongitude, markerLatitude])
				.addTo(map);
			totalBbox.extend([markerLongitude, markerLatitude]);
		}
		
		// Add markers specified as child elements.
		var markers = $(container).find(".maplibre-map-marker");
		markers.each(function () {
			var markerLatitude = $(this).data("lat");
			var markerLongitude = $(this).data("lon");
			var markerTitle = $(this).data("title");
			totalBbox.extend([markerLongitude, markerLatitude]);
			var marker = new maplibregl.Marker()
				.setLngLat([markerLongitude, markerLatitude]);
			marker.on('click', function (e) {
				var coordinates = e.getLngLat().toArray();
	
	            // Ensure that if the map is zoomed out such that multiple
	            // copies of the feature are visible, the popup appears
	            // over the copy being pointed to.
	            while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
	                coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
	            }
	
	            new maplibregl.Popup()
	                .setLngLat(coordinates)
	                .setHTML(markerTitle || "")
	                .addTo(map);
	        });
	
	        // Change the cursor to a pointer when the mouse is over the places layer.
		    marker.on('mouseover', function () {
	            map.getCanvas().style.cursor = 'pointer';
	        });
		
		        // Change it back to a pointer when it leaves.
		    marker.on('mouseleave', function () {
	            map.getCanvas().style.cursor = '';
	        });
			marker.addTo(map);
		});
		
		map.once("styledata", function (event) {
			// Call any runtime styling functions that need to run after the layer loads.
			var functionNames = layer.afterLoad || [];
			functionNames.forEach(function (functionName) {
				var fn = layerFunctions[functionName];
				if (fn) {
					fn(container);
				} else {
					console.warn("%s layer requires unavailable function “%s”.", layer.name, functionName);
				}
			});
			
			// Load map data from Wikimedia Commons.
			var commonsBbox = loadCommonsOverlay(container, layer, data) || null;
			totalBbox.extend(commonsBbox);
			if (!totalBbox.isEmpty() && (!$(container).data("lat") || !$(container).data("lon"))) {
				if (totalBbox.getNorth() == totalBbox.getSouth() && totalBbox.getEast() == totalBbox.getWest())
					map.jumpTo({
						center: totalBbox.getCenter().toArray(),
						zoom: $(container).data("zoom") || 11,
						bearing: 0,
						pitch: 0
					});
				else {
					var padding = 50; // approximately 10 + navigation control width + 10
					map.fitBounds(totalBbox, {
						animate: false,
						padding: {
							top: padding,
							bottom: padding,
							left: padding,
							right: padding,
						},
					});
				}
			}
		});
	}
	
	function wrapTileset(layer) {
		var style = {
			version: 8,
			name: layer.name,
			sources: {},
			layers: [{
				id: "raster",
				type: "raster",
				source: "raster",
			}],
		};
		style.sources.raster = {
			type: "raster",
			tiles: [layer.tileset],
			tileSize: 256,
		};
		if (typeof(layer.attribution) === "string") {
			style.sources.raster.attribution = layer.attribution;
		}
		if ("maxZoom" in layer) {
			// Raster zoom levels are one more than vector zoom levels.
			style.sources.raster.maxzoom = layer.maxZoom - 1;
		}
		return style;
	}
	
	function installShields(container) {
		var routeParser = {
			parse: function (imageID) {
				var m = imageID.match(/^shield\n(.+?)=(.+?)(?:\n(.+))?$/);
				return {
					network: m && m[1],
					ref: m && m[2],
					name: m && m[3],
				};
			},
			format: function (network, ref, name) {
				return "shield\n" + network + "=" + ref + "\n" + name;
			},
		};
		var shieldPredicate = function (imageID) {
			return imageID && imageID.startsWith("shield");
		};
		var networkPredicate = function (network) {
			return !/^[lrni][chimpw]n$/.test(network);
		};
		import(scriptServer + mw.util.getUrl("MediaWiki:Gadget-maplibre-shield-generator.js", { action: "raw", ctype: "text/javascript" })).then(function (generator) {
			return new generator.URLShieldRenderer("https://zelonewolf.github.io/openstreetmap-americana/shields.json", routeParser)
				.filterImageID(shieldPredicate)
				.filterNetwork(networkPredicate)
				.renderOnMaplibreGL(container.map);
		});
	}
	
	/**
	 * Filters the map’s features by the `date` data attribute.
	 * 
	 * @param container The HTML element containing the map.
	 */
	function filterByDate(container) {
		var date = $(container).data("date");
		var decimalYear = date && decimalYearFromISODate(date);
		if (!decimalYear) return;
		
		var map = container.map;
		map.getStyle().layers.map(function (layer) {
			if (!("source-layer" in layer)) return;
			
			var filter = constrainFilterByDate(layer.filter, decimalYear);
			map.setFilter(layer.id, filter);
		});
	}
	
	/**
	 * Converts the given ISO 8601-1 date to a decimal year.
	 * 
	 * @param isoDate A date string in ISO 8601-1 format.
	 * @returns A floating point number of years since year 0.
	 */
	function decimalYearFromISODate(isoDate) {
		// Require a valid YYYY, YYYY-MM, or YYYY-MM-DD date, but allow the year
		// to be a variable number of digits or negative, unlike ISO 8601-1.
		if (!isoDate || !/^-?\d{1,4}(?:-\d\d){0,2}$/.test(isoDate)) return;
		
		var ymd = isoDate.split("-");
		// A negative year results in an extra element at the beginning.
		if (ymd[0] === "") {
			ymd.shift();
			ymd[0] *= -1;
		}
		var year = +ymd[0];
		var date = dateFromUTC(year, +ymd[1] - 1, +ymd[2]);
		if (isNaN(date)) return;
		
		// Add the year and the fraction of the date between two New Year’s Days.
		var nextNewYear = dateFromUTC(year + 1, 0, 1).getTime();
		var lastNewYear = dateFromUTC(year, 0, 1).getTime();
		return year + (date.getTime() - lastNewYear) / (nextNewYear - lastNewYear);
	}
	
	/**
	 * Returns a `Date` object representing the given UTC date components.
	 * 
	 * @param year A one-based year in the proleptic Gregorian calendar.
	 * @param month A zero-based month.
	 * @param day A one-based day.
	 * @returns A date object.
	 */
	function dateFromUTC(year, month, day) {
		var date = new Date(Date.UTC(year, month, day));
		// Date.UTC() treats a two-digit year as an offset from 1900.
		date.setUTCFullYear(year);
		return date;
	}
	
	/**
	 * Returns a modified version of the given filter that only evaluates to
	 * true if the feature coincides with the given decimal year.
	 * 
	 * @param filter The original layer filter.
	 * @param decimalYear The decimal year to filter by.
	 * @returns A filter similar to the given filter, but with added conditions
	 *	that require the feature to coincide with the decimal year.
	 */
	function constrainFilterByDate(filter, decimalYear) {
		var dateFilter = [
			"all",
			["any", ["!has", "start_decdate"], ["<=", "start_decdate", decimalYear]],
			["any", ["!has", "end_decdate"], [">=", "end_decdate", decimalYear]],
		];
		if (filter) {
			dateFilter.push(filter);
		}
		return dateFilter;
	}
	
	/**
	 * Adds a map overlay to the given map based on data from Wikimedia Commons.
	 * 
	 * GeoJSON features are styled according to [simplestyle-spec](https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0/).
	 * 
	 * @param container The HTML element containing the map.
	 * @param layer The current layer metadata object.
	 * @param data The queried commons data of all containers
	 * @param paddingFunctions An object mapping sides of a box to arrays of functions that return padding for the side.
	 * @return [Bounding box for the overlay, last zoom level of layers]
	 */
	function loadCommonsOverlay(container, layer, data, paddingFunctions) {
		var query = data && data.query;
		var pages = query && query.pages;
		if (!pages) return;
		pages = pages.filter(function(page) {
			return $(container).data("commonsTitles").includes(page.title);
		});
		if (!pages) return;
		
		var pageContentsByTitle = {};
		pages.forEach(function (page) {
			var revision = page && page.revisions && page.revisions[0];
			var slot = revision && revision.slots && revision.slots.main;
			if (!slot || !slot.content || slot.contentformat !== "application/json") {
				console.warn("Unable to load “%s” from Wikimedia Commons.", page.title);
				return;
			}
			
			var content;
			try {
				content = JSON.parse(slot.content);
			} catch (err) {
				console.warn("Unable to parse “%s” from Wikimedia Commons: %o", page.title, err);
			}
			
			pageContentsByTitle[page.title] = content;
		});
		
		// The fill and line layers need to go below the series of symbol
		// layers at the top of the layer stack. It does not necessarily go
		// below the bottommost symbol layer, which could be a one-way arrow
		// layer in the midst of various road layers).
		var map = container.map;
		var styleLayers = map.getStyle().layers;
		styleLayers.reverse();
		var topmostNonSymbolLayerIndex = styleLayers.findIndex(function (layer) {
			return layer.type !== "symbol";
		});
		var layerAboveOverlays = styleLayers[topmostNonSymbolLayerIndex - 1];
		
		var totalBbox = new maplibregl.LngLatBounds();
		var lastCameraZoom;
		var collatedData = {
		  "type": "FeatureCollection",
		  "features": []
		};
		var attribution = '';
		$(container).children( ".maplibre-map-geojson" ).each(function (i, overlay) {
			var title = "Data:" + $(overlay).data("commons");
			var content = pageContentsByTitle[title];
			if (content===null) return;
			var FeatureCollection = content.data.type == "FeatureCollection" ? content.data.features : [content.data];

			var styleAttrs = ["title", "description", "stroke", "stroke-width", "stroke-opacity", "fill", "fill-opacity"]; //override styles based on data attributes at container level
			styleAttrs.forEach (function (styleAttr) {
				var overwriter = styleAttr == "stroke" ? $(container).data("stroke-color") : $(container).data(styleAttr);
				if (!overwriter) return;
				try { overwriter = JSON.parse(overwriter); } catch (e) {}
				if (typeof overwriter === 'object') 
					FeatureCollection.forEach(function (Feature) {
						var searchValue = Feature.properties[styleAttr == "stroke" ? $(container).data("stroke-color-key") : $(container).data(styleAttr+"-key") || "title"];
						Feature.properties[styleAttr] = overwriter[searchValue] || Feature.properties[styleAttr];
					});
				else 
					FeatureCollection.forEach(function (Feature) {
						Feature.properties[styleAttr] = overwriter;
					});
			});
			
			styleAttrs.forEach (function (styleAttr) { //once map-wide overrides are done, layer overrides can be done
				console.log(styleAttr);
				var overwriter = styleAttr == "stroke" ? $(overlay).data("stroke-color") : $(overlay).data(styleAttr);
				if (!overwriter) return;
				console.log(overwriter);
				try { overwriter = JSON.parse(overwriter); } catch (e) {}
				if (typeof overwriter === 'object') 
					FeatureCollection.forEach(function (Feature) {
						var searchValue = Feature.properties[styleAttr == "stroke" ? $(overlay).data("stroke-color-key") : $(overlay).data(styleAttr+"-key") || "title"];
						Feature.properties[styleAttr] = overwriter[searchValue] || Feature.properties[styleAttr];
					});
				else 
					FeatureCollection.forEach(function (Feature) {
						Feature.properties[styleAttr] = overwriter;
					});
			});

			collatedData.features = collatedData.features.concat(FeatureCollection);
			var fileURL = "https://commons.wikimedia.org/wiki/" + mw.util.wikiUrlencode(title);
			attribution += "<a href='" + fileURL + "'>Wikimedia Commons</a>, " + mw.html.escape(content.license) + " | ";
			
			if (content.data.bbox)
				totalBbox.extend(content.data.bbox);
		});
		if (collatedData.features.length == 1)
			collatedData = collatedData.features[0];
		
		attribution = attribution.substring(0, attribution.length-3);
		var sourceID = "Commons";
		map.addSource(sourceID, {
			type: "geojson",
			attribution: attribution,
			data: collatedData,
			generateId: true
		});
			
		map.addLayer({
			id: "Commons/symbol",
			type: "symbol",
			source: sourceID,
			layout: {
				"icon-image": [
					"concat",
					["get", "marker-symbol"],
					"-",
					[
						"match",
						["get", "marker-size"],
						"small", 11,
						15
					],
				],
				"icon-allow-overlap": true,
			},
			paint: {
				"icon-color": [
					"to-color",
					["get", "marker-color"],
					"#7e7e7e",
				],
				"icon-halo-color": [
                    'case',
                    ['boolean', ['feature-state', 'clicked'], false],
                    [ "to-color", "rgba(255, 255, 255, 1)" ],
                    [ "to-color", "rgba(0, 0, 0, 0)" ]
                ],
				"icon-halo-width": 3
			},
		}); // points go on top
			
		map.addLayer({
			id: "Commons/line",
			type: "line",
			source: sourceID,
			layout: {
				"line-cap": "round",
				"line-join": "bevel",
			},
			paint: {
				"line-color": [
					"to-color",
					["get", "stroke"],
					"#c00",
				],
				"line-opacity": [
					"coalesce",
					["get", "stroke-opacity"],
					1,
				],
				"line-width": [
					"coalesce",
					["get", "stroke-width"],
					3,
                ]
			},
		}, layerAboveOverlays && layerAboveOverlays.id);
		
		map.addLayer({
			id: "Commons/line-select",
			type: "line",
			source: sourceID,
			layout: {
				"line-cap": "round",
				"line-join": "bevel",
			},
			paint: {
				"line-color": [
					"to-color",
					"#888888",
				],
				"line-opacity": [
                    'case',
                    ['boolean', ['feature-state', 'clicked'], false],
                    1,
					0
				],
				"line-width": ['+', 
                	[
						"coalesce",
						["get", "stroke-width"],
						2,
					], 
					5
				],
                'line-blur': 5
			},
		}, "Commons/line");
		
		map.addLayer({
			id: "Commons/fill",
			type: "fill",
			source: sourceID,
			filter: [
				"any",
				["has", "fill"],
				["has", "fill-opacity"],
			],
			paint: {
				"fill-color": [
					"to-color",
					["get", "fill-color"],
					["get", "fill"],
					"#555555",
				],
				"fill-opacity": [
					"coalesce",
					["get", "fill-opacity"],
					0.5,
				],
			},
		}, "Commons/line-select");
			
		setClick(map, sourceID, "Commons/symbol");
		setClick(map, sourceID, "Commons/line");
		setClick(map, sourceID, "Commons/fill");
		
		return totalBbox.extend(geojsonBbox(collatedData));
	}
	
	/**
	 * Populates an HTML element with an interactive comparison between two
	 * maps.
	 * 
	 * @param container The HTML element to populate.
	 */
	function populateComparison(comparison) {
		var width = $(comparison).data("width");
		if (width === "full") {
			width = "100%";
		}
		$(comparison).css({
			position: "relative",
			width: width,
			height: $(comparison).data("height"),
		});
		
		var containers = $(comparison).find(".maplibre-map");
		containers.css({
			position: "absolute",
			top: 0,
			bottom: 0,
			width: "100%",
		});
		
		comparison.compare = new maplibregl.Compare(containers[0].map, containers[1].map, comparison);
	}
	
	var selectedId = null;
	
	function setClick (map, sourceId, layer) {
		// When a click event occurs on a feature in the places layer, open a popup at the
        // location of the feature, with description HTML from its properties.
        map.on('click', layer, function (e) {
            var coordinates;
            if (layer.endsWith("symbol"))
            	coordinates = e.features[0].geometry.coordinates.slice();
        	else
        		coordinates = e.lngLat.toArray();
            var popupText;
            if (e.features[0].properties.title !== undefined)
            	popupText = "<p style='font-size: 14px;'><b>" +
            	e.features[0].properties.title+"</b>" + 
            	(e.features[0].properties.description !== undefined ? "<br/>"+e.features[0].properties.description : "") +
            	"</p>";

            // Ensure that if the map is zoomed out such that multiple
            // copies of the feature are visible, the popup appears
            // over the copy being pointed to.
            while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
                coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
            }

            new maplibregl.Popup()
                .setLngLat(coordinates)
                .setHTML(popupText)
                .addTo(map);
                
            if (selectedId) {
                map.setFeatureState(
                    {source: sourceId, id: selectedId},
                    {clicked: false}
                );
            }
            selectedId = e.features[0].id;
            map.setFeatureState(
                {source: sourceId, id: selectedId},
                {clicked: true}
            );
        });

        // Change the cursor to a pointer when the mouse is over the places layer.
        map.on('mouseenter', layer, function () {
            map.getCanvas().style.cursor = 'pointer';
        });

        // Change it back to a pointer when it leaves.
        map.on('mouseleave', layer, function () {
            map.getCanvas().style.cursor = '';
        });
	}
	/**
	 * Calculates a bounding box for a given GeoJSON object
	 * 
	 * @param gj The GeoJSON object
	**/
	function geojsonBbox (gj) {
	  var coords, bbox;
	  if (!gj.hasOwnProperty('type')) return;
	  coords = getCoordinatesDump(gj);
	  bbox = [ Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,
	      Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY,];
	  return coords.reduce(function(prev,coord) {
	    return [
	      Math.min(coord[0], prev[0]),
	      Math.min(coord[1], prev[1]),
	      Math.max(coord[0], prev[2]),
	      Math.max(coord[1], prev[3])
	    ];
	  }, bbox);
	}
	
	function getCoordinatesDump(gj) {
	  var coords;
	  if (gj.type == 'Point') {
	    coords = [gj.coordinates];
	  } else if (gj.type == 'LineString' || gj.type == 'MultiPoint') {
	    coords = gj.coordinates;
	  } else if (gj.type == 'Polygon' || gj.type == 'MultiLineString') {
	    coords = gj.coordinates.reduce(function(dump,part) {
	      return dump.concat(part);
	    }, []);
	  } else if (gj.type == 'MultiPolygon') {
	    coords = gj.coordinates.reduce(function(dump,poly) {
	      return dump.concat(poly.reduce(function(points,part) {
	        return points.concat(part);
	      },[]));
	    },[]);
	  } else if (gj.type == 'Feature') {
	    coords =  getCoordinatesDump(gj.geometry);
	  } else if (gj.type == 'GeometryCollection') {
	    coords = gj.geometries.reduce(function(dump,g) {
	      return dump.concat(getCoordinatesDump(g));
	    },[]);
	  } else if (gj.type == 'FeatureCollection') {
	    coords = gj.features.reduce(function(dump,f) {
	      return dump.concat(getCoordinatesDump(f));
	    },[]);
	  }
	  return coords;
	}
});