Source: SlippyMap.js

/**
 * The Configuration of a map.
 * <br>
 * This is only used before calling "showMap". Any changes made to the configuration
 * after the initial "showMap" will be ignored.
 */
class Configuration {
  /**
   * Set to true if the HTML attributes of the map's div can be used to override
   * the configuration.
   * 
   * @type {boolean}
   */
  canUseDivAttributes = true;
  /**
   * The Center of the Map. This should be an array containing 2 numbers.
   * <br>
   * Default value is roughly in the center of Belgium.
   * 
   * @type {number[]}
   */
  center = [50.605902641613284, 4.752960205078125];
  /**
   * The Zoom level of the map.
   * <br>
   * Default value is 14.
   * 
   * @type {number}
   */
  zoom = 14;
  /**
   * The TileLayer to use.
   * <br>
   * See https://leafletjs.com/reference-1.5.0.html#tilelayer for more information.
   * <br>
   * Default value is the OSM default map: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
   * 
   * @type {string}
   */
  tileLayer = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
  /**
   * The tileLayer's attribution text.
   * <br>
   * Default value is the attribution for the OSM default map (see above).
   * 
   * @type {string}
   */
  tileLayerAttribution = '<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
  /**
   * The maximum number of elements that can be shown on-screen
   * Set to undefined, or a number less or equal to zero to remove the limit.
   * <br>
   * Default value is 500
   * 
   * @type {number|undefined}
   */
  maxElements = 500;
  /**
   * The "padding" we should use for the bounds when showing the markers.
   * This value is a percentage, where 1 = 100%, 0 = 0%, and -1 = -100%.
   * <br>
   * This value shouldn't be too large, and should be positive.
   * A value between 0.2 and 0.5 is probably ideal for performance.
   * Larger values will reduce performance in the common case, and values
   * too small (<0.2) will reduce performance for users that move the map
   * around a lot.
   * <br>
   * Default value is 0.25
   * 
   * @type {number}
   */
  boundsPadding = 0.25;
  /**
   * @name PopupTextProvider
   * @function
   * @param {object} element The element returned by the Overpass API.
   * @param {object} element.tags The tags of the element. Look for tags by checking if
   *                 element.tags['yourTag'] is undefined or not.
   * @returns {string|undefined}
   */
  /**
   * The function that is called whenever we want to fetch the marker popup text
   * for a given element.
   * <br>
   * This function is always called with the element object, which has an array
   * of tags (element.tags) that you can use.
   * <br>
   * Return undefined if you don't want a popup
   * <br>
   * Popups support HTML.
   * 
   * @type {PopupTextProviderType}
   */
  popupTextProvider = defaultPopupTextProvider;
  /**
   * Overrides this configuration based on a div's attributes.
   * <br>
   * The attributes must have the same name as the members.
   * <br>
   * Attributes will be checked against a RegEx to see if they're well formed.
   * If they aren't, they'll be ignored an a warning will be printed to the console.
   * 
   * @param {object} div the div object
   */
  useDivAttributes(div) {
    // Utils
    function warnIllFormedAttr(attrName, value) {
      console.warn('Ill-formed attribute for %o: %o', attrName, value);
    }

    // Overriding center
    if (div.hasAttribute('center')) {
      let value = div.getAttribute('center');
      // Must be a string with 2 floating-point numbers, separated by a comma
      // and between brackets. e.g [3.14, 3.14]
      if (/^\[(\d+.\d+)\s?,\s?(\d+.\d+)\]$/.test(value)) {
        this.center = JSON.parse(value);
        hasSetcenter = true;
      }
      else
        warnIllFormedAttr('center', value);
    }

    // Overriding zoom
    if (div.hasAttribute('zoom')) {
      let value = div.getAttribute('zoom');
      // Must be a number, maybe a decimal one.
      if (/^\d+(.\d+)?$/.test(value))
        this.zoom = parseInt(value);
      else
        warnIllFormedAttr('zoom', value);
    }

    // Overriding tileLayer
    if (div.hasAttribute('tileLayer'))
      this.tileLayer = div.getAttribute('tileLayer');

    // Overriding tileLayerAttribution
    if (div.hasAttribute('tileLayerAttribution'))
      this.tileLayerAttribution = div.getAttribute('tileLayerAttribution');

    // Overriding maxElements
    if(div.hasAttribute('maxElements')) {
      let value = div.getAttribute('maxElements');
      // Must be a number
      if (/^\d+?$/.test(value))
        this.maxElements = parseInt(value);
      else
        warnIllFormedAttr('maxElements', value);
    }
  }
}

/**
 *
 * Creates and displays a map in place of the div with id mapId. 
 * <br>
 * If conf.canUseDivAttributes is set to true, this will call conf.useDivAttributes to update the configuration 
 * based on the attributes of the div (when present)
 * 
 * @param {string} mapId The HTML id of the div that should become the map
 * @param {Configuration} conf the Configuration object. 
 *                        Can be undefined. If that's the case, a default-constructed object will be used.
 *                        However, if you want to reuse the configuration object later, it's better to pass one.
 * @param {string|undefined} focusQuery If defined, calls focusOn to initialize the map using the focusQuery.
 *                           Note that if this parameter is used, the focusOn attribute of the div will be ignored.
 * @returns {Leaflet.Map} The created Leaflet Map.
 */
function showMap(mapId, conf, focusQuery) {
  if (conf == undefined)
    conf = new Configuration();

  let div = document.getElementById(mapId);
  if (div == null) {
    console.error("Cannot create map - div with id '%o' does not exist", mapId);
    return;
  }

  if (conf.canUseDivAttributes)
    conf.useDivAttributes(div);

  // Create the map and give it a tile layer.
  let map = L.map(mapId);
  L.tileLayer(conf.tileLayer, { attribution: conf.tileLayerAttribution }).addTo(map);

  // Set the zoom level
  map.setZoom(conf.zoom);

  // Handle the focusOn attribute.
  if(div.hasAttribute('focusOn')) {
    if(focusQuery == undefined)
      focusQuery = div.getAttribute('focusOn');
    else
      console.warn("showMap - focusOn attribute of the map div overriden by the focusQuery parameter");
  }

  // If we got a focusQuery, use focusOn to initialize the map's position.
  // Else, center the view using the configuration parameters.
  if(focusQuery != undefined)
    focusOn(map, focusQuery, L.latLng(conf.center));
  else
    map.setCenter(conf.center);

  map.addEventListener('moveend', onMoveEnd);

  // This is the function called whenever the user (or a programmer) is done moving the view.
  // It performs some checks and may fire an additional "drawmarkers" event.
  // the "drawmarkers" event is only called if:
  //    - this is the first time moving the view
  //    - the view has been moved outside the bounds of the "largest" previous view
  function onMoveEnd() {
    function fire(bounds) {
      onMoveEnd.bounds = bounds;
      map.fireEvent('drawmarkers'); 
    };
    
    let curBounds = map.getBounds();

    // If this is the first time calling the function, fire the event and save the bounds.
    // If this isn't the first time and the new bounds are outside the old ones, 
    // set them as the current bounds and fire the event.
    // If both conditions are false, don't do anything.
    if((onMoveEnd.bounds == undefined) || !onMoveEnd.bounds.contains(curBounds))
        return fire(curBounds.pad(conf.boundsPadding));
    return;
  }

  return map;
}

/**
 * Centers the map on something.
 * <br>
 * This will perform a search using Nominatim, and center the map on the first result.
 * If no result is found, the coordinates are unchanged.
 * <br>
 * If the result of this function isn't satisfying, try to make a more specific query
 * e.g. "Antwerpen Belgium", or simply set the coordinates manually.
 * <br>
 * The nominatim query will be performed using the following options:
 * <ul>
 *    <li> limit=1 
 *    <li> viewbox=current bounds (map.getBounds().toBBoxString())
 *    <li> format=json 
 *    <li> q=query (the query string)
 * </ul>
 * <br>
 * This function can be used even if the map has not been initialized yet.
 * If that's the case, the parameter 'center' must be provided.
 * <br>
 * If you use this on the map returned by showMap, it has already be initialized
 * so the center parameter is useless and can be omitted.
 * 
 * @param {Leaflet.Map} map the Leaflet map
 * @param {string} query The nominatim query. Example "Antwerpen", "Brugge", etc.
 * @param {Leaflet.LatLng|undefined} center The center of the search. Must be provided if 
 * the map has not been initialized yet. If the map has been initialized, the bounds of
 * its view will be used instead.
 */
function focusOn(map, query, center) {
  map.stop();
  if(typeof query !== 'string') {
    console.error("Query is not a string");
    return;
  }
  // Prepare the request
  let xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState != 4) return;
    if  (this.status == 200)
      handleQueryResult(this.responseText);
    else
      console.error("focusOn - Nominatim query failed with code %o: %o", this.status, this.responseText);
  };

  function getBounds() {
    // Try to use the map bounds
    try {
      return map.getBounds().toBBoxString()
    }
    catch(err) {
      if(center == undefined)
        throw "focusOn - Map has not been initialized map and there's no center for the search";
      // Create a 1Km bound box from the point.
      return center.toBounds(1000).toBBoxString();
    }
  }

  // Prepare the query
  let queryURL = `https://nominatim.openstreetmap.org/search?q=${query}&format=json&viewbox=${getBounds()}&limit=1`;
  // Send the request
  xhttp.open("GET", encodeURI(queryURL));
  xhttp.send();
  /**
   * Handles the result of a successful nominatim query.
   * @param {string} resultString the result string of the query. Must be JSON
   */
  function handleQueryResult(resultString) {
    let result;
    try {
      result = JSON.parse(resultString);
    }
    catch(err) {
      console.error("focusOn - an error occured while parsing the Nominatim query result: %o", err);
      return;
    }
    // Fetch the first result. If there's no result, don't do anything.
    result = result[0];
    if(result == undefined) {
      console.log("focusOn - No result found for query '%o'", query);
      return;
    }
    // Center the map on the lat/lon
    map.setView(L.latLng(result.lat, result.lon));
  }
}

/**
 * Returns a simple overpass query string that returns all nodes, ways and relations
 * in the bbox that have a given key/value pair.
 * <br>
 * If key is undefined, all points will be returned. 
 * (Please be careful with the latter, if it's a large bbox, performance will be terrible, especially
 * if you don't cap the number of features shown on screen)
 * 
 * @param {string|undefined} key The key we're looking for
 * @param {string|undefined} value The value of the key we're looking for.
 * @returns {string} the query string
 */
function getSimpleQuery(key, value) {
  function getParam() {
    if(key == undefined)
      return "";
    return `["${key}"="${value}"]`;
  }

  var query = `
  [out:json][timeout:25];
  // gather results
  (
    node${getParam()}({{bbox}});
  );
  // print results
  out body;
  >;
  out skel qt;`;
  return query;
}

/**
 * Queries the Overpass API and adds the resulting features to the map.
 * This is asynchronous, the points will be added as soon as the API gives us a response.
 * <br>
 * Errors will be printed to the console if the query fails. 
 * <br>
 * A warning will be printed to the console if some points are ignored due to the cap.
 * (see Configuration.maxElements)
 * 
 * @param {Leaflet.Map} map the map
 * @param {string} query the query. For generating simple queries, @see getSimpleQuery
 *                 Note: occurences of {{bbox}} inside the query will be replaced with the bounds
 *                 of the map.
 * @param {Configuration|undefined} conf the configuration object. Can be undefined to use the default configuration.
 */
function queryAndShowFeatures(map, query, conf) {
  // Create a configuration object if we don't have one
  if (conf == undefined)
    conf = new Configuration();

  // Note: We cannot use map.getBounds().toBBoxString() because
  // for some reason overpass wants another format which is 
  //    southwest_lat,southwest_lng,northeast_lat,northeast_lng
  // instead of
  //    southwest_lng,southwest_lat,northeast_lng,northeast_lat

  // Create the bbox string
  let bounds = map.getBounds().pad(conf.boundsPadding);
  let sw = bounds.getSouthWest();
  let ne = bounds.getNorthEast();
  let bboxString = `${sw.lat},${sw.lng},${ne.lat},${ne.lng}`;

  // Replace all occurences of {{bbox}} inside the query with 
  // the bbox string
  query = query.replace("{{bbox}}", bboxString);

  // Create the query URL for the OHM Overpass API & encode it
  let queryURL = "http://overpass-api.openhistoricalmap.org/api/interpreter?data=".concat(query);
  queryURL = encodeURI(queryURL);

  // Create the XMLHttpRequest to send the GET request asynchronously
  let xhttp = new XMLHttpRequest();
  xhttp.open("GET", queryURL);
  // Set the callback
  xhttp.onreadystatechange = function () {
    // Only handle the result if the request has been completed
    if (this.readyState != 4)
      return;
    // 200 = OK, anything else is considered a failure.
    if (this.status === 200)
      handleQueryResult(this.responseText, configuration);
    else
      console.error("The Request could not be completed. Status: %o", this.status);
  }
  xhttp.send();

  /**
   * Creates a single marker
   * @param {*} element an element returned by the overpass query (object with a .lat and .lon field)
   */
  function createMarker(layerGroup, element) {
    let marker = L.marker(L.latLng(element.lat, element.lon)).addTo(layerGroup);
    // Fetch the popup text using the provider
    let popupText = conf.popupTextProvider(element);
    if(popupText == undefined)
      return;
    // If the provider has given us some text, create the popup.
    marker.bindPopup(conf.popupTextProvider(element));
    marker.on("mouseover", function() {
      this.openPopup();
    })
    marker.on("mouseout", function() {
      this.closePopup();
    })
  }

  /**
  * Handles the result of a successful query to the Overpass API, adding the features as markers on the map.
  * @param {string} rawJSON the raw JSON string
  */
  function handleQueryResult(rawJSON) {
    // First, parse the raw json.
    let json;
    try {
      json = JSON.parse(rawJSON);
    }
    catch (err) {
      console.error("JSON Parsing Error!");
      console.log("string that we tried to parse: %o", rawJSON);
      console.log("JSON.parse() Result: %o", json);
      return;
    }

    let maxElements = conf.maxElements;
    if(maxElements <= 0)
      maxElements = undefined;
    // Once that's done, gather the list of features. They're located in the "elements" part of the
    // JSON.
    if (json.elements != undefined) {
      // Create a layer group for the markers if that's not done yet.
      let layerGroup = queryAndShowFeatures.layerGroup;
      if(layerGroup == undefined)
        queryAndShowFeatures.layerGroup = layerGroup = L.layerGroup().addTo(map);
      // If we already have a layergroup, clear it.
      else 
        layerGroup.clearLayers();
      // Fetch the elements
      let elements = json.elements;
      // Add the markers to the layerGroup
      for (let element of elements.slice(0, maxElements))
        createMarker(layerGroup, element);
      // Show some debug info, including a omitted markers count if we have omitted some
      // markers.
      if ((maxElements != undefined) && (elements.length > maxElements)) {
        let omitted = elements.length - maxElements;
        console.warn("queryAndShowFeatures - Markers Cap Reached: %o markers were not shown."
          + " (Cap is %o and query returned %o features)", omitted, maxElements, elements.length);
      }
      else
        console.log("queryAndShowFeatures - Added %o elements", elements.length);
    }
    else
      console.log("queryAndShowFeatures - No elements found");
  }
}

/**
 * The default popup text provider, which tries its best to provide a description
 * (in English) based on the tags of the element.
 * 
 * @param {object} element The element returned by the Overpass API.
 * @param {object} element.tags The tags of the element
 * @returns {string|undefined}
 */
function defaultPopupTextProvider(element) {
  let tags = element.tags;
  // Can't do anything without tags.
  if(tags == undefined || tags.length == 0)
    return undefined;

  let text = "";

  // Print name
  if(tags.name != undefined)
    text += `<h2>${tags.name}</h2>`

  // Print description
  if(tags.description != undefined)
    text += `<p>${tags.description}</p>`
  
  // Try to parse a start_date and end_date if we got one
  function getDateLine() {
    let dateLine = "";
    let start_date = tags["start_date"];
    let end_date = tags["end_date"];
    if((start_date != undefined) && (end_date != undefined))
      dateLine += `<p><b>From</b> ${start_date} <b>to</b> ${end_date}</p>`;
    return dateLine;
  }

  text += getDateLine();

  // Try to parse an address if we got one
  function getAddressLine() {
    let city = tags["addr:city"];
    let postcode = tags["addr:postcode"];
    let housenumber = tags["addr:housenumber"];
    let street = tags["addr:street"];

    // Only display this if we got both a streetname and
    // a housenumber.
    if((housenumber != undefined) && (street != undefined)) {
      let addressLine = "<p>";
      addressLine += housenumber + ' ' + street + '<br>';
      // If we got a city + postcode, display that as well.
      if((postcode != undefined) && (city != undefined)) {
        addressLine += postcode + ' ' + city;
      }
      addressLine += '</p>';
      return addressLine;
    }
    return "";
  }

  text += getAddressLine();

  return text;
}