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)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/*
MapsExtended.js
Author: Macklin
Provides a framework for extending Interactive Maps, adding some useful functions in the process
*/
(function() // <- Immediately invoked function expression to scope variables and functions to this script
{
/*
EventHandler
This is similar to the event handler model in C#. You can subscribe or "listen" to an event to be notified when it triggered.
Unlike addEventListener, these events don't need to be attached to elements in the DOM,
Usage:
var onSomething = new EventHandler();
onSomething.subscribe(function(args)
{
// This function is called when invoke is called
})
onSomething.invoke({ someString: "someValue" });
*/
function EventHandler()
{
this._listeners = [];
this._listenersOnce = [];
}
EventHandler.prototype =
{
subscribe: function(listener)
{
this._listeners.push(listener);
},
subscribeOnce: function(listener)
{
this._listenersOnce.push(listener);
},
unsubscribe: function(listener)
{
var index = this._listeners.indexOf(listener);
if (index != -1) this._listeners.splice(index, 1);
},
invoke: function(args)
{
if (this._listeners)
{
for (var i = 0; i < this._listeners.length; i++)
this._listeners[i](args);
}
if (this._listenersOnce)
{
for (var i = 0; i < this._listenersOnce.length; i++)
this._listenersOnce[i](args);
this._listenersOnce = [];
}
}
};
// Helper functions
// Loads a *single* module of name, returns a promise which resolves when the module is loaded, with a reference to the exported module
function loadModule(name)
{
var moduleName = mw.loader.getModuleNames().find(function(n){ return n === name || n.startsWith(name + "-"); });
return mw.loader.using(moduleName).then(function(require){ return require(moduleName); });
}
// Deep copies the value of all keys from source to target, in-place and recursively
// This is an additive process. If the key already exists on the target, it is unchanged.
// This way, the target is only ever added to, values are never modified or removed
// Arrays are not recursed, and are treated as a value unless recurseArrays is true
// The string array ignoreList may be used to skip copying specific keys (at any depth) from the source
function traverseCopyValues(source, target, ignoreList, recurseArrays)
{
// Return if the source is empty
if (!source) return target;
// Intialize target if it's not defined
if (!target)
{
if (Array.isArray(source))
target = [];
else
target = {};
}
if (typeof source != typeof target)
{
console.error("Type mismatch");
return target;
}
if (Array.isArray(source))
{
if (!recurseArrays) return target;
/*
if (Array.isArray(source) && source.length != target.length)
{
console.error("Length mismatch between source and target");
return;
}
*/
}
// This traverses both objects and arrays
for (var key in source)
{
if (!source.hasOwnProperty(key) || (ignoreList && ignoreList.includes(key))) continue;
// Replicate this value on the target if it doesn't exist
if (target[key] == undefined)
{
// If the source is an object or array, traverse into it and create new values
if (typeof source[key] === "object")
target[key] = traverseCopyValues(source[key], target[key], ignoreList, recurseArrays);
else
target[key] = source[key];
}
// If the value on the target does exist
else
{
// If the source is an object or array, traverse into it (non-modify)
if (key !== "e" && typeof source[key] === "object")
traverseCopyValues(source[key], target[key], ignoreList, recurseArrays);
}
}
return target;
}
// Find a specific value in an object using a path
function traverse(obj, path)
{
// Convert indexes to properties, and strip leading periods
path = path.replace("/\[(\w+)\]/g", ".$1").replace("/^\./", "");
var pathArray = path.split(".");
for (var i = 0; i < pathArray.length; i++)
{
var key = pathArray[i];
if (key in obj)
obj = obj[key];
else
return;
}
return obj;
}
// This function takes an array xs and a key (which can either be a property name or a function)
function groupByArray(xs, key)
{
// Reduce is used to call a function for each element in the array
return xs.reduce(function(rv, x)
{
// Here we're checking whether key is a function, and if it is, we're calling it with x as the argument
// Otherwise, we're assuming that key is a property name and we're accessing that property on x
var v = key instanceof Function ? key(x) : x[key];
// rv is the returned array of key-value pairs, that we're building up as we go
// Find the existing kvp in the results with a key property equal to v
var el = rv.find(function(r){ return r && r.key === v; });
// If we find an existing pair, we'll add x to its values array.
if (el) el.values.push(x);
// If we don't find one, create one with an array contain just the value
else rv.push({ key: v, values: [x] });
return rv;
}, []);
}
function isEmptyObject(obj)
{
for (var i in obj) return false;
return true;
}
var decodeHTMLEntities = (function()
{
// This prevents any overhead from creating the object each time
var element = document.createElement("div");
function decodeHTMLEntities(str)
{
if (str && typeof str === "string")
{
// Strip script/html tags
str = str.replace("/<script[^>]*>([\S\s]*?)<\/script>/gmi", "");
str = str.replace("/<\/?\w(?:[^\"'>]|\"[^\"]*\"|'[^']*')*>/gmi", "");
element.innerHTML = str;
str = element.textContent;
element.textContent = "";
}
return str;
}
return decodeHTMLEntities;
})();
function capitalizeFirstLetter(string)
{
return string.charAt(0).toUpperCase() + string.slice(1);
}
function generateRandomString(length)
{
var result = "";
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charsLength = chars.length;
var counter = 0;
while (counter < length)
{
result += chars.charAt(Math.floor(Math.random() * charsLength));
counter += 1;
}
return result;
}
function getIntersectionPoint(line1, line2)
{
var x1 = line1[0][0];
var y1 = line1[0][1];
var x2 = line1[1][0];
var y2 = line1[1][1];
var x3 = line2[0][0];
var y3 = line2[0][1];
var x4 = line2[1][0];
var y4 = line2[1][1];
var denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denominator === 0)
{
// Lines are parallel, there is no intersection
return null;
}
var ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator;
var ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denominator;
var intersectionX = x1 + ua * (x2 - x1);
var intersectionY = y1 + ua * (y2 - y1);
return [intersectionX, intersectionY];
}
function preventDefault(e){ e.preventDefault(); }
function stopPropagation(e){ e.stopPropagation(); }
function rejectPromise(){ return Promise.reject(); }
// This function returns a new function that can only be called once.
// When the new function is called for the first time, it will call the "fn"
// function with the given "context" and arguments and save the result.
// On subsequent calls, it will return the saved result without calling "fn" again.
function once(fn, context)
{
var result;
return function()
{
if (fn)
{
result = fn.apply(context || this, arguments);
fn = context = null;
}
return result;
};
}
// This function finds a rule with a specific selector. We do this to modify some built-in rules so they don't have to be redefined
function findCSSRule(selectorString, styleSheet)
{
// helper function searches through the document stylesheets looking for @selectorString
// will also recurse through sub-rules (such as rules inside media queries)
function recurse(node, selectorString)
{
if (node.cssRules)
{
for (var i = 0; i < node.cssRules.length; i++)
{
if (node.cssRules[i].selectorText == selectorString)
return node.cssRules[i];
if (node.cssRules[i].cssRules)
{
var rule = recurse(node.cssRules[i], selectorString);
if (rule) return rule;
}
}
}
return false;
}
// Find from a specific sheet
if (styleSheet)
{
var rule = recurse(styleSheet, selectorString);
if (rule) return rule;
}
// Find from all stylesheets in document
else
{
for (var i = 0; i < document.styleSheets.length; i++)
{
var sheet = document.styleSheets[i];
try
{
if (sheet.cssRules)
{
var rule = recurse(sheet, selectorString);
if (rule) return rule;
}
}
catch(e)
{
continue;
}
}
}
//console.error("Could not find a CSS rule with the selector \"" + selectorString + "\"");
return;
}
function getIndexOfCSSRule(cssRule, styleSheet)
{
if (!styleSheet.cssRules)
return -1;
for (var i = 0; i < styleSheet.cssRules.length; i++)
{
if (styleSheet.cssRules[i].selectorText == cssRule.selectorText)
return i;
}
return -1;
}
function deleteCSSRule(selector, styleSheet)
{
var rule = findCSSRule(selector, styleSheet);
if (rule != null)
{
var ruleIndex = getIndexOfCSSRule(rule, rule.parentStyleSheet);
rule.parentStyleSheet.deleteRule(ruleIndex);
}
}
// Modifies the first CSS rule found with a <selector> changing it to <newSelector>
function changeCSSRuleSelector(selector, newSelector, styleSheet)
{
var rule = findCSSRule(selector, styleSheet);
if (rule != null) rule.selectorText = newSelector;
return rule;
}
function appendCSSRuleSelector(selector, additionalSelector, styleSheet)
{
var rule = findCSSRule(selector, styleSheet);
if (rule != null) rule.selectorText = ", " + additionalSelector;
return rule;
}
// Modifies a CSS rule with a <selector>, setting it's new style block declaration entirely
function changeCSSRuleText(selector, cssText, styleSheet)
{
var rule = findCSSRule(selector, styleSheet);
if (rule != null) rule.style.cssText = cssText;
return rule;
}
// Modifies a CSS rule with a <selector>, setting the value of a specific property
function changeCSSRuleStyle(selector, property, value, styleSheet)
{
var rule = findCSSRule(selector, styleSheet);
if (rule != null) rule.style[property] = value;
return rule;
}
// Create a WDS checkbox
// id: The ID to assign to the input
// label: A string or HTML element to append to the label
// Returns an object containing { root, input, label }
function createWdsCheckbox(id, label)
{
var checkboxRoot = document.createElement("div");
checkboxRoot.className = "wds-checkbox";
var checkboxInput = document.createElement("input");
checkboxInput.setAttribute("type", "checkbox");
checkboxInput.setAttribute("name", id);
checkboxInput.setAttribute("id", id);
var checkboxLabel = document.createElement("label");
checkboxLabel.setAttribute("for", id);
if (label)
{
if (typeof label == "string")
checkboxLabel.textContent = label;
else if (label instanceof Node)
checkboxLabel.append(label);
}
checkboxRoot.append(checkboxInput, checkboxLabel);
checkboxInput.checked = true;
return { root: checkboxRoot, input: checkboxInput, label: checkboxLabel };
}
// MapsExtended
function mx()
{
var urlParams = new URLSearchParams(window.location.search);
var isDebug = urlParams.get("debugMapsExtended") == "1" || localStorage.getItem("debugMapsExtended") == "1";
var isDisabled = urlParams.get("disableMapsExtended") == "1" || localStorage.getItem("disableMapsExtended") == "1";
if (isDebug)
{
var log = console.log.bind(window.console);
var error = console.error.bind(window.console);
}
else
{
var log = function(){};
var error = function(){};
}
if (isDisabled)
return;
console.log("Loaded MapsExtended.js!" + (isDebug ? " (DEBUG MODE)" : ""));
// Do not run on pages without interactive maps
var test = document.querySelector(".interactive-maps-container");
if (test == undefined)
{
log("No interactive maps found on page. document.readyState is \"" + document.readyState + "\"");
return;
}
/*
ExtendedMap
This prototype stores everything to do with the map in context of MapExtensions
It uses the original definitions from the JSON (and keeps the original objects intact)
Unfortunately while MediaWiki can use ES6, user-created scripts are stuck with using ES5 syntax (not types!) due to the ancient syntax parser
*/
// Contructor function, takes the root Element of the map (a child of the element with
// the class interactive-maps-container, with an unique id like "interactive-map-xxxxxxxx")
function ExtendedMap(root)
{
this.markerCompareFunctions();
this.creationTime = performance.now();
// This ID is contained in the mw.config, and is the page ID of the Map page
// It is unique to a map definition, but not an instance if there are multiple on the page
this.id;
// We generate this at random, and the map root element is assigned it as an ID
// It is unique to each instance
this.instanceId = root.id;
// Map ID is unique to the map definition on this page, but not unique to each instance on the page
// It is the class name "interactive-map-xxxxxxxxxxxxxxxx", and has the ID equivalency of the name of the map
this.mapId = root.className;
// This element is permanently part of the parser output, as it is transcluded from the Map: page
this.rootElement = root;
this.openPopups = [];
// This element is the container in which Leaflet operates
// It is created by Interactive Maps after the page is loaded, and will always be present when
// this script is first fired (we don't need to check for its existence)
this.elements = {};
this.elements.rootElement = root;
this.elements.rootElementParent = root.parentElement;
this.elements.rootElementChild = root.querySelector(".interactive-maps");
this.elements.mapModuleContainer = root.querySelector(".Map-module_container__dn27-");
// To ensure that Interactive Maps doesn't fullscreen, override the fullscreen API function for the element
var rec = this.elements.rootElementChild;
if (rec) rec.requestFullscreen = rec.msRequestFullscreen = rec.mozRequestFullscreen = rec.webkitRequestFullscreen = rejectPromise;
// Copy each of the properties from the already-existing deserialization of the JSON into ExtendedMap
// We could use Object.assign(this, map) (a shallow copy), then any objects are shared between the original map and the extended map
// This isn't ideal, we want to preserve the original for future use (and use by other scripts), so we must do a deep copy
// jQuery's extend is the fastest deep copy we have on hand
jQuery.extend(true, this, mw.config.get("interactiveMaps")[this.mapId]);
this.mapPageId = this.id;
// Lookup tables (iterating interactiveMap.markers is slow when the map has a lot of markers)
// markerLookup may contain markers that do not yet have an associated element!
this.markerLookup = new Map();
this.categoryLookup = new Map();
// Unscaled size / bounds
this.size = { width: Math.abs(this.bounds[1][0] - this.bounds[0][0]),
height: Math.abs(this.bounds[1][1] - this.bounds[0][1]) };
var hasGlobalConfig = mapsExtended.isGlobalConfigLoaded;
var hasLocalConfig = mapsExtended.localConfigs[this.name] != undefined && !isEmptyObject(mapsExtended.localConfigs[this.name]);
var hasEmbedConfig = mapsExtended.embedConfigs[this.instanceId] != undefined && !isEmptyObject(mapsExtended.embedConfigs[this.instanceId]);
// Check whether a local config is present
if (hasLocalConfig)
{
var localConfig = mapsExtended.localConfigs[this.name];
}
// Check whether an embedded config is present
if (hasEmbedConfig)
{
var embedConfig = mapsExtended.embedConfigs[this.instanceId];
}
// Use the config based on precedence embed -> local -> global -> default
this.config = hasEmbedConfig ? embedConfig :
hasLocalConfig ? localConfig :
hasGlobalConfig ? mapsExtended.globalConfig :
mapsExtended.defaultConfig;
// Short circuit if the config says this map should be disabled
if (this.config.disabled == true)
return;
this.events =
{
// Fired when a category for this map is toggled. Contains the args: map, category, value
onCategoryToggled: new EventHandler(),
// Fired when a popup is created for the first time. Contains the args: map, marker, popup
onPopupCreated: new EventHandler(),
// Fired when a popup in this map is shown. Contains the args: map, marker, isNew (bool)
onPopupShown: new EventHandler(),
// Fired when a popup for this map is hidden. Contains the args: map, marker
onPopupHidden: new EventHandler(),
// Fired when a marker appears for the first time on this map. Contains the args: map, marker
onMarkerShown: new EventHandler(),
// Fired when a marker is hovered. Contains the args: map, marker, value, event
onMarkerHovered: new EventHandler(),
// Fired when a marker is clicked on this map. Contains the args: map, marker, event
onMarkerClicked: new EventHandler(),
// Fired when the map appears on the page or is otherwise initialized. Contains the args: map, isNew.
// This may be a refresh of the existing map (which occurs when the map is resized), in which case isNew is false.
// A refreshed map should be treated like a new map - any references to the old map and its markers will be invalid and should be discarded
onMapInit: new EventHandler(),
// Fired when the map disappears from the page, or is otherwise deinitialized before it is refreshed
onMapDeinit: new EventHandler(),
// Fired when the map is clicked, before any "click" events are fired. Contains the args:
// map (the map that was clicked)
// isDragging (to detect whether the click was the end of a drag),
// isMarker (to detect whether the click was on a marker)
// e (the event, note that target will not always be the base layer)
onMapClicked: new EventHandler(),
// Fired when the user started or ended dragging a map
onMapDragged: new EventHandler(),
// Fired when the user paused or resumed an in-progress drag, by not moving their mouse
onMapDraggedMove: new EventHandler(),
// Zoom event triggered by the attributeObserver, contains the args
// map (the map that was zoomed)
// value (true if starting a zoom, false if ending a zoom)
// type (the type of zoom this is, typically how it was initiated. Values are button, wheel, box, key)
// center (the center position of the zoom in viewport pixel units)
// scaleDelta (the delta scale factor that the map is zooming to)
// scale (the new scale of the map after the zoom)
onMapZoomed: new EventHandler(),
// Pan event triggered by the attributeObserver
onMapPanned: new EventHandler(),
// Fired when the map goes fullscreen
onMapFullscreen: new EventHandler(),
// Fired when the leaflet container element is resized.
onMapResized: new EventHandler(),
// Fired when the map-container element is resized
onMapModuleResized: new EventHandler(),
// Triggered after a search has been performed. Contains the args: map, search
onSearchPerformed: new EventHandler()
};
// Hook ExtendedMap events into MapsExtended events, effectively forwarding all events to the mapsExtended events object
Object.keys(this.events).forEach(function(eventKey)
{
mapsExtended.events = mapsExtended.events || {};
// Create EventHandler for this event if it doesn't exist on the mapsExtended object
if (!mapsExtended.events.hasOwnProperty(eventKey))
mapsExtended.events[eventKey] = new EventHandler();
// Get reference to the source event on this map, and the targetEvent on the mapsExtended object
var sourceEvent = this.events[eventKey];
var targetEvent = mapsExtended.events[eventKey];
// Add a listener to the source event, which invokes the target event with the same args
if (targetEvent && targetEvent instanceof EventHandler &&
sourceEvent && sourceEvent instanceof EventHandler)
sourceEvent.subscribe(function(args){ targetEvent.invoke(args); });
}.bind(this));
// Bind certain prototype functions so that they're unique to each instance
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
// Infer iconAnchor from iconPosition
if (this.config.iconPosition != undefined)
{
this.config.iconAnchor = "";
if (this.config.iconPosition.startsWith("top")) this.config.iconAnchor += "bottom";
if (this.config.iconPosition.startsWith("center")) this.config.iconAnchor += "center";
if (this.config.iconPosition.startsWith("bottom")) this.config.iconAnchor += "top";
if (this.config.iconPosition.endsWith("left")) this.config.iconAnchor += "-right";
if (this.config.iconPosition.endsWith("center")) this.config.iconAnchor += "";
if (this.config.iconPosition.endsWith("right")) this.config.iconAnchor += "-left";
}
// Process category definitions
for (var i = 0; i < this.categories.length; i++)
this.categories[i] = new ExtendedCategory(this, this.categories[i]);
// Process marker definitions
for (var i = 0; i < this.markers.length; i++)
this.markers[i] = new ExtendedMarker(this, this.markers[i]);
// Add category from transclusionOptions to visibleCategories
this.config.visibleCategories = this.config.visibleCategories || [];
// When marker attribute is present in embed, inherantly disable all categories
// <interactive-map marker="xyz">
if (this.transclusionOptions.marker)
{
var visibleMarker = this.markerLookup.get(this.transclusionOptions.marker);
if (visibleMarker) this.config.visibleCategories.push(visibleMarker.categoryId);
visibleMarker.category.setIndeterminateMarkers([ visibleMarker ]);
}
// When category-name or category-id attribute is present, disable
// <interactive-map category-id="xyz">
if (this.transclusionOptions.categoryId)
{
var visibleCategory = this.categoryLookup.get(this.transclusionOptions.categoryId);
if (visibleCategory) this.config.visibleCategories.push(visibleCategory.id);
}
// <interactive-map category-name="xyz">
else if (this.transclusionOptions.categoryName)
{
var visibleCategory = this.categories.find(function(c){ return c.name == this.transclusionOptions.categoryName; }.bind(this));
if (visibleCategory) this.config.visibleCategories.push(visibleCategory.id);
}
// Remove empty categories (categories that contain no markers)
for (var i = 0; i < this.categories.length; i++)
{
var category = this.categories[i];
// Determine whether the category should be disabled by default
// A category is disabled if it has the "disabled" hint, isn't present in enabledCategories, or is present in disabledCategories
category.startDisabled = category.hints.includes("disabled") ||
(this.config.enabledCategories && this.config.enabledCategories.length > 0 && !this.config.enabledCategories.includes(category.id)) ||
(this.config.disabledCategories && this.config.disabledCategories.length > 0 && this.config.disabledCategories.includes(category.id));
// Determine whether the category should be hidden by default
// A category is hidden if it has the "hidden" hint, isn't present in visibleCategories, or is present in hiddenCategories
category.startHidden = category.hints.includes("hidden") ||
(this.config.visibleCategories && this.config.visibleCategories.length > 0 && !this.config.visibleCategories.includes(category.id)) ||
(this.config.hiddenCategories && this.config.hiddenCategories.length > 0 && this.config.hiddenCategories.includes(category.id));
if (category.markers.length == 0)
{
log("Removed category \"" + category.name + "\" (" + category.id + ") because it contained no markers");
// Remove from lookup
this.categoryLookup.delete(category.id);
// Remove elements from DOM
var filterInputElement = document.getElementById(this.mapId + "__checkbox-" + category.id);
if (filterInputElement)
{
var filterElement = filterInputElement.closest(".interactive-maps__filter");
if (filterElement) filterElement.remove();
}
// Delete instance
delete this.categories[i];
// Splice loop
this.categories.splice(i, 1);
i--;
}
}
// Sort marker definitions, but instead of rearranging the original array, store the index of the sorted marker
var sortedMarkers = this.markers.slice().sort(this.markerCompareFunction(this.config.sortMarkers));
for (var i = 0; i < sortedMarkers.length; i++) sortedMarkers[i].order = i;
// Correct the coordinateOrder
// It's very important we do this AFTER processing the marker definitions,
// so that they know what coordinateOrder and origin to expect
if (this.coordinateOrder == "yx")
{
this.coordinateOrder = "xy";
// Swap x and y of mapBounds
var y0 = this.bounds[0][0];
var y1 = this.bounds[1][0];
this.bounds[0][0] = this.bounds[0][1];
this.bounds[0][1] = y0;
this.bounds[1][0] = this.bounds[1][1];
this.bounds[1][1] = y1;
}
// Correct the origin to always use top-left
// Don't need to correct mapBounds since it will be the same anyway
if (this.origin == "bottom-left")
this.origin = "top-left";
// Set up a MutationObserver which will observe all changes from the root interactive-map-xxxxxxx
// This is used in the rare occasion that this constructor is called before .Map-module_container__dn27- is created
this.rootObserved = function(mutationList, observer)
{
// Stop observing root if the map has already been initialized
if (this.initialized == true)
{
observer.disconnect();
return;
}
// If there were any added or removed nodes, check whether the map is fully created now
if (mutationList.some(function(mr) { return mr.addedNodes.length > 0 || mr.removedNodes.length > 0; }) && this.isMapCreated())
{
// Resolve waitForPresence
if (this._waitForPresenceResolve)
{
this._waitForPresenceResolve();
this._waitForPresenceResolve = undefined;
}
// Stop observing
observer.disconnect();
// Init
this.init();
}
}.bind(this);
// Set up a MutationObserver which will look at the parent of the leaflet-container Element for node removals
// This is important because the leaflet map will be completely recreated if the map is ever hidden and shown again
this.selfObserved = function(mutationList, observer)
{
for (var i = 0; i < mutationList.length; i++)
{
var mutationRecord = mutationList[i];
// Map was removed, invalidating any elements
if (this.initialized && mutationRecord.removedNodes.length > 0 &&
mutationRecord.removedNodes[0] == this.elements.leafletContainer)
{
this.deinit();
}
// Map was added, connect to the elements
if (!this.initialized && mutationRecord.addedNodes.length > 0 &&
mutationRecord.addedNodes[0].classList.contains("leaflet-container"))
{
if (this._waitForPresenceResolve)
{
this._waitForPresenceResolve();
this._waitForPresenceResolve = undefined;
}
this.init();
}
}
}.bind(this);
var attributeObserverConfig =
[
/*
{
targetClass: "leaflet-container",
toggledClass: "leaflet-drag-target",
booleanName: "isDragging",
eventName: "onMapDragged"
},
*/
{
targetClass: "leaflet-map-pane",
toggledClass: "leaflet-zoom-anim",
booleanName: "isZooming",
eventName: "onMapZoomed"
},
{
targetClass: "leaflet-map-pane",
toggledClass: "leaflet-pan-anim",
booleanName: "isPanning",
eventName: "onMapPanned"
}
];
// This function is used to observe specific leaflet elements for attribute changes which indicate the map is being zoomed or dragged
this.leafletAttributeObserved = function(mutationList, observer)
{
for (var i = 0; i < mutationList.length; i++)
{
var mutationRecord = mutationList[i];
if (mutationRecord.type != "attributes" || mutationRecord.attributeName != "class") continue;
for (var j = 0; j < attributeObserverConfig.length; j++)
{
// Using a config just saves us having to repeat the same ol' steps for every attribute
var config = attributeObserverConfig[j];
if (mutationRecord.target.classList.contains(config.targetClass))
{
var value = mutationRecord.target.classList.contains(config.toggledClass);
// Only fire if the value changes
if (this[config.booleanName] != value)
{
log(config.booleanName + " - " + value);
this[config.booleanName] = value;
if (config.eventName == "onMapZoomed")
this.onMapZoomed(value);
else
this.events[config.eventName].invoke({ map: this, value: value });
}
}
}
}
}.bind(this);
// Create a MutationObserver function to know when marker elements are added
this.markerObserved = function (mutationList, observer)
{
var addedMarkers = 0;
var removedMarkers = 0;
var matched = 0;
for (var i = 0; i < mutationList.length; i++)
{
if (mutationList[i].type != "childList") continue;
if (mutationList[i].removedNodes.length > 0 &&
mutationList[i].removedNodes[0].classList.contains("leaflet-marker-icon") &&
!mutationList[i].removedNodes[0].classList.contains("marker-cluster"))
{
removedMarkers++;
}
// Check that it was indeed a marker that was added
if (mutationList[i].addedNodes.length > 0 &&
mutationList[i].addedNodes[0].classList.contains("leaflet-marker-icon") &&
!mutationList[i].addedNodes[0].classList.contains("marker-cluster"))
{
var markerElement = mutationList[i].addedNodes[0];
var markerJson = null;
// Check if the marker has not yet been associated, by assuming that ids are always present on associated marker elements
if (markerElement.id == false)
{
addedMarkers++;
// Try to match the newly-added element with a marker in the JSON definition
for (var j = 0; j < this.markers.length; j++)
{
if (this.compareMarkerAndJsonElement(markerElement, this.markers[j]))
{
markerJson = this.markers[j];
break;
}
}
// If a match was found...
if (markerJson)
{
matched++;
markerJson.init(markerElement);
this.events.onMarkerShown.invoke({ map: this, marker: markerJson });
}
// Otherwise error out
else
{
var unscaledPos = ExtendedMarker.prototype.getUnscaledMarkerPosition(markerElement);
log("Could not associate marker element at position " + unscaledPos + " with a definition in the JSON.");
}
}
}
}
if (addedMarkers > 0)
log(addedMarkers + " markers appeared, matched " + matched + " to markers in the JSON definition");
if (removedMarkers > 0)
log(removedMarkers + " markers removed");
}.bind(this);
// Create a MutationObserver function to know when a popup is created/shown (and destroyed/hidden)
this.popupObserved = function (mutationList, observer)
{
if (mutationList[0].type != "childList")
return;
// Nodes removed
if (mutationList[0].removedNodes.length > 0)
{
var removedPopupElement = mutationList[0].removedNodes[0];
if (removedPopupElement.popup)
{
var removedPopup = removedPopupElement.popup;
var removedPopupMarker = removedPopup.marker;
var removedPopupMarkerId = removedPopupElement.id;
}
else if (removedPopupElement && removedPopupElement instanceof Element && removedPopupElement.id.startsWith("popup_"))
{
var removedPopupMarkerId = mutationList[0].removedNodes[0].id.replace("popup_", "");
var removedPopupMarker = mutationList[0].removedNodes[0].marker || this.markerLookup.get(removedPopupMarkerId);
var removedPopup = removedPopupMarker.popup;
}
else
{
// Popup wasn't associated to a marker before it was removed, likely a custom popup
return;
}
log("Popup removed: " + removedPopupMarkerId);
removedPopup.events.onPopupHidden.invoke();
this.events.onPopupHidden.invoke({ map: this, marker: removedPopupMarker });
}
// Nodes added
if (mutationList[0].addedNodes.length > 0 && mutationList[0].addedNodes[0] instanceof Element)
{
var popupElement = mutationList[0].addedNodes[0];
var marker = null;
// Popup content is created on-demand, on the first time the popup is shown.
// Check to see whether the popup content hasn't been created, and if so skip this
// (another mutation will be observed as Interactive Maps creates the content)
// Return on addition of root popup element without content
if (popupElement.classList.contains("leaflet-popup") && !popupElement.querySelector(".MarkerPopup-module_content__9zoQq"))
return;
// Rescope to root popup on addition of content in subtree
else if (!popupElement.classList.contains("leaflet-popup"))
popupElement = popupElement.closest(".leaflet-popup");
// If we can't get an element, return
if (!popupElement) return;
// If the last marker clicked doesn't have an associated marker object (i.e. it didn't have an ID), try and associate it now
if (!this.lastMarkerClicked && !this.lastMarkerHovered)
{
var markerElement = this.lastMarkerElementClicked;
var markerPos = this.getElementTransformPos(popupElement);
// Try to find the marker definition in the JSON file that matches the marker element in the DOM,
// using the content of the popup that was just shown as the basis of comparison
var elements = ExtendedPopup.prototype.fetchPopupElements(popupElement);
if (elements.popupTitle)
var popupTitle = elements.popupTitle.textContent.trim();
if (elements.popupDescription)
var popupDesc = elements.popupDescription.textContent.trim();
if (elements.popupLinkLabel)
{
var wikiPath = mw.config.get("wgServer") + mw.config.get("wgArticlePath").replace("$1", "");
var popupLinkUrl = elements.popupLinkLabel.getAttribute("href").replace(wikiPath, "");
var popupLinkLabel = elements.popupLinkLabel.textContent.trim();
}
else
{
var popupLinkUrl = "";
var popupLinkLabel = "";
}
marker = this.markers.find(function(m)
{
// Rather than matching for true, take the path of invalidating options one at a time until it HAS to be the same marker
// Skip if the marker already has an associated element
if ((m.markerElement) ||
(m.popup.title && popupTitle != m.popup.title) ||
(m.popup.link.url && popupLinkUrl != m.popup.link.url) ||
(m.popup.link.label && popupLinkLabel == m.popup.link.label))
return false;
return true;
});
if (marker)
{
marker.init(this.lastMarkerElementClicked);
log("Associated clicked marker with " + marker.id + " using its popup");
}
else
{
log("Could not associate clicked marker!");
return;
}
}
else
{
if (this.config.openPopupsOnHover == true)
marker = this.lastMarkerHovered;
else
marker = this.lastMarkerClicked || this.lastMarkerHovered;
}
if (marker)
{
// Check if this is a "new" popup, and if so, cache it
// Leaflet doesn't recreate popups, and will remove the element from the DOM once it disappears (but cache it for later)
// The exception to this rule is when a marker is hidden (for example when the category is unchecked), in which case a new popup will be created
// Deinit popup if the marker already has an associated popup (and if it's not this one)
if (marker.popup.initialized && !marker.popup.isCustomPopup && marker.popup.elements && marker.popup.elements.popupElement != popupElement)
marker.popup.deinitPopup();
// Init popup if the marker doesn't already have an associated popup
if (!marker.popup.initialized && !marker.popup.elements)
{
marker.popup.initPopup(popupElement);
// Re-grab the popupElement reference since it may have changed
popupElement = marker.popup.elements.popupElement;
}
log("Popup shown: " + popupElement.id);
if (marker.popup._waitForPresenceResolve)
{
marker.popup._waitForPresenceResolve(marker);
marker.popup._waitForPresenceResolve = undefined;
}
// Fire onPopupShown
marker.popup.events.onPopupShown.invoke();
this.events.onPopupShown.invoke({ map: this, marker: marker, popup: marker.popup });
}
}
}.bind(this);
this.resizeObserved = OO.ui.throttle(function(e)
{
for (var i = 0; i < e.length; i++)
{
var entry = e[i];
if (entry.target == this.elements.leafletContainer)
{
this.events.onMapResized.invoke(
{
map: this,
rect: entry.contentRect,
lastRect: this.events.onMapResized.lastRect || entry.contentRect
});
this.events.onMapResized.lastRect = entry.contentRect;
}
else if (entry.target == this.elements.mapModuleContainer)
{
this.events.onMapModuleResized.invoke(
{
map: this,
rect: entry.contentRect,
lastRect: this.events.onMapModuleResized.lastRect || entry.contentRect
});
this.events.onMapModuleResized.lastRect = entry.contentRect;
}
}
}.bind(this), 250);
this.rootObserver = new MutationObserver(this.rootObserved);
this.selfObserver = new MutationObserver(this.selfObserved);
this.leafletAttributeObserver = new MutationObserver(this.leafletAttributeObserved);
this.markerObserver = new MutationObserver(this.markerObserved);
this.popupObserver = new MutationObserver(this.popupObserved);
this.resizeObserver = new ResizeObserver(this.resizeObserved);
// Finally, connect to the DOM
// At this point Interactive Maps may have created the container (underneath the interactive-map-xxxxxxx stub),
// but Leaflet may not have actually created the map.
// If we decide to initialize the map now without checking, it may not have any marker elements to connect to
if (this.isMapCreated() == false)
{
// Leaflet not finished initializing
console.log(this.instanceId + " (" + this.name + ") - Leaflet not yet initialized for map. Init will be deferred");
this.rootObserver.observe(this.elements.rootElement, { subtree: true, childList: true });
}
else
{
// Leaflet finished initializing
this.init(root);
}
}
ExtendedMap.prototype =
{
// Init associates the map to the DOM.
// It should be passed the root element with the class "interactive-map-xxxxxxxx",
// though it will use the rootElement in this.element.rootElement if not
init: function(root)
{
if (this.initialized)
{
log(this.instanceId + " (" + this.name + ") - Tried to initialize map when it was already initialized");
return;
}
var isNew = !this.initializedOnce;
if (!root) root = this.elements != null ? this.elements.rootElement : null;
if (!root) console.error("ExtendedMap.init did not find a reference to the root interactive-map-xxxxxxxx element!");
// References to Leaflet elements in the DOM
this.elements = this.elements || {};
this.elements.rootElement = root;
this.elements.rootElementChild = root.querySelector(".interactive-maps");
this.elements.mapModuleContainer = root.querySelector(".Map-module_container__dn27-");
this.elements.interactiveMapsContainer = root.closest(".interactive-maps-container");
// Filters/category elements
this.elements.filtersList = root.querySelector(".interactive-maps__filters-list");
this.elements.filtersDropdown = this.elements.filtersList.querySelector(".interactive-maps__filters-dropdown");
this.elements.filtersDropdownContent = this.elements.filtersDropdown.querySelector(".wds-dropdown__content");
this.elements.filtersDropdownButton = this.elements.filtersDropdown.querySelector(".interactive-maps__filters-dropdown-button");
this.elements.filtersDropdownList = this.elements.filtersDropdown.querySelector(".interactive-maps__filters-dropdown-list");
this.elements.filterAllCheckboxInput = this.elements.filtersDropdownList.querySelector(".interactive-maps__filter-all input");
this.elements.filterElements = this.elements.filtersDropdownList.querySelectorAll(".interactive-maps__filter");
// Leaflet-specific elements
this.elements.leafletContainer = root.querySelector(".leaflet-container");
this.elements.leafletMapPane = this.elements.leafletContainer.querySelector(".leaflet-map-pane");
this.elements.leafletOverlayPane = this.elements.leafletMapPane.querySelector(".leaflet-overlay-pane");
this.elements.leafletMarkerPane = this.elements.leafletMapPane.querySelector(".leaflet-marker-pane");
this.elements.leafletTooltipPane = this.elements.leafletMapPane.querySelector(".leaflet-tooltip-pane");
this.elements.leafletPopupPane = this.elements.leafletMapPane.querySelector(".leaflet-popup-pane");
this.elements.leafletProxy = this.elements.leafletMapPane.querySelector(".leaflet-proxy");
this.elements.leafletBaseImageLayer = this.elements.leafletOverlayPane.querySelector(".leaflet-image-layer");
this.elements.leafletControlContainer = this.elements.leafletContainer.querySelector(".leaflet-control-container");
this.elements.leafletControlContainerTopLeft= this.elements.leafletControlContainer.querySelector(".leaflet-top.leaflet-left");
this.elements.leafletControlContainerTopRight= this.elements.leafletControlContainer.querySelector(".leaflet-top.leaflet-right");
this.elements.leafletControlContainerBottomRight = this.elements.leafletControlContainer.querySelector(".leaflet-bottom.leaflet-right");
this.elements.leafletControlContainerBottomLeft = this.elements.leafletControlContainer.querySelector(".leaflet-bottom.leaflet-left");
// Leaflet control elements
this.elements.editButton = this.elements.leafletControlContainer.querySelector(".interactive-maps__edit-control");
this.elements.zoomButton = this.elements.leafletControlContainer.querySelector(".leaflet-control-zoom");
this.elements.zoomInButton = this.elements.leafletControlContainer.querySelector(".leaflet-control-zoom-in");
this.elements.zoomOutButton = this.elements.leafletControlContainer.querySelector(".leaflet-control-zoom-out");
this.elements.fullscreenButton = this.elements.leafletControlContainer.querySelector(".leaflet-control:has(.map-fullscreen-control)");
// Get the initial zoomScale
this.zoomScale = this.getElementTransformScale(this.elements.leafletProxy, true) * 2
// Things to do only once (pre-match)
if (isNew)
{
this.selfObserver.observe(this.elements.mapModuleContainer, { childList: true });
// Associate category/filter elements with the categories in the JSON
// We only need to do this once because it's not part of Leaflet and will never be destroyed
for (var i = 0; i < this.elements.filterElements.length; i++)
{
var filterElement = this.elements.filterElements[i]
var categoryId = filterElement.querySelector("input").getAttribute("value");
var category = this.categories.find(function(x) { return x.id == categoryId; });
// Initialize the category with the filter element
if (category) category.init(filterElement);
}
this.initCursorDebug();
this.initMinimalLayout();
// Create fullscreen button
this.initFullscreen();
// Create filters
this.initFilters();
// Create category groups
this.initCategoryGroups();
// Create search dropdown
this.initSearch();
// Create sidebar
this.initSidebar();
// Rearrange controls
this.initControls();
// Set up events for hover popups
this.initOpenPopupsOnHover();
// Set up tooltips
this.initTooltips();
// Set up canvas
//this.initThreadedCanvas();
//this.initCanvas();
// Set up collectibles
this.initCollectibles();
// Set up zoom layers
this.initZoomLayers();
}
else
{
// To ensure that Interactive Maps doesn't fullscreen, override the fullscreen API function for the element
var rec = this.elements.rootElementChild;
if (rec) rec.requestFullscreen = rec.msRequestFullscreen = rec.mozRequestFullscreen = rec.webkitRequestFullscreen = rejectPromise;
if (this.elements.fullscreenButton)
this.elements.fullscreenButton.remove();
// Changing the size of the leafet container causes it to be remade (and the fullscreen button control destroyed)
// Re-add the fullscreen button to the DOM
if (this.config.enableFullscreen == true && this.controlAssociations["fullscreen"].isPresent)
this.elements.leafletControlContainerBottomRight.prepend(this.elements.fullscreenControl);
this.initControls();
}
// Ensure the fade-anim class is set on the container (this was removed at some point)
this.elements.leafletContainer.classList.add("leaflet-fade-anim");
this.initMapEvents();
var skipIndexAssociation = false;
var skipAssociationForCategories = [];
// List of all marker elements
var markerElements = this.elements.leafletMarkerPane.querySelectorAll(".leaflet-marker-icon:not(.marker-cluster)");
for (var i = 0; i < this.markers.length; i++)
{
var marker = this.markers[i];
var markerElement = null;
// Check to see if the category of the marker is hidden, if so the marker won't be in the DOM
// and we shouldn't bother trying to associate the category
/*
if (marker.category && marker.category.visible == false)
{
if (!skipAssociationForCategories.includes(marker.category.id))
{
skipAssociationForCategories.push(marker.category.id);
log("Skipping association of markers with the category \"" + marker.category.id + "\", as they are currently filtered");
}
continue;
}
*/
// Associate markers in the JSON definition with the marker elements in the DOM
// Index-based matching
// If all markers are present, we can just pick the element at the same position/index as the element
// This is the most bulletproof method, and works most of the time, hence why it is used here.
// The Leaflet-created marker elements don't always have identifying information that can be used
// to associate them with markers in the JSON. However they are created in the same order they
// appear in the JSON, and we can use this to associate the two (assuming all are present)
// Without any extensions, the amount of elements will always match the definition, since there
// is no way to disable certain categories by default. I assume there will be a way to do so in
// the future, so there's no harm writing some preemptive code for it
if (markerElements.length == this.markers.length && !skipIndexAssociation)
{
// Even if the amount of elements and definitions is equal, if some categories are disabled by
// default, when they are re-enabled, the new markers will be added to the bottom of the DOM,
// and therefore will be out of order. Although we don't really need to (see the last paragraph
// above), here we test for this just to make sure:
// Properly test to make sure - Compare based on position
// Even though this is what tryAssociateMarkerJson does anyway, by using the index
// we save having to iterate every marker definition to test them one-by-one
if (this.compareMarkerAndJsonElement(markerElements[i], marker) == true)
{
markerElement = markerElements[i];
}
// If *any* of the elements tested negative, we can't take any chances on matching this way
else
{
log("Could not confirm index association between the marker " + marker.id + " and the element at index " + i);
log("All markers are present in the DOM, but they appear to be out of order. Falling back to position matching.");
// Abort and set a flag to always try to associate programmatically
skipIndexAssociation = true;
}
}
// More complex matching
// Otherwise it's a bit tricker, as we try to associate using their id (may not always be present), position, and colour
// This could also mean some markers will not have a markerElement attached!
if (!markerElement)
{
// Skip if the marker already has an associated element
if (marker.initialized || marker.markerElement)
continue;
// Try to find the marker element in the DOM that matches this marker definition in the JSON file.
// If a marker element was found, it is returned
for (var j = 0; j < markerElements.length; j++)
{
if (this.compareMarkerAndJsonElement(markerElements[j], marker))
{
markerElement = markerElements[j];
break;
}
}
}
// If a marker element was found...
if (markerElement)
marker.init(markerElement);
else
{
// Couldn't associate (will attempt popup contents matching later)
log("Could not associate marker definition " + marker.id + " with an element in the DOM.");
}
}
// After matching
if (!isNew)
{
// Because we lost the marker references, we need to re-show and re-highlight the markers in the search results
// Could just do the marker icon-centric stuff, but it's easier to update everything
if (this.search.lastSearch)
this.updateSearchList(this.search.lastSearch);
if (this.search.selectedMarker)
this.toggleMarkerHighlight(this.search.selectedMarker, true);
if (this.zoomLayers.length > 0)
this.updateZoomLayers();
}
this.updateFilter();
// Set initialized when we've done everything
this.initialized = true;
this.initializedOnce = true;
this.toggleMarkerObserver(true);
this.togglePopupObserver(true);
this.leafletAttributeObserver.disconnect();
this.leafletAttributeObserver.observe(this.elements.leafletContainer, { attributes: true });
this.leafletAttributeObserver.observe(this.elements.leafletMapPane, { attributes: true });
this.resizeObserver.observe(this.elements.leafletContainer);
this.resizeObserver.observe(this.elements.mapModuleContainer);
var associatedCount = this.markers.filter(function(x) { return x.markerElement; }).length;
console.log(this.instanceId + " (" + this.name + ") - Initialized, associated " + associatedCount + " of " + this.markers.length + " markers (using " + markerElements.length + " elements), isNew: " + isNew);
// Invoke init event
this.events.onMapInit.invoke({ map: this, isNew: isNew });
},
initMapEvents: function()
{
// Mouse down event (remove first to ensure this only gets added once)
this.elements.leafletContainer.removeEventListener("mousedown", this.onMouseDown);
this.elements.leafletContainer.addEventListener("mousedown", this.onMouseDown);
// Mouse up event (remove first to ensure this only gets added once)
window.removeEventListener("mouseup", this.onMouseUp);
window.addEventListener("mouseup", this.onMouseUp);
if (this.elements.zoomInButton && this.elements.zoomOutButton)
{
// Remove non-navigating hrefs, which show a '#' in the navbar, and a link in the bottom-left
this.elements.zoomInButton.removeAttribute("href");
this.elements.zoomOutButton.removeAttribute("href");
this.elements.zoomInButton.setAttribute("tabindex", "0");
this.elements.zoomOutButton.setAttribute("tabindex", "0");
this.elements.zoomInButton.style.cursor = this.elements.zoomOutButton.style.cursor = "pointer";
this.elements.zoomInButton.addEventListener("click", zoomButtonClick.bind(this));
this.elements.zoomOutButton.addEventListener("click", zoomButtonClick.bind(this));
function zoomButtonClick(e)
{
// If this was from a keydown event with the enter key, click the button
if (e instanceof KeyboardEvent && e.key == "Enter")
{
var clickEvent = new PointerEvent("click",
{
view: window,
bubbles: true,
cancelable: false,
// This is important to handle "big" zooms
shiftKey: e.shiftKey
});
e.currentTarget.dispatchEvent(clickEvent);
}
else if (e instanceof PointerEvent)
e.preventDefault();
this.zoomType = "button";
this.zoomCenter = [ this.elements.leafletContainer.clientWidth / 2, this.elements.leafletContainer.clientHeight / 2 ];
this.zoomStartTransform = this.getElementTransformPos_css(this.elements.leafletBaseImageLayer);
this.zoomStartViewportPos = this.transformToViewportPosition(this.zoomStartTransform);
this.zoomStartSize = this.getElementSize(this.elements.leafletBaseImageLayer);
e.preventDefault();
};
}
// Record zoom position when scroll wheel is used
this.elements.leafletContainer.addEventListener("wheel", function(e)
{
this.zoomType = "wheel";
this.zoomCenter = [ e.offsetX, e.offsetY ];
this.zoomStartTransform = this.getElementTransformPos_css(this.elements.leafletBaseImageLayer);
this.zoomStartViewportPos = this.transformToViewportPosition(this.zoomStartTransform);
this.zoomStartSize = this.getElementSize(this.elements.leafletBaseImageLayer);
}.bind(this));
// Record key zoom when keyboard keys are used
this.elements.leafletContainer.addEventListener("keydown", function(e)
{
if (e.key == "-" || e.key == "=")
{
this.zoomType = "key";
this.zoomCenter = [ this.elements.leafletContainer.clientWidth / 2, this.elements.leafletContainer.clientHeight / 2 ];
this.zoomStartTransform = this.getElementTransformPos_css(this.elements.leafletBaseImageLayer);
this.zoomStartViewportPos = this.transformToViewportPosition(this.zoomStartTransform);
this.zoomStartSize = this.getElementSize(this.elements.leafletBaseImageLayer);
}
}.bind(this));
/*
// Intercept wheel events to normalize zoom
// This doesn't actually cancel the wheel event (since it cannot be cancelled)
// but instead clicks the zoom buttons so that the wheel zoom doesn't occur
this.elements.leafletContainer.addEventListener("wheel", function(e)
{
var button = e.deltaY < 0 ? this.elements.zoomInButton : this.elements.zoomOutButton;
var rect = button.getBoundingClientRect();
var x = rect.left + window.scrollX + (button.clientWidth / 2);
var y = rect.top + window.scrollY + (button.clientHeight / 2);
var clickEvent = new MouseEvent("click", { clientX: x, clientY: y, shiftKey: e.shiftKey });
button.dispatchEvent(clickEvent);
e.preventDefault();
}.bind(this));
*/
this.events.onMapZoomed.subscribe(function(args)
{
this._isScaledMapImageSizeDirty = true;
}.bind(this));
},
// Is called on mousemove after mousedown, and for subsequent mousemove events until dragging more than 2px
onMouseMove: function(e)
{
// Don't consider this a drag if shift was held on mouse down
if (this.isBoxZoomDragging) return;
if (!this.isDragging)
{
// If the position of the move is 2px away from the mousedown position
if (Math.abs(e.pageX - this.mouseDownPos[0]) > 2 ||
Math.abs(e.pageY - this.mouseDownPos[1]) > 2)
{
log("Started drag at x: " + this.mouseDownPos[0] + ", y: " + this.mouseDownPos[1] + " (" + this.mouseDownMapPos + ")");
// This is a drag
this.isDragging = true;
//this.elements.leafletContainer.removeEventListener("mousemove", onMouseMove);
this.events.onMapDragged.invoke({value: true});
}
}
else
{
// Determine whether we're resuming a drag
if (!this.isDraggingMove)
{
log("Resuming drag");
this.isDraggingMove = true;
this.events.onMapDraggedMove.invoke(true);
}
// Cancel the timeout which in 300ms will indicate we've paused dragging
clearTimeout(this.mouseMoveStopTimer);
this.mouseMoveStopTimer = setTimeout(function()
{
log("Pausing drag");
this.isDraggingMove = false;
this.events.onMapDraggedMove.invoke(false);
}.bind(this), 100);
}
},
// Mouse down event (should be added as an event listener on the leaflet container)
// Set up an event to cache the element that was last clicked, regardless of if it's actually a associated marker or not
onMouseDown: function(e)
{
// Ignore right clicks
if (e.button == 2) return;
// Determine whether this is a box zoom
if (e.shiftKey) this.isBoxZoomDragging = true;
// Save the position of the event
this.mouseDownPos = [ e.pageX, e.pageY ];
this.mouseDownMapPos = [ e.offsetX, e.offsetY ];
this.pageToMapOffset = [ e.offsetX - e.pageX, e.offsetY - e.pageY ];
// Subscribe to the mousemove event so that the movement is tracked
this.elements.leafletContainer.addEventListener("mousemove", this.onMouseMove);
this._invalidateLastClickEvent = false;
// Traverse up the click element until we find the marker or hit the root of the map
// This is because markers may have sub-elements that may be the target of the click
var elem = e.target;
while (true)
{
// No more parent elements
if (!elem || elem == e.currentTarget)
break;
if (elem.classList.contains("leaflet-marker-icon"))
{
this.lastMarkerClicked = elem.marker;
this.lastMarkerElementClicked = elem;
break;
}
elem = elem.parentElement;
}
},
// Mouse up event. Should be added as an event listener to the window, as mouseup
// won't trigger if on the leafletContainer and the mouse is released outside the leaflet window
onMouseUp: function(e)
{
// If the mouse was released on the map container or any item within it, the map was clicked
if (this.elements.leafletContainer.contains(e.target))
{
var isOnBackground = e.target == this.elements.leafletContainer || e.target == this.elements.leafletBaseImageLayer;
this.events.onMapClicked.invoke(
{
map: this, event: e,
// Clicked on map background
isOnBackground: isOnBackground,
// Clicked on marker,
isMarker: this.lastMarkerHovered != undefined,
marker: this.lastMarkerHovered,
// Was the end of the drag
wasDragging: this.isDragging,
});
// Custom popups - If mousing up on the map background, not the end of a drag, and there is a popup showing
if (this.config.useCustomPopups == true && isOnBackground && !this.isDragging && this.lastPopupShown)
{
// Hide the last popup shown
this.lastPopupShown.hide();
}
}
this.elements.leafletContainer.removeEventListener("mousemove", this.onMouseMove);
// If mousing up after dragging, regardless of if it ended within the window
if (this.isDragging == true)
{
this.mouseUpPos = [ e.pageX, e.pageY ];
this.mouseUpMapPos = [ e.pageX + this.pageToMapOffset[0], e.pageY + this.pageToMapOffset[1] ];
log("Ended drag at x: " + e.pageX + ", y: " + e.pageY + " (" + this.mouseUpMapPos.toString() + ")");
// No longer dragging
this.isDragging = false;
this.events.onMapDragged.invoke({value: false});
// Invalidate click event on whatever marker is hovered
if (this.lastMarkerHovered)
this._invalidateLastClickEvent = true;
}
// If mousing up after starting a box zoom, record this zoom as a box zoom
if (this.isBoxZoomDragging == true)
{
this.isBoxZoomDragging = false;
this.zoomType = "box";
this.zoomCenter = [ this.mouseUpMapPos[0] - this.mouseDownMapPos[0],
this.mouseUpMapPos[1] - this.mouseDownMapPos[1] ];
this.zoomStartTransform = this.getElementTransformPos_css(this.elements.leafletBaseImageLayer);
this.zoomStartViewportPos = this.transformToViewportPosition(this.zoomStartTransform);
this.zoomStartSize = this.getElementSize(this.elements.leafletBaseImageLayer);
}
},
onMapZoomed: function(value)
{
this.zoomScaleDelta = value ? this.getElementTransformScale(this.elements.leafletBaseImageLayer, true) : this.zoomScaleDelta;
this.zoomScale = this.getElementTransformScale(this.elements.leafletProxy, true) * 2;
this.zoomState = value ? "zoomStart" : "zoomEnd";
var args =
{
map: this,
value: value,
state: this.zoomState,
direction: this.zoomScaleDelta >= 1 ? "in" : "out",
center: this.zoomCenter,
type: this.zoomType,
scaleDelta: this.zoomScaleDelta,
scale: this.zoomScale
};
this.events.onMapZoomed.invoke(args);
},
// Deinit effectively disconnects the map from any elements that may have been removed in the DOM (with the exception of filter elements)
// After a map is deinitialized, it should not be used until it is reinitialized with init
deinit: function()
{
if (!this.initialized)
{
console.error(this.instanceId + " (" + this.name + ") Tried to de-initialize map when it wasn't initialized");
return;
}
this.toggleMarkerObserver(false);
this.togglePopupObserver(false);
this.leafletAttributeObserver.disconnect();
this.resizeObserver.disconnect();
this.isDragging = this.isZooming = false;
this._isScaledMapImageSizeDirty = true;
window.removeEventListener("mouseup", this.onMouseUp);
this.elements.leafletContainer.removeEventListener("mousedown", this.onMouseDown);
this.elements.leafletContainer.removeEventListener("mousemove", this.onMouseMove);
this.initialized = false;
for (var i = 0; i < this.markers.length; i++)
this.markers[i].deinit();
for (var i = 0; i < this.categories.length; i++)
this.categories[i].deinit();
console.log(this.instanceId + " (" + this.name + ") - Deinitialized");
// Invoke deinit event
this.events.onMapDeinit.invoke({map: this});
},
// Returns a Promise which is fulfilled when the elements of a map become available, or were already available
// and rejected if it will never become available in the current state (i.e. map container hidden)
waitForPresence: function()
{
if (this.initialized)
{
return Promise.resolve(this.instanceId + " (" + this.name + ") - The map was initialized immediately (took " + Math.round(performance.now() - this.creationTime) + "ms)");
}
return new Promise(function(resolve, reject)
{
// Store resolve function (it will be called by selfObserver above)
this._waitForPresenceResolve = function()
{
resolve(this.instanceId + " (" + this.name + ") - Successfully deferred until Leaflet fully initialized (took " + Math.round(performance.now() - this.creationTime) + "ms)");
};
// Alternatively timeout after 10000ms
setTimeout(function(){ reject(this.instanceId + " (" + this.name + ") - Timed out after 10 sec while waiting for the map to appear."); }.bind(this), 10000);
}.bind(this));
},
createLoadingOverlay: function()
{
var placeholder = document.createElement("div");
placeholder.innerHTML = "<div class=\"LoadingOverlay-module_overlay__UXv3B\"><div class=\"LoadingOverlay-module_container__ke-21\"><div class=\"fandom-spinner LoadingOverlay-module_spinner__Wl7dt\" style=\"width: 40px; height: 40px;\"><svg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" xmlns=\"http:\/\/www.w3.org\/2000\/svg\"><g transform=\"translate(20, 20)\"><circle fill=\"none\" stroke-width=\"2\" stroke-dasharray=\"119.38052083641213\" stroke-dashoffset=\"119.38052083641213\" stroke-linecap=\"round\" r=\"19\"><\/circle><\/g><\/svg><\/div><\/div><\/div>";
return placeholder.firstElementChild;
},
isMapCreated: function()
{
var mapModuleContainer = this.elements.rootElement.querySelector(".Map-module_container__dn27-");
var leafletContainer = this.elements.rootElement.querySelector(".leaflet-container");
// The process for creating the map is
// 0. interactive-maps-xxxxxx stub exists
// 1. interactive-maps created
// 2. interactive-maps__filters-list and all filters created
// 3. Map-module_container__dn27- created
// 4. img Map-module_imageSizeDetect__YkHxA created (optionally)
// 5. leaflet-container created
// 6. leaflet-map-pane created (and all empty pane containers underneath it)
// 7. leaflet-control-container created (and all empty top/bottom/left/right underneath it)
// 8. leaflet-proxy created under leaflet-map-pane
// At this point the map may be destroyed and recreated from step 3.
// 8. leaflet-control-zoom added under leaflet-control-container
// 9. leaflet-image-layer added under leaflet-overlay-pane
// 10. leaflet-marker-icons added under leaflet-marker-pane
// 11. interactive-maps__edit-control added under leaflet-control-container
// We can check whether it is still creating the map by:
// -> The lack of a Map-module_container__dn27- element (this is created first)
// -> The lack of a leaflet-container element (this is created second)
// -> The lack of any children under Map-module_container__dn27-
// -> The lack of any children under leaflet-container
// Still loading
// -> The existence of an img "Map-module_imageSizeDetect__YkHxA" under "Map-module_container__dn27-" (this is removed first)
// -> The existence of a div "LoadingOverlay-module_overlay__UXv3B" under "leaflet-container"
// -> The lack of any elements under leaflet-overlay-pane
// -> The lack of the zoom controls
if (mapModuleContainer == null || leafletContainer == null ||
mapModuleContainer.childElementCount == 0 || leafletContainer.childElementCount == 0 ||
mapModuleContainer.querySelector("img.Map-module_imageSizeDetect__YkHxA") != null ||
leafletContainer.querySelector(".LoadingOverlay-module_overlay__UXv3B") != null ||
leafletContainer.querySelector(".leaflet-map-pane > .leaflet-overlay-pane > *") == null)
{
return false;
}
return true;
},
isMapHidden: function()
{
return (this.rootElement.offsetParent == null);
},
isMapVisible: function()
{
return !this.isMapHidden();
},
// Determine whether the element is displayed
isElementVisible: function(element)
{
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
},
getMapLink: function(name, returns)
{
name = name || this.name;
returns = returns || "string";
if (returns == "element")
{
var a = document.createElement(a);
a.href = "/wiki/" + encodeURIComponent(name);
a.textContent = "Map:" + name;
return a;
}
else if (returns == "wikitext")
return "[[Map:" + name + "]]";
else if (returns == "string")
return "<a href=\"/wiki/Map:" + encodeURIComponent(name) + "\">Map:" + name + "</a>";
},
adjustMapDropdown: function(button, content)
{
var root = this.elements.rootElement;
var buttonRect = button.getBoundingClientRect();
var contentRect = content.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
// Resize the list to be a bit less than the height of the root map container
var bottomPadding = (this.isFullscreen || this.isWindowedFullscreen || this.isMinimalLayout ? 60 : 35);
var maxHeight = Math.min(600, (rootRect.height - bottomPadding) + 2);
content.style.maxHeight = maxHeight + "px";
// Resize the list to be no greater than the width of the root map container
var maxWidth = Math.min(320, Math.round(rootRect.width + 2));
var width = Math.min(maxWidth, contentRect.width);
content.style.maxWidth = maxWidth + "px";
// When the list would overflow, shift it across
var leftOffset = rootRect.right - (buttonRect.x + width);
if (leftOffset < 0)
content.style.left = leftOffset + "px";
else
content.style.left = "";
},
togglePopupObserver: function(state)
{
this.popupObserver.disconnect();
if (state) this.popupObserver.observe(this.elements.leafletPopupPane, { childList: true, subtree: true });
},
toggleMarkerObserver: function(state)
{
this.markerObserver.disconnect();
if (state) this.markerObserver.observe(this.elements.leafletMarkerPane, { childList: true });
},
// This mess is to mitigate a bug that occurs after panning a map with the popup open
// whereby no click events after that will actually register
clickPositionOfElement: function(elem)
{
var rect = elem.getBoundingClientRect();
var x = rect.left + window.scrollX + (elem.clientWidth / 2);
var y = rect.top + window.scrollY + (elem.clientHeight / 2);
var eventArgs =
{
"bubbles": true,
"cancelable": true
};
var mouseDownEvent = new MouseEvent("mousedown", eventArgs);
var mouseUpEvent = new MouseEvent("mouseup", eventArgs);
var clickEvent = new MouseEvent("click", eventArgs);
//var e = document.elementFromPoint(x, y);
elem.dispatchEvent(mouseDownEvent);
elem.dispatchEvent(mouseUpEvent);
elem.dispatchEvent(clickEvent);//click();
document.activeElement.blur();
},
compareMarkerAndJsonElement: function(markerElem, markerJson)
{
return markerJson.compareMarkerAndJsonElement(markerElem);
},
markerCompareFunctions: function()
{
var collator = new Intl.Collator();
this.markerCompareFunctions =
{
"latitude-asc": function(a, b) { return Math.sign(a.position[1] - b.position[1]); },
"latitude-desc": function(a, b) { return Math.sign(b.position[1] - a.position[1]); },
"longitude-asc": function(a, b) { return Math.sign(a.position[0] - b.position[0]); },
"longitude-desc": function(a, b) { return Math.sign(b.position[0] - a.position[0]); },
"category-asc": function(a, b) { return (b.map.categories.indexOf(b.category) - a.map.categories.indexOf(a.category)) || (a.position[1] - b.position[1]); },
"category-desc": function(a, b) { return (a.map.categories.indexOf(a.category) - b.map.categories.indexOf(b.category)) || (a.position[1] - b.position[1]); },
"name-asc": function(a, b) { return collator.compare(a.popup.title, b.popup.title); }
};
},
// Returns a function that can be used to compare markers
markerCompareFunction: function(sortType)
{
sortType = sortType || this.config.sortMarkers;
sortType = sortType.toLowerCase();
if (!sortType.endsWith("desc") && !sortType.endsWith("asc"))
sortType += "-asc";
// Return a cached version of the compare function
// This prevents a new function from being created every time this is called
if (this.markerCompareFunctions[sortType])
return this.markerCompareFunctions[sortType];
else
{
console.log("A compare function with the name " + sortType + " does not exist!");
return this.markerCompareFunctions["latitude-asc"];
}
},
/*
Some notes about positions
- An unscaled position is one which matches the JSON definition, relative
to the original size of the map with the bounds applied.
- A pixel position is one that matches the resolution of the map image,
as defined by the JSON, but it won't always match the JSON definition
specifically because it does not factor in shifted lower or upper bounds
- A scaled position is the pixel position scaled up to the current map
scale/zoom level. It is relative to the top left corner of the map image
at the current zoom level. It is analogous to DOM position of a map element
relative to the base image layer.
- A transform position is the position that Leaflet objects use. It is
relative to the leaflet-map-pane which gets translated when the user drags
and scales the map. Transform marker positions only changes when the map is
zoomed in and out. The transform position is in the same scale as the scaled
position, but just shifted by the transform position of the base layer.
Transform positions become invalid when the map is zoomed
- A viewport position is a position relative to the map viewport (that is,
the container that defines the size of the interactive map, and clips the
content within). A position at 0, 0 is always the top left corner of the
container. Viewport positions and transform positions are closely related.
*/
// Gets the rect of any element
getElementRect: function(elem)
{
return elem.getBoundingClientRect();
},
// Gets the rect position of any element, relative to the window
getElementPos: function(elem)
{
var rect = elem.getBoundingClientRect();
return [ rect.x, rect.y ];
},
// Gets the rect size of any element, relative to the window
getElementSize: function(elem)
{
var rect = elem.getBoundingClientRect();
return [ rect.width, rect.height ];
},
// Get the current position of the viewport
getViewportPos: function()
{
return this.getElementPos(this.elements.leafletContainer);
},
// Get the current size of the viewport
getViewportSize: function()
{
return [ this.elements.leafletContainer.clientWidth, this.elements.leafletContainer.clientHeight ];
},
// Scale a "unscaled" (JSON) position to current map size, returning the scaled position
unscaledToScaledPosition: function(unscaledPos)
{
var scaledPos = [];
var imageSize = this.getScaledMapImageSize();
// Scale the position to the current size of the map, from the original coordinates, and round
scaledPos[0] = Math.round(((unscaledPos[0] - this.bounds[0][0]) / this.size.width) * imageSize[0]);
scaledPos[1] = Math.round(((unscaledPos[1] - this.bounds[0][1]) / this.size.height) * imageSize[1]);
return scaledPos;
},
// Converts a scaled (zoomed) position at the current zoom level to an unscaled (JSON) position
// This position is equivalent to the JSON positions (assuming the CORRECT origin of top-left)
scaledToUnscaledPosition: function(scaledPos)
{
var unscaledPos = [];
var imageSize = this.getScaledMapImageSize();
unscaledPos[0] = (scaledPos[0] / imageSize[0]) * this.size.width + this.bounds[0][0];
unscaledPos[1] = (scaledPos[1] / imageSize[1]) * this.size.height + this.bounds[0][1];
return unscaledPos;
},
scaledToPixelPosition: function(scaledPos)
{
var pixelPos = [];
var imageSize = this.getScaledMapImageSize();
// Scale the position down to the original range
pixelPos[0] = (scaledPos[0] / imageSize[0]) * this.size.width;
pixelPos[1] = (scaledPos[1] / imageSize[1]) * this.size.height;
return pixelPos;
},
pixelToScaledPosition: function(pixelPos)
{
var scaledPos = [];
var imageSize = this.getScaledMapImageSize(true);
// Scale the position back up to the scaled range
scaledPos[0] = (pixelPos[0] / this.size.width) * imageSize[0];
scaledPos[1] = (pixelPos[1] / this.size.width) * imageSize[0];
return scaledPos;
},
// Converts a scaled position at the current zoom level to a position which is accurate to
// transforms used in the Leaflet map. A transform position is typically identical, but is
// shifted by the map pane offset
scaledToTransformPosition: function(scaledPos)
{
// Get base layer transform position. This needs to be calculated on the fly as it will change as the user zooms
var baseLayerPos = this.getElementTransformPos(this.elements.leafletBaseImageLayer);
// Add the position of the base layer to the scaled position to get the transform position
return [ scaledPos[0] + baseLayerPos[0],
scaledPos[1] + baseLayerPos[1] ];
},
// Converts a transform position to a scaled position which is accurate to the current zoom level
transformToScaledPosition: function(transformPos)
{
// Get base layer transform position. This needs to be calculated on the fly as it will change as the user zooms
var baseLayerPos = this.getElementTransformPos(this.elements.leafletBaseImageLayer);
return [ transformPos[0] - baseLayerPos[0],
transformPos[1] - baseLayerPos[1] ];
},
// Converts a viewport position to a transform position that is relative to the map pane
viewportToTransformPosition: function(viewportPos)
{
// The transform position is simply the passed viewport position, minus the map pane viewport position (or transform position, they are identical in its case)
var mapPaneViewportPos = this.getElemMapViewportPos(this.elements.leafletMapPane);
return [ viewportPos[0] - mapPaneViewportPos[0],
viewportPos[1] - mapPaneViewportPos[1] ];
},
// Converts a transform position relative to the map pane to a viewport pos
transformToViewportPosition: function(transformPos)
{
// The transform position is simply the passed viewport position, minus the map pane viewport position (or transform position, they are identical in its case)
var mapPaneViewportPos = this.getElemMapViewportPos(this.elements.leafletMapPane);
return [ transformPos[0] + mapPaneViewportPos[0],
transformPos[1] + mapPaneViewportPos[1] ];
},
// Converts a client position to a transform position on the map, relative to the map pane
// A client position is one relative to the document viewport, not the document itself
// getBoundingClientRect also returns client positions
clientToTransformPosition: function(mousePos)
{
/*
// mousePos is [ e.clientX, e.clientY ]
var viewportRect = this.getElementRect(this.elements.leafletContainer);
var mapPaneRect = this.getElementRect(this.elements.leafletMapPane);
// Get the mouse position relative to the viewport
var mouseViewportPos = [ mousePos[0] - viewportRect.x, mousePos[1] - viewportRect.y ];
// Get the map pane position relative to the viewport
var mapPaneViewportPos = [ mapPaneRect.x - viewportRect.x , mapPaneRect.y - viewportRect.y ];
//var mapPaneViewportPos = this.getElementTransformPos(this.elements.leafletMapPane);
var mouseTransformPos = [ mouseViewportPos[0] - mapPaneViewportPos[0],
mouseViewportPos[1] - mapPaneViewportPos[1] ];
*/
// The transform is just the offset from the mapPane's position
var mapPaneRect = this.getElementRect(this.elements.leafletMapPane);
return [ mousePos[0] - mapPaneRect.x, mousePos[1] - mapPaneRect.y ];
},
clientToUnscaledPosition: function(mousePos)
{
var scaledPos = this.clientToScaledPosition(mousePos);
return this.scaledToUnscaledPosition(scaledPos);
},
clientToScaledPosition: function(mousePos)
{
// The transform is just the offset from the mapPane's position
var baseImageRect = this.getElementRect(this.elements.leafletBaseImageLayer);
return [ mousePos[0] - baseImageRect.x, mousePos[1] - baseImageRect.y ];
},
// Gets the position of an element relative to the map image
// Keep in mind this is the top-left of the rect, not the center, so it will not be accurate to marker positions if used with the marker element
// You can pass true to centered to add half of the element's width and height to the output position
getElemMapScaledPos: function(elem, centered)
{
var baseRect = this.elements.leafletBaseImageLayer.getBoundingClientRect();
var elemRect = elem.getBoundingClientRect();
var pos = [ elemRect.x - baseRect.x, elemRect.y - baseRect.y ];
if (centered == true)
{
pos[0] += elemRect.width / 2;
pos[1] += elemRect.height / 2;
}
return pos;
/*
// Get base layer transform position. This needs to be calculated on the fly as it will change as the user zooms
var baseLayerPos = this.getElementTransformPos(this.map.elements.leafletBaseImageLayer);
// Subtract the current position of the map overlay from the marker position to get the scaled position
var pos = this.map.getElementTransformPos(elem);
pos[0] -= baseLayerPos[0];
pos[1] -= baseLayerPos[1];
*/
},
// Get the position of an element relative to the map viewport
// Like with getElemMapScaledPos, this is the top left-of the rect, not the center
getElemMapViewportPos: function(elem, centered)
{
var viewRect = this.elements.leafletContainer.getBoundingClientRect();
var elemRect = elem.getBoundingClientRect();
var pos = [ elemRect.x - viewRect.x , elemRect.y - viewRect.y ];
if (centered == true)
{
pos[0] += elemRect.width / 2;
pos[1] += elemRect.height / 2;
}
return pos;
},
// Get the transform position of the element relative to the map pane
getElemMapTransformPos: function(elem, centered)
{
var scaledPos = this.getElemMapScaledPos(elem, centered);
return this.scaledToTransformPosition(scaledPos);
},
getElementTransformPos_css: function(element)
{
var values = element.style.transform.split(/\w+\(|\);?/);
if (!values[1] || !values[1].length) return {};
values = values[1].split(/,\s?/g);
return [ parseInt(values[0], 10), parseInt(values[1], 10), parseInt(values[2], 10) ];
},
// Get the existing transform:translate XY position from an element
getElementTransformPos: function(element, accurate)
{
// Throw error if the passed element is not in fact an element
if (!(element instanceof Element))
{
console.error("getElementTransformPos expects an Element but got the following value:", element);
return [0, 0];
}
// This is the more programatic way to get the position, calculating it
if (accurate && this.elements.leafletMapPane.contains(element))
{
/*
// The same as below, but using JQuery
var pos = $(element).position();
console.log("jQuery.position took " + (performance.now() - t));
return [ pos.left, pos.top ];
*/
var mapRect = this.elements.leafletMapPane.getBoundingClientRect();
var elemRect = element.getBoundingClientRect();
// We can't just use half the width and height to determine the offsets
// since the user may have implemented custom offsets
var computedStyle = window.getComputedStyle(element);
var elemOffset = [ parseFloat(computedStyle.marginLeft) + parseFloat(computedStyle.marginRight),
parseFloat(computedStyle.marginTop) + parseFloat(computedStyle.marginBottom) ];
return [ (elemRect.x - mapRect.x) - elemOffset[0],
(elemRect.y - mapRect.y) - elemOffset[1] ];
}
if (element._leaflet_pos)
return [ element._leaflet_pos.x, element._leaflet_pos.y ];
else
{
var values = element.style.transform.split(/\w+\(|\);?/);
if (!values[1] || !values[1].length) return {};
values = values[1].split(/,\s?/g);
return [ parseInt(values[0], 10), parseInt(values[1], 10), parseInt(values[2], 10) ];
}
/*
else
{
var style = window.getComputedStyle(element)
var matrix = new DOMMatrixReadOnly(style.transform)
return {
x: matrix.m41,
y: matrix.m42
}
}
*/
},
getElementTransformScale: function(element, css)
{
// Throw error if the passed element is not in fact an element
if (!(element instanceof Element))
{
console.error("getElementTransformScale expects an Element but got the following value:", element);
return [0, 0];
}
// CSS scale
if (css)
{
/*
// Computed style - It may not be valid if the scale style was added this frame
var style = window.getComputedStyle(element);
// Calculate the scale factor using the transform matrix
var matrix = new DOMMatrixReadOnly(style.transform);
return [ Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b),
Math.sqrt(matrix.c * matrix.c + matrix.d * matrix.d) ]
/*
// Get the transform property value
var transformValue = style.getPropertyValue("transform");
// Extract the scale value from the transform property
var match = transformValue.match(/scale\(([^\)]+)\)/);
var scaleValue = match ? match[1] : "1";
return scaleValue;
*/
var match = element.style.transform.match(/scale\(([^\)]+)\)/);
return match ? parseFloat(match[1]) : 1;
}
// Actual scale
else
{
var rect = element.getBoundingClientRect();
return [ rect.width / element.offsetWidth, rect.height / element.offsetHeight ];
}
},
getElementSize: function(element)
{
var rect = element.getBoundingClientRect();
return [ rect.width, rect.height ];
},
// Get the current background image size at the current zoom level
getScaledMapImageSize: function(live)
{
/*
// Return the cached size if we have one and it doesn't need to be updated
if (!this._isScaledMapImageSizeDirty && this.scaledMapImageSize && !live)
return this.scaledMapImageSize;
*/
// If we need a live-updating value, use an expensive calculation to get it
if (live)
{
var rect = this.elements.leafletBaseImageLayer.getBoundingClientRect();
var size = [ rect.width, rect.height ];
}
else
{
var size = [ this.elements.leafletBaseImageLayer.width, this.elements.leafletBaseImageLayer.height ];
// If the map was just shown, the base image layer may not have a width and height
// However, the style will always be correct, so we can fetch the size from that instead (at a minor performance penalty)
if (size[0] == 0 && size[1] == 0)
{
size[0] = parseFloat(this.elements.leafletBaseImageLayer.style.width);
size[1] = parseFloat(this.elements.leafletBaseImageLayer.style.height);
}
}
this._isScaledMapImageSizeDirty = false;
this.scaledMapImageSize = size;
return size;
},
initCursorDebug: function()
{
return;
if (isDebug)
{
var cursorDebug = document.createElement("div");
cursorDebug.className = "mapsExtended_cursorDebug";
cursorDebug.style.cssText = "position: absolute; top: 0; right: 0; z-index: 1; padding: 0.1em; background-color: var(--theme-page-background-color); color: var(--theme-body-text-color); font-family: monospace; text-align: right; line-height: 1.2em; white-space: pre"
this.elements.mapModuleContainer.append(cursorDebug);
var updateText = function(e)
{
if (e instanceof Event)
cursorPos = [ e.clientX, e.clientY ];
else
cursorPos = e;
var transformPos = this.clientToTransformPosition(cursorPos);
var scaledPos = this.clientToScaledPosition(cursorPos);
var unscaledPos = this.clientToUnscaledPosition(cursorPos);
var str = "Transform pos: " + Math.round(transformPos[0]) + ", " + Math.round(transformPos[1]);
str += "\r\nScaled (pixel) pos: " + Math.round(scaledPos[0]) + ", " + Math.round(scaledPos[1]);
str += "\r\nUnscaled (JSON) pos: " + Math.round(unscaledPos[0]) + ", " + Math.round(unscaledPos[1]);
if (points.length > 0)
{
str += "\r\nCtrl+Click to add to list";
str += "\r\n" + points.map(function(p){ return "[" + Math.round(p[0]) + ", " + Math.round(p[1]) + "]"; }).join("\r\n");
str += "\r\nClick here to finish and copy";
}
else
str += "\r\nCtrl+Click to start list";
cursorDebug.textContent = str;
}.bind(this);
var points = [];
this.elements.leafletContainer.addEventListener("click", function(e)
{
if (!e.ctrlKey) return;
var cursorPos = [ e.clientX, e.clientY ];
var unscaledPos = this.clientToUnscaledPosition(cursorPos);
points.push(unscaledPos);
updateText(cursorPos);
}.bind(this));
cursorDebug.addEventListener("click", function(e)
{
if (points.length > 0)
{
navigator.clipboard.writeText("[ " + points.map(function(p){ return "[" + Math.round(p[0]) + ", " + Math.round(p[1]) + "]"; }).join(", ") + " ]");
points = [];
}
});
this.elements.leafletContainer.addEventListener("mousemove", updateText.bind(this));
}
},
initMinimalLayout: function()
{
if (this.config["minimalLayout"] == true)
{
this.isMinimalLayout = true;
this.elements.interactiveMapsContainer.style.padding = "0";
this.elements.rootElement.classList.add("mapsExtended_minimalLayout");
this.elements.mapModuleContainer.prepend(this.elements.filtersList);
}
},
// openPopupsOnHover
initOpenPopupsOnHover: function()
{
// Mouse enter marker element - Stop timeout for popup
if (this.config.openPopupsOnHover != true)
return;
this.events.onMarkerHovered.subscribe(function(args)
{
var e = args.e;
var marker = args.marker || e.currentTarget.marker || this.markerLookup.get(e.currentTarget.id) || null;
if (!marker) return;
// Mouse enter marker element
if (args.value == true)
{
// Stop the hide timer
if (this.config.popupHideDelay > 0.0)
marker.popup.stopPopupHideDelay();
// Start the show timer
if (this.config.popupShowDelay > 0.0)
marker.popup.startPopupShowDelay();
// Or just show if there is no delay
else
marker.popup.show();
}
// Mouse leave marker element - Start timeout for popup
else
{
// Stop the show timer
if (this.config.popupShowDelay > 0.0)
marker.popup.stopPopupShowDelay();
// Start the hide timer
if (this.config.popupHideDelay > 0.0)
marker.popup.startPopupHideDelay();
// Or just hide if there is no delay
else
marker.popup.hide();
}
}.bind(this));
},
// Tooltips
initTooltips: function()
{
// Don't continue if tooltips are disabled
if (this.config.enableTooltips == false)
return;
var tooltipElement = document.createElement("div");
tooltipElement.className = "leaflet-tooltip leaflet-zoom-animated";
this.elements.tooltipElement = tooltipElement;
// This function is called by requestAnimationFrame and will update the transform of the tooltip
// to match the transform of the marker element every frame (plus an offset for the local transform)
var start, prev, zoomStepId, zoomStepFn = function(time)
{
if (!this.tooltipMarker) return;
// Record the start time
if (!start) start = time;
// Only apply the new transform if the time actually changed
if (prev != time) tooltipElement.style.transform = this.tooltipMarker.markerElement.style.transform + " " + tooltipElement.localTransform;
// Queue the next frame as long as the elapsed time is less than 300ms
// This is more a timeout feature than anything
if (time - start < 300) zoomStepId = window.requestAnimationFrame(zoomStepFn);
prev = time;
}.bind(this);
// Show tooltip on marker hover enter, hide it on hover exit
this.events.onMarkerHovered.subscribe(function(args)
{
if (args.value == true)
this.showTooltipForMarker(args.marker);
else
this.hideTooltip();
}.bind(this));
// Hide the tooltip with display:none when the popup for a marker is shown
this.events.onPopupShown.subscribe(function(args)
{
if (args.marker == this.tooltipMarker && this.elements.tooltipElement.isConnected)
this.elements.tooltipElement.style.display = "none";
}.bind(this));
// Re-show the tooltip when the popup for a marker is hidden again
this.events.onPopupHidden.subscribe(function(args)
{
// Only if the popup is of the marker that is also the tooltip marker
if (args.marker == this.tooltipMarker && this.elements.tooltipElement.isConnected)
this.elements.tooltipElement.style.display = "";
}.bind(this));
// When the map is zoomed, animate the tooltip with the zoom
this.events.onMapZoomed.subscribe(function()
{
if (this.isTooltipShown == true)
{
window.cancelAnimationFrame(zoomStepId);
window.requestAnimationFrame(zoomStepFn);
}
}.bind(this));
},
showTooltipForMarker: function(marker)
{
this.isTooltipShown = true;
this.tooltipMarker = marker;
var tooltipElement = this.elements.tooltipElement;
// Show the marker on top of everything else
marker.markerElement.style.zIndex = (marker.order + this.markers.length).toString();
// Set the content of the tooltip
tooltipElement.textContent = marker.popup.title;
tooltipElement.style.display = marker.popup.isPopupShown() ? "none" : "";
// Remove last margin offset
tooltipElement.style.marginLeft = tooltipElement.style.marginTop = "";
// Remove last left/right/top/bottom/center classes
tooltipElement.classList.remove("leaflet-tooltip-left", "leaflet-tooltip-right", "leaflet-tooltip-top", "leaflet-tooltip-bottom", "leaflet-tooltip-center");
var offset = [ this.config["tooltipOffset"][0], this.config["tooltipOffset"][1] ];
var direction = this.config["tooltipDirection"];
var localTransform;
// Handle "auto"
// Change whether the tooltip is shown on the left or right side of the marker depending
// on the marker's position relative to the viewport.
// Markers on the right side of the viewport will show a tooltip on the left and vice versa
if (direction == "auto")
{
var isShownOnLeftSide = marker.getViewportMarkerPosition()[0] > this.getViewportSize()[0] / 2;
direction = isShownOnLeftSide ? "left" : "right";
// Invert the X offset if shown on right
if (!isShownOnLeftSide) offset[0] = -offset[0];
}
switch (direction)
{
case "left":
{
tooltipElement.classList.add("leaflet-tooltip-left");
localTransform = "translate(-100%, -50%)";
break;
}
case "right":
{
tooltipElement.classList.add("leaflet-tooltip-right");
localTransform = "translate(0, -50%)";
break;
}
case "top":
{
tooltipElement.classList.add("leaflet-tooltip-top");
localTransform = "translate(-50%, -100%)";
break;
}
case "bottom":
{
tooltipElement.classList.add("leaflet-tooltip-bottom");
localTransform = "translate(-50%, 0)";
break;
}
case "center":
{
localTransform = "translate(-50%, -50%)";
tooltipElement.classList.add("leaflet-tooltip-center"); // Isn't actually a built-in class
break;
}
}
// Offset the tooltip based on the iconAnchor and the marker size
if (marker.iconAnchor.startsWith("top"))
offset[1] += marker.height * 0.5;
else if (marker.iconAnchor.startsWith("bottom"))
offset[1] += marker.height * -0.5;
if (marker.iconAnchor.endsWith("left"))
offset[0] += marker.width * 0.5;
else if (marker.iconAnchor.endsWith("right"))
offset[0] += marker.width * -0.5;
// Add the tooltip tip
if (marker.iconAnchor.endsWith("left") || marker.iconAnchor.endsWith("right"))
{
// - 6 (tooltip tip on right)
if (direction == "left") offset[0] += -6;
// + 6 (tooltip tip on left)
if (direction == "right") offset[0] += 6;
}
if (marker.iconAnchor.startsWith("top") || marker.iconAnchor.startsWith("bottom"))
{
// - 6 (tooltip tip on bottom)
if (direction == "top") offset[1] += -6;
// + 6 (tooltip tip on top)
if (direction == "bottom") offset[1] += 6;
}
// We use two transforms, the transform of the marker and a local one which shifts the tooltip
tooltipElement.localTransform = localTransform;
tooltipElement.style.transform = marker.markerElement.style.transform + " " + localTransform;
// Then, the pixel offset is applied using margins
if (offset[0] != 0) tooltipElement.style.marginLeft = offset[0] + "px";
if (offset[1] != 0) tooltipElement.style.marginTop = offset[1] + "px";
// Finally, add the tooltip to the DOM
this.elements.leafletTooltipPane.appendChild(tooltipElement);
},
hideTooltip: function()
{
this.isTooltipShown = false;
var marker = this.tooltipMarker;
// Don't set zIndex if the marker is highlighted in search
if (marker && !marker.markerElement.classList.contains(".search-result-highlight"))
marker.markerElement.style.zIndex = marker.order.toString();
this.elements.tooltipElement.remove();
this.tooltipMarker = undefined;
},
// Main thread canvas
initCanvas: function()
{
// Performance options
// The amount of pixels each side of the viewport to draw, in order to prevent constant redrawing when the
var CANVAS_EDGE_BUFFER = 200;
// Don't continue if there are no paths
if (!this.config.paths || this.config.paths.length == 0)
return;
// Set the canvas width and height to be the size of the container, so that we're always drawing at the optimal resolution
// We can't set it to the scaled size of the map image because at high zoom levels the max pixel count will be exceeded
var leafletContainerSize = this.getElementSize(this.elements.leafletContainer);
// Create a pane to contain all the ruler points
var canvasPane = document.createElement("div");
canvasPane.className = "leaflet-pane leaflet-canvas-pane";
this.elements.leafletCanvasPane = canvasPane;
this.elements.leafletTooltipPane.after(canvasPane);
var canvas = document.createElement("canvas");
//canvas.className = "leaflet-zoom-animated";
canvas.style.pointerEvents = "none";
//canvas.style.willChange = "transform";
canvas.width = leafletContainerSize[0];
canvas.height = leafletContainerSize[1];
this.elements.leafletCanvasPane.appendChild(canvas);
//var offscreenCanvas = new OffscreenCanvas(leafletContainerSize[0], leafletContainerSize[1]);
//var ctx = offscreenCanvas.getContext("2d");
var ctx = canvas.getContext("2d");
var points = [];
var iconIndexes = [];
var icons = [];
var initialized = false;
function pointsToBSpline(points)
{
var ax, ay, bx, by, cx, cy, dx, dy;
// Add last two points to the start of the array
points.unshift(points[points.length - 2], points[points.length - 1]);
// Add first two points to the end of the array
points.push(points[2], points[3]);
var splinePoints = [];
for (var t = 0; t < 1; t += 0.1)
{
ax = (-points[0].x + 3 * points[1].x - 3 * points[2].x + points[3].x) / 6;
ay = (-points[0].y + 3 * points[1].y - 3 * points[2].y + points[3].y) / 6;
bx = (points[0].x - 2 * points[1].x + points[2].x) / 2;
by = (points[0].y - 2 * points[1].y + points[2].y) / 2;
cx = (-points[0].x + points[2].x) / 2;
cy = (-points[0].y + points[2].y) / 2;
dx = (points[0].x + 4 * points[1].x + points[2].x) / 6;
dy = (points[0].y + 4 * points[1].y + points[2].y) / 6;
splinePoints.push([ax * Math.pow(t + 0.1, 3) + bx * Math.pow(t + 0.1, 2) + cx * (t + 0.1) + dx,
ay * Math.pow(t + 0.1, 3) + by * Math.pow(t + 0.1, 2) + cy * (t + 0.1) + dy]);
}
return splinePoints;
}
// https://observablehq.com/@pamacha/chaikins-algorithm
function chaikin(arr, num)
{
if (num === 0) return arr;
var l = arr.length;
var smooth = arr.map(function(c,i)
{
return[[0.75*c[0] + 0.25*arr[(i + 1)%l][0],0.75*c[1] + 0.25*arr[(i + 1)%l][1]],
[0.25*c[0] + 0.75*arr[(i + 1)%l][0],0.25*c[1] + 0.75*arr[(i + 1)%l][1]]];
}).flat();
return num === 1 ? smooth : chaikin(smooth, num - 1);
}
// Given a source style, remove properties from a target style that are the same as those on the source
// This allows us to avoid unnecessarily setting properties to the same value
function removeDuplicateStyleProps(target, source)
{
for (var key in target)
{
if (Object.hasOwn(target, key) && Object.hasOwn(source, key) && target[key] == source[key])
delete target[key];
}
}
for (var i = 0; i < this.categories.length; i++)
{
if (this.categories[i].icon && !icons.includes(this.categories[i].icon))
icons.push(this.categories[i].icon);
}
for (var i = 0; i < 1000; i++)
{
points.push({x: Math.floor(Math.random() * this.size.width), y: Math.floor(Math.random() * this.size.height)});
iconIndexes.push(Math.floor(Math.random() * (icons.length - 0) + 0));
}
// Create blobs from HTMImageElements
Promise.resolve()
.then(function(blobs)
{
return Promise.all(icons.map(function(icon, index)
{
return createImageBitmap(icon.img, { resizeWidth: icon.scaledWidth, resizeHeight: icon.scaledHeight, resizeQuality: "high" });
}));
})
.then(function(bitmaps)
{
for (var i = 0; i < bitmaps.length; i++)
{
var icon = icons[i];
icon.bitmap = bitmaps[i];
}
})
.finally(function()
{
initialized = true;
});
var stylesLookup = new Map();
var pathsByStyle = [];
// Process styles, performing some error checking and creating a lookup table
for (var i = 0; i < this.config.styles.length; i++)
{
var s = this.config.styles[i];
var error = false;
if (!s.id)
{
console.error("Path style at index " + index + " does not contain an id!");
error = true;
}
if (stylesLookup.has(s.id))
{
console.error("Path style at index " + index + " has an ID that is used by another style");
error = true;
}
// Remove this style from config
if (error)
{
this.config.styles.splice(i, 1);
i--;
continue;
}
// Add this style to the lookup
else
{
stylesLookup.set(s.id, s);
if (this.config.canvasRenderOrderMode == "auto")
pathsByStyle.push({ id: s.id, style: s, paths: [], pathsWithOverrides: [] });
}
}
// Process paths, adding them to pathsByStyle
for (var i = 0; i < this.config.paths.length; i++)
{
var path = this.config.paths[i];
// Ensure ID uniqueness
if (!path.id || this.config.paths.some(function(p){ return p.id == path.id && p != path; }))
{
path.id = generateRandomString(8);
console.error("Path at the index " + i + " does not have a unique ID! Forced its ID to " + path.id);
}
var hasInheritedStyle = stylesLookup.has(path.styleId);
var hasOverrideStyle = path.style != undefined && typeof path.style == "object";
var styleGroup = null;
// Ensure that the styleId matches a style in pathStyles
if (!hasInheritedStyle && path.styleId != undefined)
{
console.error("Path " + path.id + " uses an ID of \"" + path.styleId + "\" that was not found in the styles array!");
delete path.styleId;
}
// Catch any paths that don't define a style at all
if (!hasInheritedStyle && !hasOverrideStyle)
{
console.error("Path " + path.id + " must contain either a styleId or should define its own style");
this.config.paths.splice(i, 1);
i--;
continue;
}
if (hasInheritedStyle && hasOverrideStyle)
{
removeDuplicateStyleProps(path.overrideStyle, stylesLookup.get(path.styleId));
}
// This is the final style that the path will use
var style = hasInheritedStyle && hasOverrideStyle ? jQuery.extend(true, {}, stylesLookup.get(path.styleId), path.overrideStyle) :
hasInheritedStyle && !hasOverrideStyle ? stylesLookup.get(path.styleId) :
!hasInheritedStyle && hasOverrideStyle ? path.style :
!hasInheritedStyle && !hasOverrideStyle ? null : null;
path.style = style;
if (path.pointsType == "coordinate")
{
path.position = path.points;
delete path.points;
delete path.pointsType;
delete path.pointsDepth;
}
// Smooth the vertices in this path
if (style.smoothing == true)
{
for (var p = 0; p < path.pointsFlat.length; p++)
{
if (path.pointsFlat[p].length * Math.pow(2, style.smoothingIterations) > 250000)
console.error("Path " + path.id + " at index " + i + " with " + style.smoothingIterations + " Chaikin iterations will not be smoothed as the number of points would exceed 250,000");
else
path.pointsFlat[p] = chaikin(path.pointsFlat[p], style.smoothingIterations);
}
}
// Paths are grouped by style, and drawn in the order of the styles array
if (this.config.canvasRenderOrderMode == "auto")
{
if (hasInheritedStyle)
{
// Get the style (group) it references
styleGroup = pathsByStyle.find(function(v) { return v.id == path.styleId; });
// If the path has overrides, add it to pathsWithOverrides
if (hasOverrideStyle)
styleGroup.pathsWithOverrides.push(path);
// Otherwise just add it to paths
else
styleGroup.paths.push(path);
}
else
{
// Create a new styleGroup that contains just this path and its unique style
styleGroup = { id: "_path_" + path.id + "_style_", style: path.style, paths: [ path ]};
// Insert a new styleGroup after the group of the last path (this is not always the end of the pathsByStyle array)
var lastStyleGroupIndex = i == 0 ? 0 : pathsByStyle.indexOf(this.config.paths[i - 1].styleGroup) + 1;
pathsByStyle.splice(lastStyleGroupIndex, 0, styleGroup);
}
}
// Paths are drawn in the same order of the paths array, just lump together adjacent paths with the same style
else if (this.config.canvasRenderOrderMode == "manual")
{
// If the last styleGroup uses the same style as this path, add this path to that group
if (hasInheritedStyle && i > 0 && this.paths[i - 1].styleGroup.id == path.styleId)
{
styleGroup = this.paths[i - 1].styleGroup;
// If the path has overrides, add it to pathsWithOverrides
if (hasOverrideStyle)
styleGroup.pathsWithOverrides.push(path);
// Otherwise just add it to paths
else
styleGroup.paths.push(path);
}
else
{
// Otherwise just create a whole new styleGroup
styleGroup = { id: "_path_" + path.id + "_style_", style: style, paths: [ path ]};
}
}
path.styleGroup = styleGroup;
}
this.pathsByStyle = pathsByStyle;
function applyStyleToCanvas(ctx, style)
{
if (style.fill == true)
{
if (style.fillColor != undefined) ctx.fillStyle = style.fillColor;
}
if (style.stroke == true)
{
if (style.strokeWidth != undefined) ctx.lineWidth = style.strokeWidth;
if (style.strokeColor != undefined) ctx.strokeStyle = style.strokeColor;
if (style.lineCap != undefined) ctx.lineCap = style.lineCap;
if (style.lineJoin != undefined) ctx.lineJoin = style.lineJoin;
if (style.miterLimit != undefined) ctx.miterLimit = style.miterLimit;
if (style.lineDashArray != undefined)
{
if (style.lineDashOffset != undefined) ctx.lineDashOffset = style.lineDashOffset;
ctx.setLineDash(style.lineDashArray);
}
}
if (style.shadowColor != undefined) ctx.shadowColor = style.shadowColor;
if (style.shadowBlur != undefined) ctx.shadowBlur = style.shadowBlur;
if (style.shadowOffset != undefined)
{
ctx.shadowOffsetX = style.shadowOffset[0];
ctx.shadowOffsetY = style.shadowOffset[1];
}
}
function drawPath(ctx, path)
{
switch (path.type)
{
case "polygon":
{
switch (path.pointsType)
{
case "single":
drawPolygon(ctx, path.points);
break;
case "singleWithHoles":
drawSinglePolygonWithHoles(ctx, path.points);
break;
case "multiple":
drawMultiplePolygonsWithHoles(ctx, path.points);
break;
}
break;
}
case "polyline":
{
switch (path.pointsType)
{
case "single":
drawPolyline(ctx, path.points);
break;
case "multiple":
drawMultiplePolylines(ctx, path.points);
break;
}
break;
}
case "circle":
{
if (path.pointsType == "single")
drawCircles(ctx, path.points, path.radius);
else
drawCircle(ctx, path.position, path.radius);
break;
}
case "ellipse":
{
drawEllipse(ctx, path.position, path.radiusX, path.radiusY, path.rotation);
break;
}
case "rectangle":
{
drawRectangle(ctx, path.position, path.width, path.height);
break;
}
case "rounded_rectangle":
{
drawRoundedRectangle(ctx, path.position, path.width, path.radii);
break;
}
}
if (path.type != "polyline")
{
if (path.style.fill == true)
ctx.fill(path.style.fillRule);
}
if (path.style.stroke == true)
ctx.stroke();
}
// Draw with just the moveTo and lineTo, no beginPath or closePath
function drawPoints(ctx, points)
{
ctx.moveTo(points[0][0], points[0][1]);
for (p = 1; p < points.length; p++)
ctx.lineTo(points[p][0], points[p][1]);
}
function drawPolyline(ctx, points)
{
ctx.beginPath();
drawPoints(ctx, points);
}
function drawMultiplePolylines(ctx, points)
{
ctx.beginPath();
for (mp = 0; mp < points.length; mp++)
drawPoints(ctx, points[mp]);
}
// Draw a single polygon, without holes
function drawPolygon(ctx, points)
{
ctx.beginPath();
drawPoints(ctx, points);
ctx.closePath();
}
function drawSinglePolygonWithHoles(ctx, points)
{
ctx.beginPath();
for (sp = 0; sp < points.length; sp++)
{
drawPoints(ctx, points[sp]);
ctx.closePath();
}
}
function drawMultiplePolygonsWithHoles(ctx, points)
{
for (mp = 0; mp < points.length; mp++)
drawSinglePolygonWithHoles(ctx, points[mp]);
}
function drawCircle(ctx, position, radius)
{
ctx.beginPath();
ctx.arc(position[0], position[1], radius, 0, Math.PI * 2);
}
function drawCircles(ctx, points, radius)
{
ctx.beginPath();
for (p = 0; p < points.length; p++)
{
ctx.moveTo(points[p][0], points[p][1]);
ctx.arc(points[p][0], points[p][1], radius, 0, Math.PI * 2);
}
}
function drawEllipse(ctx, position, radiusX, radiusY, rotation)
{
ctx.beginPath();
ctx.ellipse(position[0], position[1], radiusX, radiusY, rotation, 0, Math.PI * 2);
}
function drawRectangle(ctx, position, width, height)
{
ctx.beginPath();
ctx.rect(position[0], position[1], width, height);
}
function drawRoundedRectangle(ctx, position, width, height, radii)
{
ctx.beginPath();
ctx.roundRect(position[0], position[1], width, height, radii);
}
var offset = [0,0], lastOffset = [NaN,NaN];
var ratio, lastRatio, mapPanePos, baseImagePos, baseImageLayerSize;
var i, j, p, mp, sp, path;
var render = function()
{
var start = performance.now();
// Reset the transform matrix so we're not applying it additively
ctx.setTransform(1, 0, 0, 1, 0, 0);
//ctx.reset();
//ctx.setTransform(ratio, 0, 0, ratio, offset[0], offset[1]);
// Clear the new buffer,
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// Translate so that we start drawing the map in the top left of the base image canvas
ctx.translate(offset[0], offset[1]);
// Commented out, for some reason scaling the coordinate system will make images blurry
// even if the ratio is factored out of the scale. This does mean we have to manually scale
// up and down the coordinates, but it's a good trade-off if it means crispy images
ctx.scale(ratio, ratio);
/*
for (var i = 0; i < points.length; i++)
{
var icon = icons[iconIndexes[i]];
// Scale the points so they operate at the current scale
// Round the pixels so that we're drawing across whole pixels (and not fractional pixels)
var x = Math.round(points[i].x * ratio) - Math.round(icon.scaledWidth / 2);
var y = Math.round(points[i].y * ratio) - Math.round(icon.scaledWidth / 2);
var width = icon.scaledWidth;
var height = icon.scaledHeight;
ctx.drawImage(icon.bitmap, x, y, width, height);
}
for (var i = 0; i < points.length; i++)
{
var icon = icons[iconIndexes[i]];
// Scale the points so they operate at the current scale
// Round the pixels so that we're drawing across whole pixels (and not fractional pixels)
ctx.drawImage(icon.bitmap, Math.round(points[i].x - (icon.scaledWidth / ratio / 2)),
Math.round(points[i].y - (icon.scaledHeight / ratio / 2)),
Math.round(icon.scaledWidth / ratio),
Math.round(icon.scaledHeight / ratio));
}
*/
for (i = 0; i < this.pathsByStyle.length; i++)
{
currentStyle = this.pathsByStyle[i].style;
// Apply the style to the context
applyStyleToCanvas(ctx, this.pathsByStyle[i].style);
// Draw all the shapes
for (j = 0; j < this.pathsByStyle[i].paths.length; j++)
{
drawPath(ctx, this.pathsByStyle[i].paths[j]);
}
// Draw all the overrides
for (j = 0; j < this.pathsByStyle[i].pathsWithOverrides.length; j++)
{
applyStyleToCanvas(ctx, this.pathsByStyle[i].pathsWithOverrides[j].style);
drawPath(ctx, this.pathsByStyle[i].pathsWithOverrides[j]);
}
}
for (var i = 0; i < points.length; i++)
{
continue;
ctx.beginPath();
ctx.arc(points[i].x, points[i].y, 5, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
}
// Write offscreen canvas to onscreen canvas
//rctx.clearRect(0, 0, rctx.canvas.width, rctx.canvas.height);
//rctx.drawImage(offscreenCanvas, 0, 0);
log("Rendered canvas in " + Math.round(performance.now() - start) + "ms");
}.bind(this);
var renderId, lastRequestTime, continuousRenderEnabled;
var updateCanvas = function()
{
// Negate the map-pane transformation so the canvas stays in the same place (over the leaflet canvas)
mapPanePos = this.getElementTransformPos(this.elements.leafletMapPane);
canvas.style.transform = "translate(" + Math.round(-mapPanePos[0]) + "px, " + Math.round(-mapPanePos[1]) + "px)";
// Calculate a transform offset so that we start drawing the map in the top left of the base image canvas
baseImagePos = this.getElementTransformPos(this.elements.leafletBaseImageLayer, true);
offset = [ mapPanePos[0] + baseImagePos[0], mapPanePos[1] + baseImagePos[1] ];
// This ratio is a multiplier to the coordinate system so that coordinates are scaled down to the scale of the canvas
// allowing us to use pixel coordinates and have them translate correctly (this does mean that sizes also scale
// we can negate this by dividing sizes by the ratio)
baseImageLayerSize = this.getElementSize(this.elements.leafletBaseImageLayer);
ratio = Math.min(baseImageLayerSize[0] / this.size.width, baseImageLayerSize[1] / this.size.height);
}.bind(this);
var renderOnce = function(dontUpdate)
{
if (!dontUpdate) updateCanvas();
// Don't render if the offset or ratio didn't actually change
if (ratio == lastRatio && offset[0] == lastOffset[0] && offset[1] == lastOffset[1])
{
console.log("Skipping render");
}
else
{
lastOffset = offset;
lastRatio = ratio;
render();
}
}.bind(this);
var renderLoop = function(time)
{
if (lastRequestTime < time)
{
renderOnce();
}
lastRequestTime = time;
// Queue next render
if (continuousRenderEnabled == true)
renderId = requestAnimationFrame(renderLoop);
}.bind(this);
// This function should be called when we want continuous render to be turned on or off
// If does not actually toggle continuous render, it just updates the state depending on whether
// the map is currently being dragged, panned, or is zooming
var triggerContinuousRender = function()
{
var enabled = (this.isDragging && this.isDraggingMove) || this.isPanning || this.isZooming;
if (this.isZoomingStatic) enabled = false;
if (continuousRenderEnabled != enabled)
{
log("Toggled continuous rendering " + (enabled ? "on" : "off"));
continuousRenderEnabled = enabled;
//canvas.classList.remove("leaflet-zoom-animation");
// Request render for next frame
cancelAnimationFrame(renderId);
requestAnimationFrame(renderLoop);
}
}.bind(this);
// Instead of redrawing the canvas for every frame of the zoom
// Scale it using a CSS transform and transition,
// then render a single frame and scale back to the original
this.events.onMapZoomed.subscribe(function(args)
{
// If we're dragging or panning while a zoom is initiated, do a continuous render instead of a CSS scale
if (((this.isDragging && this.isDraggingMove) || this.isPanning) && this.isZoomingStatic == false)
{
// Ensure we're no longer performing CSS scale
canvas.classList.remove("leaflet-zoom-animated");
triggerContinuousRender();
return;
}
this.isZoomingStatic = args.value;
triggerContinuousRender();
// Perform a CSS scale animation
if (args.value == true)
{
cancelAnimationFrame(renderId);
this.zoomEndTransform = this.getElementTransformPos_css(this.elements.leafletBaseImageLayer);
this.zoomEndViewportPos = this.transformToViewportPosition(this.zoomEndTransform);
this.zoomEndSize = [ this.zoomStartSize[0] * args.scaleDelta, this.zoomStartSize[1] * args.scaleDelta ];
this.zoomScaleDelta = args.scaleDelta;
this.zoomScale = args.scale;
// Typically the center of the zoom will be
// - the center of the screen (if button or key zooming),
// - the mouse position (if wheel zooming)
// - the center of the drawn box (if box zooming)
// However, Leaflet modifies the origin such that a zoom out does not result in the map having to be moved back
// Reverse engineering the methods by which leaflet calculates this is too difficult, but since we already have
// the before and after rects, we can just use that to determine a "center of enlargement", which will either be
// the center of zoom above, or a clamped/modified origin
// Determine the center of enlargement by:
// - Drawing a line between the top left corner of the baseImageLayer pre-transform, to the top left corner post-transform
// - Drawing another line between the top right corner pre-transform, to the top right post-transform
// - Calculating the intersection between those lines
var zoomStartTopRight = [ this.zoomStartViewportPos[0] + this.zoomStartSize[0], this.zoomStartViewportPos[1] ];
var zoomEndTopRight = [ this.zoomEndViewportPos[0] + this.zoomEndSize[0], this.zoomEndViewportPos[1] ];
var intersectionPoint = getIntersectionPoint([ this.zoomStartViewportPos, this.zoomEndViewportPos ], [ zoomStartTopRight, zoomEndTopRight ]);
// Apply the scale transformation at the origin (the leaflet-zoom-animation class handles the transition itself)
canvas.style.transformOrigin = intersectionPoint[0] + "px " + intersectionPoint[1] + "px";
// Perform "preemptive scaling"
var startScale, endScale;
// When we're zooming in, use the current render and scale up
if (args.scaleDelta >= 1.0)
{
startScale = 1.0;
endScale = args.scaleDelta;
}
// When we're zooming out, we don't want the edges of the canvas showing
// Render at the end scale, scale the canvas up so that it appears the same size as the current render, and then animate it scale down
else
{
startScale = 1.0 / args.scaleDelta;
endScale = 1.0;
// Negate the map-pane transformation so the canvas stays in the same place (over the leaflet canvas)
mapPanePos = this.getElementTransformPos(this.elements.leafletMapPane);
canvas.style.transform = "translate(" + Math.round(-mapPanePos[0]) + "px, " + Math.round(-mapPanePos[1]) + "px)";
// Calculate a transform offset so that we start drawing the map in the top left of the base image canvas
offset = [ mapPanePos[0] + this.zoomEndTransform[0], mapPanePos[1] + this.zoomEndTransform[1] ];
ratio = Math.min(this.zoomEndSize[0] / this.size.width, this.zoomEndSize[1] / this.size.height);
renderOnce(true);
}
// We have better control over initial states using the Web Animation API versus CSS transitions,
// So while we could use the leaflet-zoom-anim / leaflet-zoom-animated classes, doing it this way
// Means we don't need to use any tricks when we want an initial state that differs from the current
canvas.animate(
[{ transform: canvas.style.transform + " scale(" + startScale + ")" },
{ transform: canvas.style.transform + " scale(" + endScale + ")" } ],
{
easing: "cubic-bezier(0, 0, 0.25, 1)",
duration: 250
})
.addEventListener("finish", function()
{
//canvas.style.transform = canvas.style.transform + " scale(" + endScale + ")";
renderOnce();
});
log("Started CSS canvas scale animation to x" + args.scale + " (" + args.scaleDelta + ") at an origin of " + intersectionPoint);
}
else
{
// Remove the scale transformation
//canvas.style.transformOrigin = "";
//var scaleIndex = canvas.style.transform.indexOf("scale(");
//if (scaleIndex >= 0) canvas.style.transform = canvas.style.transform.substring(0, scaleIndex);
if (this.zoomScaleDelta >= 1.0)
{
// Render the canvas once, which also moves the transform back to fill the viewport
//renderOnce();
}
}
}.bind(this));
this.events.onMapDragged.subscribe(triggerContinuousRender);
this.events.onMapDraggedMove.subscribe(triggerContinuousRender);
this.events.onMapPanned.subscribe(triggerContinuousRender);
this.events.onMapResized.subscribe(function()
{
var leafletContainerSize = this.getElementSize(this.elements.leafletContainer);
canvas.width = leafletContainerSize[0];
canvas.height = leafletContainerSize[1];
renderOnce();
}.bind(this));
},
// Canvas
initThreadedCanvas: function()
{
// Create a pane to contain all the ruler points
var canvasPane = document.createElement("div");
canvasPane.className = "leaflet-pane leaflet-canvas-pane";
this.elements.leafletCanvasPane = canvasPane;
this.elements.leafletTooltipPane.after(canvasPane);
// Although modern browsers technically double buffer canvases already, we still need to keep a double buffer
// because of the flicker encountered when changing the transform at the same time as setting a new canvas.
// The performance is the same, it's just that we can keep the old frame visible on screen in the space between
// clearing the screen and drawing the new frame
var canvas1 = document.createElement("canvas");
var canvas2 = document.createElement("canvas");
canvas1.style.pointerEvents = canvas2.style.pointerEvents = "none";
canvas1.style.willChange = canvas2.style.willChange = "transform";
this.elements.leafletCanvasPane.appendChild(canvas1);
this.elements.leafletCanvasPane.appendChild(canvas2);
// Set the canvas width and height to be the size of the container, so that we're always drawing at the optimal resolution
// We can't set it to the scaled size of the map image because at high zoom levels the max pixel count will be exceeded
var leafletContainerSize = this.getElementSize(this.elements.leafletContainer);
canvas1.width = canvas2.width = leafletContainerSize[0];
canvas1.height = canvas2.height = leafletContainerSize[1];
var points = [];
var urlIndexes = [];
var icons = [];
for (var i = 0; i < this.categories.length; i++)
{
if (this.categories[i].icon && !icons.includes(this.categories[i].icon))
icons.push( { url: this.categories[i].icon.url,
width: this.categories[i].icon.width,
height: this.categories[i].icon.height,
scaledWidth: this.categories[i].icon.scaledWidth,
scaledHeight: this.categories[i].icon.scaledHeight });
}
for (var i = 0; i < 1000; i++)
{
points.push({x: Math.floor(Math.random() * this.size.width), y: Math.floor(Math.random() * this.size.height)});
urlIndexes.push(Math.floor(Math.random() * (icons.length - 0) + 0));
}
// Create a new Blob which contains our code to execute in order to render the canvas in a separate thread
// var blob = new Blob([`
// var canvas1, canvas2, ctx1, ctx2, points, images, indexes;
// var initialized; // Whether the canvas has been initialized and is ready to render
// var offset, ratio; // Current offsets and scale of the canvas
// var bufferState; // The current buffer being worked on
// var renderId; // requestAnimationFrame id
// var intervalId; // setTimeout id
// var renderMode = "once"; // The current render mode
// var renderInterval = 300; // The current render interval
// var doubleBuffered = true; // Whether double buffering is currently enabled
// var renderRequestTime, renderStartTime, renderEndTime, lastRenderTime;
// // Below are control functions
// function startRender(args)
// {
// //stopRender();
// renderMode = args.mode;
// renderInterval = args.interval;
// doubleBuffered = args.doubleBuffered;
// requestRender();
// }
// function stopRender()
// {
// renderMode = "once";
// clearTimeout(intervalId);
// cancelAnimationFrame(renderId);
// // Do one more render with the renderMode of "once"
// requestRender();
// }
// // Below are internal functions
// // Asks the host to update the canvas offset and ratio before we can update
// function requestRender()
// {
// // If we're double buffering, invert the state so that we're working on the other canvas
// if (doubleBuffered) bufferState = !bufferState;
// renderRequestTime = performance.now();
// self.postMessage({cmd: "requestUpdate", bufferState: bufferState});
// }
// // This is called when the renderRequest returned a response
// function onBeginRender()
// {
// if (!initialized) return;
// renderStartTime = performance.now();
// // Cancel the last requested render
// cancelAnimationFrame(renderId);
// // Schedule a new render
// renderId = requestAnimationFrame(render);
// }
// // This is called after the render completed
// function onEndRender()
// {
// renderEndTime = performance.now();
// console.log("Rendered canvas " + (bufferState ? 1 : 2) + " in " + Math.round(renderEndTime - renderStartTime) + "ms");
// // Tell the main thread the render is done, so that the canvas may be presented
// self.postMessage({ cmd: "present", bufferState: bufferState });
// // Queue another render if required
// if (renderMode == "continuous")
// {
// requestRender();
// }
// else if (renderMode == "interval")
// {
// var interval = Math.max(0, renderInterval - (renderEndTime - renderStartTime));
// intervalId = setTimeout(function(){ requestRender(); }, interval);
// }
// }
// function render(time)
// {
// // Don't render if no time has passed since the last render
// if (lastRenderTime != time)
// {
// var ctx = bufferState ? ctx1 : ctx2;
// // Reset the transform matrix so we're not applying it additively
// ctx.setTransform(1, 0, 0, 1, 0, 0);
// // Clear the new buffer,
// ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// // Translate so that we start drawing the map in the top left of the base image canvas
// ctx.translate(offset[0], offset[1]);
// // Commented out, for some reason scaling the coordinate system will make images blurry
// // even if the ratio is factored out of the scale. This does mean we have to manually scale
// // up and down the coordinates, but it's a good trade-off if it means crispy images
// //ctx.scale(ratio, ratio);
// for (var i = 0; i < points.length; i++)
// {
// var icon = icons[indexes[i]];
// // Scale the points so they operate at the current scale
// // Round the pixels so that we're drawing across whole pixels (and not fractional pixels)
// var x = Math.round((points[i].x * ratio) - (icon.scaledWidth / 2));
// var y = Math.round((points[i].y * ratio) - (icon.scaledHeight / 2));
// var width = icon.scaledWidth;
// var height = icon.scaledHeight;
// ctx.drawImage(icon.bitmap, x, y, width, height);
// /*
// ctx.beginPath();
// ctx.arc(points[i].x, points[i].y, 2 / ratio, 0, 2 * Math.PI);
// ctx.fill();
// */
// }
// }
// lastRenderTime = time;
// onEndRender();
// }
// self.addEventListener("message", function(e)
// {
// switch (e.data.cmd)
// {
// case "poke":
// {
// console.log("Ouch!");
// break;
// }
// // Initialize the worker. This is passed the points array, and the OffscreenCanvas (which we cache)
// case "init":
// {
// points = e.data.points;
// ctx1 = e.data.canvas1.getContext("2d");
// ctx2 = e.data.canvas2.getContext("2d");
// indexes = e.data.indexes;
// var requests = e.data.icons.map(function(i) { return fetch(i.url); });
// var responses = Promise.all(requests)
// .then(function(values)
// {
// return Promise.all(values.map(function(r) { return r.blob(); }));
// })
// .then(function(blobs)
// {
// return Promise.all(blobs.map(function(b, index)
// {
// var icon = e.data.icons[index];
// return createImageBitmap(b, { resizeWidth: icon.scaledWidth, resizeHeight: icon.scaledHeight, resizeQuality: "high" });
// }));
// })
// .then(function(bitmaps)
// {
// icons = [];
// for (var i = 0; i < bitmaps.length; i++)
// {
// var icon = e.data.icons[i];
// icon.bitmap = bitmaps[i];
// icons.push(icon);
// }
// })
// .finally(function()
// {
// initialized = true;
// });
// break;
// }
// case "start":
// {
// startRender(e.data);
// break;
// }
// case "stop":
// {
// stopRender();
// break;
// }
// // The host updated the drawing offset, ratio, and the buffer we're working on
// case "update":
// {
// // Update the drawing offset, drawing ratio, and the buffer we're working on
// if (e.data.offset) offset = e.data.offset;
// if (e.data.ratio) ratio = e.data.ratio;
// // Resize the canvas if a new size was passed
// if (e.data.size && (e.data.size[0] != ctx1.canvas.width || e.data.size[1] != ctx1.canvas.height))
// {
// ctx1.canvas.width = ctx2.canvas.width = e.data.size[0];
// ctx1.canvas.height = ctx2.canvas.height = e.data.size[1];
// }
// onBeginRender();
// break;
// }
// }
// });
// `]);
// Create a blob with the data above (this is the only way to make a new worker without creating a separate file)
var blobUrl = window.URL.createObjectURL(blob);
var worker = new Worker(blobUrl);
var offscreenCanvas1 = canvas1.transferControlToOffscreen();
var offscreenCanvas2 = canvas2.transferControlToOffscreen();
// Initialize the worker with these canvases
worker.postMessage({ cmd: "init", canvas1: offscreenCanvas1, canvas2: offscreenCanvas2, points: points, icons: icons, indexes: urlIndexes }, [offscreenCanvas1, offscreenCanvas2]);
worker.addEventListener("message", function(e)
{
// Present the updated buffer and hide the old one
if (e.data.cmd == "present")
{
var canvasNew = e.data.bufferState ? canvas1 : canvas2;
var canvasOld = e.data.bufferState ? canvas2 : canvas1;
canvasNew.hidden = false;
canvasOld.hidden = true;
}
// The worker requested updated transformation state
if (e.data.cmd == "requestUpdate")
{
var canvas = e.data.bufferState ? canvas1 : canvas2;
// Negate the map-pane transformation so the canvas stays in the same place (over the leaflet canvas)
var mapPanePos = this.getElementTransformPos(this.elements.leafletMapPane);
canvas.style.transform = "translate(" + -mapPanePos[0] + "px, " + -mapPanePos[1] + "px)";
// Calculate a transform offset so that we start drawing the map in the top left of the base image canvas
var baseImagePos = this.getElementTransformPos(this.elements.leafletBaseImageLayer, true);
var offset = [ mapPanePos[0] + baseImagePos[0], mapPanePos[1] + baseImagePos[1] ];
// This ratio is a multiplier to the coordinate system so that coordinates are scaled down to the scale of the canvas
// allowing us to use pixel coordinates and have them translate correctly (this does mean that sizes also scale
// we can negate this by dividing sizes by the ratio)
var baseImageLayerSize = this.getElementSize(this.elements.leafletBaseImageLayer);
var ratio = Math.min(baseImageLayerSize[0] / this.size.width, baseImageLayerSize[1] / this.size.height);
// Send the updated data to the worker
worker.postMessage({ cmd: "update", offset: offset, ratio: ratio });
}
}.bind(this));
// Redraws the canvas every <interval> milliseconds until called again with value == false
function doContinuousRender(value)
{
if (value == true)
worker.postMessage({ cmd: "start", mode: "continuous", doubleBuffered: false });
else
worker.postMessage({ cmd: "stop" });
}
function doIntervalRender(value)
{
if (value == true)
worker.postMessage({ cmd: "start", mode: "interval", interval: 300, doubleBuffered: true });
else
worker.postMessage({ cmd: "stop" });
}
var renderState = false;
var doRenderBasedOnMapState = function()
{
var state = (this.isDragging || this.isPanning || this.isZooming);
if (state != renderState)
{
renderState = state;
doContinuousRender(state);
}
}.bind(this);
this.events.onMapZoomed.subscribe(doRenderBasedOnMapState);
this.events.onMapDragged.subscribe(doRenderBasedOnMapState);
this.events.onMapPanned.subscribe(doRenderBasedOnMapState);
this.events.onMapResized.subscribe(function()
{
var leafletContainerSize = this.getElementSize(this.elements.leafletContainer);
worker.postMessage({ cmd: "update", size: leafletContainerSize });
}.bind(this));
},
// Ruler
initRuler: function()
{
// Create a pane to contain all the ruler points
var rulerPane = document.createElement("div");
rulerPane.className = "leaflet-pane leaflet-ruler-pane";
this.elements.leafletRulerPane = rulerPane;
this.elements.leafletTooltipPane.after(rulerPane);
var prev, zoomStepTimeoutId, zoomStepId, zoomStepFn = function(time)
{
// Only apply the new transform if the time actually changed
if (prev != time)
{
if (this.elements.rulerPoints)
{
for (var i = 0; i < this.elements.rulerPoints.length; i++)
{
var elem = this.elements.rulerPoints[i];
var pixelPos = elem._pixel_pos;
// This is a combined pixel to scaled, then scaled to transform function
var imageSize = this.getScaledMapImageSize(true);
var baseLayerPos = this.getElementTransformPos(this.elements.leafletBaseImageLayer, true);
// Scale the pixel position back up to the scaled range and add the position
// of the base layer to the scaled position to get the transform position
var transformPos = [ ((pixelPos[0] / this.size.width) * imageSize[0]) + baseLayerPos[0],
((pixelPos[1] / this.size.width) * imageSize[0]) + baseLayerPos[1] ];
// Set the transform position of the element back to the _leaflet_pos (for caching)
elem._leaflet_pos.x = transformPos[0];
elem._leaflet_pos.y = transformPos[1];
elem.style.transform = "translate3d(" + transformPos[0] + "px, " + transformPos[1] + "px, 0px)";
}
}
}
prev = time;
zoomStepId = window.requestAnimationFrame(zoomStepFn);
}.bind(this);
// Subscribe to an event that fires on the start and end of the zoom
// in order to animate the popup transform alongside the marker transform
this.events.onMapZoomed.subscribe(function(e)
{
// Cancel the last callback so that we're not running two at the same time
window.cancelAnimationFrame(zoomStepId);
window.clearInterval(zoomStepTimeoutId);
// Zoom start
if (e.value == true)
{
// Start a new animation
zoomStepId = window.requestAnimationFrame(zoomStepFn);
// Start a timeout for it too
// This is more of a safety mechanism if anything, we don't want a situation where our zoomStep function is looping indefinetely
zoomStepTimeoutId = window.setTimeout(function() { window.cancelAnimationFrame(zoomStepId); }, 300);
}
// Zoom end
else
{
}
}.bind(this));
this.events.onMapClicked.subscribe(function(args)
{
if (args.wasDragging) return;
var transformPosOfClick = this.clientToTransformPosition([ args.event.clientX, args.event.clientY ]);
var pixelPosition = this.scaledToPixelPosition(this.clientToScaledPosition([ args.event.clientX, args.event.clientY ]));
var dot = document.createElement("div");
dot.className = "mapsExtended_rulerDot";
dot.style.cssText = "transform: translate3d(" + transformPosOfClick[0] + "px, " + transformPosOfClick[1] + "px, 0px);";
dot.innerHTML = "<svg viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"50\" cy=\"50\" r=\"38\" stroke-width=\"16\"></circle></svg>";
dot._leaflet_pos = { x: transformPosOfClick[0], y: transformPosOfClick[1] };
dot._pixel_pos = pixelPosition;
this.elements.leafletRulerPane.appendChild(dot);
this.elements.rulerPoints = this.elements.rulerPoints || [];
this.elements.rulerPoints.push(dot);
}.bind(this));
},
// Fullscreen
// This is called by the click and keydown event on the fullscreen button
onFullscreenButton: function(e)
{
// If this is a keyboard event, only continue if it's a enter keypress
if (e instanceof KeyboardEvent && e.key != "Enter")
return;
else if (e instanceof PointerEvent)
e.stopPropagation();
// Remove marker query parameter from URL so that when the map goes fullscreen, it isn't zoomed into the marker again
var url = window.location;
if (urlParams.has("marker"))
{
urlParams.delete("marker");
window.history.replaceState({}, document.title, url.origin + url.pathname + (urlParams != "" ? "?" : "") + urlParams.toString() + url.hash);
}
// Always exit fullscreen if in either mode
if (this.isFullscreen || this.isWindowedFullscreen)
{
if (this.isFullscreen) this.setFullscreen(false);
if (this.isWindowedFullscreen) this.setWindowedFullscreen(false);
}
// If control key is pressed, use the opposite mode
else if (e.ctrlKey || e.metaKey)
{
if (this.config.fullscreenMode == "screen")
this.setWindowedFullscreen(true);
else if (this.config.fullscreenMode == "window")
this.setFullscreen(true);
}
// Otherwise use the default mode
else
{
if (this.config.fullscreenMode == "screen")
this.setFullscreen(true);
else if (this.config.fullscreenMode == "window")
this.setWindowedFullscreen(true);
}
},
// Transition the map to and from fullscreen
setFullscreen: function(value)
{
// Don't do anything if we're currently transitioning to or from fullscreen
if (this.isFullscreenTransitioning == true) return;
// Return if the map is already the requested state
if (this.isFullscreen == value) return;
this.isFullscreenTransitioning = true;
if (value == true)
{
return this.elements.rootElement.requestFullscreen()
.catch(function(error)
{
console.error("Error attempting to enable fullscreen mode: " + error.message + " (" + error.name + ")");
});
}
else if (value == false)
return document.exitFullscreen();
else
return Promise.resolve();
},
setWindowedFullscreen: function(value)
{
this.isWindowedFullscreen = value;
// Save the scroll position
if (value) this.fullscreenScrollPosition = window.scrollY;
// Toggle some classes which do most of the heavy lifting
document.documentElement.classList.toggle("windowed-fullscreen", value);
// Toggle the fullscreen class on the root element
this.elements.rootElement.classList.toggle("mapsExtended_fullscreen", value);
// Enter windowed fullscreen
if (value)
{
}
// Exit windowed fullscreen
else
{
// Restore the scroll position
window.scroll({ top: this.fullscreenScrollPosition, left: 0, behavior: "auto" });
}
// Change the tooltip that is shown to the user on hovering over the button
this.elements.fullscreenControlButton.setAttribute("title", value ? mapsExtended.i18n.msg("fullscreen-exit-tooltip").plain()
: mapsExtended.i18n.msg("fullscreen-enter-tooltip").plain());
this.elements.fullscreenControlButton.classList.toggle("leaflet-control-fullscreen-button-zoom-in", !this.isWindowedFullscreen);
this.elements.fullscreenControlButton.classList.toggle("leaflet-control-fullscreen-button-zoom-out", this.isWindowedFullscreen);
this.events.onMapFullscreen.invoke({ map: this, fullscreen: value, mode: "window" });
},
toggleFullscreen: function(value)
{
this.setFullscreen(!this.isFullscreen);
},
toggleWindowedFullscreen: function(value)
{
this.setWindowedFullscreen(!this.isWindowedFullscreen);
},
initFullscreenStyles: once(function()
{
// Change scope of rule covering .leaflet-control-zoom to cover all leaflet-control
changeCSSRuleSelector(".Map-module_interactiveMap__135mg .leaflet-control-zoom",
".Map-module_interactiveMap__135mg .leaflet-control");
changeCSSRuleSelector(".Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-in, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-out",
".Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-in, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-out, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-fullscreen-button, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-popup-button");
changeCSSRuleSelector(".leaflet-control-zoom-in, .leaflet-control-zoom-out",
".leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-fullscreen-button, .leaflet-control-popup-button");
changeCSSRuleSelector(".Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-in:hover, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-out:hover",
".Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-in:hover, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-out:hover, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-fullscreen-button:hover, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-popup-button:hover");
changeCSSRuleSelector(".Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-in:active, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-out:active",
".Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-in:active, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-zoom-out:active, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-fullscreen-button:active, .Map-module_interactiveMap__135mg .leaflet-bar .leaflet-control-popup-button:active");
changeCSSRuleText(".leaflet-touch .leaflet-bar a:first-child", "border-top-left-radius: 3px; border-top-right-radius: 3px;");
changeCSSRuleText(".leaflet-touch .leaflet-bar a:last-child", "border-bottom-left-radius: 3px; border-bottom-right-radius: 3px;");
}, window),
// Creates a fullscreen button for the map, sets up various events to control fullscreen
initFullscreen: function(isNew)
{
this.isFullscreen = this.isWindowedFullscreen = false;
// Modify and set up some styles - this is only executed once
this.initFullscreenStyles();
// Remove built-in fullscreen button
if (this.elements.fullscreenButton) this.elements.fullscreenButton.remove();
// Don't continue if fullscreen is disabled
if (this.config.enableFullscreen == false)
return;
// Fullscreen button - Create a new leaflet-control before the zoom control which when clicked will toggle fullscreen
var fullscreenControl = document.createElement("div");
fullscreenControl.className = "leaflet-control-fullscreen leaflet-bar leaflet-control";
var fullscreenControlButton = document.createElement("a");
fullscreenControlButton.className = "leaflet-control-fullscreen-button leaflet-control-fullscreen-button-zoom-in";
fullscreenControlButton.setAttribute("tabindex", "0");
fullscreenControlButton.setAttribute("title", mapsExtended.i18n.msg("fullscreen-enter-tooltip").plain());
mw.hook("dev.wds").add(function(wds)
{
var zoomInIcon = wds.icon("zoom-in-small");
var zoomOutIcon = wds.icon("zoom-out-small");
fullscreenControlButton.appendChild(zoomInIcon);
fullscreenControlButton.appendChild(zoomOutIcon);
});
fullscreenControl.appendChild(fullscreenControlButton);
this.elements.leafletControlContainerBottomRight.prepend(fullscreenControl);
this.elements.fullscreenControl = fullscreenControl;
this.elements.fullscreenControlButton = fullscreenControlButton;
// Click event on fullscreen button
fullscreenControlButton.addEventListener("click", this.onFullscreenButton.bind(this));
fullscreenControlButton.addEventListener("keydown", this.onFullscreenButton.bind(this));
fullscreenControlButton.addEventListener("dblclick", stopPropagation);
fullscreenControlButton.addEventListener("mousedown", stopPropagation);
document.addEventListener("keydown", function(e)
{
if (!this.isFullscreen && !this.isWindowedFullscreen) return;
// True if the browser is in either Fullscreen API or browser-implemented fullscreen (via F11)
var inBrowserFullscreen = matchMedia("(display-mode: fullscreen)").matches;
// Escape pressed
if (e.keyCode == 27) // Escape
{
// Ignore if the lightbox is showing (close lightbox first)
if (document.getElementById("LightboxModal") != undefined)
return;
// ...while in windowed fullscreen and not browser fullscreen
if (this.isWindowedFullscreen)// && !inBrowserFullscreen)
this.setWindowedFullscreen(false);
}
}.bind(this));
this.elements.rootElement.addEventListener("fullscreenchange", function(e)
{
this.isFullscreen = document.fullscreenElement == e.currentTarget;
this.isFullscreenTransitioning = false;
// Toggle the fullscreen class on the document body
document.documentElement.classList.toggle("fullscreen", this.isFullscreen);
// Toggle the fullscreen class on the root map element
this.elements.rootElement.classList.toggle("mapsExtended_fullscreen", this.isFullscreen || this.isWindowedFullscreen);
// Change the tooltip that is shown to the user on hovering over the button
this.elements.fullscreenControlButton.setAttribute("title", this.isFullscreen || this.isWindowedFullscreen ? mapsExtended.i18n.msg("fullscreen-exit-tooltip").plain() : mapsExtended.i18n.msg("fullscreen-enter-tooltip").plain());
// Toggle classes on the fullscreen A element to influence which icon is displayed
this.elements.fullscreenControlButton.classList.toggle("leaflet-control-fullscreen-button-zoom-in", !this.isFullscreen && !this.isWindowedFullscreen);
this.elements.fullscreenControlButton.classList.toggle("leaflet-control-fullscreen-button-zoom-out", this.isFullscreen || this.isWindowedFullscreen);
// Move overlay elements to show on top of the fullscreen elements
this.moveOverlayElementsFullscreen(this.isFullscreen);
if (this.isFullscreen == true)
this.fullscreenOverlayObserver.observe(document.body, { childList: true });
else
this.fullscreenOverlayObserver.disconnect();
this.events.onMapFullscreen.invoke({ map: this, fullscreen: this.isFullscreen || this.isWindowedFullscreen, mode: "screen" });
}.bind(this));
// Add an observer which triggers every time elements get added to the document body while in fullscreen
this.fullscreenOverlayObserver = new MutationObserver(function(mutationList, observer)
{
// Don't use while not in actual fullscreen
if (!this.isFullscreen) return;
if (mutationList.some(function(ml) { return ml.type == "childList" && ml.addedNodes.length > 0; }))
{
this.moveOverlayElementsFullscreen(true);
}
}.bind(this));
},
moveOverlayElementsFullscreen: function(value)
{
var classes = ["notifications-placeholder", "oo-ui-windowManager", "lightboxContainer"];
classes.forEach(this.moveElementFullscreen.bind(this));
},
// This function is a general purpose function used to move elements to and from the map root so they appear while in fullscreen
// If entered fullscreen: Moves the element to the end of map.rootElement
// If exited fullscreen: Moves the element back to the body
moveElementFullscreen: function(className)
{
var value = this.isFullscreen;
var element = value ? document.querySelector("body > ." + className) : this.elements.rootElement.querySelector("." + className);
if (!element) return;
var isElementFullscreened = element.parentElement == this.elements.rootElement;
if (value && !isElementFullscreened)
this.elements.rootElement.append(element);
else if (!value && isElementFullscreened)
document.body.append(element);
},
// Controls
controlAssociations:
{
"zoom": { class: "leaflet-control-zoom" },
"fullscreen": { class: "leaflet-control-fullscreen" },
"edit": { class: "interactive-maps__edit-control", useParent: true }
},
// This may be called multiple times for one map, and should be because leaflet controls are recreated on deinitialization
initControls: function()
{
// Build a list of controls to look up where they are (we can't always assume where the controls are)
for (var key in this.controlAssociations)
{
var control = this.controlAssociations[key];
control.name = key;
control.element = this.elements.leafletControlContainer.querySelector("." + control.class);
control.isPresent = control.element != undefined;
control.isPresentInConfig = this.config.hiddenControls.includes(key) || this.config.mapControls.some(function(mc) { return mc.includes(key); });
control.position = "";
if (control.isPresent)
{
// Use parent of control if required
if (control.useParent == true)
{
control.element = control.element.parentElement;
}
// Determine location of control
if (control.element.parentElement.matches(".leaflet-bottom"))
{
if (control.element.parentElement.matches(".leaflet-left"))
control.position = "bottom-left";
else if (control.element.parentElement.matches(".leaflet-right"))
control.position = "bottom-right";
}
else if (control.element.parentElement.matches(".leaflet-top"))
{
if (control.element.parentElement.matches(".leaflet-left"))
control.position = "top-left";
else if (control.element.parentElement.matches(".leaflet-right"))
control.position = "top-right";
}
}
}
// Only modify control positions if mapControls is present, and all arrays within mapControls are an array
if (this.config.mapControls && Array.isArray(this.config.mapControls) && this.config.mapControls.length === 4 &&
this.config.mapControls.every(function(mc) { return mc != undefined && Array.isArray(mc); }));
{
for (var i = 0; i < this.config.mapControls.length; i++)
{
switch (i)
{
case 0:
{
var position = "top-left";
var container = this.elements.leafletControlContainerTopLeft;
break;
}
case 1:
{
var position = "top-right";
var container = this.elements.leafletControlContainerTopRight;
break;
}
case 2:
{
var position = "bottom-right";
var container = this.elements.leafletControlContainerBottomRight;
break;
}
case 3:
{
var position = "bottom-left";
var container = this.elements.leafletControlContainerBottomLeft;
break;
}
}
for (var j = 0; j < this.config.mapControls[i].length; j++)
{
var id = this.config.mapControls[i][j];
var controlToMove = this.controlAssociations[id];
// Control invalid
if (controlToMove == undefined)
log("No control found with the id " + id + " at mapControls[" + i + "][" + j + "] (" + position + ")");
// Control valid, present, and in a different position to the one requested
else if (controlToMove.isPresent && controlToMove.position != position)
{
controlToMove.position = position;
// Append the element under a new control container
container.appendChild(controlToMove.element);
}
}
}
}
// Hide controls in hiddenControls
if (this.config.hiddenControls && Array.isArray(this.config.hiddenControls) && this.config.hiddenControls.length > 0)
{
for (var i = 0; i < this.config.hiddenControls.length; i++)
{
var id = this.config.hiddenControls[i];
var controlToHide = this.controlAssociations[id];
// Control invalid
if (controlToHide == undefined)
log("No control found with the id " + id + " at hiddenControls[" + i + "]");
// Control valid and present
else if (controlToHide.isPresent)
{
controlToHide.hidden = true;
// Don't remove it from the DOM, just hide it
controlToHide.element.style.display = "none";
}
}
}
// For edit control, change position of dropdown depending on its location
var editControl = this.controlAssociations["edit"];
if (editControl.isPresent)
{
var dropdown = editControl.element.querySelector(".wds-dropdown");
var dropdownContent = editControl.element.querySelector(".wds-dropdown__content");
if (dropdown && dropdownContent)
{
// Bottom position adds wds-is-flipped to root
dropdown.classList.toggle("wds-is-flipped", editControl.position.startsWith("bottom"));
// Left/right adds wds-is-left-aligned / wds-is-right-aligned to content
dropdownContent.classList.toggle("wds-is-left-aligned", editControl.position.endsWith("left"));
dropdownContent.classList.toggle("wds-is-right-aligned", editControl.position.endsWith("right"));
}
}
// First time initializing, create rules to specifically hide controls in the wrong corner
// This helps to reduce flicker when the map is reinitialized and the controls have to be repositioned
if (!this.initializedOnce)
{
for (var key in this.controlAssociations)
{
var control = this.controlAssociations[key];
if (!control || !control.isPresent || control.isHidden)
continue;
var cornerSelector = "";
if (control.position.startsWith("bottom")) cornerSelector += ".leaflet-bottom";
else if (control.position.startsWith("top")) cornerSelector += ".leaflet-top";
if (control.position.endsWith("left")) cornerSelector += ".leaflet-left";
else if (control.position.endsWith("right")) cornerSelector += ".leaflet-right";
var selector = "." + this.mapId + "[id='" + this.instanceId + "'] .leaflet-control-container > *:not(" + cornerSelector + ") ." + control.class;
mapsExtended.stylesheet.insertRule(selector + " { display: none; }");
}
// If there are controls in the top left, edit the margins on the fullscreen filters panel
if (Array.isArray(this.config.mapControls[0]) && this.config.mapControls[0].length > 0)
mapsExtended.stylesheet.insertRule(".mapsExtended_fullscreen .interactive-maps .interactive-maps__filters-list { margin-left: 56px !important; }");
}
},
// Search
initSearch: function()
{
var search = {};
search.elements = {};
this.search = search;
// Create the search dropdown
var searchDropdown = document.createElement("div");
searchDropdown.className = "mapsExtended_searchDropdown wds-dropdown"
searchDropdown.innerHTML = "<div class=\"wds-dropdown__toggle\" role=\"button\"><button type=\"button\" class=\"wds-pill-button mapsExtended_searchDropdownButton\"><span class=\"wds-pill-button__icon-wrapper\"></span></button></div><div class=\"wds-dropdown__content wds-is-left-aligned wds-is-not-scrollable\"><div class=\"mapsExtended_search\"><div class=\"mapsExtended_searchBox wds-input has-hint\"><input class=\"wds-input__field\" id=\"mapsExtended_searchInput\" type=\"text\" placeholder=\"Search\"><div class=\"wds-input__hint-container\"><div class=\"wds-input__hint\">No results found</div></div></div><div class=\"mapsExtended_searchResults interactive-maps__filters-dropdown-list--can-scroll-down interactive-maps__filters-dropdown-list--can-scroll-up\"></div></div></div>";
// Add a search icon from wds-icons to the dropdown
mw.hook("dev.wds").add(function(wds)
{
var searchIcon = wds.icon("magnifying-glass-tiny");
var dropdownIcon = wds.icon("dropdown-tiny");
dropdownIcon.classList.add("wds-icon", "wds-pill-button__toggle-icon")
var wdsIconWrapper = searchDropdown.querySelector(".wds-pill-button__icon-wrapper");
wdsIconWrapper.appendChild(searchIcon);
wdsIconWrapper.after(dropdownIcon);
});
var searchRoot = searchDropdown.querySelector(".mapsExtended_search");
var searchBox = searchRoot.querySelector(".mapsExtended_searchBox");
var searchBoxInput = searchBox.querySelector("#mapsExtended_searchInput");
var searchBoxHint = searchBox.querySelector(".wds-input__hint");
var searchBoxHintContainer = searchBox.querySelector(".wds-input__hint-container");
var searchResultsList = searchRoot.querySelector(".mapsExtended_searchResults");
var searchDropdownContent = searchDropdown.querySelector(".wds-dropdown__content");
var searchDropdownButton = searchDropdown.querySelector(".mapsExtended_searchDropdownButton");
// Cache the elements
search.elements.searchRoot = searchRoot;
search.elements.searchBox = searchBox;
search.elements.searchBoxInput = searchBoxInput;
search.elements.searchBoxHint = searchBoxHint;
search.elements.searchBoxHintContainer = searchBoxHintContainer;
search.elements.searchResultsList = searchResultsList;
search.elements.searchDropdown = searchDropdown;
search.elements.searchDropdownContent = searchDropdownContent;
search.elements.searchDropdownButton = searchDropdownButton;
// Set some strings from i18n
searchBoxInput.setAttribute("placeholder", mapsExtended.i18n.msg("search-placeholder").plain());
this.updateSearchSubtitle();
/* Events and functions */
// Add a listener which fires when the input value of the search box changes. This drives search
searchBoxInput.addEventListener("input", function(e)
{
if (e.target.value == "" || e.target.value == undefined)
this.updateSearchList(this.search.emptySearch);
else
this.updateSearchList(this.searchMarkers(e.target.value));
}.bind(this));
// Resize the searchRoot to be a bit less than the height of the root map container
//searchRoot.style.maxHeight = (this.elements.rootElement.clientHeight - 35) + "px";
this.adjustMapDropdown(this.search.elements.searchDropdownButton, this.search.elements.searchDropdownContent);
// Add a listener which changes the min height of the search box when it is opened
searchDropdownButton.addEventListener("mouseenter", function(e)
{
this.adjustMapDropdown(this.search.elements.searchDropdownButton, this.search.elements.searchDropdownContent);
}.bind(this));
var onListItemHovered = function(e)
{
var marker = e.currentTarget.marker;
this.toggleMarkerHighlight(marker, e.type == "mouseenter");
}.bind(this);
var onListItemClicked = function(e)
{
var marker = e.currentTarget.marker;
if (!marker || !marker.markerElement) return;
if (!marker.category.visible || marker.category.disabled) return;
// Determine whether this item should be selected or unselected
var selected = marker.searchResultsItem.classList.contains("selected");
selected = !selected;
// Deselect the previous marker
if (selected == true && this.search.selectedMarker && marker != this.search.selectedMarker)
{
var deselectedMarker = this.search.selectedMarker;
this.search.selectedMarker = undefined;
this.toggleMarkerHighlight(deselectedMarker, false);
this.toggleMarkerSelected(deselectedMarker, false);
}
this.toggleMarkerHighlight(marker, selected);
this.toggleMarkerSelected(marker, selected);
}.bind(this);
var onCategoryHeaderHovered = function(e)
{
var category = e.currentTarget.category;
var show = e.type == "mouseenter";
this.toggleCategoryMarkerHighlight(category, show);
}.bind(this);
var onCategoryHeaderClicked = function(e)
{
var category = e.currentTarget.category;
var container = category.elements.searchResultsContainer;
container.classList.toggle("collapsed");
// Scroll to item if we've scrolled past it
if (searchResultsList.scrollTop > container.offsetTop)
searchResultsList.scrollTop = container.offsetTop;
}.bind(this);
this.events.onCategoryToggled.subscribe(function(args)
{
if (args.category.disabled) return;
// Deselect the current marker if it belongs to the category being filtered out
if (args.value == false && this.search.selectedMarker && this.search.selectedMarker.categoryId == args.category.id)
this.toggleMarkerSelected(this.search.selectedMarker, false);
// Toggle the "filtered" class on the container
args.category.elements.searchResultsContainer.classList.toggle("filtered", !args.value);
// Toggle the "collapsed" class on the container
args.category.elements.searchResultsContainer.classList.toggle("collapsed", !args.value);
this.updateSearchSubtitle();
}.bind(this));
this.events.onMarkerShown.subscribe(function(args)
{
if (this.search.lastSearch == undefined || this.search.lastSearch.isEmptySearch == true)
return;
// Re-apply search results class if the newly-shown markers are included in the results
if (this.search.lastSearch.markerMatches.includes(args.marker) && args.marker.markerElement)
args.marker.markerElement.classList.add("search-result");
}.bind(this));
search.elements.searchCategories = [];
for (var i = 0; i < this.categories.length; i++)
{
var category = this.categories[i];
if (category.disabled || category.startDisabled) continue;
var searchCategory = {};
searchCategory.category = category;
// Create a container for markers in this category
var container = document.createElement("div");
container.className = "mapsExtended_searchResults_container" + (category.visible ? "" : " filtered");
category.elements.searchResultsContainer = container;
// Create a header list item
var header = document.createElement("div");
header.className = "mapsExtended_searchResults_header";
header.category = category;
header.addEventListener("mouseenter", onCategoryHeaderHovered);
header.addEventListener("mouseleave", onCategoryHeaderHovered);
header.addEventListener("click", onCategoryHeaderClicked);
var headerIcon = category.elements.categoryIcon.cloneNode(true);
header.appendChild(headerIcon);
var headerTextWrapper = document.createElement("div");
var headerText = document.createElement("span");
headerText.textContent = category.name;
var headerCount = document.createElement("span");
headerTextWrapper.appendChild(headerText);
headerTextWrapper.appendChild(new Text(" "));
headerTextWrapper.appendChild(headerCount);
header.appendChild(headerTextWrapper);
category.elements.searchResultsHeader = header;
category.elements.searchResultsHeaderText = headerText;
category.elements.searchResultsHeaderCount = headerCount;
// Create a header wrapper
var headerWrapper = document.createElement("div");
headerWrapper.className = "mapsExtended_searchResults_headerWrapper";
headerWrapper.appendChild(header);
// Create an item wrapper
var itemsList = document.createElement("div");
itemsList.className = "mapsExtended_searchResults_items";
category.elements.searchResultsItemsList = itemsList;
container.appendChild(headerWrapper);
container.appendChild(itemsList);
// Create a new array of the markers in this category, sorted by their popup title
var sortedMarkers = category.markers.slice().sort(this.markerCompareFunction("name"));
// Create a marker list item for each marker
for (var j = 0; j < sortedMarkers.length; j++)
{
var item = document.createElement("div");
item.className = "mapsExtended_searchResults_item";
item.marker = sortedMarkers[j];
var itemText = document.createElement("div");
itemText.textContent = sortedMarkers[j].popup.title;
item.appendChild(itemText);
var itemId = document.createElement("div");
itemId.textContent = "(" + sortedMarkers[j].id + ")";
item.appendChild(itemId);
itemsList.appendChild(item);
sortedMarkers[j].searchResultsItem = item;
sortedMarkers[j].searchResultsItemText = itemText;
item.addEventListener("mouseenter", onListItemHovered);
item.addEventListener("mouseleave", onListItemHovered);
item.addEventListener("click", onListItemClicked);
}
searchResultsList.appendChild(container);
searchCategory.elements =
{
container: container,
header: header,
headerIcon: headerIcon,
headerText: headerText,
headerCount: headerCount,
headerWrapper: headerWrapper,
itemsList: itemsList
};
search.elements.searchCategories.push(searchCategory);
};
// Hide the seach box if the config says to
if (this.config.enableSearch == false)
searchBox.style.display = searchDropdown.style.display = "none";
// Finally, add the searchDropdown to the map
this.elements.filtersList.prepend(searchDropdown);
// Initialize search with an empty-term "full" search
var emptySearch = { searchTerm: "" };
emptySearch.results = this.markers;
emptySearch.categories = this.categories;
emptySearch.markerMatches = [],
emptySearch.categoryMatches = [],
emptySearch.counts = {};
emptySearch.isEmptySearch = true;
for (var i = 0; i < this.categories.length; i++)
emptySearch.counts[this.categories[i].id] = this.categories[i].markers.length;
this.search.emptySearch = emptySearch;
// Construct update the search list with a full search
this.updateSearchList();
},
// Updates the search list using a completed search. The search object should be { searchTerm, results }
// Pass this.emptySearch, or null to reset the search list
updateSearchList: function(search)
{
var t0 = performance.now();
if (!search) search = this.search.emptySearch;
var numFilteredCategories = 0;
var numDisplayedCategories = 0;
// Toggle mapsExtended_searchFiltered class on if the search has results
this.elements.rootElement.classList.toggle("mapsExtended_searchFiltered", !search.isEmptySearch);
// Hide search results element if the search has no results
this.search.elements.searchResultsList.style.display = search.results.length > 0 ? "" : "none";
for (var i = 0; i < this.markers.length; i++)
{
var marker = this.markers[i];
// Skip if marker category is disabled
if (marker.category.disabled) continue;
var isInResults = search.results.includes(marker);
var isInMatches = search.markerMatches.includes(marker);
var wasInMatches = this.search.lastSearch != undefined && this.search.lastSearch.markerMatches.includes(marker);
if (marker.markerElement)
marker.markerElement.classList.toggle("search-result", isInResults);
if (marker.searchResultsItem)
marker.searchResultsItem.classList.toggle("search-result", isInResults);
if (isInMatches)
this.highlightTextWithSearchTerm(marker.searchResultsItemText, marker.popup.title, marker.nameNormalized, search.searchTerm);
else if (wasInMatches)
marker.searchResultsItemText.textContent = marker.popup.title;
}
// Show or hide categories depending on whether there are markers in the results in the category
for (var i = 0; i < this.categories.length; i++)
{
// If any of the results have a categoryId of this category, we should show the category header
var category = this.categories[i];
// Skip if category is disabled
if (category.disabled) continue;
var isInResults = search.categories.includes(category);
var isInMatches = search.categoryMatches.includes(category);
var wasInMatches = this.search.lastSearch != undefined && this.search.lastSearch.categoryMatches.includes(category);
// Update the highlighted search string in the category header
if (isInMatches && !search.isEmptySearch)
this.highlightTextWithSearchTerm(category.elements.searchResultsHeaderText, category.name, category.nameNormalized, search.searchTerm);
else if (wasInMatches)
category.elements.searchResultsHeaderText.replaceChildren(category.name);
// Toggle the hidden class on if markers of the category don't appear in the results - this hides the category
category.elements.searchResultsContainer.classList.toggle("search-result", isInResults);
// Toggle the filtered class on if this category is not visible - this greys out the category
category.elements.searchResultsContainer.classList.toggle("filtered", !category.visible);
// Update the current marker highlights if the category header is still being hovered over
if (category.elements.searchResultsHeader.matches(":hover"))
this.toggleCategoryMarkerHighlight(category, true);
// Update the label to reflect the amount of markers in the results
category.elements.searchResultsHeaderCount.textContent = "(" + (search.counts[category.id] || 0) + ")";
}
// We're starting a search if the last search was empty and this search was not
var isStartingSearch = (!this.search.lastSearch || this.search.lastSearch.isEmptySearch) && !search.isEmptySearch;
// We're ending a search if the last search was not empty and this search is
var isEndingSearch = (this.search.lastSearch && !this.search.lastSearch.isEmptySearch) && search.isEmptySearch;
this.search.isSearching = !search.isEmptySearch;
this.search.lastSearch = search;
this.updateSearchSubtitle();
var t1 = performance.now();
log("Updating search elements took " + Math.round(t1 - t0) + " ms.");
this.events.onSearchPerformed.invoke(
{
map: this,
search: search,
isSearching: this.search.isSearching,
isStartingSearch: isStartingSearch,
isEndingSearch: isEndingSearch
});
},
highlightTextWithSearchTerm: function(element, text, textNormalized, searchTerm)
{
if (!element || !searchTerm || !text)
return;
// Get index of the search term in the text
var index = textNormalized.toLowerCase().indexOf(searchTerm.toLowerCase());
if (index == -1)
console.error("Tried to highlight term \"" + searchTerm + "\" that was not found in the text \"" + textNormalized + "\"");
// Create a new element that represents the highlighted term, adding the search term found within the text to it
var highlight = document.createElement("mark");
highlight.textContent = text.slice(index, index + searchTerm.length);
// Replace all children on the element with
// 1. The first part of the string, before the term
// 2. The highlighted search term
// 3. The last part of the string, after the term
element.replaceChildren(new Text(text.slice(0, index)), highlight, new Text(text.slice(index + searchTerm.length)));
},
toggleCategoryMarkerHighlight: function(category, value)
{
for (var i = 0; i < category.markers.length; i++)
{
this.toggleMarkerHighlight(category.markers[i], value && this.search.lastSearch.results.includes(category.markers[i]));
}
},
toggleMarkerSelected: function(marker, value)
{
if (!marker || !marker.markerElement || !marker.searchResultsItem) return;
if (value == true)
{
this.lastMarkerClicked = marker;
this.lastMarkerElementClicked = marker.markerElement;
}
this.search.selectedMarker = value ? marker : undefined;
// Set/unset the selected class on the list item
marker.searchResultsItem.classList.toggle("selected", value);
// Set/unset the search-result-highlight-fixed class on the marker element
//marker.markerElement.classList.toggle("search-result-highlight", value);
marker.markerElement.classList.toggle("search-result-highlight-fixed", value);
// Show/hide the marker popup
marker.popup.toggle(value);
},
// This sets and unsets a highlighting circle that is shown behind a marker
// (this used to be animated, but it feels much better having it be snappy)
toggleMarkerHighlight: function(marker, value)
{
if (!(marker && marker.markerElement)) return;
// Don't allow highlighting a marker that is already selected in the search list
if (this.search.selectedMarker == marker) return;
this.search.highlightedMarker = value ? marker : undefined;
// Set the value if it wasn't passed to the opposite of whatevr it currently is
if (value == undefined)
value = !marker.markerElement.classList.contains("search-result-highlight");
marker.markerElement.classList.toggle("search-result-highlight", value);
marker.markerElement.style.zIndex = (value ? (9999999 + marker.order) : marker.order).toString();
},
// This updates the hint shown under the search box to reflect the state of the search
updateSearchSubtitle: function()
{
var lastSearch = this.search.lastSearch;
var hasResults = lastSearch && lastSearch.results && lastSearch.results.length > 0;
this.search.elements.searchBox.classList.toggle("has-error", lastSearch && !hasResults);
if (lastSearch)
{
if (hasResults)
{
var numMarkers = lastSearch.results.length;
// Number of categories that are represented in the search and displayed
var numDisplayedCategories = lastSearch.categories.length;
// Number of categories that are represented in the search and hidden/filtered
var numFilteredCategories = lastSearch.categories.filter(function(c) { return c.visible == false; }).length;
if (numFilteredCategories > 0)
this.search.elements.searchBoxHint.textContent = mapsExtended.i18n.msg("search-hint-resultsfiltered", numMarkers, numDisplayedCategories, numFilteredCategories).plain();
else
this.search.elements.searchBoxHint.textContent = mapsExtended.i18n.msg("search-hint-results", numMarkers, numDisplayedCategories).plain();
}
else
{
this.search.elements.searchBoxHint.textContent = mapsExtended.i18n.msg("search-hint-noresults", lastSearch.searchTerm).plain();
}
}
},
// Searches the "popup.title" field of all markers to check whether it contains a specific search term
// This utilizes memoizing, where we save past searches to reduce the amount of markers that need to be searched through,
// should an old search term include a term that is used in the new search term
// Use an empty string "" or don't pass a searchTerm to get all markers
searchMarkers: function(searchTerm)
{
var t0 = performance.now();
if (this.search.searchHistory == undefined)
this.search.searchHistory = [ this.search.emptySearch ];
if (!searchTerm || searchTerm == "")
return emptySearch;
searchTerm = searchTerm.toLowerCase();
var closestSearchIndex = -1;
// For the closest matching previous search, this is the amount of characters that were added to the new search
var closestSearchMinimumDiff = Infinity;
for (var i = this.search.searchHistory.length - 1; i >= 0; i--)
{
// If the new search term was exactly the same as a previous term, don't bother repeating the search
if (searchTerm == this.search.searchHistory[i].searchTerm)
{
closestSearchIndex = i;
closestSearchMinimumDiff = 0;
break;
}
// If the old search term is found within the new search term
else if (searchTerm.includes(this.search.searchHistory[i].searchTerm))
{
// ...determine how many character less it has
var diff = searchTerm.length - this.search.searchHistory[i].searchTerm.length;
/// And if it has the smallest difference so far, remember it
if (diff < closestSearchMinimumDiff)
{
closestSearchIndex = i;
closestSearchMinimumDiff = diff;
}
}
}
var baseSearch;
var search =
{
searchTerm: searchTerm,
results: [], // A combination of all markers of the below
categories: [], // Categories of markerMatches or categoryMatches
markerMatches: [], // Markers whose name or category name matched the search term
categoryMatches: [], // Categories whose name matched the search term
counts: {} // Object with keys of all category.id in categories, and values of the amount of markers in the results in that category
};
// Reuse previous search results as a basis for the new results
if (closestSearchIndex != -1)
{
baseSearch = this.search.searchHistory[closestSearchIndex];
log("Centering search on \"" + baseSearch.searchTerm + "\" with " + baseSearch.markerMatches.length + " marker matches and " + baseSearch.categoryMatches.length + " category matches");
}
// Otherwise base off all markers
else
{
baseSearch = this.search.emptySearch;
log("Centering search on all markers");
}
// Only perform search if the search is different to the one it is based off, and the last search had results
// This executes even with empty results, as we want to retrieve the amount of results regardless
if (closestSearchMinimumDiff > 0 && baseSearch && baseSearch.results.length > 0)
{
var category;
for (var i = 0; i < baseSearch.categories.length; i++)
{
category = baseSearch.categories[i];
// Skip if this category is disabled
if (category.disabled) continue;
// Find all category names that include the search term
if (category.nameNormalized.toLowerCase().includes(searchTerm))
{
// Add all markers in this category to the results
var length = category.markers.length;
for (var j = 0; j < length; j++)
{
search.results.push(category.markers[j]);
}
// Store the length in the counts element for this category
search.counts[category.id] = length;
// Add this category to the results
search.categories.push(category);
search.categoryMatches.push(category);
}
}
var len = baseSearch.results.length;
var marker;
for (var i = 0; i < len; i++)
{
marker = baseSearch.results[i];
// Skip if this category is disabled
if (marker.category.disabled) continue;
// Find all markers that include the search term
if (marker.nameNormalized.toLowerCase().includes(searchTerm))
{
// Add matcher to markerMatches
search.markerMatches.push(marker);
// Don't re-add to results if this marker's category was included as the result of a categoryMatch
if (!search.categoryMatches.includes(marker.category))
{
// Add marker to results
search.results.push(marker);
// Add 1 to the count for this category
search.counts[marker.category.id] = search.counts[marker.category.id] + 1 || 1;
// Add category to results (need to check because we only want to add one of each)
if (!search.categories.includes(marker.category))
search.categories.push(marker.category);
}
}
}
// Add this search to the search history
this.search.searchHistory.push(search);
// Remove the first item in the search history if it exceeds 100 searches
if (this.search.searchHistory.length > 100)
this.search.searchHistory.unshift();
}
// Search is idential
else if (closestSearchMinimumDiff == 0)
{
log("Search was identical, using previous results");
search = baseSearch;
}
var t1 = performance.now();
log("Search took " + Math.round(t1 - t0) + " ms.");
return search;
},
// Sidebar
initSidebar: function()
{
// Nothing relies on the sidebar existing, so if it shouldn't be enabled, don't run the code at all
if (this.config.enableSidebar == false)
return;
var sidebar = {};
sidebar.elements = {};
sidebar.isShowing = false;
this.sidebar = sidebar;
// Enable or disable automatically showing or hiding the sidebar
this.sidebar.autoShowHide = (this.config.sidebarBehaviour == "autoAlways" || this.config.sidebarBehaviour == "autoInitial");
// Show and hide the sidebar automatically as the size of the map module changes
this.events.onMapModuleResized.subscribe(function(args)
{
if (!this.sidebar.autoShowHide) return;
if (sidebar.isShowing == true && args.rect.width < 1000 && args.lastRect.width >= 1000)
{
log("Toggled sidebar off automatically");
this.toggleSidebar(false, true);
}
else if (sidebar.isShowing == false && args.rect.width >= 1000 && args.lastRect.width < 1000)
{
log("Toggled sidebar on automatically");
this.toggleSidebar(true, true);
}
}.bind(this));
// To to avoid the filtersList being shown over the sidebar on fullscreen (or minimal layout), move it to the map-module-container
this.events.onMapFullscreen.subscribe(function(args)
{
if (sidebarSearchBody.expanded == true && sidebarSearchBody.resizedExpandedHeight == undefined)
{
sidebarSearchBody.ignoreNextResize = true;
sidebarSearchBody.style.height = sidebarSearchBody.calculateExpandedHeight() + "px";
}
sidebarFloatingToggle.updateToggle(false);
// Don't move filters list if we're already in a minimal layout
if (this.isMinimalLayout == true) return;
if (args.fullscreen)
this.elements.mapModuleContainer.prepend(this.elements.filtersList);
else
{
var elem = this.elements.rootElement.querySelector(".interactive-maps");
elem.prepend(this.elements.filtersList);
}
}.bind(this));
// Get sidebar width from rule
var sidebarWrapper = document.createElement("div");
sidebarWrapper.className = "mapsExtended_sidebarWrapper";
sidebarWrapper.classList.add("mapsExtended_sidebarWrapper" + capitalizeFirstLetter(this.config.sidebarSide));
if (this.config.sidebarOverlay == true) sidebarWrapper.classList.add("overlay");
this.elements.mapModuleContainer.prepend(sidebarWrapper);
// Create the sidebar in the same parent as the leaflet-container div
var sidebarRoot = document.createElement("div");
sidebarRoot.className = "mapsExtended_sidebar";
sidebarRoot.resizeObserver = new ResizeObserver(function(e)
{
resizeCategoryToggles();
if (categorySectionBody.classList.contains("expanded"))
categorySectionBody.style.maxHeight = categorySectionBody.scrollHeight + "px";
});
sidebarWrapper.append(sidebarRoot);
var sidebarContent = document.createElement("div");
sidebarContent.className = "mapsExtended_sidebarContent";
sidebarRoot.append(sidebarContent);
sidebarContent.addEventListener("scroll", OO.ui.throttle(function()
{
sidebarFloatingToggle.updateToggle();
}, 150), { passive: true });
// Create the button that toggles the sidebar
var sidebarToggleButton = document.createElement("button");
sidebarToggleButton.className = "mapsExtended_sidebarToggle wds-pill-button";
sidebarToggleButton.title = mapsExtended.i18n.msg("sidebar-hide-tooltip").plain();
sidebarToggleButton.addEventListener("click", function()
{
this.sidebar.elements.sidebarToggleButton.blur();
this.toggleSidebar();
if (this.config.sidebarBehaviour == "autoInitial")
this.sidebar.autoShowHide = false;
}.bind(this));
this.elements.filtersList.prepend(sidebarToggleButton);
// Header
var sidebarHeader = document.createElement("div");
sidebarHeader.className = "mapsExtended_sidebarHeader";
sidebarHeader.textContent = mapsExtended.i18n.msg("sidebar-header", this.name).plain();
sidebarContent.append(sidebarHeader);
// Create a button that floats over the sidebar
var sidebarFloatingToggle = document.createElement("div");
sidebarFloatingToggle.className = "mapsExtended_sidebarFloatingToggle";
sidebarFloatingToggle.title = mapsExtended.i18n.msg("sidebar-hide-tooltip").plain();
sidebarFloatingToggle.addEventListener("click", function()
{
this.toggleSidebar();
if (this.config.sidebarBehaviour == "autoInitial")
this.sidebar.autoShowHide = false;
}.bind(this));
sidebarFloatingToggle.updateToggle = function(forceValue)
{
sidebarFloatingToggle.classList.toggle("mapsExtended_sidebarFloatingToggleScrolled", forceValue != undefined ? forceValue : sidebarContent.scrollTop > 20);
};
sidebarContent.after(sidebarFloatingToggle);
// Search
// Create an element that will clear the search box when it is clicked
var searchClearButton = document.createElement("div");
searchClearButton.className = "mapsExtended_sidebarSearchClearButton";
searchClearButton.style.display = "none";
searchClearButton.addEventListener("click", function(e)
{
searchBoxInput.value = "";
searchBoxInput.dispatchEvent(new Event("input"));
searchBoxInput.focus();
searchBoxInput.select();
});
// Create an element that sits over the input which is used to expand and collapse the results
var searchDropdownButton = document.createElement("div");
searchDropdownButton.className = "mapsExtended_sidebarSearchDropdownButton";
var searchDropdownIcon;
// Expose some variables so they can be hoisted by the function below
var searchBoxInput = this.search.elements.searchBoxInput;
var searchBoxHintContainer = this.search.elements.searchBoxHintContainer;
var searchResultsList = this.search.elements.searchResultsList;
searchDropdownButton.addEventListener("click", function(e)
{
// Invert expanded state
sidebarSearchBody.expanded = !sidebarSearchBody.expanded;
var expanded = sidebarSearchBody.expanded;
// When search is expanded, the toggle that reveals the results list is shifted to the right
// When search is collapsed, the toggle covers the entire search input
searchDropdownButton.classList.toggle("expanded", expanded);
sidebarSearchBody.classList.toggle("expanded", expanded);
// Make sure the resizeObserver doesn't respond to changes while we're animating
sidebarSearchBody.ignoreAllResize = true;
sidebarSearchBody.style.height = sidebarSearchBody.clientHeight + "px";
if (expanded)
{
// Focus text box if expanded
searchBoxInput.focus();
searchBoxInput.select();
var idealExpandedHeight = sidebarSearchBody.calculateExpandedHeight();
var maxHeight = sidebarSearchBody.calculateMaxHeight();
// If the user has set a custom expanded height, snap it to the maxHeight if it's close enough
if (Math.abs(maxHeight - sidebarSearchBody.resizedExpandedHeight) <= 10)
sidebarSearchBody.resizedExpandedHeight = maxHeight;
// The expanded height is either the one that has been set by the user, or the ideal height, but no less than the maxHeight
var toHeight = Math.min(sidebarSearchBody.resizedExpandedHeight || idealExpandedHeight, maxHeight) + "px";
sidebarSearchBody.style.maxHeight = maxHeight + "px";
}
else
{
// Reset minHeight
sidebarSearchBody.style.minHeight = sidebarSearchBody.style.maxHeight = "";
// Collapsed height is always 0
var toHeight = 0 + "px";
}
if (!sidebarSearchBody.onTransitionEnd)
{
sidebarSearchBody.onTransitionEnd = function(e)
{
sidebarSearchBody.style.transition = "";
if (sidebarSearchBody.expanded)
{
// Set min height programatically
var hintContainerStyle = window.getComputedStyle(searchBoxHintContainer);
var hintMarginTop = parseInt(hintContainerStyle["marginTop"] || 0);
var minHeight = searchBoxHintContainer.clientHeight + hintMarginTop + 1;
sidebarSearchBody.minHeight = minHeight;
sidebarSearchBody.style.minHeight = minHeight + "px";
}
else
{
searchBoxInput.value = "";
// Trigger input change event on searchBox after height transition has finished, in order to reset search
searchBoxInput.dispatchEvent(new Event("input", { bubbles: true }));
}
sidebarSearchBody.ignoreAllResize = false;
sidebarSearchBody.ignoreNextResize = true;
}
}
requestAnimationFrame(function()
{
sidebarSearchBody.style.transition = "height 0.35s ease";
sidebarSearchBody.addEventListener("transitionend", sidebarSearchBody.onTransitionEnd, { once: true });
sidebarSearchBody.style.height = toHeight;
});
searchDropdownIcon.style.transform = "rotate(" + (expanded ? 180 : 360) + "deg)";
}.bind(this));
// This triggers when the scrollHeight of the searchResultsList changes
// which happens whenever a search is performed, or a category is collapsed
searchResultsList.resizeObserver = new ResizeObserver(function(e)
{
if (!sidebar.isShowing || !sidebarSearchBody.expanded) return;
// This flag is set to prevent overwriting our saved expandedHeight when setting the maxHeight
sidebarSearchBody.ignoreNextResize = true;
var searchResultsMaxHeight = (searchResultsList.scrollHeight + $(searchBoxHintContainer).outerHeight(true) + 2);
// Set the max height on the searchBody
if (this.search.lastSearch.results.length > 0)
sidebarSearchBody.style.maxHeight = searchResultsMaxHeight + "px";
else
sidebarSearchBody.style.maxHeight = Math.min(searchResultsMaxHeight, sidebarSearchBody.minHeight) + "px"
}.bind(this));
// Disable resize when no results
this.events.onSearchPerformed.subscribe(function(args)
{
if (!sidebar.isShowing) return;
// Show the clear button if there is a search term present
searchClearButton.style.display = args.search.searchTerm.length > 0 ? "" : "none";
// Show the resize handle if there are results, hide if there aren't
sidebarSearchBody.style.resize = args.search.results.length == 0 ? "none" : "";
});
// Create new element which will contain the results list
var sidebarSearchBody = document.createElement("div");
sidebarSearchBody.className = "mapsExtended_sidebarSearchBody";
sidebarSearchBody.expanded = false;
sidebarSearchBody.resizeObserver = new ResizeObserver(/*mw.util.debounce(200, */function(e)
{
// Ignore this resize if the ignoreNextResize flag is set
if (sidebarSearchBody.ignoreNextResize || sidebarSearchBody.ignoreAllResize)
{
sidebarSearchBody.ignoreNextResize = false;
return;
}
if (!sidebar.isShowing || !sidebarSearchBody.expanded) return;
// Save the expanded size of the search body
sidebarSearchBody.resizedExpandedHeight = e[0].contentRect.height;
});//);
// This function returns a value that is the "ideal" expanded height
sidebarSearchBody.calculateExpandedHeight = function()
{
// Get top of sidebarSearchBody
var sidebarSearchBodyRect = sidebarSearchBody.getBoundingClientRect();
// Get bottom of sidebarRoot
var sidebarRootRect = sidebarRoot.getBoundingClientRect();
var categorySectionBodyRect = categorySectionBody.getBoundingClientRect();
var categorySectionBodyHeight = categorySectionBody.classList.contains("expanded") ? categorySectionBodyRect.height : 0;
// Add some offsets to keep the other buttons within view
// Toggle button height + toggle button margin + sidebar root padding bottom
var expandedHeight = (sidebarRootRect.bottom - sidebarSearchBodyRect.top) - (42 + 42 + 12 + 12 + 20) - categorySectionBodyHeight;
// If the resulting height is too small (< 400), add the categorySectionBody back onto the height
if (expandedHeight < 400) expandedHeight += categorySectionBodyHeight;
return expandedHeight;
};
// This function returns the maximum height of the contents of the searchBody
sidebarSearchBody.calculateMaxHeight = function()
{
var maxHeight = 1;
// Add margins of sidebarSearchBody
var styles = window.getComputedStyle(sidebarSearchBody);
maxHeight += ( parseFloat(styles.marginTop || 0) + parseFloat(styles.marginBottom || 0) )
// Add scrollHeight of each child of sidebarSearchBody
for (var i = 0; i < sidebarSearchBody.children.length; i++)
{
maxHeight += sidebarSearchBody.children[i].scrollHeight;
}
return maxHeight;
}
// Categories
// Show all / hide all buttons
var categoryToggleButtons = document.createElement("div");
categoryToggleButtons.className = "mapsExtended_sidebarCategoryToggleButtons";
var showAllButton = document.createElement("div");
showAllButton.className = "mapsExtended_sidebarControl";
showAllButton.textContent = mapsExtended.i18n.msg("sidebar-show-all-button").plain();
showAllButton.addEventListener("click", function(e)
{
for (var i = 0; i < this.categories.length; i++)
this.categories[i].toggle(true);
this.updateFilter();
}.bind(this));
categoryToggleButtons.append(showAllButton);
var hideAllButton = document.createElement("div");
hideAllButton.className = "mapsExtended_sidebarControl";
hideAllButton.textContent = mapsExtended.i18n.msg("sidebar-hide-all-button").plain();
hideAllButton.addEventListener("click", function(e)
{
for (var i = 0; i < this.categories.length; i++)
this.categories[i].toggle(false);
this.updateFilter();
}.bind(this));
categoryToggleButtons.append(hideAllButton);
sidebarContent.append(categoryToggleButtons);
// Category section header
var categorySectionHeader = document.createElement("div");
categorySectionHeader.className = "mapsExtended_sidebarControl mapsExtended_sidebarCategorySectionHeader";
categorySectionHeader.textContent = mapsExtended.i18n.msg("sidebar-categories-header").plain();
sidebarContent.append(categorySectionHeader);
categorySectionHeader.addEventListener("click", function(e)
{
var value = categorySectionBody.classList.toggle("expanded");
// Rotate menuControlIcon
menuControlIcon.style.transform = "rotate(" + (value ? 180 : 360) + "deg)";
categorySectionBody.style.maxHeight = (value ? categorySectionBody.scrollHeight : 0) + "px";
});
// Category section body
var categorySectionBody = document.createElement("div");
categorySectionBody.className = "mapsExtended_sidebarCategorySectionBody expanded"
sidebarContent.append(categorySectionBody);
var menuControlIcon;
mw.hook("dev.wds").add(function(wds)
{
// Add a menu icon to the sidebarToggleButton
var menuIcon = wds.icon("menu-tiny");
sidebarToggleButton.appendChild(menuIcon);
var closeIcon = wds.icon("close-tiny");
sidebarFloatingToggle.appendChild(closeIcon.cloneNode(true));
// Add a foldout icon to the category header
menuControlIcon = wds.icon("menu-control-tiny");
menuControlIcon.style.marginLeft = "auto";
menuControlIcon.style.transform = "rotate(180deg)";
menuControlIcon.style.transition = "transform 0.35s ease";
categorySectionHeader.appendChild(menuControlIcon);
// Add a cross button to the searchClearButton
searchClearButton.appendChild(closeIcon.cloneNode(true));
// Add a foldout icon to the search box
searchDropdownIcon = menuControlIcon.cloneNode(true);
searchDropdownIcon.style.transform = "rotate(360deg)";
searchDropdownButton.appendChild(searchDropdownIcon);
// Add eye icons to show all and hide all buttons
var eyeIcon = wds.icon("eye-small");
eyeIcon.style.marginRight = "6px"
showAllButton.prepend(eyeIcon);
var eyeCrossedIcon = wds.icon("eye-crossed-small");
eyeCrossedIcon.style.marginRight = "6px"
hideAllButton.prepend(eyeCrossedIcon);
});
// If there are less than 10 categories, use a single column layout
var useOneColumnLayout = this.categories.length <= 10;
// This function creates the category toggles shown in the sidebar for a specific CategoryGroup
var createCategoryGroup = function(categoryGroup, addToElement)
{
var sidebarCategoryGroup = {};
sidebarCategoryGroup.elements = {};
sidebarCategoryGroup.label = categoryGroup.label;
sidebarCategoryGroup.categories = categoryGroup.categories;
sidebarCategoryGroup.categoryToggles = [];
sidebarCategoryGroup.categoryGroup = categoryGroup;
sidebar.categoryGroups = sidebar.categoryGroups || [];
sidebar.categoryGroups.push(sidebarCategoryGroup);
var categoryContainer = document.createElement("div");
categoryContainer.className = "mapsExtended_sidebarCategory_container";
sidebarCategoryGroup.elements.categoryContainer = categoryContainer;
// Create a label for the category group
if (!categoryGroup.isRoot)
{
var categoryHeader = document.createElement("div");
categoryHeader.className = "mapsExtended_sidebarCategory_header";
sidebarCategoryGroup.elements.categoryHeader = categoryHeader;
// Build category group label by traversing parents and adding hyphen separator
var groupLabel = categoryGroup.label;
var parentGroup = categoryGroup.parentGroup;
while (parentGroup != undefined && parentGroup.isRoot == false)
{
groupLabel = parentGroup.label + " – " + groupLabel;
parentGroup = parentGroup.parentGroup;
}
categoryHeader.textContent = groupLabel;
categoryContainer.append(categoryHeader);
sidebarCategoryGroup.labelWithPrefix = groupLabel;
// Prevent double click from selecting text
document.addEventListener("mousedown", function(e)
{
if (e.detail > 1) e.preventDefault();
}, false);
// Toggle all categories by clicking category header
categoryHeader.addEventListener("click", function(e)
{
// Hide if any are shown
var anyShown = this.categories.some(function(c) { return c.visible == true; });
// Perform the hiding/showing using the toggle function of ExtendedCategory
for (var i = 0; i < this.categories.length; i++)
this.categories[i].toggle(!anyShown)
this.categoryGroup.updateCheckedVisualState();
this.categoryGroup.map.updateFilter();
}.bind(sidebarCategoryGroup));
}
else
{
var categoryHeader = document.createElement("div");
categoryHeader.className = "mapsExtended_sidebarCategory_header";
sidebarCategoryGroup.elements.categoryHeader = categoryHeader;
categoryContainer.append(categoryHeader);
}
// Create a list to hold each of the categories
var categoryList = document.createElement("div");
categoryList.className = "mapsExtended_sidebarCategory_list";
if (useOneColumnLayout == true) categoryList.style.columnCount = "1";
sidebarCategoryGroup.elements.categoryList = categoryList;
categoryContainer.append(categoryList);
// Create a new item for each of the categories in this group
for (var i = 0; i < categoryGroup.categories.length; i++)
{
var category = categoryGroup.categories[i];
var categoryNumMarkers = document.createElement("span");
categoryNumMarkers.textContent = category.markers.length.toString();
var categoryListItem = document.createElement("div");
categoryListItem.category = category;
categoryListItem.className = "mapsExtended_sidebarCategory_listItem";
categoryListItem.classList.toggle("hidden", !category.visible);
categoryListItem.append(category.elements.categoryIcon.cloneNode(true),
category.elements.categoryLabel.cloneNode(true),
categoryNumMarkers);
// Toggle specific category by clicking on item
categoryListItem.addEventListener("click", function(e)
{
this.toggle();
this.map.updateFilter();
}.bind(category));
// Update the visual toggle state whenever the actual category visibility changes
category.onCategoryToggled.subscribe(function(value){ this.classList.toggle("hidden", !value); }.bind(categoryListItem));
categoryList.append(categoryListItem);
sidebarCategoryGroup.categoryToggles.push(categoryListItem);
}
categorySectionBody.append(categoryContainer);
// Create subgroups
for (var i = 0; i < categoryGroup.subgroups.length; i++)
{
var subgroup = categoryGroup.subgroups[i];
createCategoryGroup(subgroup, categoryContainer);
}
// This is used to nest subgroups
//addToElement.append(categoryContainer);
};
// Create category groups starting with the root
createCategoryGroup(this.categoryGroups[0], categorySectionBody);
// Finally, add the sidebar to the page
sidebarWrapper.append(sidebarRoot);
// Resize all categoryToggles to the closest multiple of 30
var resizeCategoryToggles = function()
{
for (var i = 0; i < this.sidebar.categoryGroups.length; i++)
{
for (var j = 0; j < this.sidebar.categoryGroups[i].categoryToggles.length; j++)
{
var categoryToggle = this.sidebar.categoryGroups[i].categoryToggles[j];
categoryToggle.style.height = "30px";
var d = Math.round(categoryToggle.scrollHeight / 30);
if (d > 1) categoryToggle.style.height = (30 * d) + "px";
}
}
if (categorySectionBody.classList.contains(""))
categorySectionBody.style.maxHeight = categorySectionBody.scrollHeight + "px";
}.bind(this);
// Save sidebar elements
sidebar.elements.sidebarWrapper = sidebarWrapper;
sidebar.elements.sidebarRoot = sidebarRoot;
sidebar.elements.sidebarContent = sidebarContent;
sidebar.elements.sidebarToggleButton = sidebarToggleButton;
sidebar.elements.sidebarHeader = sidebarHeader;
sidebar.elements.sidebarFloatingToggle = sidebarFloatingToggle;
sidebar.elements.searchClearButton = searchClearButton;
sidebar.elements.searchDropdownButton = searchDropdownButton;
sidebar.elements.sidebarSearchBody = sidebarSearchBody;
if (this.config.sidebarInitialState == "show" || (this.config.sidebarInitialState == "auto" && this.elements.mapModuleContainer.clientWidth >= 800))
this.toggleSidebar(true, true);
else
this.toggleSidebar(false, true);
},
// Toggles the sidebar elements
toggleSidebar: function(value, noAnimation)
{
// If value isn't passed, just invert sidebar.isShowing
value = value != undefined ? value : !this.sidebar.isShowing;
// Save the previous value
var lastValue = this.sidebar.isShowing;
// Set sidebar.isShowing to the new value
this.sidebar.isShowing = value;
var leafletMapPane = this.elements.leafletMapPane;
// Search elements
var searchRoot = this.search.elements.searchRoot;
var searchDropdown = this.search.elements.searchDropdown;
var searchResultsList = this.search.elements.searchResultsList;
var searchBox = this.search.elements.searchBox;
var searchBoxInput = this.search.elements.searchBoxInput;
var searchBoxHintContainer = this.search.elements.searchBoxHintContainer;
// Sidebar elements
var sidebarRoot = this.sidebar.elements.sidebarRoot;
var sidebarWrapper = this.sidebar.elements.sidebarWrapper;
var sidebarHeader = this.sidebar.elements.sidebarHeader;
var sidebarToggleButton = this.sidebar.elements.sidebarToggleButton;
var sidebarSearchBody = this.sidebar.elements.sidebarSearchBody;
var searchClearButton = this.sidebar.elements.searchClearButton;
var searchDropdownButton = this.sidebar.elements.searchDropdownButton;
sidebarToggleButton.title = mapsExtended.i18n.msg(value ? "sidebar-hide-tooltip" : "sidebar-show-tooltip").plain();
this.elements.filtersDropdown.classList.toggle("disabled", value);
searchDropdown.classList.toggle("disabled", value);
// Toggles and not animating
if (!this.sidebar.isAnimating)
{
this.sidebar.isAnimating = true;
// Create an element to test the width of the sidebar when it's fully expanded
// (without actually expanding it)
var sidebarWrapperWidthTest = this.sidebar.elements.sidebarWrapperWidthTest;
if (!sidebarWrapperWidthTest)
{
sidebarWrapperWidthTest = document.createElement("div");
sidebarWrapperWidthTest.className = sidebarWrapper.className;
sidebarWrapperWidthTest.classList.add("expanded");
sidebarWrapperWidthTest.style.display = "none";
this.sidebar.elements.sidebarWrapperWidthTest = sidebarWrapperWidthTest;
}
sidebarWrapper.after(sidebarWrapperWidthTest);
var sidebarWidth = parseInt(window.getComputedStyle(sidebarWrapperWidthTest).minWidth || 0);
//var sidebarWidth = sidebarRoot.offsetWidth + (sidebarRoot.offsetWidth - sidebarRoot.clientWidth);
var sidebarHalfWidth = sidebarWidth / 2;
sidebarWrapperWidthTest.remove();
// Show sidebar elements
if (value == true) toggleSidebarElements(true);
var jqueryStartPos = $(leafletMapPane).position();
var startPos = this.sidebar._mapPaneStartPos = [ jqueryStartPos.left, jqueryStartPos.top ];//this.getElementTransformPos(leafletMapPane, true);
var endPos = this.sidebar._mapPaneEndPos = [ startPos[0] + (value ? -sidebarHalfWidth : sidebarHalfWidth), startPos[1] ];
if (noAnimation)
{
this.sidebar.isAnimating = false;
if (value == false) toggleSidebarElements(false);
}
else
{
// Set transition properties
leafletMapPane.style.transition = "transform 0.35s ease";
sidebarWrapper.style.transition = "min-width 0.35s ease";
sidebarRoot.style.transition = "transform 0.35s ease";
sidebarRoot.addEventListener("transitionend", function onTransitionEnd(e)
{
if (!(e.propertyName == "transform" && e.target == sidebarRoot)) return;
// Remove callback
e.currentTarget.removeEventListener("transitionend", onTransitionEnd);
// Remove transition
leafletMapPane.style.transition =
sidebarWrapper.style.transition =
sidebarRoot.style.transition = "";
// Remove supporting data
this.sidebar._mapPaneStartPos = this.sidebar._mapPaneEndPos = this.sidebar._onTransitionEnd = undefined;
// Hide sidebar elements
if (this.sidebar.isShowing == false) toggleSidebarElements(false);
this.sidebar.isAnimating = false;
}.bind(this));
}
}
// Toggled while already animating
else
{
// Reverse start and end pos
var startPos = this.sidebar._mapPaneEndPos;
var endPos = this.sidebar._mapPaneStartPos;
this.sidebar._mapPaneStartPos = startPos;
this.sidebar._mapPaneEndPos = endPos;
}
requestAnimationFrame(function()
{
// Immediately toggle wrapper expanded. Most of the actual transitioning occurs in CSS.
sidebarWrapper.classList.toggle("expanded", value);
// Offsets the map pan transform while the map width is changing (as a result of the sidebar growing)
// This is done so that the transform doesn't snap after the fact, which can be distracting
// Only do this when the value actually changes do avoid moving the map pane without any change to the sidebar
if (lastValue != value) leafletMapPane.style.transform = "translate3d(" + endPos[0] + "px, " + endPos[1] + "px, 0px)";
});
// Only set the following if we're not animating
if (!this.sidebar.isAnimating)
{
/*
var widthChanged = (value ? sidebarWidth : 0) + "px" != sidebarWrapper.style.minWidth;
// Change the min-width of the sidebarWrapper
sidebarWrapper.style.minWidth = (value ? sidebarWidth : 0) + "px";
if (widthChanged == true)
{
// Change the min-width of the sidebarWrapper
var startPos = this.getElementTransformPos(this.elements.leafletMapPane, true);
var endPos = [ startPos[0] + (value ? -sidebarHalfWidth : sidebarHalfWidth), startPos[1] ];
this.elements.leafletMapPane.style.transform = "translate3d(" + endPos[0] + "px, " + endPos[1] + "px, 0px)";
}
// Change the transform of the sidebarRoot
sidebarRoot.style.transform = "translateX(" + (value ? 0 : -sidebarWidth) + "px)";
*/
}
function toggleSidebarElements(value)
{
if (value)
{
// Move the search box to the sidebar
searchBox.classList.remove("mapsExtended_searchBox");
searchBox.classList.add("has-hint");
searchBox.classList.add("mapsExtended_sidebarSearchBox");
sidebarHeader.after(searchBox);
// Add sidebar control class to search input
searchBoxInput.classList.add("mapsExtended_sidebarControl");
// Add searchClearButton to searchBoxInput
searchBoxInput.after(searchClearButton);
// Add searchDropdownButton to searchBoxInput
searchBoxInput.after(searchDropdownButton);
// Move searchBoxHintContainer to sidebarSearchBody
sidebarSearchBody.appendChild(searchBoxHintContainer);
// Move the searchResultsList to the searchBox
searchResultsList.classList.add("mapsExtended_sidebarSearchResults");
sidebarSearchBody.appendChild(searchResultsList);
// Append the searchBody to the searchBox
searchBox.appendChild(sidebarSearchBody);
sidebarRoot.resizeObserver.observe(sidebarRoot);
for (var i = 0; i < searchResultsList.children.length; i++)
searchResultsList.resizeObserver.observe(searchResultsList.children[i]);
sidebarSearchBody.resizeObserver.observe(sidebarSearchBody);
}
else
{
// Move the search box to searchRoot
searchBox.classList.add("mapsExtended_searchBox");
searchBox.classList.add("has-hint");
searchBox.classList.remove("mapsExtended_sidebarSearchBox");
searchRoot.append(searchBox);
// Remove sidebar control class from search input
searchBoxInput.classList.remove("mapsExtended_sidebarControl");
// Move searchBoxHintContainer to the searchBox
searchBox.appendChild(searchBoxHintContainer);
// Move the searchResultsList to the searchRoot
searchResultsList.classList.remove("mapsExtended_sidebarSearchResults");
searchRoot.appendChild(searchResultsList);
// Remove the searchBody, searchClearButton, and searchDropdownButton from the DOM
sidebarSearchBody.remove();
searchClearButton.remove();
searchDropdownButton.remove();
sidebarRoot.resizeObserver.disconnect();
searchResultsList.resizeObserver.disconnect();
sidebarSearchBody.resizeObserver.disconnect();
}
}
},
// Zoom layers
initZoomLayers: function()
{
this.zoomLayers = [];
this.zoomLayersAllEmpty = true;
// Separate regexes from category and marker IDs
function regexFilterFn(s){ return typeof s == "string" && s.charAt(0) == '/' && s.charAt(s.length) == '/' && s.length > 2; };
function splitIntoIdsAndRegexes(strings)
{
var ids = [];
var regexes = [];
if (Array.isArray(strings))
{
for (var s = 0; s < strings.length; s++)
{
if (regexFilterFn(strings[s]))
{
try
{
var regex = new RegEx(strings[s].slice(1, -1))
regexes.push(regex);
}
catch (e)
{
console.error("The regex pattern \"" + strings[s] + "\" for zoom layer " + layer.id + " could not be parsed. It will be ignored");
}
}
else
{
ids.push(strings[s]);
}
}
}
return { ids: ids, regexes: regexes };
};
// Collect the markers for each zoom layer
for (var i = 0; i < this.config.zoomLayers.length; i++)
{
var layer = this.config.zoomLayers[i];
layer.visible = true;
var cir = splitIntoIdsAndRegexes(layer.categories);
var categoryIds = cir.ids;
var categoryRegexes = cir.regexes;
var mir = splitIntoIdsAndRegexes(layer.markers);
var markerIds = mir.ids;
var markerRegexes = mir.regexes;
// Add categoryIds based on categoryRegexes
var categoryIds = Object.assign(categoryIds, this.categories.filter(function(c)
{
// Exclude categories already in IDs
return !categoryIds.includes(c.id) &&
// Include those match one of the regex patterns
categoryRegexes.some(function(r){ r.test(c.id); })
}));
// Next, filter the markers, building an array of markers that belong to this layer
layer.markers = this.markers.filter(function(m)
{
var isInZoomLayer = markerIds.includes(m.id) ||
categoryIds.includes(m.categoryId) ||
markerRegexes.some(function(r){ r.test(m.id); });
// Add a reference to the zoomLayer to each marker
if (isInZoomLayer) m.zoomLayer = layer;
return isInZoomLayer;
});
// We no longer need these properties (the rest of the above will be GC'd)
layer.categories = null;
if (layer.markers.length > 0)
this.zoomLayersAllEmpty = false;
this.zoomLayers.push(layer);
}
if (this.zoomLayers.length > 0)
{
// While searching, this re-adds any markers that have been removed from the map so that they may appear in the search
this.events.onSearchPerformed.subscribe(function(search)
{
if (search.isStartingSearch || search.isEndingSearch)
this.updateFilter();
}.bind(this));
this.filterFunctions.push(function(marker)
{
// Ignore the zoomLayer visibility when searching
if (marker.map.search.isSearching) return true;
return marker.zoomLayer != null ? marker.zoomLayer.visible : true;
});
this.events.onMapZoomed.subscribe(function(e)
{
if ((e.state == "zoomStart" && e.direction == "out") ||
(e.state == "zoomEnd" && e.direction == "in"))
this.updateZoomLayers(e);
}.bind(this));
// Do the first update
this.updateZoomLayers();
}
},
// Sets the visibility property on each zoom layer depending on the current scale. This a callback of the onMapZoomed event
updateZoomLayers: function()
{
var zoomLayersChanged = false;
for (var i = 0; i < this.zoomLayers.length; i++)
{
var layer = this.zoomLayers[i];
var visible = this.zoomScale >= layer.minZoom &&
this.zoomScale < layer.maxZoom;
// Layer visibility changed, show or hide the markers
if (layer.visible != visible)
{
layer.visible = visible;
zoomLayersChanged = true;
// When hiding, always just remove
if (visible)
log("Zoom layer " + layer.id + " became visible (zoomScale is: " + this.zoomScale + ", which is >= " + layer.minZoom + " and < " + layer.maxZoom + ")");
else
log("Zoom layer " + layer.id + " is no longer visible (zoomScale is: " + this.zoomScale + ", which is " + (this.zoomScale < layer.minZoom ? "< " + layer.minZoom : ">= " + layer.maxZoom) + ")");
}
}
// Only update the filter if the zoom layers changed
// Don't do so via a zoom if we're currently searching,
// or if there aren't any markers to exclude to begin with (this can happen if the user doesn't define any markers)
if (zoomLayersChanged && !this.search.isSearching && !this.zoomLayersAllEmpty)
this.updateFilter();
},
// Collectibles
hasCollectibles: false,
// Called on each of the maps to set up collectibles
initCollectibles: function()
{
var map = this;
// Set up the checked summary on each of the collectible category labels
for (var i = 0; i < this.categories.length; i++)
{
var category = this.categories[i];
// Collectible categories are those whose ID's end with __c or __ch or __hc
// or categories included in the collectibleCategories array in the map config
// or categories where the custom property "collectible" is true
category.collectible = category.hints.includes("collectible")
|| (Array.isArray(this.config.collectibleCategories) && this.config.collectibleCategories.includes(category.id))
|| category.collectible;
if (!category.collectible)
continue;
this.hasCollectibles = true;
if (category.elements && category.elements.filter)
{
// Ctrl-Clicking on the category filter should mark all as collected, or clear all as collected
category.elements.filter.addEventListener("click", function(e)
{
if (e.ctrlKey == true || e.metaKey == true)
{
if (this.isAnyCollected())
this.clearAllCollected();
else
this.markAllCollected();
this.map.updateFilter();
e.preventDefault();
e.stopPropagation();
}
}.bind(category));
}
}
// Remove the built-in "your progress" foldout if it should be disabled, or if we have no collectibles
if (!this.config.enableYourProgressFilter || this.hasCollectibles == false)
{
this.elements.filterProgressSection.remove();
}
// Skip this map if there are no collectibles
if (this.hasCollectibles == false)
return;
this.elements.filtersDropdownList.style.maxHeight = "none";
// Add a "Clear collected" button to the filters
if (this.config.enableClearCollectedButton)
{
var clearButton = document.createElement("a");
clearButton.className = "mapsExtended_collectibleClearButton";
clearButton.textContent = mapsExtended.i18n.msg("clear-collected-button").plain();
this.elements.clearCollectedButton = clearButton;
if (this.config.enableYourProgressFilter)
this.elements.filterProgressSectionContent.append(clearButton);
else
this.elements.filterCategoriesSectionContent.append(clearButton);
// When BannerNotifications is loaded,
mw.hook("dev.banners").add(function(banners)
{
map.elements.collectedMessageBanner = new BannerNotification("", "confirm", null, 5000);
// When the "Clear collected" button is clicked in the filters dropdown
map.elements.clearCollectedButton.addEventListener("click", function()
{
var confirmMsg = mapsExtended.i18n.msg("clear-collected-confirm").plain();
// Create a simple OOUI modal asking the user if they really want to clear the collected state on all markers
OO.ui.confirm(confirmMsg).done(function(confirmed)
{
if (confirmed)
{
var bannerMsg = mapsExtended.i18n.msg("clear-collected-banner", map.getNumCollected(), map.getMapLink(null, "wikitext")).parse();
new BannerNotification(bannerMsg, "notify", null, 5000).show();
map.clearCollectedStates();
map.updateFilter();
}
else
return;
});
});
});
}
// Load collected states from localStorage
this.loadCollectedStates();
// Update the collected labels to reflect the collected states
this.categories.forEach(function(c) { c.updateCollectedLabel(); });
// Events
// Update all collected labels and nudge collected states when the map is refreshed
this.events.onMapInit.subscribe(function(args)
{
// Nudge collected states
this.nudgeCollectedStates();
// Update labels
this.categories.forEach(function(c) { c.updateCollectedLabel(); });
this.updateCollectedFilterLabels();
}.bind(this));
// New marker shown - Set it's collected state to itself update the marker opacity
this.events.onMarkerShown.subscribe(function(args)
{
args.marker.setMarkerCollected(args.marker.collected, true);
});
this.events.onPopupShown.subscribe(function(args)
{
args.popup.updateCollectibleElements();
});
// Save collected states when the tab loses focus
window.addEventListener("beforeunload", function(e)
{
mapsExtended.maps.forEach(function(map)
{
if (map.hasCollectibles)
map.saveCollectedStates();
});
});
},
updateCollectedFilterLabels: function()
{
// Number collected, subtract the counts for
var collected = this.getNumCollected(true, true);
var notCollected = this.getNumCollected(false, true);
this.elements.filterCompleteLabel.textContent = collected.toString();
this.elements.filterIncompleteLabel.textContent = notCollected.toString();
},
// Get the amount of markers that have been collected in total
getNumCollected: function(state, excludeFiltered)
{
var count = 0;
if (state == null) state = true;
if (excludeFiltered == null) excludeFiltered = false;
for (var i = 0; i < this.categories.length; i++)
count += this.categories[i].getNumCollected(state, excludeFiltered);
return count;
},
// Get the key used to store the collected states in localStorage
getStorageKey: function()
{
return mw.config.get("wgDBname") + "_" + this.name.replaceAll(" ", "_") + "_collected";
},
// Trigger the collected setter on all markers to update their opacity
nudgeCollectedStates: function()
{
for (var i = 0; i < this.categories.length; i++)
{
if (!this.categories[i].collectible)
continue;
for (var j = 0; j < this.categories[i].markers.length; j++)
this.categories[i].markers[j].setMarkerCollected(this.categories[i].markers[j].collected, true, false, false);
this.categories[i].updateCollectedLabel();
}
},
// Clear the collected state on all markers for this map, and then also the data of this map in localStorage
clearCollectedStates: function()
{
for (var i = 0; i < this.categories.length; i++)
{
// Clear the collected states
for (var j = 0; j < this.categories[i].markers.length; j++)
this.categories[i].markers[j].setMarkerCollected(false, true, false, false);
// Update label
this.categories[i].updateCollectedLabel();
}
var storageKey = this.getStorageKey();
localStorage.removeItem(storageKey);
},
// Iterates over all markers in a map and stores an array of the IDs of "collected" markers
saveCollectedStates: function()
{
var collectedMarkers = [];
for (var i = 0; i < this.markers.length; i++)
{
if (this.markers[i].collected) collectedMarkers.push(this.markers[i].id);
}
var storageKey = this.getStorageKey();
//localStorage.setItem(storageKey, JSON.stringify(collectedMarkers));
// Use the mw.storage API instead of using localStorage directly, because of its expiry feature
mw.storage.set(storageKey, JSON.stringify(collectedMarkers), this.config.collectibleExpiryTime == -1 ? undefined : this.config.collectibleExpiryTime);
},
// Fetch the collected state data from localStorage and set the "collected" bool on each marker that is collected
loadCollectedStates: function()
{
var storageKey = this.getStorageKey();
var stateJson = mw.storage.get(storageKey) || "[]";
var stateData = JSON.parse(stateJson);
for (var i = 0; i < stateData.length; i++)
{
if (this.markerLookup.has(stateData[i]))
{
var marker = this.markerLookup.get(stateData[i]);
// Ensure that this marker is a collectible one
if (marker && marker.category.collectible == true)
marker.setMarkerCollected(true, true, false, false);
}
}
this.resetCollectedStateExpiry();
},
// Resets the timer on the expiry of collected states
resetCollectedStateExpiry: function()
{
if (!mw.storage.setExpires) return;
var storageKey = this.getStorageKey();
// Clear expiry time with a collectibleExpiryTime of -1
if (this.config.collectibleExpiryTime == -1)
mw.storage.setExpires(storageKey);
else
mw.storage.setExpires(storageKey, this.config.collectibleExpiryTime);
},
// General filtering
initFilters: function()
{
this.collectedVisible = true;
this.nonCollectedVisible = true;
this.createFilterSections();
// Add default filter functions, which determine how the markers are filtered when the filter is updated
// Only show visible categories (although in some cases markers may set their own visibility)
this.filterFunctions.push(function(m){ return m.visible || m.category.visible; });
// When we have collectibles, only show the collectibles if the current "incomplete/complete" filter allows for its collected state
this.filterFunctions.push(function(m){return m.map.hasCollectibles && m.category.collectible ? m.collected ? m.map.collectedVisible : m.map.nonCollectedVisible : true; })
},
createFilterSections: function()
{
// Remove existing filter sections
var filterSections = this.elements.filtersDropdownList.querySelectorAll(".interactive-maps__section");
if (filterSections.length > 0) Array.from(filterSections).forEach(function(s){ s.remove(); });
var sectionHtml = "<div class=\"interactive-maps__section-label\"><!-- Section label --><svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 12 12\" class=\"interactive-maps__section-label-icon\"><path fill-rule=\"evenodd\" d=\"M11.707 3.293a.999.999 0 00-1.414 0L6 7.586 1.707 3.293A.999.999 0 10.293 4.707l5 5a.997.997 0 001.414 0l5-5a.999.999 0 000-1.414\"></path></svg></div><div class=\"interactive-maps__section-content\"><!-- Section content --></div></div>";
// Create "Categories" section
this.elements.filterCategoriesSection = document.createElement("div");
this.elements.filterCategoriesSection.className = "interactive-maps__section interactive-maps__categories-section";
this.elements.filterCategoriesSection.innerHTML = sectionHtml;
this.elements.filterCategoriesSectionContent = this.elements.filterCategoriesSection.querySelector(".interactive-maps__section-content");
this.elements.filterCategoriesSectionLabel = this.elements.filterCategoriesSection.querySelector(".interactive-maps__section-label");
this.elements.filterCategoriesSectionLabel.prepend(mapsExtended.i18n.msg("filter-section-categories").plain());
// Create "Your Progress" section
this.elements.filterProgressSection = document.createElement("div");
this.elements.filterProgressSection.className = "interactive-maps__section interactive-maps__progress-section";
this.elements.filterProgressSection.innerHTML = sectionHtml;
this.elements.filterProgressSectionContent = this.elements.filterProgressSection.querySelector(".interactive-maps__section-content");
this.elements.filterProgressSectionLabel = this.elements.filterProgressSection.querySelector(".interactive-maps__section-label");
this.elements.filterProgressSectionLabel.prepend(mapsExtended.i18n.msg("filter-section-collectibles").plain());
// Create "incomplete" and "complete" filters (Fandom terminology)
var completeFilter = document.createElement("div");
var incompleteFilter = document.createElement("div");
var completeFilterText = document.createElement("span");
var incompleteFilterText = document.createElement("span");
var completeFilterCheckbox = createWdsCheckbox(this.instanceId + "__checkbox-" + "complete", mapsExtended.i18n.msg("filter-collectibles-collected").plain());
var incompleteFilterCheckbox = createWdsCheckbox(this.instanceId + "__checkbox-" + "incomplete", mapsExtended.i18n.msg("filter-collectibles-not-collected").plain());
completeFilter.className = "interactive-maps__filter";
incompleteFilter.className = "interactive-maps__filter";
completeFilterText.className = "interactive-maps__filter-value";
incompleteFilterText.className = "interactive-maps__filter-value";
completeFilter.append(completeFilterCheckbox.root, completeFilterText);
incompleteFilter.append(incompleteFilterCheckbox.root, incompleteFilterText);
this.elements.filterProgressSectionContent.append(completeFilter, incompleteFilter);
this.elements.filtersDropdownList.append(this.elements.filterProgressSection, this.elements.filterCategoriesSection);
// Save checkbox input references
this.elements.filterComplete = completeFilter;
this.elements.filterIncomplete = incompleteFilter;
this.elements.filterCompleteCheckbox = completeFilterCheckbox.input;
this.elements.filterIncompleteCheckbox = incompleteFilterCheckbox.input;
this.elements.filterCompleteLabel = completeFilter.querySelector(".interactive-maps__filter-value");
this.elements.filterIncompleteLabel = incompleteFilter.querySelector(".interactive-maps__filter-value");
this.elements.filterCompleteCheckbox.addEventListener("change", function(e)
{
this.collectedVisible = e.target.checked;
this.updateFilter();
}.bind(this));
this.elements.filterIncompleteCheckbox.addEventListener("change", function(e)
{
this.nonCollectedVisible = e.target.checked;
this.updateFilter();
}.bind(this));
// Set up filter groups (right now, "Your Progress" and "Collectibles")
var progressLabel = this.elements.filterProgressSection.querySelector(".interactive-maps__section-label");
var categoriesLabel = this.elements.filterCategoriesSection.querySelector(".interactive-maps__section-label");
var toggleSection = function(e)
{
var section = e.target.closest(".interactive-maps__section");
section.classList.toggle("interactive-maps__section--hidden");
};
progressLabel.addEventListener("click", toggleSection);
categoriesLabel.addEventListener("click", toggleSection);
},
markersFiltered: [],
markersFilteredInverse: [],
// This is an array of functions that take a marker, and should return true or false
// depending on whether they should be displayed on the map at any given time.
// A marker is only shown if every function returns true
filterFunctions: [],
updateFilter: function()
{
this.filteredMarkers = [];
this.unfilteredMarkers = [];
for (var i = 0; i < this.markers.length; i++)
{
if (!this.markers[i].markerElement) continue;
// By default the marker is shown
if (this.markers[i].passesFilter())
this.filteredMarkers.push(this.markers[i]);
else
this.unfilteredMarkers.push(this.markers[i]);
}
// Remove unfilteredMarkers
for (var i = 0; i < this.unfilteredMarkers.length; i++)
this.unfilteredMarkers[i].markerElement.remove();
// Add filteredMarkers (if they're not already present)
var fragment = document.createDocumentFragment();
for (var i = 0; i < this.filteredMarkers.length; i++)
{
if (this.filteredMarkers[i].markerElement.parentElement == null)
fragment.append(this.filteredMarkers[i].markerElement);
}
this.elements.leafletMarkerPane.append(fragment);
// Hide the currently-showing popup if it belongs to a hidden marker
if (this.lastPopupShown && this.lastPopupShown.marker && !this.lastPopupShown.marker.passesFilter())
this.lastPopupShown.hide();
// Update the counts of the collectible filters
this.updateCollectedFilterLabels();
},
// Category groups
initCategoryGroupsStyles: once(function()
{
// Change selectors that are rooted to interactive-maps__filters-dropdown to instead be rooted to interactive-maps__filters-list
// so that they apply to all dropdowns within interactive-maps__filters-list
changeCSSRuleSelector(".interactive-maps__filters-dropdown .wds-dropdown::after, .interactive-maps__filters-dropdown .wds-dropdown::before",
".interactive-maps__filters-list .wds-dropdown::after, .interactive-maps__filters-list .wds-dropdown::before");
changeCSSRuleSelector(".interactive-maps__filters-dropdown .wds-dropdown__content", ".interactive-maps__filters-list .wds-dropdown__content");
// Change some of the scroll up/down shadows
deleteCSSRule(".interactive-maps__filters-dropdown-list--can-scroll-down::after, .interactive-maps__filters-dropdown-list--can-scroll-up::before");
}, mapsExtended),
// This function creates all the categoryGroups from the definitions in the categoryGroups array
// It's fairly complex since it supports nesting categories to any depth
initCategoryGroups: function()
{
// Simplify the filters dropdown by making interactive-maps__filters-dropdown and .wds-dropdown the same object
var filtersDropdownInner = this.elements.filtersDropdown.querySelector(".wds-dropdown");
this.elements.filtersDropdown.classList.add("wds-dropdown");
filtersDropdownInner.before(this.elements.filtersDropdown.querySelector(".wds-dropdown__toggle"));
filtersDropdownInner.before(this.elements.filtersDropdown.querySelector(".wds-dropdown__content"));
filtersDropdownInner.remove();
// Modify and set up some styles - this is only executed once
this.initCategoryGroupsStyles();
// Remove original "Select all" checkbox
var selectAllFilterElement = this.elements.filterAllCheckboxInput.closest(".interactive-maps__filter-all");
var selectAllLabelText = this.elements.filterAllCheckboxInput.nextElementSibling.textContent;
selectAllFilterElement.remove();
// If there are no category groups, or if the object is not an array
// just map the categories directly so that all categories are at the root
if (!this.config.categoryGroups || !Array.isArray(this.config.categoryGroups))
{
this.config.categoryGroups = this.categories
.filter(function(c){ return !c.startDisabled; })
.map(function(c){ return c.id; });
}
// Move categoryGroups from config to this
// To simplify the hierarchical structure, create a brand new root "Select all" group
// the children of which is the elements of categoryGroups
this.categoryGroups =
[
{
label: selectAllLabelText,
children: structuredClone(this.config.categoryGroups),
map: this
}
]
// Do some pre-processing on categoryGroups to remove invalid groups
var preprocessGroup = function(group)
{
// Group must have a label
if (!group.label || typeof group.label != "string")
{
log("Category group with the children " + group.children + " does not have a label!");
return false;
}
// Group must have children
if (!group.children || !Array.isArray(group.children) || group.children.length == 0)
return false;
group.categories = [];
group.allCategories = [];
group.subgroups = [];
group.allSubgroups = [];
// Process children, and remove invalid entries
for (var i = 0; i < group.children.length; i++)
{
var c = group.children[i];
// Child is category ID
if (typeof c == "string")
{
if (this.categoryLookup.has(c))
{
group.children[i] = this.categoryLookup.get(c);
group.categories.push(group.children[i]);
group.allCategories.push(group.children[i]);
}
else
{
log("A category with the ID \"" + c + "\" defined in the category group \"" + group.label + "\" does not exist!");
c = null;
}
}
// Child is nested group
else if (typeof c == "object")
{
if (preprocessGroup(c))
{
group.subgroups.push(c);
group.allSubgroups.push(c);
// If nested group has groups, add them to allSubgroups
if (c.allSubgroups.length > 0)
{
for (var j = 0; j < c.allSubgroups.length; j++)
group.allSubgroups.push(c.allSubgroups[j]);
}
// If nested group has categories, add them to allCategories
if (c.allCategories.length > 0)
{
for (var j = 0; j < c.allCategories.length; j++)
group.allCategories.push(c.allCategories[j]);
}
}
else
{
console.log("Category group \"" + (c.label || "undefined") + "\" was invalid and will be removed");
c = null;
}
}
// c is set to null if the child is invalid
if (c == null)
{
group.children.splice(i, 1);
i--;
}
}
// The group still has children, it's valid
return group.children.length > 0;
}.bind(this);
preprocessGroup(this.categoryGroups[0]);
// Finally actually create the CategoryGroups out of the definition (they will be created recursively in the ctor)
var rootGroup = this.categoryGroups[0] = new CategoryGroup(this.categoryGroups[0]);
var categoryGroupTree = rootGroup.flattenedGroups;
categoryGroupTree[rootGroup.id] = rootGroup;
// Use filter() to get a list of category matching the predicate
// In this case, all categories that have not been assigned to any of the
// category groups at any level in the hierarchy
var ungroupedCategories = this.categories.filter(function(c)
{
// Don't include disabled categories
if (c.startDisabled == true) return false;
// Check if any category group in the config contains this category
return !Object.values(categoryGroupTree).some(function(cg)
{
// Check if a category group contains a category with this ID
return cg.categories.some(function(cgc)
{
// Check if this category ID matches the testing ID
return cgc.id == c.id;
});
});
});
// If there are ungrouped categories
if (ungroupedCategories.length > 0)
{
// Add any categories that aren't grouped to the rootGroup
ungroupedCategories.forEach(function(uc)
{
rootGroup.addCategoryToGroupById(uc.id);
rootGroup.children.push(uc);
});
// Update the checked visual state
//rootGroup.updateCheckedVisualState();
}
rootGroup.updateCheckedVisualState();
// Resize the searchRoot to be a bit less than the height of the root map container
//this.elements.filtersDropdownContent.style.maxHeight = (this.elements.rootElement.clientHeight - 35) + "px";
this.adjustMapDropdown(this.elements.filtersDropdownButton, this.elements.filtersDropdownContent);
// Add a listener which changes the min height of the search box when it is opened
this.elements.filtersDropdownButton.addEventListener("mouseenter", function(e)
{
this.adjustMapDropdown(this.elements.filtersDropdownButton, this.elements.filtersDropdownContent);
}.bind(this));
this.elements.filtersDropdownList.addEventListener("scroll", OO.ui.throttle(function(e)
{
var scroll = e.target.scrollTop / (e.target.scrollHeight - e.target.offsetHeight);
e.target.classList.toggle("can-scroll-up", scroll > 0.02);
e.target.classList.toggle("can-scroll-down", scroll < 0.98);
}, 150), { passive: true });
}
};
function CategoryGroup(group, parentGroup)
{
// Save some fields from the definition
this.isRoot = !parentGroup;
this.id = this.isRoot ? "root" : group.label.toLowerCase().replace(" ", "_"),
this.label = group.label;
this.path = this.isRoot ? "root" : parentGroup.path + "." + this.id;
this.parentGroup = parentGroup;
this.collapsible = (group.collapsible == true || group.collapsible == undefined) && !this.isRoot;
this.collapsed = group.collapsed == true;
this.hidden = group.hidden;
this.children = group.children;
this.map = group.map || parentGroup.map;
this.categories = this.categories || [];
this.subgroups = this.subgroups || [];
this.allCategories = this.allCategories || [];
this.allSubgroups = this.allSubgroups || [];
this.flattenedGroups = {};
this.checkboxes = [];
this.elements = this.elements || {};
this.onCategoryGroupToggled = new EventHandler();
this.updateCheckedVisualStateThis = this.updateCheckedVisualState.bind(this);
if (this.isRoot)
{
// Set the initial maxHeight on all collapsible elements as soon as the filters dropdown is opened
// This is because the elements are created when the dropdown is hidden, and so the heights aren't
// calculated/valid isn't set until the element is first displayed and its height is determined
this.map.elements.filtersDropdownButton.addEventListener("mouseenter", function(e)
{
this.setInitialHeight();
}.bind(this), { once: true });
}
var groupElem = document.createElement("div");
groupElem.className = "mapsExtended_categoryGroup";
// Create a header element
var headerElem = document.createElement("div");
headerElem.className = "mapsExtended_categoryGroupHeader interactive-maps__filter";
// Create the checkbox elements
var checkboxId = this.map.instanceId + "__checkbox-categoryGroup-" + this.path;
// Create a header label element
var headerLabel = document.createElement("div");
headerLabel.className = "mapsExtended_categoryGroupHeaderLabel";
headerLabel.textContent = this.label.toString();
// Create header dropdown arrow element (to indicate collapsed state)
var headerArrow = document.createElement("div");
headerArrow.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 12 12\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M11.707 3.293a.999.999 0 00-1.414 0L6 7.586 1.707 3.293A.999.999 0 10.293 4.707l5 5a.997.997 0 001.414 0l5-5a.999.999 0 000-1.414\"></path></svg>";
headerArrow.className = "mapsExtended_categoryGroupHeaderArrow";
headerArrow.classList.toggle("mapsExtended_categoryGroupHeaderArrow--collapsed", this.collapsed);
//headerArrow.textContent = this.collapsed == true ? "▲" : "▼";
headerArrow.style.display = this.collapsible == false ? "none" : "";
var checkbox = createWdsCheckbox(checkboxId);
checkbox.label.appendChild(headerLabel);
checkbox.root.appendChild(headerArrow);
headerElem.appendChild(checkbox.root);
this.elements.root = groupElem;
this.elements.header = headerElem;
this.elements.headerLabel = headerLabel;
this.elements.checkbox = checkbox.input;
this.elements.headerArrow = headerArrow;
// Create a container element
var containerElem = document.createElement("div");
containerElem.className = "mapsExtended_categoryGroupChildren";
containerElem.style.marginLeft = this.isRoot ? "0" : "";
this.elements.container = containerElem;
// Insert the header and the container in the group itself
groupElem.appendChild(headerElem);
groupElem.appendChild(containerElem);
// Append the group as a child of its parent
if (this.isRoot)
{
var rootContainer = this.map.elements.filterCategoriesSectionContent || this.map.elements.filtersDropdownList;
rootContainer.appendChild(groupElem);
}
else
parentGroup.elements.container.appendChild(groupElem);
// Move actual category filters into group
for (var i = 0; i < this.children.length; i++)
{
if (typeof this.children[i] == "object")
{
// Child is category
if (this.children[i] instanceof ExtendedCategory)
this.addCategoryToGroup(this.children[i])
// Child is subgroup (we can trust that any other object is a subgroup because of the preprocessing)
else
this.children[i] = this.addSubgroupToGroup(this.children[i]);
}
}
// Events
// Click event on "parent" group checkbox
this.elements.checkbox.addEventListener("change", function(e)
{
this.visible = e.target.checked;
this.map.updateFilter();
}.bind(this));
// If this category group should be hidden, hide it (click all checkboxes if they are checked)
if (this.hidden == true)
this.visible = false;
// Update the visual checked state of the group checkbox
this.updateCheckedVisualState();
// Set up collapsible on group
headerArrow.addEventListener("click", function(e)
{
var collapsed = !this.collapsed;
this.collapsed = collapsed;
headerArrow.classList.toggle("mapsExtended_categoryGroupHeaderArrow--collapsed", this.collapsed);
if (collapsed == false)
{
containerElem.style.width = "";
containerElem.style.maxHeight = this.expandedHeight + "px";
//headerArrow.textContent = "▼";
}
else
{
containerElem.style.maxHeight = "0px";
//headerArrow.textContent = "▲";
containerElem.addEventListener("transitionend", function(e)
{
if (e.propertyName != "max-height") return;
//containerElem.style.width = collapsed ? "0" : "";
}, { once: true });
}
}.bind(this));
/*
// Cursor enters the group element
groupElem.addEventListener("mouseenter", function(e)
{
// Show (set maxHeight to full scrollHeight)
var container = e.currentTarget.lastElementChild;
container.style.maxHeight = container.scrollHeight + "px";
// Hide every other group
this.categoryGroups.forEach(function(cg)
{
if (cg.groupElement != e.currentTarget) cg.containerElement.style.maxHeight = "0px";
});
}.bind(this));
// Cursor leaves the group element
groupElem.addEventListener("mouseleave", function(e)
{
// Don't hide if this is the last element
if (e.currentTarget.parentElement.lastElementChild == e.currentTarget)
return;
// Hide (set maxHeight to 0)
var container = e.currentTarget.lastElementChild;
container.style.maxHeight = "0px";
});
*/
this.map.elements.filtersDropdownButton.addEventListener("mouseover", function(e)
{
this.elements.container.style.width = this.collapsed ? "0" : "";
}.bind(this));
}
CategoryGroup.prototype =
{
get visible()
{
return this.elements.checkbox.checked;
},
// Set visible state on the category
// This doesn't filter the markers, for this you need to call ExtendedMap.updateFilter
set visible(value)
{
// Set checked state on checkbox (it's used as a backing field for ExtendedCategory.visible)
// This does not fire the "change" event
this.elements.checkbox.checked = value;
this.elements.checkbox.indeterminate = false;
// Check all child categories and CategoryGroups
for (var i = 0; i < this.categories.length; i++)
this.categories[i].visible = value;
for (var i = 0; i < this.subgroups.length; i++)
this.subgroups[i].visible = value;
this.onCategoryGroupToggled.invoke({ group: this, map: this.map, value: value });
},
// Adds an ExtendedCategory to this group
addCategoryToGroup: function(category)
{
if (!this.categories.includes(category)) this.categories.push(category);
if (!this.allCategories.includes(category)) this.allCategories.push(category);
this.elements.container.appendChild(category.elements.filter);
this.checkboxes.push(category.elements.checkboxInput);
category.onCategoryToggled.subscribe(this.updateCheckedVisualStateThis);
return category;
},
// Adds a category to this group, given a category ID
addCategoryToGroupById: function(categoryId)
{
var category = this.map.categoryLookup.get(categoryId);
if (!category)
{
log("A category with the ID \"" + categoryId + "\" defined in the category group \"" + this.label + "\" does not exist!");
return;
}
return this.addCategoryToGroup(category);
},
// Adds a subgroup to this group, given a group definition (see docs)
// A group definition is an object containing { label, children } at least
addSubgroupToGroup: function(group)
{
var childGroup = new CategoryGroup(group, this);
this.subgroups.push(childGroup);
this.checkboxes.push(childGroup.elements.checkbox);
childGroup.onCategoryGroupToggled.subscribe(this.updateCheckedVisualStateThis);
this.flattenedGroups[this.id + "/" + childGroup.id] = childGroup;
for (var key in childGroup.flattenedGroups)
this.flattenedGroups[this.id + "/" + key] = childGroup.flattenedGroups[key];
return childGroup;
},
// Updates the checked and indeterminate state of a group, based on its children
// Recurses up the group tree repeating the same action for all parent groups
updateCheckedVisualState: function()
{
var group = this;
do
{
// Count the number of checked checkboxes in the group
var checkedCount = group.checkboxes.filter(function(c) { return c.checked; }).length;
var indeterminateCount = group.checkboxes.filter(function(c) { return c.indeterminate; }).length;
// Check the parent checkbox if there are any checked children.
group.elements.checkbox.checked = checkedCount > 0;
// If there are any checked children, but not all of them, set the group checkbox to be indeterminate
group.elements.checkbox.indeterminate = (checkedCount > 0 && checkedCount < group.checkboxes.length) || indeterminateCount > 0;
group = group.parentGroup;
}
while (group != undefined);
},
setInitialHeight: function()
{
// Cache the expanded height so we don't need to keep fetching the scroll height
// also because the scroll height will differ if any child groups are collapsed
this.expandedHeight = this.elements.root.clientHeight;
// Set the height of this group
this.elements.container.style.maxHeight = (this.collapsed && this.collapsible)
? "0px"
: this.expandedHeight + "px";
this.elements.container.style.width = (this.collapsed && this.collapsible) ? "0" : "";
// Set the maxHeight of all child groups of this group
this.subgroups.forEach(function(childGroup){ childGroup.setInitialHeight(); });
}
};
/*
ExtendedCategory
*/
function ExtendedCategory(map, categoryJson)
{
Object.assign(this, categoryJson);
this.id = this.id.toString();
this.markers = [];
this.map = map;
this.nameNormalized = this.name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
// Calculate some of the values needed to determine icon anchors
if (this.icon) this.calculateCustomIconAnchor();
map.categoryLookup.set(this.id, this);
// Process hints (strings added after double underscore, separated by a single underscore)
var lastIndex = this.id.lastIndexOf("__");
this.hints = lastIndex >= 0 ? this.id.slice(lastIndex + 2).split("_") : [];
// Categories always start enabled, for the same reason
this.disabled = false;
// Set up an event that will be fired when the toggle state of this category changes
this.onCategoryToggled = new EventHandler();
this.elements = {};
}
ExtendedCategory.prototype =
{
// The category (and the markers within) is visible if it's checked and not indeterminate
get visible()
{
return this.initialized ?
this.elements.checkboxInput.checked && !this.elements.checkboxInput.indeterminate :
this.startHidden || this.startDisabled;
},
// Set visible state on the category
// This doesn't filter the markers, for this you need to call ExtendedMap.updateFilter
set visible(value)
{
if (this.initialized)
{
// Set checked state on checkbox (it's used as a backing field for ExtendedCategory.visible)
// This does not fire the "change" event
this.elements.checkboxInput.checked = value;
// If there are indeterminate markers (markers that are shown even if the category is hidden), clear them
if (this.indeterminateMarkers && !this.startIndeterminate)
this.setIndeterminateMarkers(null);
// Fire events
this.map.events.onCategoryToggled.invoke({ map: this.map, category: this, value: value });
this.onCategoryToggled.invoke(value);
}
else
{
this.startHidden = value;
}
},
setIndeterminateMarkers: function(markers)
{
markers = Array.isArray(markers) ? markers :
markers instanceof ExtendedMarker ? [ markers ] :
null;
var indeterminate;
// Clear visible on old
if (markers == null)
{
indeterminate = false;
if (this.indeterminateMarkers)
{
for (var i = 0; i < this.indeterminateMarkers.length; i++)
this.indeterminateMarkers[i].visible = false;
}
}
// Set visible on new
else
{
indeterminate = true
for (var i = 0; i < markers.length; i++)
markers[i].visible = true;
}
this.indeterminateMarkers = markers;
if (this.initialized)
this.elements.checkboxInput.indeterminate = indeterminate;
else
this.startIndeterminate = indeterminate;
},
toggle: function(value)
{
value = value != null ? value : !this.visible;
this.visible = value;
},
init: function(filterElement)
{
this.initialized = true;
// If the filter is currently unchecked for this category, check it so that we can associate the markers
// This can happen with embedded maps with category-name= or category-id=, or marker=
// We click the element before removing it because Interactive Maps checkboxes only work when they're
// under an interactive-map-xyz class, and within the hierarchy
var originalFilter = filterElement;
var originalInput = filterElement.querySelector("input");
if (!originalInput.checked) originalInput.click();
filterElement.remove();
// Clone filter element and all its children to remove all event listeners
// This is easier than reconstructing the hierarchy, and more bulletproof than using hacks to remove listeners
filterElement = this.elements.filter = filterElement.cloneNode(true);
filterElement.replaceWith(this.elements.filter);
// Fetch all elements from root filter
this.elements.checkboxInput = this.elements.filter.querySelector("input");
this.elements.checkboxLabel = this.elements.filter.querySelector("label");
this.elements.categoryIcon = this.elements.checkboxLabel.querySelector(".interactive-maps__filters-marker-icon");
this.elements.categoryIconImg = this.elements.categoryIcon.querySelector("img");
this.elements.categoryLabel = this.elements.checkboxLabel.querySelector("span:last-child");
if (this.icon) this.icon.img = this.elements.categoryIconImg;
// Set some values on the filter element itself
filterElement.category = this;
filterElement.id = "filter_" + this.id;
// Subscribe to the change event on the checkbox input to update the visible bool, and invoke a toggled event
this.elements.checkboxInput.addEventListener("change", function(e)
{
this.visible = e.target.checked;
this.map.updateFilter();
}.bind(this));
// Hide categories that should start hidden (this is done *before* matching markers)
// When markers are hidden, they are destroyed, therefore matching markers in a category that will be hidden immediately after is a waste of time
// In a clustered map, this will trigger recreation of all markers (hence why we do it before initialization)
if (this.startDisabled == true)
{
this.disabled = true;
this.elements.filter.style.display = "none";
}
// Toggle every category initially
this.toggle(!(this.startHidden == true || this.startDisabled == true));
if (this.startIndeterminate)
{
this.elements.checkboxInput.indeterminate = true;
}
delete this.startHidden;
delete this.startDisabled;
delete this.startIndeterminate;
},
deinit: function()
{
// Don't actually need to do anything here since no category elements are removed on refresh
},
// Calculate the anchor styles and scaled size of an icon (in this case, an icon definition in either the category or marker)
// and add them in-place (adds scaledWidth and anchorStyles)
calculateCustomIconAnchor: function()
{
if (!this.icon) return;
// Cache the width and the height of the icon in scaled units (where markers have to fit into a box of 26px)
var ratio = Math.min(26 / this.icon.width, 26 / this.icon.height);
this.icon.scaledWidth = this.icon.width * ratio;
this.icon.scaledHeight = this.icon.height * ratio;
// Cache the styles that will be used to anchor icons on this category
this.icon.anchorStyles = {};
// Vertical portion of iconAnchor
if (this.map.config.iconAnchor.startsWith("top")) this.icon.anchorStyles["margin-top"] = "0px";
else if (this.map.config.iconAnchor.startsWith("center")) this.icon.anchorStyles["margin-top"] = "-" + (this.icon.scaledHeight * 0.5) + "px";
else if (this.map.config.iconAnchor.startsWith("bottom")) this.icon.anchorStyles["margin-top"] = "-" + (this.icon.scaledHeight * 1.0) + "px";
else console.error("Invalid vertical iconAnchor config! Should be one of: top, center, bottom");
// Horizontal portion of iconAnchor
if (this.map.config.iconAnchor.endsWith("left")) this.icon.anchorStyles["margin-left"] = "0px";
else if (this.map.config.iconAnchor.endsWith("center")) this.icon.anchorStyles["margin-left"] = "-" + (this.icon.scaledWidth * 0.5) + "px";
else if (this.map.config.iconAnchor.endsWith("right")) this.icon.anchorStyles["margin-left"] = "-" + (this.icon.scaledWidth * 1.0) + "px";
else console.error("Invalid horizontal iconAnchor config! Should be one of: left, center, right");
},
// Collectibles
isAnyCollected: function()
{
return this.collectible ? this.markers.some(function(m) { return m.collected == true; }) : false;
},
getNumCollected: function(state, excludeFiltered)
{
// Number collected is 0 for categories that aren't collectible
// or if we're filtering excluded, and this category is excluded
if (!this.collectible || excludeFiltered == true && this.visible == false)
return 0;
// Default the collected state to count to true
if (state == null)
state = true;
var count = 0;
for (var i = 0; i < this.markers.length; i++)
{
if (this.markers[i].collected == state)
count++;
}
return count;
},
getNumCollectible: function()
{
return this.collectible ? this.markers.length : 0;
},
updateCollectedLabel: function()
{
if (!this.collectible)
return;
// Align icon to top of flex
if (!this.elements.collectedLabel)
{
if (this.elements.categoryIcon) this.elements.categoryIcon.style.alignSelf = "flex-start";
var categoryLabel = this.elements.categoryLabel;
// Add amount collected "<collected> of <total> collected"
var collectedLabel = document.createElement("div");
collectedLabel.style.cssText = "font-size:small; opacity:50%";
var collectedLabelText = document.createTextNode("");
collectedLabel.appendChild(collectedLabelText);
// Add collectedLabel as child of categoryLabel
categoryLabel.appendChild(collectedLabel);
this.elements.collectedLabel = collectedLabelText;
}
var count = this.getNumCollected();
var total = this.markers.length;
var perc = Math.round((count / total) * 100); // <- Not used in default label, but may be specified
var msg = mapsExtended.i18n.msg("category-collected-label", count, total, perc).plain();
this.elements.collectedLabel.textContent = msg;
},
clearAllCollected: function(){ this.setAllCollected(false); },
markAllCollected: function(){ this.setAllCollected(true); },
setAllCollected: function(state)
{
for (var j = 0; j < this.markers.length; j++)
this.markers[j].setMarkerCollected(state, true, false, true);
// Update label
this.updateCollectedLabel();
}
}
/*
ExtendedMarker
*/
function ExtendedMarker(map, markerJson)
{
// Copy all properties from markerJson into ExtendedMarker
Object.assign(this, markerJson);
// Generate a new ID for the marker if the editor hasn't set one
if (!this.id )
{
this.id = generateRandomString(8);
this.usesNewId = true;
}
// Warn if there already exists a marker with this ID
if (map.markerLookup.has(this.id))
{
var newId = this.id + "_" + generateRandomString(8);
console.error("Multiple markers exist with the id " + this.id + "! Renamed to " + newId);
this.id = newId;
this.usesNewId = true;
}
// Add a reference to this marker in the markerLookup
map.markerLookup.set(this.id, this);
// Get the category of the marker
this.category = map.categoryLookup.get(this.categoryId);
// Add reference to this marker in the category it belongs to
this.category.markers.push(this);
this.map = map;
this.popup = new ExtendedPopup(this);
this.name = this.popup.title;
this.nameNormalized = this.name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
// Cache the width and the height of the icon in scaled units (where markers have to fit into a box of 26px)
if (this.icon) ExtendedCategory.prototype.calculateCustomIconAnchor.call(this);
// Correct the position to always use xy
if (map.coordinateOrder == "yx")
{
// Swap x and y
var y = this.position[0];
this.position[0] = this.position[1];
this.position[1] = y;
}
// Correct the position to always use top-left
if (map.origin == "bottom-left")
{
this.position[1] = map.size.height - this.position[1];
}
// Enforce string IDs
if (typeof this.id == "number")
{
this.id = this.id.toString();
}
// Set iconAnchor from config
if (this.usesCustomIcon())
this.iconAnchor = this.map.config.iconAnchor;
else
this.iconAnchor = "bottom-center";
}
ExtendedMarker.prototype =
{
// Marker (element in DOM - we don't know this yet)
markerElement: null,
// Stores references between the marker definition in the JSON and the marker element and sets up some events
// Used to be called associateMarkerWithElement
init: function(markerElement)
{
this.initialized = true;
this.markerElement = markerElement;
markerElement.marker = this;
markerElement.id = this.id;
markerElement.style.zIndex = this.order.toString();
this.width = this.icon && this.icon.scaledWidth || this.category.icon && this.category.icon.scaledWidth || this.markerElement.clientWidth;
this.height = this.icon && this.icon.scaledHeight || this.category.icon && this.category.icon.scaledHeight || this.markerElement.clientHeight;
// Update the iconAnchor if this is a custom marker
if (this.usesCustomIcon())
{
// Get anchor styles from this icon if it exists, or the category icon
var anchorStyles = this.icon && this.icon.anchorStyles || this.category.icon && this.category.icon.anchorStyles || undefined;
if (anchorStyles)
{
for (var key in anchorStyles) markerElement.style[key] = anchorStyles[key];
markerElement.classList.add("uses-icon-anchor");
}
}
// Add click events to the element
markerElement.addEventListener("click", this.onMarkerActivated.bind(this), true);
markerElement.addEventListener("keydown", this.onMarkerActivated.bind(this), true);
// Prevent zoom when double clicking on marker
markerElement.addEventListener("dblclick", function(e){ e.stopPropagation(); });
// Add mouseenter and mouseleave events to the element
markerElement.addEventListener("mouseenter", function(e)
{
this.map.lastMarkerHovered = this;
this.map.lastMarkerElementHovered = this.markerElement;
this.map.events.onMarkerHovered.invoke({ map: this.map, marker: this, value: true, event: e });
}.bind(this));
markerElement.addEventListener("mouseleave", function(e){ this.map.events.onMarkerHovered.invoke({ map: this.map, marker: this, value: false, event: e }); }.bind(this));
},
// Used to be called deassociateMarkerWithElement
deinit: function()
{
this.initialized = false;
if (this.markerElement)
{
this.markerElement.marker = undefined;
this.markerElement.id = "";
this.markerElement.style.zIndex = "";
}
this.markerElement = undefined;
this.popup.deinitPopup();
},
passesFilter: function(e)
{
// Call all filter functions for this marker, until one return false
// Only markers that return true for all filter functions will be shown
for (var i = 0; i < this.map.filterFunctions.length; i++)
{
if (this.map.filterFunctions[i](this) == false)
return false;
}
return true;
},
// Click event on marker
onMarkerActivated: function(e)
{
if (this.map.config.enablePopups == false)
{
e.stopPropagation();
e.preventDefault();
return;
}
// While using a custom popup, don't ever pass click events on to Leaflet so that the leaflet popup doesn't get recreated
// ! Keep this check at the top because we should always cancel it regardless !
if (this.map.config.useCustomPopups == true)
e.stopPropagation();
// Don't activate marker if the click was the end of a drag
if (this.map._invalidateLastClickEvent == true)
{
log("Invalidated click event on " + this.id + " because it followed the end of a drag");
this.map._invalidateLastClickEvent = false;
return;
}
if (e instanceof KeyboardEvent)
{
if (e.key != "Enter")
return;
}
if (this.map.config.useCustomPopups == true)
this.popup.toggle();
// If popups should open only on hover, only non-trusted events (those initiated from scripts)
// should allow the popup to be opened. Discard click events that are sourced from the browser
if (this.map.config.openPopupsOnHover == true && e.isTrusted == true)
{
e.stopPropagation();
return;
}
this.map.events.onMarkerClicked.invoke({ map: this.map, marker: this, event: e });
},
// Performs a direct comparison between a marker element and a marker definition just to be sure they are equal
compareMarkerAndJsonElement: function(markerElem, markerJson)
{
if (!markerJson) markerJson = this;
// Short-circuit of the element already has an associated marker
if (markerElem.marker != undefined && markerElem.marker != markerJson)
return false;
// Valid if these two are already associated
if (markerJson.markerElement == markerElem && markerJson.id == markerElem.id)
return true;
// ID-based hint
var markerElemId = this.getMarkerId(markerElem);
var markerJsonId = this.getMarkerId(markerJson);
// Sanity check to see if at least the ids match (id may NOT be present on all marker elements)
// No match if the id is present on both, but differs
if (markerElemId && markerJsonId && markerElemId != markerJsonId && !markerJson.usesNewId)
return false;
// Color-based hint
var markerElemColor = this.getMarkerColor(markerElem);
var markerJsonColor = this.getMarkerColor(markerJson);
// Sanity check to see if at least the colors match (color may NOT be present on all marker elements)
// No match if the color is present on both, but differs
if (markerElemColor && markerJsonColor && markerElemColor != markerJsonColor)
return false;
// Icon-based hint
var markerElemIcon = this.getMarkerIcon(markerElem, true);
var markerJsonIcon = this.getMarkerIcon(markerJson, true);
// Sanity check to see if at least the icons match (icon may NOT be present on all marker elements)
// No match if the icon is present on both, but differs
if (markerElemIcon && markerJsonIcon && markerElemIcon != markerJsonIcon)
return false;
// Position-based matching
// Because the element positions are scaled (and rounded) from the original fractional definition position,
// scaling them back up to the original "unscaled" state will very likely yield significant error
// So instead, we do the comparison at the current scale of the map, which should be much more representative
// Get position of marker element, scaled to the current zoom level
var markerElemPos = this.getScaledMarkerPosition(markerElem);
// Get position of the marker definition in the JSON, scaled to the current zoom level
var markerJsonPos = this.getScaledMarkerPosition(markerJson);
// The actual comparison is almost always position-based, since it's by far the most accurate
// We have 1px of error here
return Math.abs(markerElemPos[0] - markerJsonPos[0]) <= 1 &&
Math.abs(markerElemPos[1] - markerJsonPos[1]) <= 1;
},
// Returns the ID of the marker element or JSON definition.
getMarkerId: function(marker)
{
if (!marker) marker = this;
// This was added in the release of Interactive Maps. The "id" field of the marker in the JSON is
// directly exposed in the DOM, via the data-testId attribute on the child SVG element of the marker
// element. However this is only present on markers with the default marker image, not when custom
// marker graphics are used.
// In addition, uniqueness on marker IDs aren't enforced, so this ID may be shared by multiple elements
if (marker instanceof Element && !marker.id)
{
var svg = marker.querySelector("svg");
// Cache the marker id
if (svg) marker.id = svg.getAttribute("data-testid").replace("default-marker-with-id-", "");
}
return marker.id;
},
// Returns the color of the marker element or JSON definition.
// This appears exactly as entered in the JSON, which supports any valid CSS color
// When comparing, we use string comparison and not actual color value comparison.
// This is fine because the colour is always converted to a hex code when it is deserialized
getMarkerColor: function(marker)
{
if (!marker) marker = this;
// Get value of --marker-icon-color variable in the CSS
if (marker instanceof Element)
{
// Don't fetch the colour multiple times
// Only markers containing the class .MapMarker-module_markerIcon__dHSar have a colour
if (!marker.markerColor && marker.classList.contains("MapMarker-module_markerIcon__dHSar"))
{
var svg = marker.querySelector("svg");
// Cache the marker color so we don't have to re-retrieve it
if (svg) marker.markerColor = svg.style.getPropertyValue("--marker-icon-color").toLowerCase().trim();
}
// This may intentionally return undefined
return marker.markerColor;
}
// Get the color string from the category this marker belongs to
else
{
if (this.map.categoryLookup.has(marker.categoryId))
{
return this.map.categoryLookup.get(marker.categoryId).color.toLowerCase().trim();
}
}
return;
},
// Returns true if the marker uses a custom icon (either from the marker itself, or the category it belongs to)
usesCustomIcon: function()
{
/*
if (this.markerElement)
return this.markerElement.classList.contains("MapMarker-module_markerCustomIcon__YfQnB");
else
*/
return this.icon != undefined || this.category.icon != undefined;
},
// Returns the icon texture filename of the marker element or JSON definition.
// Set fileNameOnly to true to return just the file name of the icon, otherwise the full URL is returned
getMarkerIcon: function(marker, fileNameOnly)
{
if (!marker) marker = this;
if (marker instanceof Element)
{
// Don't fetch the icon multiple times if it is cached
// Only markers containing the class MapMarker-module_markerCustomIcon__YfQnB have an icon
if (!marker.icon && marker.classList.contains("MapMarker-module_markerCustomIcon__YfQnB"))
{
var img = marker.querySelector("img");
if (img && img.src)
{
var url = new URL(img.src);
// Remove all parameters (excluding cb cachebuster param)
if (url.searchParams.has("cb") != null && url.searchParams.size > 1)
url.search = "?cb=" + url.searchParams.get("cb");
else
url.search = "";
url = url.toString();
// Cache the marker icon in the element object so we don't have to re-retrieve it
marker.icon = { url: url };
// Fetch the file name using the URL
var stripIndex = marker.icon.url.indexOf("/revision/");
marker.icon.title = marker.icon.url.substring(0, stripIndex);
var lastSlashIndex = marker.icon.title.lastIndexOf("/");
marker.icon.title = marker.icon.title.substring(lastSlashIndex + 1);
// Decode URL-escaped characters
marker.icon.title = decodeURIComponent(marker.icon.title);
}
}
if (!marker.icon)
return;
return fileNameOnly ? marker.icon.title : marker.icon.url;
}
// Get the icon filename from either the marker itself, or the category this marker belongs to
else
{
// Icon object (either directly from marker or from the category it belongs to)
// containing title, url, width, height
var icon = marker.icon || marker.category.icon;
// If a custom icon is present, either from the marker itself, or from the category the marker belongs to
if (icon)
{
if (fileNameOnly)
{
if (!icon.fileName)
{
icon.fileName = icon.title;
// Remove any file: prefix (the comparing src attribute will never have this)
if (icon.title.toLowerCase().startsWith("file:") ||
icon.title.toLowerCase().startsWith(mw.config.get("wgFormattedNamespaces")[6].toLowerCase() + ":"))
{
icon.fileName = icon.title.substring(icon.title.indexOf(":") + 1);
}
// Convert any spaces to underscores
icon.fileName = icon.fileName.replace(/\s/g, "_");
// Ensure that the first letter is upper case (the img src will always be)
icon.fileName = icon.fileName.charAt(0).toUpperCase() + icon.fileName.slice(1);
}
return icon.fileName;
}
else
{
// Just return the url
return icon.url;
}
}
}
return;
},
// Returns the "unscaled" position of a marker element or JSON definition
// This is the original unchanging pixel position, or as close to it as possible.
getUnscaledMarkerPosition: function(marker)
{
if (!marker) marker = this;
var pos = [];
// Get unscaled position of a marker element in DOM
if (marker instanceof Element)
{
pos = marker.markerPos;
if (pos == undefined)
{
pos = this.getScaledMarkerPosition(marker);
var imageSize = this.getScaledMapImageSize();
// Scale the position back up to the original range, and round
pos[0] = Math.round((pos[0] / imageSize[0]) * this.map.size.width);
pos[1] = Math.round((pos[1] / imageSize[1]) * this.map.size.height);
// Cache this info in the element itself so we don't have to recalculate (or store it elsewhere)
marker.markerPos = pos;
}
}
// Get unscaled position of a marker definition from JSON
else
{
pos[0] = marker.position[0];
pos[1] = marker.position[1];
}
return pos;
},
// Returns the "scaled" position of a marker element or JSON position
// This is pixel position adjusted to the current map zoom level
// It is not accurate to the transform:translate CSS position, as it factors out the base layer position
getScaledMarkerPosition: function(marker)
{
if (!marker) marker = this;
var pos = [];
// Get scaled position of a marker element in DOM
// For elements, it's easier to simply get the transform:translate from the styles
if (marker instanceof Element)
{
// Get base layer transform position. This needs to be calculated on the fly as it will change as the user zooms
var baseLayerPos = this.map.getElementTransformPos(this.map.elements.leafletBaseImageLayer);
// Subtract the current position of the map overlay from the marker position to get the scaled position
pos = this.map.getElementTransformPos(marker);
pos[0] -= baseLayerPos[0];
pos[1] -= baseLayerPos[1];
}
// Get unscaled position of a marker definition from JSON
else
{
pos = this.map.unscaledToScaledPosition([ marker.position[0],
marker.position[1] ]);
}
return pos;
},
// Returns the position of the marker or marker element relative to the viewport
// for example a marker at 0,0 will be at the top left corner of the container (not the map itself!)
getViewportMarkerPosition: function(marker)
{
marker = marker || this;
var viewportRect = this.map.elements.leafletContainer.getBoundingClientRect();
var markerRect;
if (marker instanceof Element)
markerRect = marker.getBoundingClientRect();
else
markerRect = marker.markerElement.getBoundingClientRect();
return [ markerRect.x - viewportRect.x , markerRect.y - viewportRect.y ];
},
// If a marker definition doesn't have a (unique) ID, we can identify it based on its position+title+desc
calculateMarkerHash: function(marker)
{
marker = marker || this;
var str = "" + marker.position[0] + marker.position[1] + marker.popup.title + marker.popup.description + (marker.popup.link != undefined ? marker.popup.link.url + marker.popup.link.label : "");
var hash = 0;
if (str.length == 0)
return hash.toString();
for (var i = 0; i < str.length; i++)
{
var char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString();
},
// Collectibles
collected: false,
// Sets the collected state of the marker.
// This should be called instead of setting collected directly and is called
// by user interactions, as well as on clear and initial load
setMarkerCollected: function(state, updatePopup, updateLabel, canShowBanner)
{
// Don't try to collect markers that aren't collectible
if (!this.category.collectible) return;
state = state || false;
// Set the collected state on the marker
this.collected = state;
if (this.markerElement)
{
// Set the marker collected style using a class rather than an inline attribute
// This is required because with clustered markers, the opacity is overridden as part of the zoom animation on EVERY marker
this.markerElement.classList.toggle("mapsExtended_collectedMarker", state);
}
// Set the collected state on the connected popup (if shown)
// This does not trigger the checked change event
if (this.popup.isPopupShown() && updatePopup)
this.popup.updateCollectibleElements();
// Update the collected label
if (updateLabel)
{
this.category.updateCollectedLabel();
this.map.updateCollectedFilterLabels();
}
// Show a congratulatory banner if all collectibles were collected
if (canShowBanner && this.map.config.enableCollectedAllNotification && state == true)
{
// Check if all were collected
var numCollected = this.category.getNumCollected();
var numTotal = this.category.markers.length;
// Show a banner informing the user that they've collected all markers
if (numCollected == numTotal)
{
var msg = mapsExtended.i18n.msg("collected-all-banner", numCollected, numTotal, mw.html.escape(this.category.name), this.map.getMapLink(null, "wikitext")).parse();
this.map.elements.collectedMessageBanner.setContent(msg);
this.map.elements.collectedMessageBanner.show();
}
}
// If the marker is now in a state where it would be filtered out, hide it
if ((state == true && this.map.collectedVisible == false) ||
(state == false && this.map.nonCollectedVisible == false))
{
// If the popup for the marker is shown, hide it when it's hidden
if (this.popup.isPopupShown())
{
this.popup.events.onPopupHidden.subscribeOnce(function(){ this.updateFilter(); }.bind(this.map));
}
}
}
};
/*
Many of these functions simply make it easier to change parts of the popup
It takes into account cases where a popup element isn't associated, and will store the
pending changes and wait for the popup element to appear before making them
*/
function ExtendedPopup(marker)
{
// Shallow copy, objects are assigned by reference, this is fine because in the
// ExtendedMarker constructor, the marker (including its popup) were deep cloned already)
Object.assign(this, marker.popup);
// Store references to map and marker
this.marker = marker;
this.map = marker.map;
this.events =
{
onPopupShown: new EventHandler(),
onPopupHidden: new EventHandler(),
onPopupCreated: new EventHandler()
};
// Sanitize descriptionHtml)
if (this.description) this.descriptionHtml = this.descriptionHtml.replace(/<!--[\s\S]*?-->/g, "");
}
ExtendedPopup.prototype =
{
// This should be called after the popupElement reference is found
initPopup: function(popupElement)
{
this.initialized = true;
// Override the existing popupElement
if (this.map.config.useCustomPopups == true)
{
this.isCustomPopup = true;
// This code is used to circumvent the bug that causes the map to freeze when it is dragged
popupElement = this.createCustomPopup();
this.initCustomPopupStyles();
this.applyCustomPopupEvents();
}
// Get references to all the popup elements
this.elements = this.elements || this.fetchPopupElements(popupElement);
this.wrapPopupImages();
this.createCollectibleElements();
// Process any popup changes that are pending
this.processPendingChanges();
popupElement.id = "popup_" + this.marker.id;
popupElement.popup = this;
// Note that when using custom popups, the transform position is the exact same as the marker
// where default Leaflet-created popups use a transform that places the popup above the marker
// Because of this, we need to use two different popup offsets
if (this.map.config.useCustomPopups == true)
{
popupElement.style.bottom = "0";
popupElement.style.left = "-150px";
// Vertical offset
if (this.marker.iconAnchor.startsWith("top"))
popupElement.style.marginBottom = ((this.marker.height * 0.0) + 9 + 4) + "px"; // (0% of icon height) + 9 (popup tip) + 4 (gap)
else if (this.marker.iconAnchor.startsWith("center"))
popupElement.style.marginBottom = ((this.marker.height * 0.5) + 9 + 4) + "px"; // (50% of icon height) + 9 (popup tip) + 4 (gap)
else if (this.marker.iconAnchor.startsWith("bottom"))
popupElement.style.marginBottom = ((this.marker.height * 1.0) + 9 + 4) + "px"; // (100% of icon height) + 9 (popup tip) + 4 (gap)
// Horizontal offset
if (this.marker.iconAnchor.endsWith("left"))
popupElement.style.marginLeft = (this.marker.width * 0.5) + "px";
if (this.marker.iconAnchor.endsWith("center"))
popupElement.style.marginLeft = (this.marker.width * 0.0) + "px";
if (this.marker.iconAnchor.endsWith("right"))
popupElement.style.marginLeft = (this.marker.width * -0.5) + "px";
}
else
{
// Leaflet uses a bottom and left position of 7px and -152px, which is forced every time the popup is shown.
// This means we have to add these offsets to the margins in order to obtain our desired position
popupElement.style.marginLeft = "2px";
// Vertical offset
if (this.marker.iconAnchor.startsWith("top"))
popupElement.style.marginBottom = ((this.marker.height * -1.0) + 9 + 4 + 7) + "px"; // -26 (negate full icon height) + 9 (popup tip) + 4 (gap) + 7 (negate bottom)
else if (this.marker.iconAnchor.startsWith("center"))
popupElement.style.marginBottom = ((this.marker.height * -0.5) + 9 + 4 + 7) + "px"; // -13 (negate half icon height) + 9 (popup tip) + 4 (gap) + 7 (negate bottom)
else if (this.marker.iconAnchor.startsWith("bottom"))
popupElement.style.marginBottom = ((this.marker.height * 0.0) + 9 + 4 + 7) + "px"; // 0 (keep icon height) + 9 (popup tip) + 4 (gap) + 7 (negate bottom)
// Horizontal offset (same as above but adds 2px)
if (this.marker.iconAnchor.endsWith("left"))
popupElement.style.marginLeft = ((this.marker.width * 0.5) + 2) + "px";
if (this.marker.iconAnchor.endsWith("center"))
popupElement.style.marginLeft = ((this.marker.width * 0.0) + 2) + "px";
if (this.marker.iconAnchor.endsWith("right"))
popupElement.style.marginLeft = ((this.marker.width * -0.5) + 2) + "px";
}
// If the marker category is NOT collectible, remove the progress button
if ((!this.map.hasCollectibles || !this.marker.category.collectible) && this.elements.progressButton)
this.elements.progressButton.remove();
if (this.marker.map.config.openPopupsOnHover == true)
{
popupElement.addEventListener("mouseenter", function(e){ this.stopPopupHideDelay();}.bind(this));
popupElement.addEventListener("mouseleave", function(e){ this.startPopupHideDelay();}.bind(this));
}
// Invoke onPopupCreated
log("Popup created: " + this.marker.id);
this.events.onPopupCreated.invoke();
this.map.events.onPopupCreated.invoke({ map: this.map, marker: this.marker, popup: this });
},
// This should be called before a new popupElement is set, to invalidate the old no-longer-used popup element
deinitPopup: function()
{
this.initialized = false;
this.elements = null;
},
initCustomPopupStyles: once(function()
{
// Remove a rule that fixes the opacity to 1
deleteCSSRule(".leaflet-fade-anim .leaflet-map-pane .leaflet-popup");
}, mapsExtended),
cloneCreateCustomPopup: function()
{
// Hide the popup that was created as part of Leaflet, clone it and reshow
// the clone on our own terms (this does mean we have to handle our own animation and whatnot)
var origElements = this.fetchPopupElements(popupElement);
// Clone the original popup, with events and all, converting it to a custom popup
popupElement = origElements.popupElement.cloneNode(true);
// Hide the original popup, both via scripting and visually by setting the opacity to 0
origElements.popupCloseButton.click();
origElements.popupElement.remove();
return popupElement;
},
createCustomPopup: function()
{
var customPopup = document.createElement("div");
customPopup.className = "leaflet-popup leaflet-zoom-animated mapsExtended_customPopup";
customPopup.style.cssText = "opacity: 1; bottom: 0; left: -150px;";
// This is the maximum required HTML for a popup
customPopup.innerHTML = "<div class=\"leaflet-popup-content-wrapper\"><div class=\"leaflet-popup-content\" style=\"width: 301px;\"><div class=\"MarkerPopup-module_popup__eNi--\"><div class=\"MarkerPopup-module_content__9zoQq\"><div class=\"MarkerPopup-module_contentTopContainer__qgen9\"><div class=\"MarkerPopup-module_title__7ziRt\"><\/div><div class=\"MarkerPopup-module_actionsContainer__q-GB8\"><div class=\"wds-dropdown MarkerPopupActions-module_actionsDropdown__Aq3A2\"><div class=\"wds-dropdown__toggle MarkerPopupActions-module_actionsDropdownToggle__R5KYk\" role=\"button\"><span><\/span><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" xmlns:xlink=\"http:\/\/www.w3.org\/1999\/xlink\" viewBox=\"0 0 18 18\" width=\"1em\" height=\"1em\" class=\"wds-icon wds-icon-small wds-dropdown__toggle-chevron\"><defs><path id=\"prefix__more-small\" d=\"M9 5c1.103 0 2-.896 2-2s-.897-2-2-2-2 .896-2 2 .897 2 2 2m0 8c-1.103 0-2 .896-2 2s.897 2 2 2 2-.896 2-2-.897-2-2-2m0-6c-1.103 0-2 .896-2 2s.897 2 2 2 2-.896 2-2-.897-2-2-2\"><\/path><\/defs><use fill-rule=\"evenodd\" xlink:href=\"#prefix__more-small\"><\/use><\/svg><\/div><div class=\"wds-dropdown__content wds-is-not-scrollable\"><ul class=\"MarkerPopupActions-module_dropdownContent__GYl-7\"><li class=\"MarkerPopupActions-module_action__xeKO9\" data-testid=\"copy-link-marker-action\"><span class=\"MarkerPopupActions-module_actionIcon__VyVPj\"><svg class=\"wds-icon wds-icon-small\"><use xlink:href=\"#wds-icons-link-small\"><\/use><\/svg><\/span><span class=\"MarkerPopupActions-module_actionLabel__yEa0-\">Copy link<\/span><\/li><li class=\"MarkerPopupActions-module_action__xeKO9\" data-testid=\"marker-report-action\"><span class=\"MarkerPopupActions-module_actionIcon__VyVPj\"><svg class=\"wds-icon wds-icon-small\"><use xlink:href=\"#wds-icons-alert-small\"><\/use><\/svg><\/span><span class=\"MarkerPopupActions-module_actionLabel__yEa0-\">Report Marker<\/span><\/li><\/ul><\/div><\/div><\/div><\/div><div class=\"MarkerPopup-module_scrollableContent__0N5PS\"><div class=\"MarkerPopup-module_description__fKuSE\"><div class=\"page-content MarkerPopup-module_descriptionContent__-ypRG\"><\/div><\/div><div class=\"MarkerPopup-module_imageWrapper__HuaF2\"><img class=\"MarkerPopup-module_image__7I5s4\"><\/div><\/div><div class=\"MarkerPopup-module_link__f59Lh\"><svg class=\"wds-icon wds-icon-tiny MarkerPopup-module_linkIcon__q3Rbd\"><use xlink:href=\"#wds-icons-link-tiny\"><\/use><\/svg><a href=\"\" target=\"_blank\" rel=\"noopener noreferrer\"><\/a><\/div><\/div><\/div><\/div><\/div><div class=\"leaflet-popup-tip-container\"><div class=\"leaflet-popup-tip\"><\/div><\/div>";
if (this.marker.markerElement) customPopup.style.transform = this.marker.markerElement.style.transform;
this.elements = this.fetchPopupElements(customPopup);
// Set title content
if (this.title)
this.setTitle(this.title);
else
this.elements.popupTitle = undefined;
// Set description
if (this.description)
this.setDescription(this.descriptionHtml);
else
{
this.elements.popupDescription.remove();
this.elements.popupDescription = undefined;
}
// Set image
if (this.image && this.image.title && this.image.url)
this.setImage(this.image.title, this.image.url);
else
{
this.elements.popupImageWrapper.remove();
this.elements.popupImage.remove();
this.elements.popupImageWrapper = this.elements.popupImage = undefined;
}
// Remove scrollable content if not present
if (!this.description && !this.image)
this.elements.popupScrollableContent.remove();
// Set link label and url
if (this.link && this.link.label && this.link.url)
{
this.setLinkLabel(this.link.label);
this.setLinkUrl(this.link.url);
}
else
{
this.elements.popupLinkWrapper.remove();
this.elements.popupLinkWrapper = this.elements.popupLink = undefined;
}
return customPopup;
},
applyCustomPopupEvents: function()
{
// The following function updates the transform at each frame such that the marker and popup zoom at the same rate
var prev, zoomStep = function(time)
{
// Only apply the new transform if the time actually changed
if (prev != time)
{
this.elements.popupElement.style.transform = this.marker.markerElement.style.transform;
this.applyPopupOffsets();
}
prev = time;
// Repeat indefinetely until it is stopped outside of this function
this._zoomStepId = window.requestAnimationFrame(zoomStep);
}.bind(this);
// Subscribe to an event that fires on the start and end of the zoom
// in order to animate the popup transform alongside the marker transform
this.map.events.onMapZoomed.subscribe(function(e)
{
// Don't bother if the popup isn't actually shown
if (!this.isPopupShown()) return;
// Cancel the last callback and timeout so that we're not running two at the same time
window.cancelAnimationFrame(this._zoomStepId);
window.clearInterval(this._zoomStepTimeoutId);
// Zoom start
if (e.value == true)
{
// Start a new animation
this._zoomStepId = window.requestAnimationFrame(zoomStep);
// Start a timeout for it too
// This is more of a safety mechanism if anything, we don't want a situation where our zoomStep function is looping indefinetely
this._zoomStepTimeoutId = window.setTimeout(function() { window.cancelAnimationFrame(this._zoomStepId); }.bind(this), 300);
}
// Zoom end
else
{
// Apply the final transform
this.elements.popupElement.style.transform = this.marker.markerElement.style.transform;
this.applyPopupOffsets();
}
}.bind(this));
// Prevent mousedown's on the custom popup from causing a drag
this.elements.popupElement.addEventListener("mousedown", stopPropagation);
// Prevent double clicks on the custom popup from causing a zoom
this.elements.popupElement.addEventListener("dblclick", stopPropagation);
// Recreate the "copy link" button
this.elements.popupCopyLinkButton.addEventListener("click", function(e)
{
var markerUrl = window.location.origin + window.location.pathname + "?" + new URLSearchParams({ marker: this.marker.id });
navigator.clipboard.writeText(markerUrl).then(function()
{
new BannerNotification(mapsExtended.i18n.msg("copy-link-banner-success").escape(), "confirm", null, 5000).show();
})
.catch(function()
{
new BannerNotification(mapsExtended.i18n.msg("copy-link-banner-failure").escape(), "confirm", null, 5000).show();
});
}.bind(this));
},
applyPopupOffsets: function()
{
return;
var leafletContainerRect = this.map.elements.leafletContainer.getBoundingClientRect();
var popupRect = this.elements.popupElement.getBoundingClientRect();
var offsetElement = this.elements.popupElement.lastElementChild;
var offsets =
[
popupRect.left < leafletContainerRect.left ? leafletContainerRect.left - popupRect.left :
popupRect.right > leafletContainerRect.right ? leafletContainerRect.right - popupRect.right : 0,
popupRect.top < leafletContainerRect.top ? leafletContainerRect.top - popupRect.top :
popupRect.bottom > leafletContainerRect.bottom ? leafletContainerRect.bottom - popupRect.bottom : 0
];
// Cache offsets
this._offsets = offsets;
if (offsets[0] != 0 || offsets[1] != 0)
{
offsetElement.style.left = offsets[0] + "px";
this.elements.popupTipContainer.style.left = "calc(50% - " + offsets[0] + "px)";
}
else
{
this.elements.popupElement.style.left = "-150px";
this.elements.popupTipContainer.style.left = "";
}
},
// Returns an object containing all the sub-elements of the root popup element
// Operates without using "this" so can be uses as a psuedo-static function via ExtendedPopup.prototype
fetchPopupElements: function(popupElement)
{
var e = {};
e.popupElement = popupElement;
// Module content - will always exist
e.popupContent = e.popupElement.querySelector(".MarkerPopup-module_content__9zoQq");
// Content top container element (containing title) - will always exist
e.popupContentTopContainer = e.popupContent.querySelector(".MarkerPopup-module_contentTopContainer__qgen9");
e.popupTitle = e.popupContentTopContainer.querySelector(".MarkerPopup-module_title__7ziRt");
// Scrollable content (containing description and image) - will not exist if a description or image is not present
e.popupScrollableContent = e.popupContent.querySelector(".MarkerPopup-module_scrollableContent__0N5PS");
if (e.popupScrollableContent)
{
e.popupDescription = e.popupScrollableContent.querySelector(".MarkerPopup-module_descriptionContent__-ypRG");
e.popupImageWrapper = e.popupContent.querySelector(".MarkerPopup-module_imageWrapper__HuaF2");
if (e.popupImageWrapper)
e.popupImage = e.popupImageWrapper.querySelector(".MarkerPopup-module_image__7I5s4");
}
// Link element, will only exist if link is present
e.popupLinkWrapper = e.popupContent.querySelector(".MarkerPopup-module_link__f59Lh");
if (e.popupLinkWrapper)
e.popupLink = e.popupLinkWrapper.querySelector("a");
// Close button - Hidden by default
e.popupCloseButton = e.popupElement.querySelector(".leaflet-popup-close-button");
if (e.popupCloseButton) e.popupCloseButton.addEventListener("click", preventDefault);
// Collectible "progress" button
e.progressButton = e.popupContent.querySelector(".MarkerPopup-module_progressMarkerButton__mEkXG");
e.progressButtonLabel = e.popupContent.querySelector(".mapsExtended_collectibleButtonLabel");
// Popup actions
e.popupCopyLinkButton = e.popupElement.querySelector(".MarkerPopupActions-module_action__xeKO9[data-testid=\"copy-link-marker-action\"]");
e.popupReportMarkerButton = e.popupElement.querySelector(".MarkerPopupActions-module_action__xeKO9[data-testid=\"marker-report-action\"]");
// Popup tip (arrow coming off popup)
e.popupTipContainer = e.popupElement.querySelector(".leaflet-popup-tip-container");
return e;
},
// This adds the requisite features for an image to be shown by lightbox when it is clicked
// - img is wrapped in an <a> tag with the href pointing to the image (this isn't used, but is required by the A tag), and a class of "image"
// - img itself has a data attribute "data-image-key", the name of the file
wrapPopupImages: function()
{
if (!this.elements.popupImage) return;
// Add data attribute, sourcing it from alt (but without the File prefix)
this.elements.popupImage.dataset.imageKey = this.elements.popupImage.alt.replace("File:", "");
// Create a tag
var a = document.createElement("a");
a.href = this.elements.popupImage.src;
a.className = "image";
// Wrap image with a tag
this.elements.popupImage.before(a);
a.appendChild(this.elements.popupImage);
},
isPopupShown: function()
{
return this.elements && this.elements.popupElement
&& this.elements.popupElement.isConnected == true;
},
// Returns a function which resolves when this popup appears
waitForPresence: function()
{
if (!this._waitForPresencePromise)
{
this._waitForPresencePromise = new Promise(function(resolve, reject)
{
// Store resolve function (it will be called by popupObserver above)
// The resolved result will be the marker containing the popup element that was shown
this._waitForPresenceResolve = function(marker)
{
resolve(marker);
this._waitForPresenceResolve = undefined;
this._waitForPresencePromise = undefined;
};
}.bind(this));
}
return this._waitForPresencePromise;
},
// Shows the popup
show: function(force)
{
// Don't show popups if enablePopups is false
// Don't show if already shown
// Don't show if we're dragging
if (this.map.config.enablePopups == false ||
(this.isPopupShown() && !force) ||
this.map.isDragging == true) return;
log("Showing popup " + this.marker.id);
if (this.map.config.useCustomPopups == true)
{
// Popup is currently a custom popup
if (this.initialized)
{
// Hide the last popup that was shown if it isn't this one
if (this.map.lastPopupShown && this.map.lastPopupShown != this)
this.map.lastPopupShown.hide();
this.map.lastPopupShown = this;
this.map.elements.leafletPopupPane.appendChild(this.elements.popupElement);
this.elements.popupElement.style.transform = this.marker.markerElement.style.transform;
this.elements.popupElement.style.opacity = "0";
// Remove the event listener that was added in hide to prevent the small chance that both
// are active at the same time, which would cause the element from the DOM while it's being shown
this.elements.popupElement.removeEventListener("transitionend", this._hideDelay);
// Set opacity next frame so that the transition doesn't immediately start at the end
window.cancelAnimationFrame(this._showDelay);
this._showDelay = window.requestAnimationFrame(function()
{
this.elements.popupElement.style.opacity = "1";
this.applyPopupOffsets();
}.bind(this));
}
// Custom popup has not yet been created - create it!
else
{
this.initPopup();
// And call show again
this.show(true);
return;
}
}
else
this.marker.markerElement.click();
},
// Hides the popup
hide: function(force)
{
// Don't hide if already hidden
if (!this.isPopupShown() && !force) return;
log("Hiding popup " + this.marker.id);
if (this.map.config.useCustomPopups == true)
{
if (this.initialized)
{
// Cancel any imminent showing of the popup
window.cancelAnimationFrame(this._showDelay);
// Cancel any imminent hiding of the popup
clearTimeout(this._hideTimout);
//this.elements.popupElement.removeEventListener("transitionend", this._hideDelay);
var currentOpacity = window.getComputedStyle(this.elements.popupElement).opacity;
// If the opacity is already nearly 0, hide immediately
if (currentOpacity < 0.1)
{
this.elements.popupElement.remove();
}
// Otherwise transition it to 0 and remove after
else
{
// Set the opacity to 0
this.elements.popupElement.style.opacity = "0";
// Remove the element from the DOM at the end of the transition
this._hideDelay = function(e)
{
if (e && e.propertyName != "opacity") return;
this.elements.popupElement.remove();
}.bind(this);
// Use timeout instead because some browsers do not reliably fire the transitionend event
//this.elements.popupElement.addEventListener("transitionend", this._hideDelay, { once: true });
this._hideTimeout = setTimeout(this._hideDelay, 250);
}
}
else
log("Tried to hide custom popup that was not yet initialized!");
}
else
{
// Defer hide until drag has finished (since hiding clicks the map and will end the drag)
if (this.map.isDragging == true)
{
this.map.events.onMapDragged.subscribeOnce(function(isDragging)
{
if (isDragging == false) this.hide();
}.bind(this));
return;
}
this.map.clickPositionOfElement(this.marker.markerElement);
}
},
// Hides the popup if it is shown, shows the popup if it is hidden
// Can be passed a value to force a specific state
toggle: function(value)
{
if (value == undefined)
value = !this.isPopupShown();
if (value)
this.show();
else
this.hide();
},
hasPopupDelayTimeout: function(type)
{
return getPopupDelayTimeout(type) >= 0;
},
// Share globally cached delay for non-custom popups so that we're not showing multiple at once
getPopupDelayTimeout: function(type)
{
if (this.map.config.useCustomPopups == true)
return this["popupDelayTimeout_" + type];
else
return this.map["popupDelayTimeout_" + type];
},
setPopupDelayTimeout: function(type, timeout)
{
if (this.map.config.useCustomPopups == true)
this["popupDelayTimeout_" + type] = timeout;
else
this.map["popupDelayTimeout_" + type] = timeout;
},
// Gets the popup delay value from the map config for either type (popupHideDelay or popupShowDelay)
getPopupDelayValueMs: function(type)
{
if (type == "hide")
return this.map.config.popupHideDelay * 1000;
else if (type == "show")
return this.map.config.popupShowDelay * 1000;
return 0.0;
},
// Starts a timer that shows (if type == "show") or hides (if type == "hide") a popout after a delay specified in the config
startPopupDelay: function(type)
{
// Start the timeout at the specified delay, calling this.show or this.hide once it finishes
var timeout = window.setTimeout(function()
{
// Call show or hide
this[type]();
// Clear the timeout (so we can tell if it's still going)
this.setPopupDelayTimeout(type, -1);
}.bind(this), this.getPopupDelayValueMs(type));
// Save the ID of the timeout so that it may be cancelled with stop
this.setPopupDelayTimeout(type, timeout);
},
// Stops a timer that shows or hides the popup
stopPopupDelay: function(type)
{
var timeout = this.getPopupDelayTimeout(type);
if (timeout >= 0)
{
window.clearTimeout(timeout);
this.setPopupDelayTimeout(type, -1);
}
},
startPopupShowDelay: function() { this.startPopupDelay("show"); },
stopPopupShowDelay: function() { this.stopPopupDelay("show"); },
startPopupHideDelay: function() { this.startPopupDelay("hide"); },
stopPopupHideDelay: function() { this.stopPopupDelay("hide"); },
validPopupTextElementTypes: [ "title", "description", "link-label", "link-url" ],
// Get the text of a specific element type from the JSON definition, or if fromElement is true, from the HTML of a specific popup element
// If the definition was empty, or the element does not exist, it will return nothing
getPopupText: function(type, fromElement)
{
if (fromElement && !this.elements.popupElement)
return;
switch (type)
{
case "title":
return fromElement ? this.elements.popupTitle && this.elements.popupTitle.textContent
: this.title;
case "description":
return fromElement ? this.elements.popupDescription && this.elements.popupDescription.textContent
: this.description;
case "link-label":
return fromElement ? this.elements.popupLinkLabel && this.elements.popupLink.textContent
: this.link && this.link.label;
case "link-url":
return fromElement ? this.elements.popupLinkUrl && this.elements.popupLink.getAttribute("href")
: this.link && this.link.url;
}
},
// Sets the text or HTML of a specific popup element (see validPopupTextElementTypes above)
// This function is really only used to avoid duplicated code, and to make calling from processPendingChanges easier
// set forceHtml to true to use innerHTML instead of textContent
setPopupText: function(type, str, forceHtml)
{
if (!this.validPopupTextElementTypes.includes(type))
{
console.error("Popup text type " + type + " is invalid. Valid types are:\n" + this.validPopupTextElementTypes.toString());
return;
}
// Keep track of which strings have been modified from their default
this.modifiedTexts = this.modifiedTexts || { };
// Newly edited - If the field actually differs, flag modifiedTexts
if (!this.modifiedTexts[type] && str != this.getPopupText(type))
this.modifiedTexts[type] = true;
// Have a popup element reference
if (this.elements.popupElement)
{
// Links are treated a bit differently
if (type == "link-label" || type == "link-url")
{
// Create popup link elements if they aren't already present
this.createPopupLinkElement();
this.link[type.replace("link-")] = str;
if (type == "link-label")
this.elements.popupLink[forceHtml ? "innerHTML" : "textContent"] = str;
else
{
// Add article path if using a local page name
if (!str.startsWith("http://"))
str = mw.config.get("wgArticlePath").replace("$1", str);
this.elements.popupLink.setAttribute("href", str);
}
}
else
{
// Ensure elements are created first
if (type == "description" && !this.elements.popupDescription)
this.createPopupDescriptionElement();
this[type + forceHtml ? "Html" : ""] = str;
this.elements["popup" + (type[0].toUpperCase() + type.slice(1))][forceHtml ? "innerHTML" : "textContent"] = str;
}
}
// Don't yet have a popup element reference, add this to "pending"
else
{
this.pendingChanges = this.pendingChanges || { };
this.pendingChanges[type] = str;
}
},
// Sets the popup title innerHTML (both plain text and html are supported)
setTitle: function(str)
{
this.setPopupText("title", str);
},
// Sets the popup description
setDescription: function(str, isWikitext)
{
if (isWikitext == true)
{
var api = new mw.Api();
api.parse(str, { "disablelimitreport": true }).done(function(data)
{
this.setPopupText("description", data, true);
}.bind(this));
}
else
this.setPopupText("description", str, true);
},
// Sets the popup link label innerHTML (both plain text and html are supported)
setLinkLabel: function(str)
{
this.setPopupText("link-label", str);
},
// Sets the popup link href
// Page can be a full url, or the name of a page on the wiki
setLinkUrl: function(page)
{
this.setPopupText("link-url", page);
},
setImage: function(imageTitle, imageUrl)
{
if (!this.elements.popupImage)
return;
this.elements.popupImage.src = imageUrl;
this.elements.popupImage.setAttribute("alt", imageTitle);
// Full API call is /api.php?action=query&titles=File:Example.png&prop=imageinfo&iiprop=url&iiurlwidth=100 but this is a lot slower
if (!imageUrl)
{
// Use Special:Redirect to generate a file URL
var url = mw.util.getUrl("Special:Redirect/file/" + imageTitle) + "?width=300";
// The response will contain the file URL
fetch(url).then(function(response)
{
if (response.ok) imageUrl = response.url;
});
}
// Set the src attribute on the image
if (imageUrl) this.elements.popupImage.src = imageUrl;
},
// Create a new scrollable content element (which holds the discription and image)
// This is neccesary if the JSON didn't define a description
createPopupScrollableContentElement: function()
{
if (!this.elements.popupScrollableContent)
{
this.elements.popupScrollableContent = document.createElement("div");
this.elements.popupScrollableContent.className = "MarkerPopup-module_scrollableContent__0N5PS";
// Place after top container
if (this.elements.popupContentTopContainer)
this.elements.popupContentTopContainer.after(this.elements.popupScrollableContent);
// Or as the first child of popupContent
else if (this.elements.popupContent)
this.elements.popupContent.prepend(this.elements.popupScrollableContent);
else
log("Couldn't find a suitable position to add scrollable content element");
}
return this.elements.popupScrollableContent;
},
createPopupDescriptionElement: function()
{
if (!this.elements.popupDescription)
{
var e = document.createElement("div");
e.className = "MarkerPopup-module_description__fKuSE";
var c = document.createElement("div");
c.className = "page-content MarkerPopup-module_descriptionContent__-ypRG";
e.appendChild(c);
this.elements.popupDescription = c;
var scrollableContentElement = this.createPopupScrollableContentElement();
// Place before imageWrapperElement
if (this.elements.popupImage)
this.elements.popupImage.parentElement.before(this.elements.popupDescription);
// Or just as first child of scrollableContent
else if (scrollableContentElement)
scrollableContentElement.prepend(this.elements.popupDescription);
else
log("Couldn't find a suitable position to add popup description element");
}
return this.elements.popupDescription;
},
// If a popup link isn't present in the JSON definition, one will not be created in the DOM
// If this is the case, this function can be called to create an empty link element
createPopupLinkElement: function()
{
if (!this.elements.popupLink)
{
var fandomPopupContentRoot = this.elements.popupElement.querySelector(".map-marker-popup");
fandomPopupContentRoot.insertAdjacentHTML("beforeend", "<div class=\"MarkerPopup-module_link__f59Lh\"><svg class=\"wds-icon wds-icon-tiny MarkerPopup-module_linkIcon__q3Rbd\"><use xlink:href=\"#wds-icons-link-tiny\"></use></svg><a href=\"\" target=\"\" rel=\"noopener noreferrer\"></a></div>");
this.elements.popupLink = this.elements.popupElement.querySelector(".MarkerPopup-module_link__f59Lh > a");
this.elements.popup.link = {};
}
return this.elements.popupLink;
},
createCollectibleElements: function()
{
// Stop observing popup changes while we change the subtree of the popup
this.map.togglePopupObserver(false);
// Remove any collectible elements that may already exist
if (this.elements.progressButton) this.elements.progressButton.remove();
// Check if the marker that triggered this popup is a collectible one
if (this.map.hasCollectibles && this.marker.category.collectible)
{
if (this.map.config.collectibleCheckboxStyle == "fandom")
{
var elem = document.createElement("div");
elem.innerHTML = "<button class=\"wds-button wds-button mapsExtended_collectibleButton MarkerPopup-module_progressMarkerButton__mEkXG\" type=\"button\" ><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" xmlns:xlink=\"http:\/\/www.w3.org\/1999\/xlink\" viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" fill-rule=\"evenodd\"><path id=\"IconCheckboxEmpty__a\" d=\"M3 21h18V3H3v18zM22 1H2a1 1 0 00-1 1v20a1 1 0 001 1h20a1 1 0 001-1V2a1 1 0 00-1-1z\"><\/path><path id=\"IconCheckbox__a\" d=\"M9.293 15.707a.997.997 0 001.414 0l7-7a.999.999 0 10-1.414-1.414L10 13.586l-2.293-2.293a.999.999 0 10-1.414 1.414l3 3zM3 21h18V3H3v18zM22 1H2a1 1 0 00-1 1v20a1 1 0 001 1h20a1 1 0 001-1V2a1 1 0 00-1-1z\"><\/path><\/svg><span class=\"mapsExtended_collectibleButtonLabel\"><\/span><\/button>";
this.elements.popupContent.appendChild(elem.firstElementChild);
// Save some references
this.elements.progressButton = this.elements.popupContent.querySelector(".MarkerPopup-module_progressMarkerButton__mEkXG");
this.elements.progressButtonLabel = this.elements.popupContent.querySelector(".mapsExtended_collectibleButtonLabel");
// Set a class on the button if it is collected
this.elements.progressButton.classList.toggle("MarkerPopup-module_progressMarkerButtonCompleted__KQRMh", this.marker.collected);
// Progress button click event
this.elements.progressButton.addEventListener("click", function(e)
{
var state = !this.marker.collected;
this.marker.setMarkerCollected(state, true, true, true);
}.bind(this));
}
else
{
// Remove any old checkboxes (this can happen with live preview)
var oldCheckbox = this.elements.popupTitle.querySelector(".wds-checkbox");
if (oldCheckbox) oldCheckbox.remove();
// Create checkbox container
var popupCollectedCheckbox = document.createElement("div");
popupCollectedCheckbox.className = "wds-checkbox";
// Create the checkbox itself
var popupCollectedCheckboxInput = document.createElement("input");
popupCollectedCheckboxInput.setAttribute("type", "checkbox");
popupCollectedCheckboxInput.id = "checkbox_" + this.map.instanceId + "_" + this.marker.id;
//popupCollectedCheckboxInput.marker = this.marker; // <- Store reference to marker on checkbox so we don't have to manually look it up
popupCollectedCheckboxInput.checked = this.marker.collected;
this.elements.popupCollectedCheckbox = popupCollectedCheckboxInput;
// Create label adjacent to checkbox
var popupCollectedCheckboxLabel = document.createElement("label");
popupCollectedCheckboxLabel.setAttribute("for", popupCollectedCheckboxInput.id);
// Add checkbox input and label to checkbox container
popupCollectedCheckbox.appendChild(popupCollectedCheckboxInput);
popupCollectedCheckbox.appendChild(popupCollectedCheckboxLabel);
// Add checkbox container after title element
this.elements.popupTitle.after(popupCollectedCheckbox);
// Checked changed event
popupCollectedCheckboxInput.addEventListener("change", function(e)
{
this.setMarkerCollected(e.currentTarget.checked, true, true, true);
}.bind(this.marker));
}
}
this.map.togglePopupObserver(true);
},
updateCollectibleElements: function()
{
var state = this.marker.collected;
if (this.elements.popupCollectedCheckbox)
{
this.elements.popupCollectedCheckbox.checked = state;
}
if (this.elements.progressButton)
{
this.elements.progressButton.classList.toggle("MarkerPopup-module_progressMarkerButtonCompleted__KQRMh", state);
this.elements.progressButtonLabel.textContent = mapsExtended.i18n.msg("collect-" + (state ? "unmark" : "mark") + "-button").plain();
}
},
// Processes all the unapplied changes that were set prior to having a popup associated with this marker
processPendingChanges: function()
{
if (this.isCustomPopup == true) return;
if (this.pendingChanges && Object.keys(this.pendingChanges).length > 0)
{
for (var key in this.pendingChanges)
{
this.setPopupText(key, this.pendingChanges[key]);
}
}
if (this.modifiedTexts && Object.keys(this.modifiedTexts).length > 0)
{
for (var key in this.modifiedTexts)
{
this.setPopupText(key, this[key]);
}
}
}
};
var configValidator =
{
// Returns the type of a value, but uses "array" instead of object if the value is an array
getValidationType: function(value)
{
var type = typeof value;
if (Array.isArray(value)) type = "array";
return type;
},
flattenConfigInfoIntoDefaults: function(configInfos)
{
// Build up the flattened config object
var config = {};
for (var i = 0; i < configInfos.length; i++)
{
var configInfo = configInfos[i];
// Recurse into objects
if (configInfo.type == "object" && configInfo.children && configInfo.children.length > 0)
{
config[configInfo.name] = this.flattenConfigInfoIntoDefaults(configInfo.children);
// Also store into the original default value
//configInfo.default = config[configInfo.name];
}
else
config[configInfo.name] = configInfo.default;
}
return config;
},
// Post-process the defaultConfigInfo to add path and parent values
postProcessConfigInfo: function(children, parent)
{
for (var i = 0; i < children.length; i++)
{
var info = children[i];
info.parent = parent;
info.path = parent && (parent.path + "." + info.name) || info.name;
if (info.children != undefined && info.children.length > 0)
{
this.postProcessConfigInfo(info.children, info);
}
// Always convert info's "type" and "arrayType" field to an array to make it easier to work with
if (info.type && !Array.isArray(info.type))
info.type = [ info.type ];
if (info.arrayType && !Array.isArray(info.arrayType))
info.arrayType = [ info.arrayType ];
}
},
// Returns the config info at a path where each level is separated by a '.'
getConfigInfoAtPath: function(path, data)
{
var pathArr = path.split(".");
var currentObj = data || defaultConfigInfo;
for (var i = 0; i < pathArr.length; i++)
{
var name = pathArr[i];
var childObj = null;
if (Array.isArray(currentObj))
{
for (var j = 0; j < currentObj.length; j++)
{
if (currentObj[j].name === name)
{
childObj = currentObj[j];
break;
}
}
}
else if (typeof currentObj === 'object')
{
childObj = currentObj.children && currentObj.children.find(function(obj){ return obj.name === name; });
}
if (!childObj) return null;
currentObj = childObj;
}
return currentObj;
},
// Using a path in the config, return the value in the config
// The config must ALWAYS be a root config, no sub-configs
// Does not recurse into arrays, unless the scope is defaults
// Returns { value, key, type }, or null if no path was found
getConfigOptionAtPath: function(path, config)
{
if (path == undefined || config == undefined) return null;
var pathArr = path.split(".");
var currentData = config;
var foundKey;
// Short-circuit defaults
if (config._configScope == "defaults")
{
var info = this.getConfigInfoAtPath(path);
if (info)
return { value: info.default, key: info.name, type: this.getValidationType(info.default) };
else
return;
}
for (var i = 0; i < pathArr.length; i++)
{
var name = pathArr[i];
var info = this.getConfigInfoAtPath(name, info);
var childData = null;
if (typeof currentData === 'object')
{
if (currentData.hasOwnProperty(info.name))
{
childData = currentData[info.name];
foundKey = info.name;
}
else if (currentData.hasOwnProperty(info.alias))
{
childData = currentData[info.alias];
foundKey = info.alias;
}
}
// Short circuit if there was no value at the key
if (childData == undefined) return null;
currentData = childData;
}
return {
value: currentData,
key: foundKey,
type: this.getValidationType(currentData)
};
},
getValidationForScope: function(configScope, metadata)
{
switch (configScope)
{
case "embed":
return window.dev.mapsExtended.embedConfigValidations[metadata._configId];
case "local":
return window.dev.mapsExtended.localConfigValidations[metadata._configScope == "embed" ? metadata._configMapName : metadata._configId];
case "global":
return window.dev.mapsExtended.globalConfigValidation;
case "defaults":
return window.dev.mapsExtended.defaultConfigValidation;
default:
return null;
}
},
// Using a path in the config, return a validated result in the config
// The config must ALWAYS be a root config, no sub-configs allowed
// Does not recurse into arrays, unless the scope is defaults
// Returns the validation result, or null if no path was found
getValidationResultAtPath: function(path, validation)
{
if (path == undefined || validation == undefined) return null;
var pathArr = path.split(".");
var currentData = validation;
for (var i = 0; i < pathArr.length; i++)
{
var name = pathArr[i];
var childData = null;
for (var j = 0; j < currentData.children.length; j++)
{
if (currentData.children[j].key == name)
{
childData = currentData.children[j];
break;
}
}
// Short circuit if there was no validation with this name
if (childData == undefined) return null;
currentData = childData;
}
return currentData;
},
findValidationMatchingPredicate: function(fn, array)
{
if (!fn || !array || array.length == 0)
return null
for (var key in array)
{
var result = array[key];
if (fn(result) == true)
return result;
if (result.children && result.children.length > 0)
{
var childResult = this.findValidationMatchingPredicate(fn, result.children);
if (childResult != null) return childResult;
}
}
return null;
},
getNextScopeInChain: function(configScope)
{
switch (configScope)
{
case "embed": return "local";
case "local": return "global";
case "global": return "defaults";
default: return;
}
},
// Gets a fallback for a specific configuration source from a specific scope.
// <configType> should be the scope of the desired config, and if it is omitted will be set to the next scope down given config._configScope
// This function performs no validation, and assumes all lower configs have already been validated!
// Returns an object containing
// config: The full fallback configuration object (this will contain the config metadata)
// value: The value of the option that was found
// valueType: The type of the option that was found
// foundKey: The key/name of the option that was found
// isPresent: If false a fallback wasn't found and all of the above will not be present
getFallbackForConfigOption: function(configInfo, configMetadata, scope)
{
var fallbackConfig;
if (!configInfo || !configInfo.path) return { isPresent: false };
switch (scope)
{
case "embed":
{
fallbackConfig = window.dev.mapsExtended.embedConfigs[configMetadata._configId];
break;
}
// Embed gets fallback from local/per-map
case "local":
{
fallbackConfig = window.dev.mapsExtended.localConfigs[configMetadata._configScope == "embed" ? configMetadata._configMapName : configMetadata._configId];
break;
}
// Local/per-map gets fallback from global
case "global":
{
fallbackConfig = window.dev.mapsExtended.globalConfig;
break;
}
// Global gets fallback from defaults
case "defaults":
{
fallbackConfig = window.dev.mapsExtended.defaultConfig;
break;
}
// No more fallbacks
default:
{
return { isPresent: false };
}
}
// If we found a fallback config in the next scope, actually check whether the config contains the option
if (fallbackConfig)
{
var validation = this.getValidationForScope(scope, configMetadata);
var foundOption = this.getConfigOptionAtPath(configInfo.path, fallbackConfig);
// Found fallback
if (foundOption)
{
return {
config: fallbackConfig,
value: foundOption.value,
valueType: foundOption.type,
foundKey: foundOption.key,
isPresent: true,
validation: this.getValidationResultAtPath(configInfo.path, validation)
};
}
}
// We reach here if either no fallbackConfig was found, or no option in the fallbackConfig was found
// So try the next config down
var nextScope = this.getNextScopeInChain(scope);
return this.getFallbackForConfigOption(configInfo, configMetadata, nextScope);
},
// Validates a config option with a specific <configKey> in a <config> object against one or a collection of <configInfo>
validateConfigOption: function(configKey, configInfo, config, configMetadata)
{
configInfo = configInfo || defaultConfigInfo;
// If multiple configInfo's were passed, find the first with this name
if (Array.isArray(configInfo))
var info = configInfo.find(function(ci) { return ci.name == configKey || ci.alias == configKey; });
else
var info = configInfo;
// An configInfo was found with this name
if (info)
{
// Redirect if info has a value for "use"
if (info.use) info = this.getConfigInfoAtPath(info.use);
}
// Note that configKey is just which property is requested, it does not indicate
// that a property at that key exists, just that it "should" be there
var foundKey = (config == undefined) ? undefined :
(config.hasOwnProperty(info.name) && info.name != undefined) ? info.name :
(config.hasOwnProperty(info.alias) && info.alias != undefined) ? info.alias :
(config.hasOwnProperty(configKey) && configKey != undefined) ? configKey : undefined;
var foundValue = (config != undefined && foundKey != undefined) ? config[foundKey] : undefined;
var result =
{
// The "requested" configKey passed to this function.
key: configKey,
// If a value at the requested configKey wasn't found, but an alias (or the original key) was found
// foundKey is the value of the key that the config value actually exists under
foundKey: foundKey,
// This is the key that the configInfo expects
actualKey: info.name,
// The final value of this option, with validation fixes applied, fallbacks, etc
value: foundValue,
// The final type of this option
valueType: this.getValidationType(foundValue),
// The value of this option from the config file. This never changes
initialValue: undefined,
// The type of the value of this option in the config file.
initialValueType: undefined,
// The config info of the key. Will be undefined if no definition is found with the key
info: info,
// A boolean which is true when the input option passed all validations
isValid: true,
// A boolean which is true when the input was invalid, but the validator resolved it into a valid output (excluding fallbacks)
isResolved: false,
// A boolean which is true when the option is present in the config
isPresent: foundKey != undefined,
// True when the config found is using an alias of the actual config key
isAliased: foundKey != undefined && foundKey == info.alias,
// True when the value had to fall back to defaults or globals
// The original value will still be kept in "initialValue"
isFallback: false,
// The source of the fallback (either "defaults" or "global")
fallbackSource: undefined,
// An array of objects { code, message } saying what went wrong if issues occurred. May appear even if the option is valid
messages: [],
// An array of child results objects which may contain all the same values as above
children: [],
};
result.initialValue = result.value;
result.initialValueType = result.valueType;
var value = result.value;
var valueType = result.valueType;
var isValidType = result.valueType && info && info.type && info.type.includes(result.valueType);
// Option with this name doesn't exist at all
if (info == undefined)
{
result.messages.push({ code: "unknown", message: "This key is not a valid config option." });
result.isValid = false;
return result;
}
// Option with this name does exist in the specification, but not in the config
else if (!result.isPresent)
{
result.isValid = false;
if (info.presence)
result.messages.push({ code: "required_not_present", message: "Value not present in config and is required." });
else
result.messages.push({ code: "not_present", message: "Value is not present in the config, a fallback will be used." });
}
// Option with this name exists, and it's in the specification
else
{
// Option is present, but under the alias key instead of the normal key
if (result.isAliased)
{
result.messages.push({ code: "aliased", message: "This value exists under a key that has changed. Consider updating the key." });
}
// Option is present but undefined - Silently use defaults
if (valueType == "object" && jQuery.isPlainObject(value) && jQuery.isEmptyObject(value) ||
valueType == "string" && value == "" ||
//valueType == "array" && value.length == 0 ||
value == undefined || value == null)
{
result.messages.push({ code: "is_empty", message: "Value is an empty value, using defaults instead." });
result.isValid = false;
}
// Option is the wrong type
if (!isValidType)
{
var error = { code: "mistyped" };
result.messages.push(error);
result.isValid = false;
// Try to coerce if it can be coerced, typically from string
// Convert string or number to boolean
if (info.type.includes("boolean") && !isValidType)
{
// Convert string to boolean
if (valueType == "string")
{
var validValues = ["true", "false", "yes", "no", "1", "0"];
var valueLower = value.toLowerCase();
if (validValues.includes(valueLower))
{
// Update the values
value = result.value = (valueLower == "true" || valueLower == "yes" || valueLower == "1");
valueType = result.valueType = "boolean";
isValidType = true;
result.isResolved = true;
error.message = "Value should be a boolean but was passed a string (which was successfully interpreted as a boolean). Consider removing the quotes.";
result.messages.push({ code: "ignore", message: "The previous message may be ignored on JSON-sourced (map definition) local configs." });
}
}
// Convert number to boolean
else if (valueType == "number" && (value == 1 || value == 0))
{
value = result.value = value == 1;
valueType = result.valueType = "boolean";
isValidType = true;
result.isResolved = true;
error.message = "Value should be a boolean but was passed a number (which was successfully interpreted as a boolean)."
}
}
// Convert string to number
if (info.type.includes("number") && valueType == "string" && !isValidType)
{
var valueFloat = parseFloat(value);
if (!isNaN(valueFloat))
{
// Update the values
value = result.value = valueFloat;
valueType = result.valueType = "number";
isValidType = true;
result.isResolved = true;
error.message = "Value should be a number but was passed a string. Consider removing the quotes.";
}
}
// Convert string to object or array
if ((info.type.includes("object") || info.type.includes("array")) && valueType == "string" && !isValidType)
{
try
{
var valueObj = JSON.parse(value);
var success = false;
// String was parsed to array and we expected it
if (Array.isArray(valueObj) && info.type.includes("array"))
{
valueType = result.valueType = "array";
success = true;
}
// String was parsed to object and we expected it
else if (typeof valueObj == "object" && valueObj.constructor === Object && info.type.includes("object"))
{
valueType = result.valueType = "object";
success = true;
}
if (success == true)
{
value = result.value = valueObj;
isValidType = true;
result.isResolved = true;
}
else
{
result.messages.push({ code: "parse_unexpected", message: "Successfully parsed string as JSON, but the value was not of type " + info.type });
}
}
catch(error)
{
result.messages.push({ code: "parse_failed", message: "Could not parse string as JSON: " + error });
}
}
// There's no way to convert it
if (!isValidType)
{
error.message = "Value should be of type " + info.type + " but was passed a " + valueType + ", which could not be converted to this type.";
}
}
if (isValidType)
{
// Number option must be a valid number
if (valueType == "number" && (!isFinite(value) || isNaN(value)))
{
result.messages.push({ code: "invalid_number", message: "Value is not a valid number." });
result.isValid = false;
}
// Option with validValues must be one of a list of values
if (info.validValues && valueType == "string")
{
// Force lowercase when we have a list of values
value = value.toLowerCase();
if (!info.validValues.includes(value))
{
result.messages.push({ code: "invalid_value", message: "Should be one of: " + info.validValues.toString() });
result.isValid = false;
}
}
var customValidation = info.customValidation || configInfo.customValidation;
// Option must pass custom validation if it is present
if (customValidation != undefined && typeof customValidation == "function")
{
var customValidationResult = customValidation(value, config);
if (customValidationResult.result == false)
{
if (customValidationResult.message)
result.messages.push(customValidationResult.message);
else
result.messages.push({ code: "other", message: "Failed custom validation " });
result.isValid = false;
}
}
}
// For objects, we should recurse into any of the child configs if the definition says there should be some
// For this, we iterate over properties in the configInfo to see what should be there rather than what IS there
if (valueType == "object")
{
result.children = [];
if (info.children && info.children.length > 0)
{
// Iterate the config info for properties that may be defined
for (var i = 0; i < info.children.length; i++)
{
var childInfo = info.children[i];
var childResult = this.validateConfigOption(childInfo.name, childInfo, config[foundKey], configMetadata);
childResult.parent = result;
result.children.push(childResult);
}
}
else
{
console.error("Config info definition " + info.name + " is type object yet does not define any keys in \"children\"!");
}
}
// Recurse into arrays too, but use a single configInfo for each of the elements. The configInfo either has an arrayType or will have a
// single element in "children" that represents each element in the array
// With arrays, validation occurs on what *is* there rather than what *should be* there.
else if (valueType == "array")
{
result.children = [];
// Get info from first element of "children"
if (info.children && info.children.length > 0)
{
if (info.children.length > 1) console.error("Config info definition " + info.name + " should only contain one child as it is of type \"array\"");
var arrayElementInfo = info.children[0];
}
// Otherwise create it from arrayType
else if (info.arrayType)
{
var arrayElementInfo = { presence: false, default: undefined, type: info.arrayType };
// Array element inherits validValues if it is of type "string"
if (info.arrayType.includes("string") && info.validValues) arrayElementInfo.validValues = info.validValues;
}
//else
// console.error("Config info definition " + info.name + " contains neither an \"arrayType\" or an \"elementInfo\"");
if (arrayElementInfo)
{
// Loop over each element in the values array, and validate it against the element info
for (var i = 0; i < config[configKey].length; i++)
{
// Validate this array element, but NEVER fallback to an array element (only objects get fallbacks) the fallback will use defaults as we don't want to fall back on the values of array elements in the global config
var childResult = this.validateConfigOption(i, arrayElementInfo, config[foundKey], configMetadata);
childResult.parent = result;
result.children.push(childResult);
// Apply fallback only if they were the defaults
}
}
}
}
// Result is invalid or not present, use fallback as result
if ((!result.isValid && !result.isResolved) || !result.isPresent)
{
var fallback = this.getFallbackForConfigOption(info, configMetadata, this.getNextScopeInChain(configMetadata._configScope));
if (fallback.isPresent == true)
{
result.isFallback = true;
result.value = fallback.value;
result.valueType = fallback.valueType;
result.foundKey = fallback.foundKey;
result.fallbackSource = fallback.config._configScope;
// If the default itself is an object, We have to make a results object for each child value too
if (result.fallbackSource == "defaults" && result.valueType == "object")
{
result.children = [];
for (var i = 0; i < info.children.length; i++)
{
var childInfo = info.children[i];
var childResult = this.validateConfigOption(childInfo.name, childInfo, config[info.name], configMetadata);
childResult.parent = result;
result.children.push(childResult);
}
}
if (fallback.validation && fallback.validation.children && fallback.validation.children.length > 0)
result.children = fallback.validation.children;
}
}
// Determine what is being overridden
else
{
var override = this.getFallbackForConfigOption(info, configMetadata, this.getNextScopeInChain(configMetadata._configScope));
if (override.isPresent == true)
{
result.isOverride = true;
result.overridesSource = override.config._configScope;
// Determine whether the override is required
if (result.value == override.value)
{
result.messages.push({ code: "redundant_override", message: "This option is unnecessarily overriding an option with the same value from " + result.overridesSource + ", and may be omitted." });
}
}
}
// Assign values from child results to the base value
if (result.children && result.children.length > 0)
{
for (var i = 0; i < result.children.length; i++)
{
var childResult = result.children[i];
var childKey = result.valueType == "array" ? childResult.key : childResult.actualKey;
// If the result was aliased, move the value
if (childResult.isAliased)
{
result.value[childKey] = result.value[childResult.foundKey];
delete result.value[childResult.foundKey];
}
// If the child result was resolved or was a fallback, add it to the value property
if (childResult.isResolved || childResult.isFallback)
result.value[childKey] = childResult.value;
}
}
return result;
},
// Validates the configuration object, returning the validation containing a config filled out and any errors fixed using fallbacks and inherited values
// This means validateConfig is guaranteed to return a valid configuration, even if all the defaults are used, even if the config passed is completely incorrect
validateConfig: function(config)
{
var metadata = {
_configId: config._configId,
_configMapName: config._configMapName,
_configScope: config._configScope,
_configSource: config._configSource,
};
var validation =
{
// Validation metadata
id: config._configId,
name: config._configMapName,
scope: config._configScope,
source: config._configSource,
type: "object",
// All validations of this config, including fallbacks
children: [],
// Only validations from config options on this config
childrenSelf: [],
// The output config, a validation of the input without root fallbacks
configSelf: {},
// The output config, a validation of the input including every other config option that wasn't passed
// This can be seen as a combination of the input, and the next config source up the chain (e.g Global | Defaults)
config: {}
};
Object.assign(validation.config, metadata);
Object.assign(validation.configSelf, metadata);
// Loop over defaultConfigInfo and validate the values in the config against them. validateConfigOption will recurse into children
for (var i = 0; i < defaultConfigInfo.length; i++)
{
var configInfo = defaultConfigInfo[i];
var result = this.validateConfigOption(configInfo.name, defaultConfigInfo, config, metadata);
validation.children.push(result);
if (result.isValid || result.isResolved || result.isFallback)
{
if (!result.isFallback)
{
validation.childrenSelf.push(result);
validation.configSelf[configInfo.name] = result.value;
}
validation.config[configInfo.name] = result.value;
}
}
// Warn the editor if they have any raw boolean values in the map definition configuration
// Only do this on the map page, in edit mode, and for logged in users
if (metadata._configScope == "local" && metadata._configSource == "JSON (in map definition)" && mapsExtended.isOnMapPage && (mapsExtended.isInEditMode || mapsExtended.isDebug) && !mw.user.isAnon() &&
this.findValidationMatchingPredicate(function(r) { return r.initialValueType == "boolean"; }, validation.childrenSelf) != null)
{
var errorBox = document.createElement("div")
errorBox.className = "mw-message-box mw-message-box-warning";
errorBox.innerHTML = "<p><strong>This map uses a map definition config containing one or more raw boolean values.</strong> It is advised that you replace these values with strings instead, or use an external config, as any edits to this map in the Interactive Map Editor will cause them to be lost. Visit the <a href=\"https://dev.fandom.com/wiki/MapsExtended#JSON_configuration_(map_definition)\">documentation</a> for more info.</p>";
var previewnote = document.querySelector(".previewnote");
var content = document.getElementById("mw-content-text");
if (previewnote)
previewnote.appendChild(errorBox);
else if (content)
{
errorBox.classList.add("error");
errorBox.style.fontSize = "inherit";
content.prepend(errorBox);
}
}
return validation;
},
// Tabulate the results of the validation in the same way Extension:JsonConfig does
// Note that the layout of the root validation results list, and each result itself is such
// that all array or object-typed results have a "children" parameter. This simplifies recursion
tabulateConfigValidation: function(results)
{
var table = document.createElement("table");
table.className = "mw-json";
var tbody = table.createTBody();
var headerRow = tbody.insertRow();
var headerCell = document.createElement("th");
headerCell.setAttribute("colspan", "2");
var isRoot = results.scope != undefined;
// Build the header text (only for the root)
if (isRoot)
{
table.classList.add("mw-collapsible");
table.classList.add("mw-collapsed");
table.style.width = "100%";
table.style.marginBottom = "1em";
var scopeStr = capitalizeFirstLetter(results.scope) + " config";
var mapLink = ExtendedMap.prototype.getMapLink(results.name, "element");
var sourceStr = " - Defined as ";
var sourceLink = document.createElement("a");
if (results.source == "Wikitext")
{
sourceStr += "Wikitext (on "
var path = "";
sourceLink.href = "/wiki/" + path;
sourceLink.textContent = path;
}
if (results.source == "JavaScript")
{
sourceStr += "JavaScript (in ";
var path = "MediaWiki:Common.js";
sourceLink.href = "/wiki/" + path;
sourceLink.textContent = path;
}
else if (results.source == "JSON (in map definition)")
{
sourceStr += "JSON (in ";
var path = "Map:" + results.name;
sourceLink.href = "/wiki/" + path;
sourceLink.textContent = path;
}
else if (results.source == "JSON (in system message)")
{
sourceStr += "JSON (in ";
var path = "MediaWiki:Custom-MapsExtended/" + results.name + ".json";
sourceLink.href = "/wiki/" + path;
sourceLink.textContent = path;
}
headerCell.append(scopeStr, results.scope != "global" ? " for " : "", results.scope != "global" ? mapLink : "", sourceStr, sourceLink, ") ");
headerRow.appendChild(headerCell);
mw.hook("dev.wds").add(function(wds)
{
var helpTooltip = document.createElement("div");
helpTooltip.style.cssText = "position: absolute; left: 12px; top: 50%; transform: translateY(-50%)";
var questionIcon = wds.icon("question-small");
questionIcon.style.verticalAlign = "middle";
helpTooltip.appendChild(questionIcon);
headerCell.style.position = "relative";
headerCell.prepend(helpTooltip);
var popup = new OO.ui.PopupWidget(
{
$content: $("<span>This table is a validated output of a MapsExtended config. It is only shown in edit mode, or while debug mode for MapsExtended is enabled</span>"),
width: 250,
align: "force-right",
position: "above"
});
var popupElement = popup.$element[0];
var popupContent = popupElement.querySelector(".oo-ui-popupWidget-popup");
popupContent.style.fontSize = "14px";
popupContent.style.padding = "15px";
popupContent.style.textAlign = "left";
helpTooltip.append(popupElement);
helpTooltip.addEventListener("mouseenter", function()
{
popup.toggle(true);
});
helpTooltip.addEventListener("mouseleave", function()
{
popup.toggle(false);
});
});
}
// Handle the case of an empty object or array
if (!results.children || results.children.length == 0)
{
// Create table row
var tr = tbody.insertRow();
// Create table row value cell
var td = tr.insertCell();
td.className = "mw-json-empty";
td.textContent = "Empty " + (results.type || results.valueType);
}
else
{
for (var i = 0; i < results.children.length; i++)
{
var result = results.children[i];
// Create table row
var tr = tbody.insertRow();
// Create table row header + content
var th = document.createElement("th");
// If aliased, add the key in the config striked-out to indicate it should be changed
if (result.isAliased == true)
{
var oldKey = document.createElement("div");
oldKey.textContent = result.foundKey;
oldKey.style.textDecoration = "line-through";
th.appendChild(oldKey);
var newKey = document.createElement("span");
newKey.textContent = result.actualKey;
th.appendChild(newKey);
}
else
{
var keySpan = document.createElement("span");
keySpan.textContent = result.key;
th.appendChild(keySpan);
}
tr.appendChild(th);
// Create table row value cell
var td = tr.insertCell();
// Determine how to format the value
// Arrays and objects get a sub-table
if ((result.valueType == "array" || result.valueType == "object") && result.info.debugAsString != true)
{
td.appendChild(this.tabulateConfigValidation(result));
if (!result.isPresent)
{
if (!tr.matches(".mw-json-row-empty *"))
tr.className = "mw-json-row-empty";
}
}
// Mutable values (string, number, boolean) just get printed
else
{
td.className = "mw-json-value";
var str = "";
if (result.isPresent == true)
{
// Invalid and not resolved
if (!result.isValid && !result.isResolved)
td.classList.add("mw-json-value-error");
// Warnings
else if (result.messages.length > 0 && !result.messages.some(function(m){ return m.code == "redundant_override"; }))
td.classList.add("mw-json-value-warning");
// Not invalid and no warnings
else
td.classList.add("mw-json-value-success");
// Append old value (if it differs)
if (result.initialValue != result.value)
{
if (result.initialValueType == "string")
str += "\"" + result.initialValue + "\"";
else
str += result.initialValue
// Append arrow indicating this was changed to
str += " → "
}
// Append current value
if (result.valueType == "string")
str += "\"" + result.value + "\"";
else if (result.valueType == "array")
str += JSON.stringify(result.value);
else
str += result.value;
/*
// Append the override source
if (result.isOverride == true)
{
// Message saying this value overrides another from a specific config
str += " (overrides " + result.overridesSource + ")";
}
*/
}
else
{
if (!tr.matches(".mw-json-row-empty *"))
tr.className = "mw-json-row-empty";
// Append the fallback
if (result.isFallback == true)
{
if (result.valueType == "string")
str += "\"" + result.value + "\"";
else
str += result.value;
// Message saying this fallback is from a specific config
str += " (from " + result.fallbackSource + ")";
}
}
// Finally set the string
td.textContent = str;
}
// Append any extra validation information)
if (result.messages.length > 0 && result.isPresent)
{
var extraInfo = document.createElement("div");
extraInfo.className = "mw-json-extra-value";
extraInfo.textContent = result.messages.map(function(m) { return "(" + m.code.toUpperCase() + ") " + m.message; }).join("\n");
td.appendChild(extraInfo);
}
}
}
if (isRoot)
{
// Make the table collapsible, then add it to the page
mw.loader.using("jquery.makeCollapsible", function()
{
$(table).makeCollapsible();
// Add it either before the edit form, or after the content
var editform = document.getElementById("editform");
var content = document.getElementById("content");
if (editform != null)
editform.before(table);
else if (content != null)
content.append(table);
});
}
return table;
}
}
var defaultConfigInfo =
[
{
name: "disabled",
presence: false,
default: false,
type: "boolean",
presence: false
},
// Markers
{
name: "iconAnchor",
presence: false,
default: "center",
type: "string",
validValues: [ "top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right" ]
},
{
name: "iconPosition",
presence: false,
default: undefined,
type: "string",
validValues: [ "top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right" ]
},
{
name: "sortMarkers",
presence: false,
default: "latitude",
type: "string",
validValues: ["latitude", "longitude", "category", "unsorted"]
},
// Popups
{
name: "enablePopups",
alias: "allowPopups",
presence: false,
default: true,
type: "boolean"
},
{
name: "openPopupsOnHover",
presence: false,
default: false,
type: "boolean"
},
{
name: "popupHideDelay",
presence: false,
default: 0.5,
type: "number"
},
{
name: "popupShowDelay",
presence: false,
default: 0.1,
type: "number"
},
{
name: "useCustomPopups",
presence: false,
default: false,
type: "boolean"
},
// Categories
{
name: "hiddenCategories",
presence: false,
default: [],
type: "array",
arrayType: "string",
},
{
name: "visibleCategories",
presence: false,
default: [],
type: "array",
arrayType: "string",
},
{
name: "disabledCategories",
presence: false,
default: [],
type: "array",
arrayType: "string"
},
{
name: "categoryGroups",
presence: false,
default: [],
type: "array",
arrayType: ["string", "object"],
children:
[
{
name: "categoryGroup",
presence: false,
default: undefined,
type: ["string", "object"],
children:
[
{
name: "label",
presence: true,
default: "Group",
type: "string"
},
{
name: "collapsible",
presence: false,
type: "boolean",
default: true,
},
{
name: "collapsed",
presence: false,
default: false,
type: "boolean"
},
{
name: "hidden",
presence: false,
default: false,
type: "boolean"
},
{
name: "children",
// Use is used to point the validator to a different item
// It should only be used with the name key
use: "categoryGroups"
}
]
}
]
},
// Map interface
{
name: "minimalLayout",
presence: false,
default: false,
type: "boolean",
},
{
name: "mapControls",
presence: false,
default: [],
type: "array",
arrayType: "array",
children:
[
{
name: "mapControlGroup",
presence: true,
default: [],
type: "array",
arrayType: "string",
children:
[
{
name: "mapControlGroupItem",
presence: false,
default: "",
type: "string",
validValues: [ "edit", "zoom", "fullscreen" ]
}
]
}
]
},
{
name: "hiddenControls",
presence: false,
default: [],
type: "array",
arrayType: "string",
validValues: [ "edit", "zoom", "fullscreen" ]
},
{
name: "enableFullscreen",
alias: "allowFullscreen",
presence: false,
default: true,
type: "boolean"
},
{
name: "fullscreenMode",
presence: false,
default: "window",
type: "string",
validValues: [ "window", "screen" ]
},
// Sidebar
{
name: "enableSidebar",
presence: false,
default: false,
type: "boolean"
},
{
name: "sidebarOverlay",
presence: false,
default: false,
type: "boolean"
},
{
name: "sidebarSide",
presence: false,
default: "left",
type: "string",
validValues: [ "left", "right" ]
},
{
name: "sidebarBehaviour",
presence: false,
default: "autoInitial",
type: "string",
validValues: [ "autoAlways", "autoInitial", "manual" ]
},
{
name: "sidebarInitialState",
presence: false,
default: "auto",
type: "string",
validValues: [ "auto", "show", "hide" ]
},
// Zoom layers
{
name: "zoomLayers",
presence: false,
default: [],
type: "array",
arrayType: "object",
children:
[
{
name: "zoomLayer",
presence: false,
default: undefined,
type: "object",
children:
[
{
name: "id",
presence: true,
type: [ "string", "number" ]
},
{
name: "minZoom",
presence: false,
default: 0,
type: "number"
},
{
name: "maxZoom",
presence: false,
default: Number.POSITIVE_INFINITY,
type: "number"
},
{
name: "categories",
presence: false,
default: [],
type: "array",
arrayType: [ "string", "number" ],
},
{
markers: "markers",
presence: false,
default: [],
type: "array",
arrayType: [ "string", "number" ],
}
]
}
]
},
// Other features
{
name: "enableSearch",
alias: "allowSearch",
presence: false,
default: true,
type: "boolean"
},
// Tooltips
{
name: "enableTooltips",
alias: "allowTooltips",
presence: false,
default: true,
type: "boolean"
},
{
name: "tooltipDirection",
presence: false,
default: "auto",
type: "string",
validValues: [ "top", "bottom", "left", "right", "center", "auto" ]
},
{
name: "tooltipOffset",
presence: false,
default: [ 0, 0 ],
type: "array",
arrayType: "number"
},
// Custom features
{
name: "canvasRenderOrderMode",
presence: false,
default: "auto",
type: "string",
validValues: [ "auto", "manual" ]
},
{
name: "paths",
presence: false,
default: [],
type: "array",
arrayType: "object",
children:
[
{
name: "path",
presence: false,
default: undefined,
type: "object",
children:
[
{
name: "id",
presence: true,
type: ["string", "number"]
},
{
name: "styleId",
presence: false,
type: ["string", "number"]
},
{
name: "style",
presence: false,
type: "object",
use: "styles.style",
customValidation: function(value, config)
{
if (config.styleId != null)
config.overrideStyle = jQuery.extend(true, {}, value);
return { result: true };
}
},
{
name: "categoryId",
presence: false,
type: ["string", "number"]
},
{
name: "title",
presence: false,
type: "string"
},
{
name: "link",
presence: false,
type: "string"
},
{
name: "popup",
presence: false,
type: "object",
children:
[
{
name: "title",
presence: true,
type: "string"
},
{
name: "description",
presence: false,
type: "string"
},
{
name: "image",
presence: false,
type: "string"
},
{
name: "link",
presence: false,
type: "object",
children:
[
{
name: "url",
presence: true,
type: "string",
},
{
name: "label",
presence: true,
type: "string",
},
]
}
]
},
{
name: "type",
presence: true,
default: "polyline",
type: "string",
validValues: [ "polygon", "polyline", "line", "circle", "ellipse", "rectangle" ]
},
{
name: "scaling",
presence: false,
default: true,
type: "boolean"
},
{
name: "smoothing",
presence: false,
default: false,
type: "boolean"
},
{
name: "smoothingIterations",
presence: false,
default: 5,
type: "number"
},
{
name: "points",
presence: false,
type: "array",
arrayType: "array",
debugAsString: true,
customValidation: function(value, config)
{
var errors = [];
// Position already present
if (config.position)
{
errors.push({ code: "POINTS_ONE_ONLY", message: "\"points\" and \"position\" are mutually exclusive, only one may be present." });
}
// If we're at this point, the type and presence checks have passed already
if (value.length == 0)
{
errors.push({ code: "POINTS_EMPTY_ROOT_ARRAY", message: "If the points array is defined, it must contain at least one element" });
}
// This functions checks to see that each element in a multidimensional array has the same type across depths, among other checks
var depthTypes = [];
var depthTypeIsArray = [];
var listDepth; // The depth at which we expect a list of values
var valueDepth; // The depth at which we expect actual values (string or array[2] of number)
var indexes = [];
function isCoordinate(v)
{
if (Array.isArray(v))
return v.length == 2 && typeof v[0] == "number" && typeof v[1] == "number";
else
return typeof v == "string";
}
function traverse(a, d)
{
var isArray = Array.isArray(a);
var type = isArray ? "array" : typeof a;
// Is this a value (either array[2] or string)
var isValue = isCoordinate(a);
// Is this an array of values
var isValuesArray = !isValue && isArray && isCoordinate(a[0]);
if (!valueDepth && isValue)
{
valueDepth = d;
// Here, also determine what sort of path this is
config.pointsDepth = valueDepth;
config.pointsType = valueDepth == 0 ? "coordinate" :
valueDepth == 1 ? "single" :
valueDepth == 2 ? (config.type == "polygon" ? "singleWithHoles" : "multiple") :
valueDepth == 3 ? "multipleWithHoles" : "error";
if (config.pointsType == "error")
{
errors.push({ code: "POINTS_UNRECOGNIZED_DEPTH", message: "The points array had a depth of more than 3 nested arrays, this format is unknown" });
return false;
}
}
if (!listDepth && isValuesArray)
{
listDepth = d;
}
// If this is the depth we expect a coordinate pair (array[2] or string)
if (d == valueDepth)
{
// Check if it is indeed a value
if (!isValue)
{
errors.push({ code: "POINTS_EXPECTED_VALUE", message: "Element at points" + indexes.map(function(i){ return "[" + i + "]"; }).join() + " was of type " + type + (isArray ? "[" + a.length + "]" : ")") + ", but it needs to be either an array[2] or string." });
return false;
}
// If an array, check that it contains two AND ONLY TWO numbers
if (isArray && (a.length != 2 || typeof a[0] != "number" || typeof a[1] != "number"))
{
errors.push({ code: "POINTS_COORDS_MISLENGTH", message: "The coordinate at points" + indexes.map(function(i){ return "[" + i + "]"; }).join() + " does not have two coordinates!" });
return false;
}
}
// If one greater than the valueDepth, it should ALWAYS be a number
if (d == valueDepth + 1 && type != "number")
{
errors.push({ code: "POINTS_COORD_NOT_NUMBER", message: "The coordinate at points" + indexes.map(function(i){ return "[" + i + "]"; }).join() + " is not a number!" });
return false;
}
// If any less than valueDepth, if should ALWAYS be an array
else if (d < valueDepth && !isArray)
{
errors.push({ code: "POINTS_EXPECTED_ARRAY", message: "Element at points" + indexes.map(function(i){ return "[" + i + "]"; }).join() + " was of type " + type + ", but at this depth it should be an array." });
return false;
}
// If the depth is greater than valueDepth + 1, it shouldn't exist
else if (d > valueDepth + 1)
{
errors.push({ code: "POINTS_UNBALANCED", error: "The type of each element is not equal across depths." });
return false;
}
// Set the depthType if it hasn't been set already
if (!depthTypes[d])
{
depthTypeIsArray[d] = isArray;
depthTypes[d] = type;
}
// Check whether the type matches the type expected at this depth
if (type != depthTypes[d] || isArray != depthTypeIsArray[d])
{
}
// Recurse into this array
if (isArray)
{
if (a.length == 0)
{
errors.push({ code: "POINTS_EMPTY_SUB_ARRAY", message: "points" + indexes.map(function(i){ return "[" + i + "]"; }).join() + " contains an empty array." });
return false;
}
// Check to see if the poly array contains the correct amount of coordinates for the type of feature
if (isValuesArray)
{
config.pointsFlat = config.pointsFlat || [];
config.pointsFlat.push(a);
if (config.type == "polygon" && a.length < 3)
{
errors.push({ code: "POINTS_POLYGON_COUNT", message: "The points array at " + indexes.map(function(i, n){ return n < d ? "[" + i + "]" : ""; }).join() + " needs 3 or more points, but only has " + a.length });
return false;
}
else if ((config.type == "polyline" || config.type == "line") && a.length < 2)
{
errors.push({ code: "POINTS_POLYLINE_COUNT", message: "The points array at " + indexes.map(function(i, n){ return n < d ? "[" + i + "]" : ""; }).join() + " needs 2 or more points, but only has " + a.length });
return false;
}
}
for (var i = 0; i < a.length; i++)
{
indexes[d] = i;
if (traverse(a[i], d + 1) == false)
return false;
}
}
return true;
}
return { result: traverse(value, 0) == true && errors.length == 0, messages: errors };
}
}
]
}
]
},
{
name: "styles",
presence: false,
default: undefined,
type: "array",
arrayType: "object",
children:
[
{
name: "style",
presence: false,
default: undefined,
type: "object",
children:
[
{
name: "id",
presence: false,
type: [ "number", "string" ],
},
{
name: "stroke",
presence: false,
default: true,
type: "boolean",
},
{
name: "strokeColor",
presence: false,
default: "black",
type: "string",
},
{
name: "strokeWidth",
presence: false,
default: 1.0,
type: "number",
},
{
name: "lineDashArray",
presence: false,
default: undefined,
type: "array",
arrayType: "number"
},
{
name: "lineDashOffset",
presence: false,
default: 0.0,
type: "number"
},
{
name: "lineCap",
presence: false,
default: "round",
type: "string",
validValues: [ "butt", "round", "square" ]
},
{
name: "lineJoin",
presence: false,
default: "round",
type: "string",
validValues: [ "round", "bevel", "miter" ]
},
{
name: "miterLimit",
presence: false,
default: 1.0,
type: "number"
},
{
name: "fill",
presence: false,
default: true,
type: "boolean"
},
{
name: "fillColor",
presence: false,
default: "black",
type: "string"
},
{
name: "fillRule",
presence: false,
default: "evenodd",
type: "string",
validValues: [ "nonzero", "evenodd" ]
},
{
name: "shadowColor",
presence: false,
default: undefined,
type: "string"
},
{
name: "shadowBlur",
presence: false,
default: undefined,
type: "number"
},
{
name: "shadowOffset",
presence: false,
default: undefined,
type: "array",
arrayType: "number"
}
]
}
]
},
// Ruler
{
name: "enableRuler",
presence: false,
default: true,
type: "boolean"
},
{
name: "pixelsToMeters",
presence: false,
default: 100,
type: "number"
},
// Collectibles
{
name: "enableFandomCollectibles",
presence: false,
default: false,
type: "boolean"
},
{
name: "collectibleCategories",
presence: true,
default: [],
type: "array",
arrayType: "string",
},
{
name: "enableCollectedAllNotification",
presence: false,
default: true,
type: "boolean"
},
{
name: "collectibleExpiryTime",
presence: false,
default: 2629743,
type: "number"
},
{
name: "collectibleCheckboxStyle",
presence: false,
default: "mx",
type: "string",
validValues: [ "mx", "fandom" ]
},
{
name: "enableYourProgressFilter",
presence: false,
default: true,
type: "boolean"
},
{
name: "enableClearCollectedButton",
presence: false,
default: true,
type: "boolean"
}
];
// Finally we are done with all the prototype definitions
// ---------
function MapsExtended()
{
this.loaded = true;
this.isDebug = isDebug;
this.isDisabled = isDisabled;
this.isInEditMode = mw.config.get("wgAction") == "edit";
this.isOnMapPage = mw.config.get("wgPageContentModel") == "interactivemap" || mw.config.get("wgNamespaceNumber") == 2900;
// Flatten the defaultConfigInfo into a default config
configValidator.postProcessConfigInfo(defaultConfigInfo);
this.defaultConfig = configValidator.flattenConfigInfoIntoDefaults(defaultConfigInfo);
this.defaultConfig._configId = "defaults";
this.defaultConfig._configMapName = "";
this.defaultConfig._configSource = "JavaScript";
this.defaultConfig._configScope = "defaults";
this.globalConfig = {};
this.globalConfigValidation = {}
this.isGlobalConfigLoaded = false;
this.localConfigs = {};
this.localConfigValidations = {}
this.isLocalConfigsLoaded = false;
this.embedConfigs = {};
this.embedConfigValidations = {};
this.isEmbedConfigsLoaded = false;
}
MapsExtended.prototype =
{
ExtendedMap: ExtendedMap,
ExtendedCategory: ExtendedCategory,
ExtendedMarker: ExtendedMarker,
ExtendedPopup: ExtendedPopup,
configValidator: configValidator,
init: function()
{
// Array of ExtendedMaps currently active
this.maps = [];
// Array of map titles on the page (not parallel to either of the above and below)
this.mapTitles = Object.values(mw.config.get("interactiveMaps")).map(function(m) { return m.name; });
// interactive-map-xxx elements from the DOM
this.mapElements = document.querySelectorAll(".interactive-maps-container > [class^=\"interactive-map-\"]");
// The interactive-map-xxxxxx className is only unique to the Map definition, not the map instance, so give each map a unique instance ID
for (var i = 0; i < this.mapElements.length; i++)
this.mapElements[i].id = generateRandomString(16);
// Create a stylesheet that can be used for some MapsExtended specific styles
this.stylesheet = mw.util.addCSS("");
// Events - This object is automatically filled from the EventHandlers in the "events" object of ExtendedMap
// Using this interface is a quick way to to listen to events on ALL maps on the page rather than just a specific one
this.events = {};
this.loaded = true;
// Preprocess marker elements so there's little flicker
/*
for (var m = 0; m < this.mapElements.length; m++)
{
var customIcons = this.mapElements[m].querySelectorAll(".MapMarker-module_markerCustomIcon__YfQnB");
for (var i = 0; i < customIcons.length; i++)
customIcons[i].style.marginTop = "calc(" + customIcons[i].style.marginTop + " / 2)";
}
*/
var mapsExtended = this;
// Fetch global configuration (from JavaScript)
this.fetchGlobalConfig();
// Fetch local configurations (from JavaScript and map definitions)
this.fetchLocalConfigs();
// Fetch embedded configurations (from data attributes on page)
this.fetchEmbedConfigs();
// These promises execute in parallel, and do not depend on each other
return Promise.all(
[
// Load module dependencies (Although it means delaying the initialization, it's better we don't have to have many mw.loader.using's everywhere)
this.loadDeps(),
// Load i18n internationalization messages
this.loadi18n(),
// Fetch remote map definitions - this is no longer done
this.fetchRemoteMapDefinitions(),
// Fetch remote local (or global) configurations (from JSON system message using API)
this.fetchRemoteConfigs(),
])
// These promises execute sequentially
// Validate all configurations
.then(this.validateAllConfigs.bind(this))
// Initialize all maps on the page
.then(this.initMaps.bind(this))
.finally(function()
{
this.initialized = true;
mw.hook("dev.mapsExtended").fire(this);
}.bind(this));
},
deinit: function()
{
if (this.initialized == false) return;
this.initialized = false;
// Deinitialize all maps
for (var key in this.maps)
{
var map = this.maps[key];
map.deinit();
delete map.events;
}
delete this.maps;
delete this.mapElements;
delete this.mapTitles;
delete this.events;
/*
// Remove all styles from stylesheet
for (var i = 0; i < this.stylesheet.cssRules.length; i++)
this.stylesheet.deleteRule(i);
this.stylesheet.ownerNode.remove();
*/
},
fetchGlobalConfig: function()
{
// Fetch global config from JavaScript (set in Common.js for example), depending on which is available first
this.globalConfig = window.mapsExtendedConfigs && window.mapsExtendedConfigs["global"] || window.mapsExtendedConfig || {};
this.isGlobalConfigLoaded = !isEmptyObject(this.globalConfig);
// Apply the global config over the defaults
if (this.isGlobalConfigLoaded == true)
{
this.globalConfig._configId = "global";
this.globalConfig._configMapName = "";
this.globalConfig._configSource = "JavaScript";
this.globalConfig._configScope = "global";
this.globalConfig._configSourcePath = "";
}
},
fetchLocalConfigs: function()
{
// Fetch the local configs for each map definition currently in memory (i.e. doesn't need an API call)
for (var key in mw.config.get("interactiveMaps"))
{
var map = mw.config.get("interactiveMaps")[key];
var config = undefined;
var configSource = undefined;
// Check JavaScript (keyed by map name or map page ID)
if (window.mapsExtendedConfigs && window.mapsExtendedConfigs[map.name] != undefined)
{
config = window.mapsExtendedConfigs[map.name];
configSource = "JavaScript";
}
// Check JSON (in Map definition)
else
{
// In the markers array of a map definition, get the first marker with a "config" object
var markerWithConfig = map.markers.find(function(m){ return m.config != undefined; });
if (markerWithConfig)
{
config = markerWithConfig.config;
configSource = "JSON (in map definition)";
// Remove the config object from the marker
delete markerWithConfig.config;
}
}
// If a config was found, save it to localConfigs
if (config != undefined)
{
config._configId = config._configMapName = map.name;
config._configSource = configSource;
config._configScope = "local";
this.localConfigs[map.name] = config;
}
}
// This flag determines whether we need to try and load a config using the API
this.isLocalConfigsLoaded = Object.keys(this.localConfigs).length == Object.keys(mw.config.get("interactiveMaps")).length;
},
fetchEmbedConfigs: function()
{
// Fetch any embed configs currently present on the page
for (var i = 0; i < this.mapElements.length; i++)
{
// This is interactive-map-xxxxxxxx
var mapElem = this.mapElements[i];
// Find the definition that represents this map
var map = mw.config.get("interactiveMaps")[mapElem.className];
// Get the element DIV that encapsulates the transcluded map (the parent of interactive-map-container)
var configElem = mapElem.parentElement.parentElement;
// Short-circuit if the parent of the interactive-map-container is just the page content
// or if a map definition behind the mapElem wasn't found
if (!map || !configElem || configElem.id == "mw-content-text") continue;
var embedConfig = {};
// Check to see if a "config" data attribute exists, and if so, try to parse it for our entire embed configuration
if (configElem.hasAttribute("data-config"))
{
try
{
embedConfig = JSON.parse(comfigElem.dataset.config);
}
catch(error)
{
console.error("Could not parse data-config attribute to JSON object:\n" + error);
}
}
else
{
// Collect all the data attributes
for (var key in configElem.dataset)
{
var configInfo = configValidator.getConfigInfoAtPath(key);
if (configInfo.type == "array" || configInfo.type == "object")
{
try
{
var obj = JSON.parse(configElem.dataset[key]);
embedConfig[key] = obj;
}
catch(e)
{
console.error("Could not parse embed config option " + key + " to " + configInfo.type + "\n" + e.toString());
}
}
else
embedConfig[key] = configElem.dataset[key];
}
}
// Store in mapsExtended.embedConfigs if there were data attributes present
if (!isEmptyObject(embedConfig))
{
embedConfig._configId = mapElem.id;
embedConfig._configMapName = map.name;
embedConfig._configSource = "Wikitext";
embedConfig._configScope = "embed";
// Don't store the embed config using the map name since the same map
// may be present multiple times on the page with different embed configs
this.embedConfigs[mapElem.id] = embedConfig;
this.isEmbedConfigsLoaded = true;
}
}
},
fetchRemoteMapDefinitions: function()
{
// Unfortunately Interactive Maps doesn't deserialize all properties of the JSON into the
// interactiveMaps object (in mw.config) (notably markers always includes custom properties,
// but everything else does not).
// Custom properties are used to configure MapsExtended, and in order to fetch them we must
// manually load the Map page content rather than use the existing deserialized maps in mw.config.
// The custom properties will be written directly back into mw.config.get("interactiveMaps")
// which in turn is copied to each ExtendedMap
// Update:
// Any custom field (outside of marker objects) are now sanitized/stripped when the JSON
// is saved, meaning that the only fields that may be present are those that are allowed :(
// The following code is kept just in case this is added back
return new Promise(function(resolve, reject)
{
// Just resolve immediately
return resolve();
// If editing an interactive map in source mode, use the JSON text directly from the editor
// (this will always be valid because the script won't run unless there's an interactive map on the page)
if (mw.config.get("wgPageContentModel") == "interactivemap" && (mw.config.get("wgAction") == "edit" || mw.config.get("wgAction") == "submit"))
{
mw.hook("wikipage.editform").add(function(editform)
{
var textBox = document.getElementById("wpTextbox1");
// The definition exactly parsed from the JSON with no processing
var editorMapDefinition = JSON.parse(textBox.value);
editorMapDefinition.name = mw.config.get("wgTitle");
// The definition as parsed by Interactive Maps
var localMapDefinition = Object.values(mw.config.get("interactiveMaps"))[0];
traverseCopyValues(editorMapDefinition, localMapDefinition, ignoreSourceKeys, true);
resolve();
});
}
// If viewing an interactive map (be it one or more transclusions or on the map page),
// fetch the text directly from the page with the MediaWiki revisions API
else
{
// Build a chain of map titles, like Map:x|Map:y|Map:z, which is sorted alphabetically and does not contain dupes
// 1. Convert interactiveMaps to object array
// 2. Create an array based on a function which returns Map:map.name
// 3. Create a set from the array (which removes duplicates)
// 4. Sort the array
// 5. Join each of the elements in an array to form a string
var titles = Array.from(new Set(Array.from(Object.values(mw.config.get("interactiveMaps")), function(m) { return "Map:" + m.name; }))).sort().join("|");
// Build revisions API url, fetching the content of the latest revision of each Map page
var params = new URLSearchParams(
{
action: "query", // Query action (Fetch data from and about MediaWiki)
prop: "revisions", // Which properties to get (the revision information)
rvprop: "content", // Which properties to get for each revision (content of each revision slot)
rvslots: "main", // Which revision slots to return data for (main slot - the public revision)
format: "json", // The format of the returned data (JSON format)
formatversion: 2, // Output formatting
redirects: 1, // Follow redirects
maxage: 300, // Set the max-age HTTP cache control header to this many seconds (10 minutes)
smaxage: 300, // Set the s-maxage HTTP cache control header to this many seconds (10 minutes)
titles: titles // A list of titles to work on
});
var url = mw.config.get("wgServer") + "/api.php?" + params.toString();
// Perform the request
fetch(url)
// When the HTTP response is returned...
.then(function(response)
{
// Determine whether the response contains JSON
var contentTypeHeader = response.headers.get("content-type");
var isJson = contentTypeHeader && contentTypeHeader.includes("application/json");
var data = isJson ? response.json() : null;
if (!response.ok)
{
var error = (data && data.message) || response.status;
throw { type: "request", value: error };
}
return data;
})
// When the response body text is parsed as JSON
// An example of the returned response is:
// https://pillarsofeternity.fandom.com/api.php?action=query&prop=revisions&rvprop=content&rvslots=*&format=json&formatversion=2&redirects=1&titles=Map:The+Goose+and+Fox+-+Lower|Map:The+Goose+and+Fox+-+Upper
.then(function(data)
{
var pageData = Object.values(data.query.pages);
var localDefinitions = Array.from(Object.values(mw.config.get("interactiveMaps")));
var errors = [];
for (var i = 0; i < pageData.length; i++)
{
// Instead of throwing, just log any errors to pass back
if (pageData[i].invalid || pageData[i].missing || pageData[i].accessdenied || pageData[i].rvaccessdenied)
{
if (pageData[i].invalid)
errors.push("API query with title \"" + pageData[i].title + "\" was invalid - " + pageData[i].invalidreason);
else if (pageData[i].missing)
errors.push("A page with the title \"" + pageData[i].title + "\" does not exist!");
else if (pageData[i].accessdenied || pageData[i].rvaccessdenied)
errors.push("You do not have permission to view \"" + pageData[i].title + "\"");
else if (pageData[i].texthidden)
errors.push("The latest revision of the page \"" + pageData[i].title + "\ was deleted");
continue;
}
try
{
// Parse the content of the page as JSON into a JS object (adding the map name because the JSON will not contain this)
var remoteMapDefinition = JSON.parse(pageData[i].revisions[0].slots.main.content);
remoteMapDefinition.name = pageData[i].title.replace("Map:", "");
var localMapDefinition = localDefinitions.find(function(d) { return d.name == remoteMapDefinition.name; });
// Copy the values of the remote definition onto the values of the local definition
traverseCopyValues(remoteMapDefinition, localMapDefinition, ignoreSourceKeys, true);
}
catch(error)
{
errors.push("Error while parsing map data or deep copying into local map definition: " + error);
continue;
}
}
// Reject the promise, returning any errors
if (errors.length > 0) throw {type: "response", value: errors };
})
// Catch and log any errors that occur
.catch(function(reason)
{
var str = "One or more errors occurred while " + (reason.type == "request" ? "performing HTTP request" : "parsing the HTTP response") + ". Custom properties may not be available!\n";
if (typeof reason.value == "object")
str += "--> " + reason.value.join("\n--> ");
else
str += "--> " + reason.value;
console.error(str);
});
}
});
},
fetchRemoteConfigs: function()
{
var mapsExtended = this;
// As to not pollute the Map JSON definitions, users may also store map configurations in a separate
// file a subpage of MediaWiki:Custom-MapsExtended. For example a map with the name Map:Foobar will
// use the page MediaWiki:Custom-MapsExtended/Foobar.json
// MediaWiki: pages typically store system messages which are unabled to be edited, but those prefixed with "Custom-"
// are whitelisted such that they can be edited by logged-in users. This prefix seems to be a free-for-use space, and
// many scripts use it as a place to store configurations and such in JSON format
// Below, we fetch this config and insert it into mapsExtended.localConfigs, keyed by the map name minus the Map: prefix
// Don't bother using this method if all configs were already loaded
if (mapsExtended.isGlobalConfigLoaded == true &&
mapsExtended.isLocalConfigsLoaded == true)
return;
var MX_CONFIG_PREFIX = "MediaWiki:Custom-MapsExtended/";
var MX_CONFIG_SUFFIX = ".json";
var configNames = [].concat(mapsExtended.isLocalConfigsLoaded == false ? mapsExtended.mapTitles : [],
mapsExtended.isGlobalConfigLoaded == false ? [ "global" ] : []);
// Build a chain of map config titles, like x|y|z, which is sorted alphabetically and does not contain dupes
// 1. Create an array based on a function which returns MediaWiki:Custom-MapsExtended/<mapname>.json (using Array.map)
// 2. Create a set from the array (which removes duplicates)
// 3. Convert the set back into an array (using Array.from)
// 4. Sort the array
// 5. Join each of the elements in an array to form a string
var titles = Array.from(new Set(configNames.map(function(title) { return MX_CONFIG_PREFIX + title + MX_CONFIG_SUFFIX; }))).sort().join("|");
// Build revisions API url, fetching the content of the latest revision of each Map page
var params = new URLSearchParams(
{
action: "query", // Query action (Fetch data from and about MediaWiki)
prop: "revisions", // Which properties to get (the revision information)
rvprop: "content", // Which properties to get for each revision (content of each revision slot)
rvslots: "main", // Which revision slots to return data for (main slot - the public revision)
format: "json", // The format of the returned data (JSON format)
formatversion: 2, // Output formatting
redirects: 1, // Follow redirects
origin: "*",
maxage: 300, // Set the max-age HTTP cache control header to this many seconds (5 minutes)
smaxage: 300, // Set the s-maxage HTTP cache control header to this many seconds (5 minutes)
titles: titles // A list of titles to work on
});
var fetchParams =
{
method: "GET",
credentials: "omit",
};
var url = mw.config.get("wgServer") + mw.config.get("wgScriptPath") + "/api.php?" + params.toString();
var loadedConfigs = 0;
// Perform the request, returning the promise that is fulfilled at the end of the chain
return fetch(url, fetchParams)
// When the HTTP response is returned...
.then(function(response)
{
// Determine whether the response contains JSON
var contentTypeHeader = response.headers.get("content-type");
var isJson = contentTypeHeader && contentTypeHeader.includes("application/json");
var data = isJson ? response.json() : null;
if (!response.ok)
{
var error = (data && data.message) || response.status;
throw { type: "request", value: error };
}
return data;
})
// When the response body text is parsed as JSON...
.then(function(data)
{
var pageData = Object.values(data.query.pages);
var errors = [];
for (var i = 0; i < pageData.length; i++)
{
// Instead of throwing, just log any errors to pass back
if (pageData[i].invalid || pageData[i].missing || pageData[i].accessdenied || pageData[i].rvaccessdenied)
{
if (pageData[i].invalid)
errors.push("API query with title \"" + pageData[i].title + "\" was invalid - " + pageData[i].invalidreason);
else if (pageData[i].missing)
errors.push("A page with the title \"" + pageData[i].title + "\" does not exist!");
else if (pageData[i].accessdenied || pageData[i].rvaccessdenied)
errors.push("You do not have permission to view \"" + pageData[i].title + "\"");
else if (pageData[i].texthidden)
errors.push("The latest revision of the page \"" + pageData[i].title + "\ was deleted");
continue;
}
try
{
// Parse the content of the page as JSON into a JS object (adding the map name because the JSON will not contain this)
var config = JSON.parse(pageData[i].revisions[0].slots.main.content);
config._configId = config._configMapName = pageData[i].title.replace(MX_CONFIG_PREFIX, "").replace(MX_CONFIG_SUFFIX, "");
config._configSource = "JSON (in system message)";
// Insert it into mapsExtended.localConfig
if (config._configId == "global")
{
config._configScope = "global";
mapsExtended.globalConfig = config;
mapsExtended.isGlobalConfigLoaded = true;
loadedConfigs++;
}
// Insert it into mapsExtended.localConfigs
else
{
config._configScope = "local";
mapsExtended.localConfigs[config._configId] = config;
mapsExtended.isLocalConfigsLoaded = true;
loadedConfigs++;
}
}
catch(error)
{
errors.push("Error while parsing map data: " + error);
continue;
}
}
// Reject the promise, returning any errors
if (errors.length > 0) throw {type: "response", value: errors };
})
// Catch and log any errors that occur
.catch(function(reason)
{
var str = "One or more errors occurred while " + (reason.type == "request" ? "performing HTTP request" : "parsing the HTTP response") + ". Custom properties may not be available!\n";
if (typeof reason.value == "object")
str += "--> " + reason.value.join("\n--> ");
else
str += "--> " + reason.value;
log(str);
})
.finally(function(){
log("Loaded " + loadedConfigs + " remote MapsExtended configurations");
});
},
// Validate all configurations, storing the validated config back into their associated object
// This needs to be done backwards in order of presedence, as each scope uses the results of the last
validateAllConfigs: function()
{
this.configValidator.validateConfig(this.defaultConfig);
if (this.isGlobalConfigLoaded)
{
this.globalConfigValidation = this.configValidator.validateConfig(this.globalConfig);
this.globalConfig = this.globalConfigValidation.configSelf;
if (this.isOnMapPage && (this.isInEditMode || isDebug))
this.configValidator.tabulateConfigValidation(this.globalConfigValidation);
}
for (var key in this.localConfigs)
{
// Validate the local config for this map (the returned value will contain a new config with fallbacks of the global and default configs)
this.localConfigValidations[key] = this.configValidator.validateConfig(this.localConfigs[key]);
this.localConfigs[key] = this.localConfigValidations[key].configSelf;
if (this.isOnMapPage && (this.isInEditMode || isDebug))
this.configValidator.tabulateConfigValidation(this.localConfigValidations[key]);
}
for (var key in this.embedConfigs)
{
// Validate the embedded config for this map (the returned value will contain a new config with fallback of the local, global, and default configs)
this.embedConfigValidations[key] = this.configValidator.validateConfig(this.embedConfigs[key]);
this.embedConfigs[key] = this.embedConfigValidations[key].configSelf;
if (this.isInEditMode || isDebug)
this.configValidator.tabulateConfigValidation(this.embedConfigValidations[key]);
}
// Here, set the final configs. This is merged result of the config and all configs below it
if (this.isGlobalConfigLoaded)
this.globalConfig = this.globalConfigValidation.config;
for (var key in this.localConfigs)
this.localConfigs[key] = this.localConfigValidations[key].config;
for (var key in this.embedConfigs)
this.embedConfigs[key] = this.embedConfigValidations[key].config;
if (isDebug)
{
log("The following map configurations have been verified and loaded:");
if (this.isGlobalConfigLoaded)
{
log("Global configuration:");
log(this.globalConfig);
}
if (this.isLocalConfigsLoaded)
{
log("Local configuration(s):");
log(this.localConfigs);
}
if (this.isEmbedConfigsLoaded)
{
log("Embed configuration(s):");
log(this.embedConfigs);
}
}
},
loadDeps: function()
{
var loadStartTime = performance.now();
return mw.loader.using(["oojs-ui-core", "oojs-ui-windows"])
.then(function()
{
log("Loaded module dependencies in " + Math.round(performance.now() - loadStartTime) + "ms");
});
},
// Fetch and load i18n messages
loadi18n: function()
{
// i18n overrides (for testing purposes only)
/*
window.dev = window.dev || {};
window.dev.i18n = window.dev.i18n || {};
window.dev.i18n.overrides = window.dev.i18n.overrides || {};
var overrides = window.dev.i18n.overrides["MapsExtended"] = window.dev.i18n.overrides["MapsExtended"] || {};
console.log("i18n messages are being overridden!");
*/
var loadStartTime = performance.now();
// The core module doesn't use any translations, but we might as well ensure it's loaded before running other modules
return new Promise(function(resolve, reject)
{
mw.hook("dev.i18n").add(function(i18n)
{
var CACHE_VERSION = 5; // Increment manually to force cache to update (do this when new entries are added)
i18n.loadMessages("MapsExtended", { cacheVersion: CACHE_VERSION }).done(function(i18n)
{
log("Loaded i18n library + messages in " + Math.round(performance.now() - loadStartTime) + "ms");
// Save i18n instance to mapsExtended object
mapsExtended.i18n = i18n;
resolve();
});
});
});
},
// Get existing maps on the page and create ExtendedMaps for them
initMaps: function()
{
var initPromises = [];
for (var i = 0; i < this.mapElements.length; i++)
{
var map = new ExtendedMap(this.mapElements[i]);
this.maps.push(map);
// We may have to wait a few frames for Leaflet to initialize, so
// create a promise which resolves then the map has fully loaded
initPromises.push(map.waitForPresence());
}
// Wait for all maps to appear
return Promise.allSettled(initPromises)
// Finishing off...
.then(function(results)
{
// Log the result of the map initialization
results.forEach(function(r)
{
if (r.status == "fulfilled")
console.log(r.value);
else if (r.status == "rejected")
console.error(r.reason);
});
}.bind(this))
.catch(function(reason)
{
console.error(reason);
});
}
};
var mapsExtended = new MapsExtended();
// Cache mapsExtended in window.dev
window.dev = window.dev || {};
window.dev.mapsExtended = mapsExtended;
// This hook ensures that we init again on live preview
mw.hook("wikipage.content").add(function(content)
{
// Ignore non-page content (includes marker popups)
if (!content[0].matches("#mw-content-text"))
return;
// prevObject will not be undefined if this is a live preview.
// The issue with live preview however, is that there is no hook that fires when the content is fully loaded
// The content object is also detached from the page, so we can't observe it
if (mapsExtended.initialized && content.prevObject)
{
var wikiPreview = document.getElementById("wikiPreview");
// Deinit the existing maps
mapsExtended.deinit();
// Content is detached from the page, add a MutationObserver that will listen for re-creation of interactive-map elements
new MutationObserver(function(mutationList, observer)
{
// If there were any added or removed nodes, check whether the map is fully created now
if (mutationList.some(function(mr)
{
for (var i = 0; i < mr.addedNodes.length; i++)
{
var elem = mr.addedNodes[i];
return elem instanceof Element &&
(elem.classList.contains("interactive-maps") ||
elem.classList.contains("leaflet-container") ||
elem.closest(".interactive-maps-container") != undefined ||
elem.matches(".interactive-maps-container > [class^=\"interactive-map-\"]"));
}
return false;
}))
{
observer.disconnect();
mapsExtended.init();
}
}).observe(wikiPreview, { subtree: true, childList: true });
}
// Otherwise if it was a regular preview, just initialize as normal
else
{
mapsExtended.init();
}
});
/*
mapsExtended.stylesheet.insertRule(".interactive-maps, .interactive-maps * { pointer-events: none; cursor: default; }")
mapsExtended.stylesheet.insertRule(".LoadingOverlay-module_overlay__UXv3B { z-index: 99999; }");
// Add a loading overlay to each map
for (var i = 0; i < mapsExtended.mapElements.length; i++)
{
var mapElement = mapsExtended.mapElements[i];
mapElement.style.cursor = "default";
var leafletContainer = mapElement.querySelector(".leaflet-container");
leafletContainer.classList.add("loading");
var loadingOverlay = ExtendedMap.prototype.createLoadingOverlay();
leafletContainer.appendChild(loadingOverlay);
}
*/
var imports =
{
articles:
[
"u:dev:MediaWiki:I18n-js/code.js",
"u:dev:MediaWiki:BannerNotification.js",
"u:dev:MediaWiki:WDSIcons/code.js"
]
};
// importArticles cannot detect whether a CSS has been imported already (it will simply stack)
// Check for the presence of the .mapsExtended rule to detemine whether to import
var isMxCSSImported = findCSSRule(".mapsExtended") != undefined;
if (!isMxCSSImported) imports.articles.push("u:dev:MediaWiki:MapsExtended.css");
// Load dependencies
importArticles(imports);
// Load modules
/*
loadModule("tooltips").then(function(tooltip)
{
mw.hook("wds-tooltips").fire(tooltip);
});
*/
};
/*
Initialization
Sometimes the document is still loading even when this script is executed
(this often occurs when the page is opened in a new tab or window).
In order to prevent a situation where the script is run but the page has not
been fully loaded, check the readyState and listen to a readystatechange
event if the readystate is loading
*/
function init()
{
// Script was already loaded in this window
if (window.dev && window.dev.mapsExtended && window.dev.mapsExtended.loaded == true)
{
console.error("MapsExtended - Not running script more than once on page!");
return;
}
// Script wasn't yet loaded
else
{
mx();
}
}
// The document cannot change readyState between the if and else
if (document.readyState == "loading")
document.addEventListener("readystatechange", init);
else
init();
})();