text: /// SETUP /////////////////////////////////////////////////////////////
  // these functions set up specific areas after the boot function
  // created a basic framework. All of these functions should only ever
  // be run once.
  
  window.setupLargeImagePreview = function() {
    $('#portaldetails').on('click', '.imgpreview', function() {
      var img = $(this).find('img')[0];
      var details = $(this).find('div.portalDetails')[0];
      //dialogs have 12px padding around the content
      var dlgWidth = Math.max(img.naturalWidth+24,500);
      // This might be a case where multiple dialogs make sense, for example
      // someone might want to compare images of multiple portals.  But
      // usually we only want to show one version of each image.
      // To support that, we'd need a unique key per portal.  Example, guid.
      // So that would have to be in the html fetched into details.
      if (details) {
        dialog({
          html: '<div style="text-align: center">' + img.outerHTML + '</div>' + details.outerHTML,
          title: $(this).parent().find('h3.title').text(),
          id: 'iitc-portal-image',
          width: dlgWidth,
        });
      } else {
        dialog({
          html: '<div style="text-align: center">' + img.outerHTML + '</div>',
          title: $(this).parent().find('h3.title').text(),
          id: 'iitc-portal-image',
          width: dlgWidth,
        });
      }
    });
  }
  
  // adds listeners to the layer chooser such that a long press hides
  // all custom layers except the long pressed one.
  window.setupLayerChooserSelectOne = function() {
    $('.leaflet-control-layers-overlays').on('click taphold', 'label', function(e) {
      if(!e) return;
      if(!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.type === 'taphold')) return;
      var m = window.map;
  
      var add = function(layer) {
        if(!m.hasLayer(layer.layer)) m.addLayer(layer.layer);
      };
      var rem = function(layer) {
        if(m.hasLayer(layer.layer)) m.removeLayer(layer.layer);
      };
  
      var isChecked = $(e.target).find('input').is(':checked');
      var checkSize = $('.leaflet-control-layers-overlays input:checked').length;
      if((isChecked && checkSize === 1) || checkSize === 0) {
        // if nothing is selected or the users long-clicks the only
        // selected element, assume all boxes should be checked again
        $.each(window.layerChooser._layers, function(ind, layer) {
          if(!layer.overlay) return true;
          add(layer);
        });
      } else {
        // uncheck all
        var keep = $.trim($(e.target).text());
        $.each(window.layerChooser._layers, function(ind, layer) {
          if(layer.overlay !== true) return true;
          if(layer.name === keep) { add(layer);  return true; }
          rem(layer);
        });
      }
      e.preventDefault();
    });
  }
  
  // Setup the function to record the on/off status of overlay layerGroups
  window.setupLayerChooserStatusRecorder = function() {
    // Record already added layerGroups
    $.each(window.layerChooser._layers, function(ind, chooserEntry) {
      if(!chooserEntry.overlay) return true;
      var display = window.map.hasLayer(chooserEntry.layer);
      window.updateDisplayedLayerGroup(chooserEntry.name, display);
    });
  
    // Record layerGroups change
    window.map.on('overlayadd overlayremove', function(e) {
      var display = (e.type === 'overlayadd');
      window.updateDisplayedLayerGroup(e.name, display);
    });
  }
  
  window.setupStyles = function() {
    $('head').append('<style>' +
      [ '#largepreview.enl img { border:2px solid '+COLORS[TEAM_ENL]+'; } ',
        '#largepreview.res img { border:2px solid '+COLORS[TEAM_RES]+'; } ',
        '#largepreview.none img { border:2px solid '+COLORS[TEAM_NONE]+'; } ',
        '#chatcontrols { bottom: '+(CHAT_SHRINKED+22)+'px; }',
        '#chat { height: '+CHAT_SHRINKED+'px; } ',
        '.leaflet-right { margin-right: '+(SIDEBAR_WIDTH+1)+'px } ',
        '#updatestatus { width:'+(SIDEBAR_WIDTH+2)+'px;  } ',
        '#sidebar { width:'+(SIDEBAR_WIDTH + HIDDEN_SCROLLBAR_ASSUMED_WIDTH + 1 /*border*/)+'px;  } ',
        '#sidebartoggle { right:'+(SIDEBAR_WIDTH+1)+'px;  } ',
        '#scrollwrapper  { width:'+(SIDEBAR_WIDTH + 2*HIDDEN_SCROLLBAR_ASSUMED_WIDTH)+'px; right:-'+(2*HIDDEN_SCROLLBAR_ASSUMED_WIDTH-2)+'px } ',
        '#sidebar > * { width:'+(SIDEBAR_WIDTH+1)+'px;  }'].join("\n")
      + '</style>');
  }
  
  window.setupIcons = function() {
    $(['<svg>',
        // Material Icons
  
        // portal_detail_display.js
        '<symbol id="ic_place_24px" viewBox="0 0 24 24">',
          '<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/>',
        '</symbol>',
      '</svg>'].join('\\n')).appendTo('body');
  }
  
  function createDefaultBaseMapLayers() {
    var baseLayers = {};
  
    //OpenStreetMap attribution - required by several of the layers
    osmAttribution = 'Map data © OpenStreetMap contributors';
  
    // MapQuest - http://developer.mapquest.com/web/products/open/map
    // now requires an API key
    //var mqSubdomains = [ 'otile1','otile2', 'otile3', 'otile4' ];
    //var mqTileUrlPrefix = window.location.protocol !== 'https:' ? 'http://{s}.mqcdn.com' : 'https://{s}-s.mqcdn.com';
    //var mqMapOpt = {attribution: osmAttribution+', Tiles Courtesy of MapQuest', maxNativeZoom: 18, maxZoom: 21, subdomains: mqSubdomains};
    //baseLayers['MapQuest OSM'] = new L.TileLayer(mqTileUrlPrefix+'/tiles/1.0.0/map/{z}/{x}/{y}.jpg',mqMapOpt);
  
    // cartodb has some nice tiles too - both dark and light subtle maps - http://cartodb.com/basemaps/
    // (not available over https though - not on the right domain name anyway)
    var cartoAttr = '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <a href="http://cartodb.com/attributions">CartoDB</a>';
    var cartoUrl = 'https://{s}.basemaps.cartocdn.com/{theme}/{z}/{x}/{y}.png';
    baseLayers['CartoDB Dark Matter'] = L.tileLayer(cartoUrl,{attribution:cartoAttr,theme:'dark_all'});
    baseLayers['CartoDB Positron'] = L.tileLayer(cartoUrl,{attribution:cartoAttr,theme:'light_all'});
  
  
    // Google Maps - including ingress default (using the stock-intel API-key)
    baseLayers['Google Default Ingress Map'] = L.gridLayer.googleMutant(
      { type:'roadmap',
        maxZoom: 21,
        backgroundColor: '#0e3d4e',
        styles: [
            { featureType:"all", elementType:"all",
              stylers: [{visibility:"on"}, {hue:"#131c1c"}, {saturation:"-50"}, {invert_lightness:true}] },
            { featureType:"water", elementType:"all",
              stylers: [{visibility:"on"}, {hue:"#005eff"}, {invert_lightness:true}] },
            { featureType:"poi", stylers:[{visibility:"off"}]},
            { featureType:"transit", elementType:"all", stylers:[{visibility:"off"}] }
          ],
      });
    baseLayers['Google Roads'] = L.gridLayer.googleMutant({type:'roadmap', maxZoom: 21});
    var trafficMutant = L.gridLayer.googleMutant({type:'roadmap', maxZoom: 21});
    trafficMutant.addGoogleLayer('TrafficLayer');
    baseLayers['Google Roads + Traffic'] = trafficMutant;
    baseLayers['Google Satellite'] = L.gridLayer.googleMutant({type:'satellite', maxZoom: 21});
    baseLayers['Google Hybrid'] = L.gridLayer.googleMutant({type:'hybrid', maxZoom: 21});
    baseLayers['Google Terrain'] = L.gridLayer.googleMutant({type:'terrain', maxZoom: 21});
  
  
    return baseLayers;
  }
  
  
  window.setupMap = function() {
    $('#map').text('');
  
  
  
  
    // proper initial position is now delayed until all plugins are loaded and the base layer is set
    window.map = new L.Map('map', {
      center: [0,0],
      zoom: 1,
      zoomControl: (typeof android !== 'undefined' && android && android.showZoom) ? android.showZoom() : true,
      minZoom: MIN_ZOOM,
  //    zoomAnimation: false,
      markerZoomAnimation: false,
      bounceAtZoomLimits: false,
      preferCanvas: true // Set to true if Leaflet should draw things using Canvas instead of SVG
    });
    if (L.CRS.S2) { map.options.crs = L.CRS.S2; }
  
    if (L.Path.CANVAS) {
      // for canvas, 2% overdraw only - to help performance
      L.Path.CLIP_PADDING = 0.02;
    } else if (L.Path.SVG) {
      if (L.Browser.mobile) {
        // mobile SVG - 10% ovredraw. might help performance?
        L.Path.CLIP_PADDING = 0.1;
      } else {
        // for svg, 100% overdraw - so we have a full screen worth in all directions
        L.Path.CLIP_PADDING = 1.0;
      }
    }
  
    // add empty div to leaflet control areas - to force other leaflet controls to move around IITC UI elements
    // TODO? move the actual IITC DOM into the leaflet control areas, so dummy <div>s aren't needed
    if(!isSmartphone()) {
      // chat window area
      $(window.map._controlCorners['bottomleft']).append(
        $('<div>').width(708).height(108).addClass('leaflet-control').css({'pointer-events': 'none', 'margin': '0'}));
    }
  
    var addLayers = {};
    var hiddenLayer = [];
  
    portalsFactionLayers = [];
    var portalsLayers = [];
    for(var i = 0; i <= 8; i++) {
      portalsFactionLayers[i] = [L.layerGroup(), L.layerGroup(), L.layerGroup()];
      portalsLayers[i] = L.layerGroup(portalsFactionLayers[i]);
      map.addLayer(portalsLayers[i]);
      var t = (i === 0 ? 'Unclaimed/Placeholder' : 'Level ' + i) + ' Portals';
      addLayers[t] = portalsLayers[i];
      // Store it in hiddenLayer to remove later
      if(!isLayerGroupDisplayed(t, true)) hiddenLayer.push(portalsLayers[i]);
    }
  
    fieldsFactionLayers = [L.layerGroup(), L.layerGroup(), L.layerGroup()];
    var fieldsLayer = L.layerGroup(fieldsFactionLayers);
    map.addLayer(fieldsLayer, true);
    addLayers['Fields'] = fieldsLayer;
    // Store it in hiddenLayer to remove later
    if(!isLayerGroupDisplayed('Fields', true)) hiddenLayer.push(fieldsLayer);
  
    linksFactionLayers = [L.layerGroup(), L.layerGroup(), L.layerGroup()];
    var linksLayer = L.layerGroup(linksFactionLayers);
    map.addLayer(linksLayer, true);
    addLayers['Links'] = linksLayer;
    // Store it in hiddenLayer to remove later
    if(!isLayerGroupDisplayed('Links', true)) hiddenLayer.push(linksLayer);
  
    // faction-specific layers
    // these layers don't actually contain any data. instead, every time they're added/removed from the map,
    // the matching sub-layers within the above portals/fields/links are added/removed from their parent with
    // the below 'onoverlayadd/onoverlayremove' events
    var factionLayers = [L.layerGroup(), L.layerGroup(), L.layerGroup()];
    for (var fac in factionLayers) {
      map.addLayer (factionLayers[fac]);
    }
  
    var setFactionLayersState = function(fac,enabled) {
      if (enabled) {
        if (!fieldsLayer.hasLayer(fieldsFactionLayers[fac])) fieldsLayer.addLayer (fieldsFactionLayers[fac]);
        if (!linksLayer.hasLayer(linksFactionLayers[fac])) linksLayer.addLayer (linksFactionLayers[fac]);
        for (var lvl in portalsLayers) {
          if (!portalsLayers[lvl].hasLayer(portalsFactionLayers[lvl][fac])) portalsLayers[lvl].addLayer (portalsFactionLayers[lvl][fac]);
        }
      } else {
        if (fieldsLayer.hasLayer(fieldsFactionLayers[fac])) fieldsLayer.removeLayer (fieldsFactionLayers[fac]);
        if (linksLayer.hasLayer(linksFactionLayers[fac])) linksLayer.removeLayer (linksFactionLayers[fac]);
        for (var lvl in portalsLayers) {
          if (portalsLayers[lvl].hasLayer(portalsFactionLayers[lvl][fac])) portalsLayers[lvl].removeLayer (portalsFactionLayers[lvl][fac]);
        }
      }
    }
  
    // to avoid any favouritism, we'll put the player's own faction layer first
    if (PLAYER.team == 'RESISTANCE') {
      addLayers['Resistance'] = factionLayers[TEAM_RES];
      addLayers['Enlightened'] = factionLayers[TEAM_ENL];
    } else {
      addLayers['Enlightened'] = factionLayers[TEAM_ENL];
      addLayers['Resistance'] = factionLayers[TEAM_RES];
    }
    if (!isLayerGroupDisplayed('Resistance', true)) hiddenLayer.push (factionLayers[TEAM_RES]);
    if (!isLayerGroupDisplayed('Enlightened', true)) hiddenLayer.push (factionLayers[TEAM_ENL]);
  
    setFactionLayersState (TEAM_NONE, true);
    setFactionLayersState (TEAM_RES, isLayerGroupDisplayed('Resistance', true));
    setFactionLayersState (TEAM_ENL, isLayerGroupDisplayed('Enlightened', true));
  
    // NOTE: these events are fired by the layer chooser, so won't happen until that's created and added to the map
    window.map.on('overlayadd overlayremove', function(e) {
      var displayed = (e.type == 'overlayadd');
      switch (e.name) {
        case 'Resistance':
          setFactionLayersState (TEAM_RES, displayed);
          break;
        case 'Enlightened':
          setFactionLayersState (TEAM_ENL, displayed);
          break;
      }
    });
  
    var baseLayers = createDefaultBaseMapLayers();
  
    window.layerChooser = new L.Control.Layers(baseLayers, addLayers);
  
    // Remove the hidden layer after layerChooser built, to avoid messing up ordering of layers 
    $.each(hiddenLayer, function(ind, layer){
      map.removeLayer(layer);
  
      // as users often become confused if they accidentally switch a standard layer off, display a warning in this case
      $('#portaldetails').html('<div class="layer_off_warning">'
                              +'<p><b>Warning</b>: some of the standard layers are turned off. Some portals/links/fields will not be visible.</p>'
                              +'<a id="enable_standard_layers">Enable standard layers</a>'
                              +'</div>');
  
      $('#enable_standard_layers').on('click', function() {
        $.each(addLayers, function(ind, layer) {
          if (!map.hasLayer(layer)) map.addLayer(layer);
        });
        $('#portaldetails').html('');
      });
  
    });
  
    map.addControl(window.layerChooser);
  
    map.attributionControl.setPrefix('');
    // listen for changes and store them in cookies
    map.on('moveend', window.storeMapPosition);
  
    map.on('moveend', function(e) {
      // two limits on map position
      // we wrap longitude (the L.LatLng 'wrap' method) - so we don't find ourselves looking beyond +-180 degrees
      // then latitude is clamped with the clampLatLng function (to the 85 deg north/south limits)
      var newPos = clampLatLng(map.getCenter().wrap());
      if (!map.getCenter().equals(newPos)) {
        map.panTo(newPos,{animate:false})
      }
    });
  
    // map update status handling & update map hooks
    // ensures order of calls
    map.on('movestart', function() { window.mapRunsUserAction = true; window.requests.abort(); window.startRefreshTimeout(-1); });
    map.on('moveend', function() { window.mapRunsUserAction = false; window.startRefreshTimeout(ON_MOVE_REFRESH*1000); });
  
    // on zoomend, check to see the zoom level is an int, and reset the view if not
    // (there's a bug on mobile where zoom levels sometimes end up as fractional levels. this causes the base map to be invisible)
    map.on('zoomend', function() {
      var z = map.getZoom();
      if (z != parseInt(z))
      {
        console.warn('Non-integer zoom level at zoomend: '+z+' - trying to fix...');
        map.setZoom(parseInt(z), {animate:false});
      }
    });
  
  
    // set a 'moveend' handler for the map to clear idle state. e.g. after mobile 'my location' is used.
    // possibly some cases when resizing desktop browser too
    map.on('moveend', idleReset);
  
    window.addResumeFunction(function() { window.startRefreshTimeout(ON_MOVE_REFRESH*1000); });
  
    // create the map data requester
    window.mapDataRequest = new MapDataRequest();
    window.mapDataRequest.start();
  
    // start the refresh process with a small timeout, so the first data request happens quickly
    // (the code originally called the request function directly, and triggered a normal delay for the next refresh.
    //  however, the moveend/zoomend gets triggered on map load, causing a duplicate refresh. this helps prevent that
    window.startRefreshTimeout(ON_MOVE_REFRESH*1000);
  };
  
  //adds a base layer to the map. done separately from the above, so that plugins that add base layers can be the default
  window.setMapBaseLayer = function() {
    //create a map name -> layer mapping - depends on internals of L.Control.Layers
    var nameToLayer = {};
    var firstLayer = null;
  
    for (i in window.layerChooser._layers) {
      var obj = window.layerChooser._layers[i];
      if (!obj.overlay) {
        nameToLayer[obj.name] = obj.layer;
        if (!firstLayer) firstLayer = obj.layer;
      }
    }
  
    var baseLayer = nameToLayer[localStorage['iitc-base-map']] || firstLayer;
    map.addLayer(baseLayer);
  
    // now we have a base layer we can set the map position
    // (setting an initial position, before a base layer is added, causes issues with leaflet)
    var pos = getPosition();
    map.setView (pos.center, pos.zoom, {reset:true});
  
  
    //event to track layer changes and store the name
    map.on('baselayerchange', function(info) {
      for(i in window.layerChooser._layers) {
        var obj = window.layerChooser._layers[i];
        if (info.layer === obj.layer) {
          localStorage['iitc-base-map'] = obj.name;
          break;
        }
      }
  
      //also, leaflet no longer ensures the base layer zoom is suitable for the map (a bug? feature change?), so do so here
      map.setZoom(map.getZoom());
  
  
    });
  
  
  }
  
  // renders player details into the website. Since the player info is
  // included as inline script in the original site, the data is static
  // and cannot be updated.
  window.setupPlayerStat = function() {
    // stock site updated to supply the actual player level, AP requirements and XM capacity values
    var level = PLAYER.verified_level;
    PLAYER.level = level; //for historical reasons IITC expects PLAYER.level to contain the current player level
  
    var n = window.PLAYER.nickname;
    PLAYER.nickMatcher = new RegExp('\\b('+n+')\\b', 'ig');
  
    var ap = parseInt(PLAYER.ap);
    var thisLvlAp = parseInt(PLAYER.min_ap_for_current_level);
    var nextLvlAp = parseInt(PLAYER.min_ap_for_next_level);
  
    if (nextLvlAp) {
      var lvlUpAp = digits(nextLvlAp-ap);
      var lvlApProg = Math.round((ap-thisLvlAp)/(nextLvlAp-thisLvlAp)*100);
    } // else zero nextLvlAp - so at maximum level(?)
  
    var xmMax = parseInt(PLAYER.xm_capacity);
    var xmRatio = Math.round(PLAYER.energy/xmMax*100);
  
    var cls = PLAYER.team === 'RESISTANCE' ? 'res' : 'enl';
  
  
    var t = 'Level:\t' + level + '\n'
          + 'XM:\t' + PLAYER.energy + ' / ' + xmMax + '\n'
          + 'AP:\t' + digits(ap) + '\n'
          + (nextLvlAp > 0 ? 'level up in:\t' + lvlUpAp + ' AP' : 'Maximum level reached(!)')
          + '\n\Invites:\t'+PLAYER.available_invites
          + '\n\nNote: your player stats can only be updated by a full reload (F5)';
  
    $('#playerstat').html(''
      + '<h2 title="'+t+'">'+level+'&nbsp;'
      + '<div id="name">'
      + '<span class="'+cls+'">'+PLAYER.nickname+'</span>'
      + '<a href="/_ah/logout?continue=https://www.google.com/accounts/Logout%3Fcontinue%3Dhttps://appengine.google.com/_ah/logout%253Fcontinue%253Dhttps://intel.ingress.com/intel%26service%3Dah" id="signout">sign out</a>'
      + '</div>'
      + '<div id="stats">'
      + '<sup>XM: '+xmRatio+'%</sup>'
      + '<sub>' + (nextLvlAp > 0 ? 'level: '+lvlApProg+'%' : 'max level') + '</sub>'
      + '</div>'
      + '</h2>'
    );
  }
  
  window.setupSidebarToggle = function() {
    $('#sidebartoggle').on('click', function() {
      var toggle = $('#sidebartoggle');
      var sidebar = $('#scrollwrapper');
      if(sidebar.is(':visible')) {
        sidebar.hide().css('z-index', 1);
        $('.leaflet-right').css('margin-right','0');
        toggle.html('<span class="toggle open"></span>');
        toggle.css('right', '0');
      } else {
        sidebar.css('z-index', 1001).show();
        window.resetScrollOnNewPortal();
        $('.leaflet-right').css('margin-right', SIDEBAR_WIDTH+1+'px');
        toggle.html('<span class="toggle close"></span>');
        toggle.css('right', SIDEBAR_WIDTH+1+'px');
      }
      $('.ui-tooltip').remove();
    });
  }
  
  window.setupTooltips = function(element) {
    element = element || $(document);
    element.tooltip({
      // disable show/hide animation
      show: { effect: 'none', duration: 0, delay: 350 },
      hide: false,
      open: function(event, ui) {
        // ensure all other tooltips are closed
        $(".ui-tooltip").not(ui.tooltip).remove();
      },
      content: function() {
        var title = $(this).attr('title');
        return window.convertTextToTableMagic(title);
      }
    });
  
    if(!window.tooltipClearerHasBeenSetup) {
      window.tooltipClearerHasBeenSetup = true;
      $(document).on('click', '.ui-tooltip', function() { $(this).remove(); });
    }
  }
  
  window.setupTaphold = function() {
    @@INCLUDERAW:external/taphold.js@@
  }
  
  
  window.setupQRLoadLib = function() {
    @@INCLUDERAW:external/jquery.qrcode.min.js@@
  }
  
  window.setupLayerChooserApi = function() {
    // hide layer chooser if booted with the iitcm android app
    if (typeof android !== 'undefined' && android && android.setLayers) {
      $('.leaflet-control-layers').hide();
    }
  
    //hook some additional code into the LayerControl so it's easy for the mobile app to interface with it
    //WARNING: does depend on internals of the L.Control.Layers code
    window.layerChooser.getLayers = function() {
      var baseLayers = new Array();
      var overlayLayers = new Array();
      
      for (i in this._layers) {
        var obj = this._layers[i];
        var layerActive = window.map.hasLayer(obj.layer);
        var info = {
          layerId: i,
          name: obj.name,
          active: layerActive
        }
        if (obj.overlay) {
          overlayLayers.push(info);
        } else {
          baseLayers.push(info);
        }
      }
  
      var overlayLayersJSON = JSON.stringify(overlayLayers);
      var baseLayersJSON = JSON.stringify(baseLayers);
  
      if (typeof android !== 'undefined' && android && android.setLayers) {
          if(this.androidTimer) clearTimeout(this.androidTimer);
          this.androidTimer = setTimeout(function() {
              this.androidTimer = null;
              android.setLayers(baseLayersJSON, overlayLayersJSON);
          }, 1000);
      }
  
      return {
        baseLayers: baseLayers,
        overlayLayers: overlayLayers
      }
    }
  
    window.layerChooser.showLayer = function(id,show) {
      if (show === undefined) show = true;
      obj = this._layers[id];
      if (!obj) return false;
  
      if(show) {
        if (!this._map.hasLayer(obj.layer)) {
          //the layer to show is not currently active
          this._map.addLayer(obj.layer);
  
          //if it's a base layer, remove any others
          if (!obj.overlay) {
            for(i in this._layers) {
              if (i != id) {
                var other = this._layers[i];
                if (!other.overlay && this._map.hasLayer(other.layer)) this._map.removeLayer(other.layer);
              }
            }
          }
        }
      } else {
        if (this._map.hasLayer(obj.layer)) {
          this._map.removeLayer(obj.layer);
        }
      }
  
      //below logic based on code in L.Control.Layers _onInputClick
      if(!obj.overlay) {
        this._map.setZoom(this._map.getZoom());
        this._map.fire('baselayerchange', {layer: obj.layer});
      }
  
      return true;
    };
  
    var _update = window.layerChooser._update;
    window.layerChooser._update = function() {
      // update layer menu in IITCm
      try {
        if(typeof android != 'undefined')
          window.layerChooser.getLayers();
      } catch(e) {
        console.error(e);
      }
      // call through
      return _update.apply(this, arguments);
    }
    // as this setupLayerChooserApi function is called after the layer menu is populated, we need to also get they layers once
    // so they're passed through to the android app
    try {
      if(typeof android != 'undefined')
        window.layerChooser.getLayers();
    } catch(e) {
      console.error(e);
    }
  }
  
  window.extendLeaflet = function() {
    L.Icon.Default.mergeOptions({
      iconUrl: '@@INCLUDEIMAGE:images/marker-ingress.png@@',
      iconRetinaUrl: '@@INCLUDEIMAGE:images/marker-ingress-2x.png@@',
      shadowUrl: '@@INCLUDEIMAGE:images/marker-shadow.png@@'
    });
    L.Icon.Default.imagePath = ' '; // in order to suppress _detectIconPath (it fails with data-urls)
  
    $(['<svg>',
        // search.js, distance-to-portal.user.js, draw-tools.user.js
        '<symbol id="marker-icon" viewBox="0 0 25 41">',
          '<path d="M1.36241844765,18.67488124675 A12.5,12.5 0 1,1 23.63758155235,18.67488124675 L12.5,40.5336158073 Z" style="stroke:none;" />',
          '<path d="M1.80792170975,18.44788599685 A12,12 0 1,1 23.19207829025,18.44788599685 L12.5,39.432271175 Z" style="stroke:#000000; stroke-width:1px; stroke-opacity: 0.15; fill: none;" />',
          '<path d="M2.921679865,17.8803978722 A10.75,10.75 0 1,1 22.078320135,17.8803978722 L12.5,36.6789095943 Z" style="stroke:#ffffff; stroke-width:1.5px; stroke-opacity: 0.35; fill: none;" />',
          '<path d="M19.86121593215,17.25 L12.5,21.5 L5.13878406785,17.25 L5.13878406785,8.75 L12.5,4.5 L19.86121593215,8.75 Z M7.7368602792,10.25 L17.2631397208,10.25 L12.5,18.5 Z M12.5,13 L7.7368602792,10.25 M12.5,13 L17.2631397208,10.25 M12.5,13 L12.5,18.5 M19.86121593215,17.25 L16.39711431705,15.25 M5.13878406785,17.25 L8.60288568295,15.25 M12.5,4.5 L12.5,8.5" style="stroke:#ffffff; stroke-width:1.25px; stroke-opacity: 1; fill: none;" />',
        '</symbol>',
      '</svg>'].join('\\n')).appendTo('body');
  
    L.DivIcon.ColoredSvg = L.DivIcon.extend({
      options: {
        iconSize: [25, 41],
        iconAnchor: [12, 41],
        className: 'leaflet-div-icon-iitc-generic-marker',
                 // ^ actually any name, just to prevent default
                 // ^ (as it's inappropriately styled)
        svgTemplate: '<svg style="fill: {color}"><use xlink:href="#marker-icon"/></svg>',
        color: '#a24ac3' // for draw-tools:
        // L.divIcon does not use the option `color`, but we store it here to
        // be able to simply retrieve the color for serializing markers
      },
      initialize: function (color, options) {
        L.DivIcon.prototype.initialize.call(this, options);
        if (color) { this.options.color = color; }
        this.options.html = L.Util.template(
          this.options.svgTemplate,
          { color: this.options.color }
        );
      }
    });
    L.divIcon.coloredSvg = function (color, options) {
      return new L.DivIcon.ColoredSvg(color, options);
    };
  
    /* !!This block is commented out as it's unclear if we really need this patch
  
    // See https://github.com/IITC-CE/ingress-intel-total-conversion/issues/122
  
    // use the earth radius value used by the s2 geometry library
    // this library is used in the ingress backend, so distance calculation, etc
    // are far closer if we use the value from that
  
    L.CRS.Earth.R = 6367000;
  
    var s2SphericalMercator = L.Util.extend({}, L.Projection.SphericalMercator, {
      R: window.EARTH_RADIUS,
      bounds: (function () {
        var d = window.EARTH_RADIUS * Math.PI;
        return L.bounds([-d, -d], [d, d]);
      })()
    });
  
    L.CRS.S2 = L.Util.extend({}, L.CRS.Earth, {
      code: 'EPSG:S2',
      projection: s2SphericalMercator,
      transformation: (function () {
        var scale = 0.5 / (Math.PI * s2SphericalMercator.R);
        return L.transformation(scale, 0.5, -scale, 0.5);
      }())
    });
  
    */
  
    // Fix Leaflet: handle touchcancel events in Draggable
    L.Draggable.prototype._onDownOrig = L.Draggable.prototype._onDown;
    L.Draggable.prototype._onDown = function(e) {
      L.Draggable.prototype._onDownOrig.apply(this, arguments);
  
      if(e.type === "touchstart") {
        L.DomEvent.on(document, "touchcancel", this._onUp, this);
      }
    };
  };
  
  // BOOTING ///////////////////////////////////////////////////////////
  
  function boot() {
    if(!isSmartphone()) // TODO remove completely?
      window.debug.console.overwriteNativeIfRequired();
  
    console.log('loading done, booting. Built: @@BUILDDATE@@');
    if(window.deviceID) console.log('Your device ID: ' + window.deviceID);
    window.runOnSmartphonesBeforeBoot();
    window.extendLeaflet();
    window.extractFromStock();
    window.setupIdle();
    window.setupTaphold();
    window.setupStyles();
    window.setupIcons();
    window.setupDialogs();
    window.setupDataTileParams();
    window.setupMap();
    window.setupOMS();
    window.search.setup();
    window.setupRedeem();
    window.setupLargeImagePreview();
    window.setupSidebarToggle();
    window.updateGameScore();
    window.artifact.setup();
    window.ornaments.setup();
    window.setupPlayerStat();
    window.setupTooltips();
    window.chat.setup();
    window.portalDetail.setup();
    window.setupQRLoadLib();
    window.setupLayerChooserSelectOne();
    window.setupLayerChooserStatusRecorder();
    // read here ONCE, so the URL is only evaluated one time after the
    // necessary data has been loaded.
    urlPortalLL = getURLParam('pll');
    if(urlPortalLL) {
      urlPortalLL = urlPortalLL.split(",");
      urlPortalLL = [parseFloat(urlPortalLL[0]) || 0.0, parseFloat(urlPortalLL[1]) || 0.0];
    }
    urlPortal = getURLParam('pguid');
  
    $('#sidebar').show();
  
    if(window.bootPlugins) {
      // check to see if a known 'bad' plugin is installed. If so, alert the user, and don't boot any plugins
      var badPlugins = {
        'arc': 'Contains hidden code to report private data to a 3rd party server: <a href="https://plus.google.com/105383756361375410867/posts/4b2EjP3Du42">details here</a>',
      };
  
      // remove entries from badPlugins which are not installed
      $.each(badPlugins, function(name,desc) {
        if (!(window.plugin && window.plugin[name])) {
          // not detected: delete from the list
          delete badPlugins[name];
        }
      });
  
      // if any entries remain in the list, report this to the user and don't boot ANY plugins
      // (why not any? it's tricky to know which of the plugin boot entries were safe/unsafe)
      if (Object.keys(badPlugins).length > 0) {
        var warning = 'One or more known unsafe plugins were detected. For your safety, IITC has disabled all plugins.<ul>';
        $.each(badPlugins,function(name,desc) {
          warning += '<li><b>'+name+'</b>: '+desc+'</li>';
        });
        warning += '</ul><p>Please uninstall the problem plugins and reload the page. See this <a href="https://iitc.modos189.ru/faq.html">FAQ entry</a> for help.</p><p><i>Note: It is tricky for IITC to safely disable just problem plugins</i></p>';
  
        dialog({
          title: 'Plugin Warning',
          html: warning,
          width: 400
        });
      } else {
        // no known unsafe plugins detected - boot all plugins
        $.each(window.bootPlugins, function(ind, ref) {
          try {
            ref();
          } catch(err) {
            console.error("error starting plugin: index "+ind+", error: "+err);
            debugger;
          }
        });
      }
    }
  
    window.setMapBaseLayer();
    window.setupLayerChooserApi();
  
    window.runOnSmartphonesAfterBoot();
  
    // workaround for #129. Not sure why this is required.
    // setTimeout('window.map.invalidateSize(false);', 500);
  
    window.iitcLoaded = true;
    window.runHooks('iitcLoaded');
  
  
    if (typeof android !== 'undefined' && android && android.bootFinished) {
      android.bootFinished();
    }
  
  }
  
  @@INCLUDERAW:external/load.js@@
  
  try { console.log('Loading included JS now'); } catch(e) {}
  @@INCLUDERAW:external/leaflet-src.js@@
  @@INCLUDERAW:external/L.Geodesic.js@@
  @@INCLUDERAW:external/Leaflet.GoogleMutant.js@@
  @@INCLUDERAW:external/autolink.js@@
  @@INCLUDERAW:external/oms.min.js@@
  
  @@INCLUDERAW:external/jquery-3.3.1.min.js@@
  @@INCLUDERAW:external/jquery-ui-1.12.1.min.js@@
  
  try { console.log('done loading included JS'); } catch(e) {}
  
  $(boot);
  