Source: src/leafletMap.js

/** @module slippyMap */

import * as L from 'leaflet'
import * as d3 from 'd3'
import { getCentroid, getGjson } from 'brc-atlas-bigr'
import { dataAccessors } from './dataAccess.js'
import { svgLegend } from './svgLegend.js'
import { constants } from './constants.js'
import { downloadCurrentData } from './download.js'

/**
 * @typedef {Object} basemapConfig
 * @property {string} name - name of layer to be displayer in layer control.
 * @property {string} type - either 'tileLayer' or 'wms'.
 * @property {boolean} selected - indicate whether or not this is to be the layer initially selected.
 * @property {string} url - the standard leaflet formatted URL for the layer.
 * @property {Object} opts - standard leaflet layer options.
 */

/**
 * @param {Object} opts - Initialisation options.
 * @param {string} opts.selector - The CSS selector of the element which will be the parent of the leaflet map.
 * @param {string} opts.mapid - The id for the slippy map to be created.
 * @param {boolean} opts.showCountries - Indicates whether or not the map will display Country boundaries.
 * @param {boolean} opts.showVcs - Indicates whether or not the map will display Vice County boundaries.
 * @param {boolean} opts.showVcsTooltips - Indicates whether or not the name and number of the VC should be shown on click.
 * Note that you will need to ensure that the VC has 'fill' style property set to true if you want users to be able to click
 * anywhere within a VC boundary. You can also set the 'fillOpacity' property to 0 if you don't want the fill to be visible.
 * (Note that the default styleVcs properties include these values.)
 * @param {Array.<object>} opts.styleVcs - An array of objects defining styles for VCs at different zoom levels. The properties
 * of each can be any that are meaningful to a path object in Leaflet (https://leafletjs.com/reference.html#path-option). Each
 * object also has an property called 'zoom' which can be set to an array of Leaflet zoom levels. The style properties will
 * only be applied if the map zoom level is in the array. A shortcut to indicating all zoom levels not included in other
 * array members is an empty array. If the property includes only one style object, with the zoom property set to an empty
 * array, then the style properties will be applied at all zoom levels.
 * @param {string} opts.captionId - The id of a DOM element into which feature-specific HTML will be displayed
 * as the mouse moves over a dot on the map. The HTML markup must be stored in an attribute called 'caption'
 * in the input data.
 * @param {number} opts.clusterZoomThreshold - The leaflet zoom level above which clustering will be turned
 * off for point display (except for points in same location) (default 1 - i.e. clustering always one)
 * @param {function} opts.onclick - A function that will be called if user clicks on a map
 * element. The function will be passed these attributes, in this order, if they exist on the
 * element: gr, id, caption. (Default - null.)
 * @param {number} opts.height - The desired height of the leaflet map.
 * @param {number} opts.width - The desired width of the leaflet map.
 * @param {Array.<basemapConfig>} opts.basemapConfigs - An array of map layer configuration objects.
 * @param {Object} opts.mapTypesSel - Sets an object whose properties are data access functions. The property
 * names are the 'keys' which should be human readable descriptiosn of the map types.
 * @param {string} opts.mapTypesKey - Sets the key of the selected data accessor function (map type).
 * @param {legendOpts} opts.legendOpts - Sets options for a map legend.
 * @param {Array.<function>} opts.callbacks - An array of callbacks that can be used during data loading/display.
 * Typically these can be used to display/hide busy indicators.
 * <br/>callbacks[0] is fired at the start of data redraw.
 * <br/>callbacks[1] is fired at the end of data redraw.
 * <br/>callbacks[2] is fired at the start of data download.
 * <br/>callbacks[3] is fired at the end of data download.
 * @returns {module:slippyMap~api} Returns an API for the map.
 */

export function leafletMap({
  // Default options in here
  selector = 'body',
  mapid = 'leafletMap',
  showCountries=false,
  showVcs = false,
  showVcsTooltips = true,
  styleVcs = [
    {zoom: [], color: 'black', fill: true, weight: 2, opacity: 0.4, fillOpacity: 0},
    {zoom: [7,6,5,4,3,2,1], color: 'black', fill: true, weight: 1, opacity: 0.3, fillOpacity: 0}
  ],
  captionId = '',
  clusterZoomThreshold = 19,
  onclick = null,
  height = 500,
  width = 300,
  basemapConfigs = [],
  mapTypesKey = 'Standard hectad',
  mapTypesSel = dataAccessors,
  legendOpts = {display: false},
  callbacks = []
} = {}) {
  let taxonIdentifier, precision
  let dots = {}
  const geojsonLayers = {}
  let markers = null
  const vcs = {mbrs: null, vcs1000: null, vcs100: {}, vcs10: {}, vcsFull: {}}
  const countries = {countries1000: null, countries100: null, countries10: null, countriesFull: null}

  d3.select(selector).append('div')
    .attr('id', mapid)
    .style('width', `${width}px`)
    .style('height', `${height}px`)

  // Create basemaps from config
  let selectedBaselayerName
  const baseMaps = basemapConfigs.reduce((bm, c) => {
    let lyrFn
    if (c.type === 'tileLayer') {
      lyrFn = L.tileLayer
    } else if (c.type === 'wms') {
      lyrFn = L.tileLayer.wms
    } else {
      return bm
    }
    bm[c.name] = lyrFn(c.url, c.opts)
    if (c.selected) {
      selectedBaselayerName = c.name
    }
    return bm
  }, {})
  // If no basemaps configured, provide a default
  if (basemapConfigs.length === 0) {
    baseMaps['OpenStreetMap'] = L.tileLayer ('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
        {
          maxZoom: 19,
          attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        }
      )
  }
  // If no basemap selected, select the first
  if (!selectedBaselayerName) {
    selectedBaselayerName = Object.keys(baseMaps)[0]
  }

  const map = new L.Map(mapid, {center: [55, -4], zoom: 6, layers:[baseMaps[selectedBaselayerName]]})

  map.on("viewreset", redraw) // Not firing on current version - seems to be a bug
  map.on("zoomstart", () => {
    //console.log("zoom start")
    svg.style('display', 'none')
  })
  map.on("zoomend", () => {
    //console.log("zoom end")
    redrawCountries()
    redraw()
  })
  map.on("moveend", () => {
    //console.log("move end")
    redrawVcs()
    redraw()
  })
  map.zoomControl.setPosition('topright')

  // Record the currently selected basemap layer
  map.on('baselayerchange', function (e) {
    selectedBaselayerName = e.name
  })

  // Add layer selection control to map if there is more than one layer
  let mapLayerControl
  if (basemapConfigs.length > 0) {
    mapLayerControl = L.control.layers(baseMaps).addTo(map)
  }

  // Legend custom control
  L.Control.Legend = L.Control.extend({
    onAdd: function() {
        const div = L.DomUtil.create('div', 'legendDiv leaflet-control leaflet-bar')
        return div
    },
    onRemove: function() {
    }
  });
  L.control.Legend = function(opts) {
      return new L.Control.Legend(opts)
  }
  L.control.Legend({ position: 'topleft' }).addTo(map)

  function projectPoint(x, y) {
    const point = map.latLngToLayerPoint(new L.LatLng(y, x))
    this.stream.point(point.x, point.y)
  }
  const transform = d3.geoTransform({point: projectPoint})
  const path = d3.geoPath().projection(transform)

  map.createPane('atlaspane')
  map.getPane('atlaspane').style.zIndex = 650
  const svg = d3.select(map.getPane('atlaspane')).append("svg")
  svg.attr('id', 'atlas-leaflet-svg')
  // Added overflow visible to svg (02/09/2021) because it was found to fix a very odd problem - svg graphics not
  // visible in ESB atlas but only on Firefox on Windows.
  svg.style('overflow', 'visible')
  //const svg = d3.select(map.getPanes().overlayPane).append("svg")

  // Necessary to set SVG pointer events to none otherwise pointer events
  // do not propagate to layers below (e.g. VCs). This does not interfer if a onclick config
  // is used to set an event on feature click.
  svg.style('pointer-events', 'none')

  // Dont use the leaflet class leaflet-zoom-hide because we are handling
  // the hide/display of SVG layer ourselves so that it is only redisplayed
  // once dots have been regenerated (because it is quite slow)
  const g = svg.append("g") //.attr("class", "leaflet-zoom-hide")

  // Create pane for Vice Counties
  map.createPane('vcpane')
  map.getPane('vcpane').style.zIndex = 649

  // Initiate VC and Country display
  redrawVcs()
  redrawCountries()

  function pointMarkers() {
    // Hide the SVG (atlas elements)
    d3.select(`#${mapid}`).select('.legendDiv').style('display', 'none')
    svg.style('display', 'none')

    // Remove any previous
    if (markers) {
      map.removeLayer(markers)
      //console.log('removing')
    }

    //console.log('remaking', clusterZoomThreshold)

    markers = L.markerClusterGroup({ maxClusterRadius: function (zoom) {
      return (zoom <= clusterZoomThreshold) ? 80 : 1; // radius in pixels
    }})
    dots.p0.records.forEach(f => {
      // Allowed colours: https://awesomeopensource.com/project/pointhi/leaflet-color-markers
      const iconColour=f.colour ? f.colour : dots.p0.colour
      const icon = new L.Icon({
        iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-${iconColour}.png`,
        shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
        iconSize: [25, 41],
        iconAnchor: [12, 41],
        popupAnchor: [1, -34],
        shadowSize: [41, 41]
      })
      const marker = L.marker(L.latLng(f.lat, f.lng), {icon: icon, id: f.id, gr: f.gr, caption: f.caption})
      markers.addLayer(marker)
    })
    map.addLayer(markers)
    if (onclick){
      markers.on("click", function (event) {
        const p = event.layer.options
        onclick(p.gr, p.id ? p.id : null, p.caption ? p.caption : null)
        //console.log(event.layer.options)
      })
    }
  }

  function redraw() {

    // redraw and yieldRedraw are separated into two separate
    // functions so that callbacks[0] can be called before
    // called the rest of the code asynchronously. This is
    // required in order to yield control to event queue so that
    // if callbacks[0] updates gui (e.g. to show busy indicator)
    // it happens before rest of code executed.

    const deg5km = 0.0447
    let data, buffer

    if (precision===10000) {
      data = dots.p10000
      buffer = deg5km * 1.5
    } else if (precision===5000) {
      data = dots.p5000
      buffer = deg5km * 0.75
    } else if (precision===2000) {
      data = dots.p2000
      buffer = deg5km / 4
    } else if (precision===1000) {
      data = dots.p1000
      buffer = deg5km / 2
    } else {
      data = []
      buffer = 0
    }

    legendOpts.accessorData = data.legend
    if (!(legendOpts.display && (legendOpts.data || legendOpts.accessorData))) {
    //if (!legendOpts || !legendOpts.data || !legendOpts.data.lines || !legendOpts.data.lines.length) {
      d3.select(`#${mapid}`).select('.legendDiv').style('display', 'none')
    } else {
      if (legendOpts.display) {
        d3.select(`#${mapid}`).select('.legendDiv').style('display', 'block')
      } else {
        d3.select(`#${mapid}`).select('.legendDiv').style('display', 'none')
      }
    }

    if (!data) {
      data = {}
    }
    if (!data.records) {
      data.records = []
    }
    if (callbacks[0]) callbacks[0]()
    setTimeout(() => yieldRedraw(data, buffer), 50)
  }

  function yieldRedraw(data, buffer) {

    // Hide point markers
    if (markers && precision!==0) {
      map.removeLayer(markers)
    }

    const view = map.getBounds()

    const filteredData = data.records.filter(function(d){
      if (d.lng  < view._southWest.lng - buffer ||
          d.lng > view._northEast.lng + buffer ||
          d.lat  < view._southWest.lat - buffer ||
          d.lat > view._northEast.lat + buffer) {
        return false
      } else {
        if (!d.geometry) {
          if (precision!==0) {
            const shape = d.shape ? d.shape : data.shape
            const size = d.size ? d.size : data.size
            d.geometry = getGjson(d.gr, 'wg', shape, size)
          }
        }
        return true
      }
    })

    //console.log(filteredData)

    if (precision!==0) {
      // Atlas data - goes onto an SVG where D3 can work with it

      const bounds = path.bounds({
        type: "FeatureCollection",
        features: filteredData.map(d => {
          return {
            type: "Feature",
            geometry: d.geometry
          }
        })
      })

      const topLeft = bounds[0]
      const bottomRight = bounds[1]

      if (isFinite(topLeft) && isFinite(bottomRight)) {
        // These values are not finite if no data specified
        svg.attr("width", bottomRight[0] - topLeft[0])
          .attr("height", bottomRight[1] - topLeft[1])
          .style("left", topLeft[0] + "px")
          .style("top", topLeft[1] + "px")

        g.attr("transform", "translate(" + -topLeft[0] + "," + -topLeft[1] + ")")
      }
      // I can't find a way of dealing with paths and circles in the same
      // enter/update statement. (There may be a way using d3 symbols for
      // creating the circles, but sizing would need work.) So instead
      // we do two enter/update statements - one for circles and one for
      // paths, but it means that we have to repeat all the common code
      // for setting properties, event handlers etc.

      // Separate data rendered with path and data rendered with Circle
      const filteredDataPath = filteredData.filter(d => {
        const shape = d.shape ? d.shape : data.shape
        return shape!=='circlerad'
      })
      const filteredDataCircle = filteredData.filter(d => {
        const shape = d.shape ? d.shape : data.shape
        return shape==='circlerad'
      })

      // Create promises to be resolved when the circles and paths
      // have been redrawn.
      let pRedrawPath, pRedrawCircle
      const up = g.selectAll("path")
        .data(filteredDataPath, function(d) {
          return d.gr
        })
      up.exit()
        .remove()

      if (filteredDataPath.length) {

        pRedrawPath = up.enter()
          .append("path")
          .style("pointer-events", "all")
          .style("cursor", () => {
            if (onclick) {
              return 'pointer'
            }
          })
          .on('click', (a1,a2) => {
            let d
            if(a1.type === 'click') {
              d=a2
            } else {
              d=a1
            }
            if (onclick) {
              onclick(d.gr, d.id ? d.id : null, d.caption ? d.caption : null)
            }
          })
          .on('mouseover', (a1,a2) => {
            let d
            if(a1.type === 'mouseover') {
              d=a2
            } else {
              d=a1
            }
            if (captionId) {
              if (d.caption) {
                d3.select(`#${captionId}`).html(d.caption)
              } else {
                d3.select(`#${captionId}`).html('')
              }
            }
          })
          .on('mouseout', (a1,a2) => {
            let d
            if(a1.type === 'mouseout') {
              d=a2
            } else {
              d=a1
            }
            if (captionId) {
              d3.select(`#${captionId}`).html(d.noCaption ? d.noCaption : '')
            }
          })
        .merge(up)
          .transition().duration(0) // Required in order to use .end promise
          .attr("d", d => {
            return path(d.geometry)
          })
          .attr("fill-opacity", d => d.opacity ? d.opacity : data.opacity)
          .attr("fill", d => d.colour ? d.colour : data.colour)
          .attr("stroke", 'black')
          .end().catch(() => null) // Catch error which comes from interrupted transition
      } else {
        pRedrawPath = Promise.resolve()
      }

      const uc = g.selectAll("circle")
        .data(filteredDataCircle, function(d) {
          return d.gr
        })
      uc.exit()
        .remove()

      if (filteredDataCircle.length) {
        // Because of projection, the radii of the circles can end up
        // with several values which looks wrong, so we set all to the
        // maximum value.
        let rad = filteredDataCircle.reduce((max, d) => {
          const c0 = d.geometry.coordinates[0][0]
          const c1 = d.geometry.coordinates[0][1]
          const x0 = map.latLngToLayerPoint(new L.LatLng(c0[1], c0[0])).x
          const x1 = map.latLngToLayerPoint(new L.LatLng(c1[1], c1[0])).x
          const rad = Math.floor(Math.abs(x1 - x0))
          return rad > max ? rad : max
        }, 0)
        if (rad === 0) rad=1

        // Update the features

        pRedrawCircle = uc.enter()
          .append("circle")
          .style("pointer-events", "all")
          .style("cursor", () => {
            if (onclick) {
              return 'pointer'
            }
          })
          .on('click', d => {
            if (onclick) {
              onclick(d.gr, d.id ? d.id : null, d.caption ? d.caption : null)
            }
          })
          .on('mouseover', d => {
            if (captionId) {
              if (d.caption) {
                d3.select(`#${captionId}`).html(d.caption)
              } else {
                d3.select(`#${captionId}`).html('')
              }
            }
          })
        .merge(uc)
          .transition().duration(0) // Required in order to use .end promise
          .attr("cx", d => {
            return  map.latLngToLayerPoint(new L.LatLng(d.lat, d.lng)).x
          })
          .attr("cy", d => {
            return map.latLngToLayerPoint(new L.LatLng(d.lat, d.lng)).y
          })
          .attr("r", d => rad * d.size)
          .attr("fill-opacity", d => d.opacity ? d.opacity : data.opacity)
          .attr("fill", d => d.colour ? d.colour : data.colour)
          .attr("stroke", 'black')
          .end().catch(() => null) // Catch error which comes from interrupted transition
      } else {
        pRedrawCircle = Promise.resolve()
      }

      pRedrawPath.then(() => {
        //console.log("Paths complete")
      })

      pRedrawCircle.then(() => {
        //console.log("Circles complete")
      })

      Promise.allSettled([pRedrawPath, pRedrawCircle]).then(() => {
        //console.log("Paths and circles complete")
        // callback[1] is fired at the end of data display
        // can be used to hide a busy indicator.
        if (callbacks[1]) callbacks[1]()

        // Redisplay the SVG
        if (precision===0) {
          svg.style('display', 'none')
        } else {
          svg.style('display', 'block')
        }
      })
    }
  }

  function redrawCountries() {

    //console.log('showCountries', showCountries, countries)

    const zoom = map.getZoom()
    const root = constants.thisCdn

    if (showCountries && zoom < 7) {
      if (!countries.countries1000) {
        countries.countries1000 = 'loading'
        d3.json(`${root}/assets/country/countries-4326-2.geojson`)
          .then(data => {
            countries.countries1000 = geojsonCountries(data)
          })
      } else if (countries.countries1000 !== 'loading') {
        if (!map.hasLayer(countries.countries1000)) {
          countries.countries1000.addTo(map)
        }
      }
    } else if (countries.countries1000 !== 'loading') {
      if (map.hasLayer(countries.countries1000)) {
        map.removeLayer(countries.countries1000)
      }
    }

    if (showCountries && zoom >= 7 && zoom < 10)  {
      if (!countries.countries100) {
        countries.countries100 = 'loading'
        d3.json(`${root}/assets/country/countries-4326-5.geojson`)
          .then(data => {
            countries.countries100 = geojsonCountries(data)
          })
      } else if (countries.countries100 !== 'loading') {
        if (!map.hasLayer(countries.countries100)) {
          countries.countries100.addTo(map)
        }
      }
    } else if (countries.countries100 !== 'loading') {
      if (map.hasLayer(countries.countries100)) {
        map.removeLayer(countries.countries100)
      }
    }

    if (showCountries && zoom >= 10 && zoom < 12)  {
      if (!countries.countries10) {
        countries.countries10 = 'loading'
        d3.json(`${root}/assets/country/countries-4326-25.geojson`)
          .then(data => {
            countries.countries10 = geojsonCountries(data)
          })
      } else if (countries.countries10 !== 'loading') {
        if (!map.hasLayer(countries.countries10)) {
          countries.countries10.addTo(map)
        }
      }
    } else if (countries.countries10 !== 'loading') {
      if (map.hasLayer(countries.countries10)) {
        map.removeLayer(countries.countries10)
      }
    }

    if (showCountries && zoom >= 12)  {
      if (!countries.countriesFull) {
        countries.countriesFull = 'loading'
        d3.json(`${root}/assets/country/countries-4326-80.geojson`)
          .then(data => {
            countries.countriesFull = geojsonCountries(data)
          })
      } else if (countries.countriesFull !== 'loading') {
        if (!map.hasLayer(countries.countriesFull)) {
          countries.countriesFull.addTo(map)
        }
      }
    } else if (countries.countriesFull !== 'loading') {
      if (map.hasLayer(countries.countriesFull)) {
        map.removeLayer(countries.countriesFull)
      }
    }

    function geojsonCountries(data) {

      return L.geoJSON(data,
        {
          pane: 'vcpane',
          style: getStyle()
        }
      ).addTo(map)
    }
  }

  function redrawVcs() {

    //console.log(map.getZoom())
    const root = constants.thisCdn

    // Load the VC mbr file if not already
    if (showVcs) {
      if (!vcs.mbrs) {
        const mbrFile = `${root}/assets/vc/mbrs.csv`
        d3.csv(mbrFile, vc => {
          return {
            vc: vc.vc,
            _southWest: {
              lat: Number(vc.lllat),
              lng: Number(vc.lllon),
            },
            _northEast: {
              lat: Number(vc.urlat),
              lng: Number(vc.urlon),
            }
          }
        }).then(data => {
          vcs.mbrs = data
          displayVcs()
        })
      } else {
        displayVcs()
      }
    } else {
      // Remove any VCs currently displayed
      if (map.hasLayer(vcs.vcs1000)) {
        map.removeLayer(vcs.vcs1000)
      }
      Object.keys(vcs.vcs100).forEach(vc => {
        if (map.hasLayer(vcs.vcs100[vc])) {
          map.removeLayer(vcs.vcs100[vc])
        }
      })
      Object.keys(vcs.vcs10).forEach(vc => {
        if (map.hasLayer(vcs.vcs10[vc])) {
          map.removeLayer(vcs.vcs10[vc])
        }
      })
      Object.keys(vcs.vcsFull).forEach(vc => {
        if (map.hasLayer(vcs.vcsFull[vc])) {
          map.removeLayer(vcs.vcsFull[vc])
        }
      })
    }

    function displayVcs() {
      const zoom = map.getZoom()

      // Because the d3.json load is asynchronous, can be
      // kicked off more than once for same file so we
      // use the 'loading' flag to prevent this.

      if (zoom < 7) {
        if (!vcs.vcs1000) {
          vcs.vcs1000 = 'loading'
          d3.json(`${root}/assets/vc/vcs-4326-1000.geojson`)
            .then(data => {
              vcs.vcs1000 = geojsonVcs(data)
            })
        } else if (vcs.vcs1000 !== 'loading') {
          if (!map.hasLayer(vcs.vcs1000)) {
            vcs.vcs1000.addTo(map)
          }
        }
      } else if (vcs.vcs1000 !== 'loading') {
        if (map.hasLayer(vcs.vcs1000)) {
          map.removeLayer(vcs.vcs1000)
        }
      }

      if (zoom >= 7 && zoom < 10)  {

        vcsInView().forEach(vc => {
          if (!vcs.vcs100[vc]) {
            vcs.vcs100[vc] = 'loading'
            d3.json(`${root}/assets/vc/100/${vc}.geojson`)
              .then(data => {
                vcs.vcs100[vc] = geojsonVcs(data)
              })
          } else if (vcs.vcs100[vc] !== 'loading'){
            if (!map.hasLayer(vcs.vcs100[vc])) {
              vcs.vcs100[vc].addTo(map)
            }
          }
        })
      } else {
        Object.keys(vcs.vcs100).forEach(vc => {
          if (vcs.vcs100[vc] !== 'loading') {
            if (map.hasLayer(vcs.vcs100[vc])) {
              map.removeLayer(vcs.vcs100[vc])
            }
          }
        })
      }

      if (zoom >= 10 && zoom < 12)  {
        //console.log('VCs simpified ten')
        vcsInView().forEach(vc => {
          if (!vcs.vcs10[vc]) {
            vcs.vcs10[vc] = 'loading'
            d3.json(`${root}/assets/vc/10/${vc}.geojson`)
              .then(data => {
                vcs.vcs10[vc] = geojsonVcs(data)
              })
          } else if (vcs.vcs10[vc] !== 'loading') {
            if (!map.hasLayer(vcs.vcs10[vc])) {
              vcs.vcs10[vc].addTo(map)
            }
          }
        })
      } else {
        Object.keys(vcs.vcs10).forEach(vc => {
          if (vcs.vcs10[vc] !== 'loading') {
            if (map.hasLayer(vcs.vcs10[vc])) {
              map.removeLayer(vcs.vcs10[vc])
            }
          }
        })
      }

      if (zoom >= 12)  {
        //console.log('VCs full res')
        vcsInView().forEach(vc => {
          if (!vcs.vcsFull[vc]) {
            vcs.vcsFull[vc] = 'loading'
            d3.json(`${root}/assets/vc/full/${vc}.geojson`)
              .then(data => {
                vcs.vcsFull[vc] = geojsonVcs(data)
              })
          } else if (vcs.vcsFull[vc] !== 'loading') {
            if (!map.hasLayer(vcs.vcsFull[vc])) {
              vcs.vcsFull[vc].addTo(map)
            }
          }
        })
      } else {
        Object.keys(vcs.vcsFull).forEach(vc => {
          if (vcs.vcsFull[vc] !== 'loading') {
            if (map.hasLayer(vcs.vcsFull[vc])) {
              map.removeLayer(vcs.vcsFull[vc])
            }
          }
        })
      }

      // Reset styles depending on zoom level
      if (vcs.vcs1000 !== 'loading' && map.hasLayer(vcs.vcs1000)) {
        vcs.vcs1000.setStyle(getStyle())
      }
      Object.keys(vcs.vcs100).forEach(vc => {
        if (vcs.vcs100[vc] !== 'loading' && map.hasLayer(vcs.vcs100[vc])) {
          vcs.vcs100[vc].setStyle(getStyle())
        }
      })
      Object.keys(vcs.vcs10).forEach(vc => {
        if (vcs.vcs10[vc] !== 'loading' && map.hasLayer(vcs.vcs10[vc])) {
          vcs.vcs10[vc].setStyle(getStyle())
        }
      })
      Object.keys(vcs.vcsFull).forEach(vc => {
        if (vcs.vcsFull[vc] !== 'loading' && map.hasLayer(vcs.vcsFull[vc])) {
          vcs.vcsFull[vc].setStyle(getStyle())
        }
      })
    }

    function geojsonVcs(data) {
      let fn = null
      if (showVcsTooltips) {
        fn = (f, l) => {
          return l.bindPopup(`VC: <b>${f.properties['CODE']}</b> ${f.properties['NAME']}`)
        }
      }
      return L.geoJSON(data,
        {
          pane: 'vcpane',
          style: getStyle(),
          interactive: showVcsTooltips,
          onEachFeature: fn
        }
      ).addTo(map)
    }
    function vcsInView() {
      return vcs.mbrs.filter(vc => overlaps(vc, map.getBounds())).map(vc => vc.vc)
    }
    function overlaps(v1, v2) {

      //console.log(v1, v2)

      const v1minx = v1._southWest.lng
      const v1maxx = v1._northEast.lng
      const v1miny = v1._southWest.lat
      const v1maxy = v1._northEast.lat

      const v2minx = v2._southWest.lng
      const v2maxx = v2._northEast.lng
      const v2miny = v2._southWest.lat
      const v2maxy = v2._northEast.lat

      // Bottom left corner of v1 overlaps v2
      if (v1minx > v2minx && v1minx < v2maxx && v1miny > v2miny && v1miny < v2maxy) return true

      // Bottom right corner of v1 overlaps v2
      if (v1maxx > v2minx && v1maxx < v2maxx && v1miny > v2miny && v1miny < v2maxy) return true

      // Top right corner of v1 overlaps v2
      if (v1maxx > v2minx && v1maxx < v2maxx && v1maxy > v2miny && v1maxy < v2maxy) return true

      // Top left corner of v1 overlaps v2
      if (v1minx > v2minx && v1minx < v2maxx && v1maxy > v2miny && v1maxy < v2maxy) return true

      // Bottom left corner of v2 overlaps v1
      if (v2minx > v1minx && v2minx < v1maxx && v2miny > v1miny && v2miny < v1maxy) return true

      // Bottom right corner of v2 overlaps v1
      if (v2maxx > v1minx && v2maxx < v1maxx && v2miny > v1miny && v2miny < v1maxy) return true

      // Top right corner of v2 overlaps v1
      if (v2maxx > v1minx && v2maxx < v1maxx && v2maxy > v1miny && v2maxy < v1maxy) return true

      // Top left corner of v2 overlaps v1
      if (v2minx > v1minx && v2minx < v1maxx && v2maxy > v1miny && v2maxy < v1maxy) return true

      // No overlap
      return false
    }
  }

  function getStyle() {
    // Get style where zoom explicity named in one of the
    // style objects zoom arrays.
    let style = styleVcs.find(s => s.zoom.indexOf(map.getZoom())>-1)
    // If not found, then find the style with empty zoom array
    if (!style) {
      style = styleVcs.find(s => s.zoom.length===0)
    }
    return style
  }

/** @function setMapType
  * @param {string} newMapTypesKey - A string which a key used to identify a data accessor function.
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * The data accessor is stored in the mapTypesSel object and referenced by this key.
  */
  function setMapType(newMapTypesKey) {
    mapTypesKey = newMapTypesKey
  }

/** @function setIdentfier
  * @param {string} identifier - A string which identifies some data to
  * a data accessor function.
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * The data accessor function, specified elsewhere, will use this identifier to access
  * the correct data.
  */
  function setIdentfier(identifier) {
    taxonIdentifier = identifier
  }

/** @function redrawMap
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Redraw the map, e.g. after changing map accessor function or map identifier.
  */
  function redrawMap(){

    // callback[2] is fired as start of data download and
    // can be used to show a busy indicator. As data is
    // loaded asychronously, the gui should be updated okay.
    if (callbacks[2]) callbacks[2]()

    const accessFunction = mapTypesSel[mapTypesKey]
    return accessFunction(taxonIdentifier).then(data => {
      if (data && data.records) {
        data.records = data.records.map(d => {
          const ll = getCentroid(d.gr, 'wg').centroid
          d.lat = ll[1]
          d.lng = ll[0]
          return d
        })
        dots[`p${data.precision}`] = data
        precision = data.precision

        //Legend
        legendOpts.accessorData = data.legend
        if (legendOpts.display && (legendOpts.data || legendOpts.accessorData)) {
          const legendSvg = d3.select(selector).append('svg')
          svgLegend(legendSvg, legendOpts)
          const bbox = legendSvg.node().getBBox()
          const w = legendOpts.width ? legendOpts.width : bbox.x + bbox.width + bbox.x
          const h = legendOpts.height ? legendOpts.height : bbox.y + bbox.height + bbox.y
          d3.select(`#${mapid}`).select('.legendDiv').html(`<svg class="legendSvg" width="${w}" height="${h}">${legendSvg.html()}</svg>`)
          legendSvg.remove()
        }
      }

      // callback[3] is fired at the end of data download and
      // can be used to hide a busy indicator.
      if (callbacks[3]) callbacks[3]()

      if (precision===0){
        pointMarkers()
      } else {
        redraw()
      }
    })
  }

/** @function setLegendOpts
  * @param {legendOpts} lo - a legend options object.
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * The legend options object can be used to specify properties of a legend and even the content
  * of the legend itself.
  */
 function setLegendOpts(lo) {
  legendOpts = lo
 }

/** @function clearMap
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Clear the map of dots and legend.
  */
 function clearMap(){
  d3.select(`#${mapid}`).select('.legendDiv').style('display', 'none')
  svg.style('display', 'none')

  // Hide point markers
  if (markers) {
    map.removeLayer(markers)
  }
}

/** @function setSize
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Change the size of the leaflet map.
  * @param {number} width - Width of the map.
  * @param {number} height - Height of the map.
  */
 function setSize(width, height){
  d3.select(`#${mapid}`)
    .style('width', `${width}px`)
    .style('height', `${height}px`)
  map.invalidateSize()
}

 /** @function invalidateSize
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Expose the leaflet map invalidate size method.
  */
  function invalidateSize(){
    map.invalidateSize()
  }

 /** @function addBasemapLayer
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Provides a method to add a basemap layer after the map is created.
  * @param {basemapConfig} config - a configuration object to define the new layer.
  */
  function addBasemapLayer(config){
    if (!baseMaps[config.name]) {
      // Add config to baseMaps
      let lyrFn
      if (config.type === 'tileLayer') {
        lyrFn = L.tileLayer
      } else if (config.type === 'wms') {
        lyrFn = L.tileLayer.wms
      }
      if (lyrFn) {
        baseMaps[config.name] = lyrFn(config.url, config.opts)
        if (Object.keys(baseMaps).length === 2) {
          // This is the second base layer - create mapLayerControl
          mapLayerControl = L.control.layers(baseMaps).addTo(map)
        } else {
          mapLayerControl.addBaseLayer(baseMaps[config.name], config.name)
        }
        if (config.selected) {
          map.removeLayer(baseMaps[selectedBaselayerName])
          map.addLayer(baseMaps[config.name])
        }
      }
    }
  }

 /** @function removeBasemapLayer
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Provides a method to remove a basemap layer after the map is created.
  * @param {string} mapName - the name by which the map layer is identified (appears in layer selection).
  */
  function removeBasemapLayer(mapName){
    if (baseMaps[mapName] && Object.keys(baseMaps).length > 1) {
      map.removeLayer(baseMaps[mapName])
      mapLayerControl.removeLayer(baseMaps[mapName])
      delete baseMaps[mapName]
      if (selectedBaselayerName === mapName) {
        // If the removed layer was previously displayed, then
        // display first basemap.
        map.addLayer(baseMaps[Object.keys(baseMaps)[0]])
      }
      if (Object.keys(baseMaps).length === 1) {
        // Only one base layer - remove mapLayerControl
        mapLayerControl.remove()
      }
    }
  }

 /** @function addGeojsonLayer
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Provides a method to add a geojson layer after the map is created.
  * @param {geojsonConfig} config - a configuration object to define the new layer.
  */
  function addGeojsonLayer(config){

    if (!geojsonLayers[config.name]) {

      if (!config.style) {
        config.style = {
          "color": "blue",
          "weight": 5,
          "opacity": 0.65
        }
      }
      d3.json(config.url).then(data => {
        geojsonLayers[config.name] = L.geoJSON(data, {style: config.style}).addTo(map)
      })
    } else {
      console.log(`Geojson layer with the name ${config.name} is already loaded.`)
    }
  }

 /** @function removeGeojsonLayer
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  * Provides a method to remove a geojson layer after the map is created.
  * @param {string} mapName - the name by which the map layer is identified.
  */
  function removeGeojsonLayer(name){
    if (geojsonLayers[name]) {
      map.removeLayer(geojsonLayers[name])
      delete geojsonLayers[name]
    } else {
      console.log(`Geojson layer with the name ${name} not found.`)
    }
  }


 /** @function showOverlay
  * @description <b>This function allows you to show/hide the leaflet overlay layer (atlas layer)</b>.
  * Provides a method to show/hide the leaflet overlay layer used to display atlas data.
  * @param {boolean} show - Set to true to display the layer, or false to hide it.
  */
  function showOverlay(show) {
    if (show) {
      if (legendOpts.display) {
        d3.select(`#${mapid}`).select('.legendDiv').style('display', 'block')
      } else {
        d3.select(`#${mapid}`).select('.legendDiv').style('display', 'none')
      }
      svg.style('display', 'block')
    } else {
      d3.select(`#${mapid}`).select('.legendDiv').style('display', 'none')
      svg.style('display', 'none')
    }
  }

 /** @function changeClusterThreshold
  * @description <b>This function allows you to change the clustering threshold zoom level for point maps</b>.
  * @param {number} clusterZoomThreshold - The leaflet zoom level above which clustering will be turned off.
  */
  function changeClusterThreshold(level) {
    clusterZoomThreshold = level
    if (precision===0){
      pointMarkers()
    }
  }

/** @function setShowVcs
  * @description <b>This function allows you to change whether or not Vice County boundaries are displayed.</b>.
  * @param {boolean} show - Indicates whether or not to display VCs.
  */
    function setShowVcs(show) {
      showVcs = show
      redrawVcs()
    }

/** @function setShowCountries
  * @description <b>This function allows you to change whether or not Country boundaries are displayed.</b>.
  * @param {boolean} show - Indicates whether or not to display Countries.
  */
 function setShowCountries(show) {
  showCountries = show
  redrawCountries()
}

/** @function downloadData
  * @param {boolean} asGeojson - a boolean value that indicates whether to generate GeoJson (if false, generates CSV).
  * @description <b>This function is exposed as a method on the API returned from the leafletMap function</b>.
  */
  function downloadData(asGeojson){

    const accessFunction = mapTypesSel[mapTypesKey]
    downloadCurrentData(accessFunction(taxonIdentifier), precision, asGeojson)
  }

  /**
   * @typedef {Object} api
   * @property {module:slippyMap~setIdentfier} setIdentfier - Identifies data to the data accessor function.
   * @property {module:slippyMap~setMapType} setMapType - Set the key of the data accessor function.
   * @property {module:slippyMap~setLegendOpts} setLegendOpts - Sets options for the legend.
   * @property {module:slippyMap~redrawMap} redrawMap - Redraw the map.
   * @property {module:slippyMap~clearMap} clearMap - Clear the map.
   * @property {module:slippyMap~setSize} setSize - Reset the size of the leaflet map.
   * @property {module:slippyMap~invalidateSize} invalidateSize - Access Leaflet's invalidateSize method.
   * @property {module:slippyMap~addBasemapLayer} addBasemapLayer - Add a basemap to the map.
   * @property {module:slippyMap~removeBasemapLayer} removeBasemapLayer - Remove a basemap from the map.
   * @property {module:slippyMap~addGeojsonLayer} addGeojsonLayer - Add a geojson layer to the map.
   * @property {module:slippyMap~removeGeojsonLayer} removeGeojsonLayer - Remove a geojson layer from the map.
   * @property {module:slippyMap~showOverlay} showOverlay - Show/hide the overlay layer.
   * @property {module:slippyMap~changeClusterThreshold} changeClusterThreshold - Change the zoom cluster threshold for points.
   * @property {module:slippyMap~setShowVcs} setShowVcs - Set the boolean flag which indicates whether or not to display VCs.
   * @property {module:slippyMap~setShowCountries} setShowCountries - Set the boolean flag which indicates whether or not to display Countries.
   * @property {module:slippyMap~downloadData} downloadData - Download a the map data as a CSV or GeoJson file.
   * @property {module:slippyMap~map} lmap - Returns a reference to the leaflet map object.
   */
  return  {
    setIdentfier: setIdentfier,
    setLegendOpts: setLegendOpts,
    redrawMap: redrawMap,
    setMapType: setMapType,
    clearMap: clearMap,
    setSize: setSize,
    invalidateSize: invalidateSize,
    addBasemapLayer: addBasemapLayer,
    removeBasemapLayer: removeBasemapLayer,
    addGeojsonLayer: addGeojsonLayer,
    removeGeojsonLayer: removeGeojsonLayer,
    showOverlay: showOverlay,
    changeClusterThreshold: changeClusterThreshold,
    setShowVcs: setShowVcs,
    setShowCountries: setShowCountries,
    downloadData: downloadData,
    lmap: map
  }
}