MediaWiki:Gadget-maps.js
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.
*
* 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).
*
* 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.openstreetmap.org");
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>";
var ohmStyleBase = "https://www.openhistoricalmap.org/map-styles";
return {
americana: {
style: "https://americanamap.org/style.json",
beforeLoad: ["installShields"],
},
baremaps: {
style: "https://demo.baremaps.com/style.json",
},
carto: {
tileset: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
name: "OpenStreetMap Carto",
attribution: osmAttribution,
},
"cycle-map": {
tileset: "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=6170aad10dfd42a38d4d8c709a536f38",
name: "OpenCycleMap",
attribution: osmAttribution + ", map style <a href='https://www.thunderforest.com/'>Thunderforest</a>",
},
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: ohmStyleBase + "/main/main.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: ohmStyleBase + "/japanese_scroll/ohm-japanese-scroll-map.json",
afterLoad: ["filterByDate"],
attribution: ohmAttribution,
},
railway: {
style: ohmStyleBase + "/rail/rail.json",
afterLoad: ["filterByDate"],
attribution: ohmAttribution,
},
"transport-map": {
tileset: "https://tile.thunderforest.com/transport/{z}/{x}/{y}.png?apikey=6170aad10dfd42a38d4d8c709a536f38",
name: "Transport",
attribution: osmAttribution + ", map style <a href='https://www.thunderforest.com/'>Thunderforest</a>",
maxZoom: 21,
},
woodblock: {
style: ohmStyleBase + "/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 () {
containers.each(function () {
populateContainer(this);
});
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);
});
});
});
/**
* Populates an HTML element with an interactive map.
*
* @param container The HTML element to populate.
*/
function populateContainer(container) {
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.baremaps;
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);
// 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);
}
// Add markers specified as child elements.
$(container).find(".maplibre-map-marker").each(function () {
var markerLatitude = $(this).data("lat");
var markerLongitude = $(this).data("lon");
new maplibregl.Marker()
.setLngLat([markerLongitude, markerLatitude])
.addTo(map);
});
// Call any functions that need to run before the layer loads.
(layer.beforeLoad || []).forEach(function (functionName) {
var fn = layerFunctions[functionName];
if (fn) {
fn(container);
} else {
console.warn("%s layer requires unavailable function “%s”.", layer.name, functionName);
}
});
map.once("styledata", function (event) {
// Call any runtime styling functions that need to run after the layer loads.
(layer.afterLoad || []).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.
loadCommonsOverlay(container, layer);
});
}
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) {
const orderedRouteAttributes = ["network", "ref", "name", "color"];
var routeParser = {
parse: function (imageName) {
var lines = imageName.split("\n");
lines.shift(); // "shield"
var parsed = Object.fromEntries(
orderedRouteAttributes.map(function (a, i) {
return [a, lines[i]];
})
);
parsed.imageName = imageName;
return parsed;
},
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://americanamap.org/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");
if (!date) return;
var map = container.map;
if ("filterByDate" in map) {
map.filterByDate(date);
} else {
mw.loader.getScript(scriptServer + mw.util.getUrl("MediaWiki:Gadget-maplibre-gl-dates.js", { action: "raw", ctype: "text/javascript" })).then(function () {
map.filterByDate(date);
});
}
}
/**
* 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 paddingFunctions An object mapping sides of a box to arrays of functions that return padding for the side.
*/
function loadCommonsOverlay(container, layer, paddingFunctions) {
var titles = $(container).find(".maplibre-map-geojson").map(function () {
var fileName = $(this).data("commons");
if (fileName.endsWith(".map")) {
return "Data:" + fileName;
}
}).get();
var legacyFileName = $(container).data("commons");
if (legacyFileName && legacyFileName.endsWith(".map")) {
titles.push("Data:" + legacyFileName);
}
if (titles.length === 0) return;
$.getJSON("https://commons.wikimedia.org/w/api.php", {
"action": "query",
"format": "json",
"formatversion": 2,
"titles": titles.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) {
var query = data && data.query;
var pages = query && query.pages;
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 lastCameraOptions;
titles.forEach(function (title) {
var content = pageContentsByTitle[title];
if (!content) return;
var fileURL = "https://commons.wikimedia.org/wiki/" + mw.util.wikiUrlencode(title);
var attribution = "<a href='" + fileURL + "'>Wikimedia Commons</a>, " + mw.html.escape(content.license);
var sourceID = "Commons:" + title;
map.addSource(sourceID, {
type: "geojson",
attribution: attribution,
data: content.data,
});
map.addLayer({
id: "Commons:" + title + "/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",
],
},
}); // points go on top
map.addLayer({
id: "Commons:" + title + "/line",
type: "line",
source: sourceID,
layout: {
"line-cap": "round",
"line-join": "bevel",
},
paint: {
"line-color": [
"to-color",
["get", "stroke"],
"#555555",
],
"line-opacity": [
"coalesce",
["get", "stroke-opacity"],
1,
],
"line-width": [
"coalesce",
["get", "stroke-width"],
2,
],
},
}, layerAboveOverlays && layerAboveOverlays.id);
map.addLayer({
id: "Commons:" + title + "/fill",
type: "fill",
source: sourceID,
filter: [
"any",
["has", "fill"],
["has", "fill-opacity"],
],
paint: {
"fill-color": [
"to-color",
["get", "fill-color"],
"#555555",
],
"fill-opacity": [
"coalesce",
["get", "fill-opacity"],
0.5,
],
},
}, "Commons:" + title + "/line");
if (content.data.bbox) {
totalBbox.extend(content.data.bbox);
} else if (content.longitude && content.latitude && content.zoom) {
lastCameraOptions = {
center: [
content.longitude,
content.latitude,
],
zoom: content.zoom - 1,
bearing: 0,
pitch: 0,
};
}
});
if (!totalBbox.isEmpty()) {
var padding = 50; // approximately 10 + navigation control width + 10
map.fitBounds(totalBbox, {
animate: false,
padding: {
top: padding,
bottom: padding,
left: padding,
right: padding,
},
});
} else if (lastCameraOptions) {
map.jumpTo(lastCameraOptions);
}
});
}
/**
* 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);
}
});