/*jslint onevar: false, white: false, bitwise: false */
/*global document, window */

// TODO: touch/tslippy3.js has much better internal logic - merge!
var Traveller, Styles, Astrometrics, MapOptions, Map, MapService;

(function() {
  "use strict";


  var LEGACY_STYLES = 1;

  // ======================================================================
  // Static functions to normalize behavior across event models
  // ======================================================================

  var DOMHelpers = {
    addEvent: function(element, type, listener, useCapture) {
      if (element.addEventListener) {
        // Gecko wants 'DOMMouseScroll', WebKit/Presto want 'mousewheel'; do both
        element.addEventListener(type, listener, useCapture);
        if (type === 'mousewheel') { element.addEventListener('DOMMouseScroll', listener, useCapture); }
      }
      else if (element.attachEvent) {
        // IE<9
        element["e" + type + listener] = listener;
        element[type + listener] = function() {
          var e = window.event;
          e.preventDefault = function() { e.returnValue = false; }
          e.stopPropagation = function() { e.cancelBubble = true; }
          element["e" + type + listener](e);
        };
        element.attachEvent("on" + type, element[type + listener]);
      }
    },
    removeEvent: function(element, type, listener, useCapture) {
      if (element.removeEventListener) {
        // Gecko wants 'DOMMouseScroll', WebKit/Presto want 'mousewheel'; do both
        element.removeEventListener(type, listener, useCapture);
        if (type === 'mousewheel') { element.removeEventListener('DOMMouseScroll', listener, useCapture); }
      }
      else if (element.detachEvent) {
        // IE<9
        element.detachEvent("on" + type, element[type + listener]);
        element[type + listener] = null;
        element["e" + type + listener] = null;
      }
    },
    setCapture: function(element) {
      if (element.setCapture) {
        element.setCapture(true);
      }
    },
    focus: function(element) {
      if (element.focus) {
        element.focus();
      }
    },
    releaseCapture: function(element) {
      if (element.releaseCapture) {
        element.releaseCapture();
      }
    },
    globalToLocal: function(x, y, element) {
      while (element) {
        x -= element.offsetLeft - element.scrollLeft;
        y -= element.offsetTop - element.scrollTop;
        element = element.offsetParent;
      }
      return { x: x, y: y };
    }
  };


  // ======================================================================
  // Generic Helpers
  // ======================================================================

  function isCallable(o) {
    return typeof o === 'function';
  }


  // ======================================================================
  // Animation Utilities
  // ======================================================================

  function interpolate(a, b, p) {
    return a * (1.0 - p) + b * p;
  }

  // Time smoothing function - input time is t within duration dur.
  // Acceleration period is a, deceleration period is d.
  //
  // Example:     t_filtered = smooth( t, 1.0, 0.25, 0.25 );
  //
  // Reference:   http://www.w3.org/TR/2005/REC-SMIL2-20050107/smil-timemanip.html
  function smooth(t, dur, a, d) {
    var dacc = dur * a;
    var ddec = dur * d;
    var r = 1 / (1 - a / 2 - d / 2);
    var r_t, tdec, pd;

    if (t < dacc) {
      r_t = r * (t / dacc);
      return t * r_t / 2;
    }
    else if (t <= (dur - ddec)) {
      return r * (t - dacc / 2);
    }
    else {
      tdec = t - (dur - ddec);
      pd = tdec / ddec;

      return r * (dur - dacc / 2 - ddec + tdec * (2 - pd) / 2);
    }
  }

  //
  // dur = total duration (seconds)
  // tick = time between callbacks (milliseconds)
  // smooth = optional smoothing function
  // set onanimate to function called with animation position (0.0 ... 1.0)
  //
  function Animation(dur, tick, smooth) {
    var pos = 0;

    var self = this;
    this.timerid = window.setInterval(function() {
      pos += (tick / 1000) / dur;
      var p = pos;

      if (isCallable(smooth)) {
        p = smooth(p);
      }

      if (isCallable(self.onanimate)) {
        self.onanimate(p);
      }

      // Next tick
      if (p >= 1.0) {
        window.clearInterval(self.timerid);
        if (isCallable(self.oncomplete)) {
          self.oncomplete();
        }
      }

    }, tick);
  }
  Animation.prototype.cancel = function() {
    if (this.timerid) {
      window.clearInterval(this.timerid);
      if (isCallable(this.oncancel)) {
        this.oncancel();
      }
    }
  };


  // ======================================================================
  // Exported Functionality
  // ======================================================================

  //----------------------------------------------------------------------
  // General Traveller stuff
  //----------------------------------------------------------------------

  Traveller = {
    fromHex: function(c) {
      return "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ".indexOf(c.toUpperCase());
    }
  };


  //----------------------------------------------------------------------
  // Enumerated types
  //----------------------------------------------------------------------

  MapOptions = {
    SectorGrid: 0x0001,
    SubsectorGrid: 0x0002,
    SectorsSelected: 0x0004,
    SectorsAll: 0x0008,
    SectorsMask: 0x000c,
    BordersMajor: 0x0010,
    BordersMinor: 0x0020,
    BordersMask: 0x0030,
    NamesMajor: 0x0040,
    NamesMinor: 0x0080,
    NamesMask: 0x00c0,
    WorldsCapitals: 0x0100,
    WorldsHomeworlds: 0x0200,
    WorldsMask: 0x0300,
    RoutesSelectedDeprecated: 0x0400,
    PrintStyleDeprecated: 0x0800,
    CandyStyleDeprecated: 0x1000,
    StyleMaskDeprecated: 0x1800,
    ForceHexes: 0x2000,
    WorldColors: 0x4000,
    FilledBorders: 0x8000,
    Mask: 0xffff
  };

  Styles = {
    Poster: "poster",
    Atlas: "atlas",
    Print: "print",
    Candy: "candy"
  };

  //----------------------------------------------------------------------
  // Astrometric Constants
  //----------------------------------------------------------------------

  Astrometrics = {
    ParsecScaleX: Math.cos(Math.PI / 6), // cos(30)
    ParsecScaleY: 1.0,
    SectorWidth: 32,
    SectorHeight: 40,
    ReferenceHexX: 1, // Reference is at Core 0140
    ReferenceHexY: 40,
    TileWidth: 256,
    TileHeight: 256,
    MinScale: 0.0078125,
    MaxScale: 512
  };

  //----------------------------------------------------------------------
  //
  // Usage:
  //
  //   var map = new Map( document.getElementById("YourMapDiv") );
  //
  //   map.OnScaleChanged   = function() { update scale indicator }
  //   map.OnOptionsChanged = function() { update control panel }
  //   map.OnStyleChanged   = function() { update control panel }
  //   map.OnDisplayChanged = function() { update permalink }
  //   map.OnHover          = function( x, y ) { show data }
  //
  //   var hx = map.GetHexX();
  //   var hy = map.GetHexY();
  //   var x = map.GetX();
  //   var y = map.GetY();
  //   var s = map.GetScale();
  //   var o = map.GetOptions();
  //
  //   map.SetScale( scale, bRefresh );
  //   map.SetOptions( flags, bRefresh );
  //   map.SetStyle( style, bRefresh );
  //   map.SetPosition( x, y );
  //
  //   map.ScaleCenterAtSectorHex( scale, sx, sy, hx, hy );
  //   map.CenterAtSectorHex( sx, sy, hx, hy );
  //   map.Scroll( dx, dy, fAnimate );
  //   map.ZoomIn();
  //   map.ZoomOut();
  //
  //----------------------------------------------------------------------

  Map = function(mapContainer) {
    // For references to "this" within callbacks and closures
    var self = this;

    // Viewport Descriptions
    var PhysicalCenteredAtX = null;
    var PhysicalCenteredAtY = null;
    var LogicalCenteredAtX = null;
    var LogicalCenteredAtY = null;
    var HexCenteredAtX = null;
    var HexCenteredAtY = null;
    var Scale = 2;
    var Options = MapOptions.SectorGrid | MapOptions.SubsectorGrid | MapOptions.SectorsSelected | MapOptions.BordersMajor | MapOptions.BordersMinor | MapOptions.NamesMajor | MapOptions.NamesMinor | MapOptions.WorldsCapitals | MapOptions.WorldsHomeworlds;
    var Style = Styles.Poster;

    //Options = Options | MapOptions.WorldColors;

    // Tiles
    var TileCache;

    // HTML Elements
    var DragContainer;
    var ScrollPane;

    // Markers and Overlays
    var markers = [];
    var overlays = [];

    //----------------------------------------------------------------------
    // Events
    //----------------------------------------------------------------------

    this.OnScaleChanged = null;
    this.OnOptionsChanged = null;
    this.OnDisplayChanged = null;
    this.OnHover = null;

    //----------------------------------------------------------------------
    // Internal Methods
    //----------------------------------------------------------------------

    function sectorHexToLogical(sx, sy, hx, hy) {
      var o = { x: 0, y: 0 };

      // Offset from origin
      o.x = (sx * Astrometrics.SectorWidth) + hx - Astrometrics.ReferenceHexX;
      o.y = (sy * Astrometrics.SectorHeight) + hy - Astrometrics.ReferenceHexY;

      // Offset from the "corner" of the hex
      o.x -= 0.5;
      o.y -= ((hx % 2) === 0) ? 0 : 0.5;

      // Scale to non-homogenous coordinates
      o.x *= Astrometrics.ParsecScaleX;
      o.y *= -Astrometrics.ParsecScaleY;

      // Drop precision (avoid animations, etc)
      o.x = Math.round(o.x * 1000) / 1000;
      o.y = Math.round(o.y * 1000) / 1000;

      return o;

    }

    function makeMarker(marker) {
      if (marker === null) {
        return;
      }

      // TODO: Consider preserving existing item        
      if (marker.element && marker.element.parentNode) {
        marker.element.parentNode.removeChild(marker.element);
        marker.element = null;
      }

      // Compute physical location
      var pt = sectorHexToLogical(marker.sx, marker.sy, marker.hx, marker.hy);
      pt.x = pt.x * Scale;
      pt.y = -pt.y * Scale;

      var div;
      div = document.createElement("div");
      div.className = "marker";
      div.id = marker.id;

      div.style.left = pt.x + "px";
      div.style.top = pt.y + "px";
      div.style.zIndex = marker.z;

      marker.element = div;
      ScrollPane.appendChild(div);
    }


    function makeOverlay(overlay) {
      if (overlay === null) {
        return;
      }

      // TODO: Consider preserving existing item        
      if (overlay.element && overlay.element.parentNode) {
        overlay.element.parentNode.removeChild(overlay.element);
        overlay.element = null;
      }

      // Compute physical location
      var x = overlay.x, y = overlay.y, w = overlay.w, h = overlay.h;
      x *= Scale;
      y *= -Scale;
      w *= Scale;
      h *= Scale;

      var div;
      div = document.createElement("div");
      div.className = "overlay";
      div.id = overlay.id;

      div.style.left = x + "px";
      div.style.top = y + "px";
      div.style.width = w + "px";
      div.style.height = h + "px";
      div.style.zIndex = overlay.z;

      overlay.element = div;
      ScrollPane.appendChild(div);
    }

    // Flush the tiles. Note that this does not refresh them.
    function clearTileCache() {
      TileCache = null;

      var child = ScrollPane.firstChild;
      while (child) {
        var cur = child;
        child = cur.nextSibling;

        if (cur.className === "tile") {
          ScrollPane.removeChild(cur);
        }
      }

      // Reposition markers and overlays
      var i;
      for (i = 0; i < markers.length; i += 1) {
        makeMarker(markers[i]);
      }
      for (i = 0; i < overlays.length; i += 1) {
        makeOverlay(overlays[i]);
      }
    }

    // Load a tile. This does not check the cache.
    // The tile element is returned.
    function makeTile(x, y) {
      var oTile;
      oTile = document.createElement("img");
      oTile.className = "tile";
      oTile.src = "Tile.aspx?x=" + x + "&y=" + y + "&scale=" + Scale + "&options=" + Options + "&style=" + Style;

      oTile.style.position = "absolute";
      oTile.style.left = (Astrometrics.TileWidth * x) + "px";
      oTile.style.top = (Astrometrics.TileHeight * y) + "px";
      oTile.style.width = Astrometrics.TileWidth + "px";
      oTile.style.height = Astrometrics.TileHeight + "px";

      ScrollPane.appendChild(oTile);
      return oTile;
    }


    // Load a tile if it isn't already cached
    function checkMakeTile(x, y) {
      var key = "tile_" + x + "_" + y;

      if (!TileCache) {
        TileCache = {};
      }

      if (!TileCache[key]) {
        TileCache[key] = makeTile(x, y);
      }
    }


    // Based on the current scroll position and scale, make
    // sure the appropriate tiles have been cached
    function updateTiles() {
      var tx = Math.round((-ScrollPane.offsetLeft) / Astrometrics.TileWidth);
      var ty = Math.round((-ScrollPane.offsetTop) / Astrometrics.TileHeight);

      var tw = Math.round(DragContainer.offsetWidth / Astrometrics.TileWidth);
      var th = Math.round(DragContainer.offsetHeight / Astrometrics.TileHeight);

      tx -= 1;
      ty -= 1;
      tw += 2;
      th += 2;

      // TODO: Spiral out from center instead, so region of interest
      // loads first!

      var x, y;
      for (x = 0; x < tw; x += 1) {
        for (y = 0; y < th; y += 1) {
          checkMakeTile(x + tx, y + ty);
        }
      }
    }


    // This places the specified physical coordinate (pixel)
    // at the center of the viewport
    function centerAtPhysical(x, y, force) {
      if (!force && PhysicalCenteredAtX === x && PhysicalCenteredAtY === y) { return; }
      PhysicalCenteredAtX = x;
      PhysicalCenteredAtY = y;

      LogicalCenteredAtX = x / Scale;
      LogicalCenteredAtY = -y / Scale;

      HexCenteredAtX = Math.round((LogicalCenteredAtX / Astrometrics.ParsecScaleX) + 0.5);
      HexCenteredAtY = Math.round((-LogicalCenteredAtY / Astrometrics.ParsecScaleY) + ((HexCenteredAtX % 2 === 0) ? 0.5 : 0));

      if (self.OnDisplayChanged) {
        self.OnDisplayChanged();
      }

      // Center the drag container offset within the map
      ScrollPane.style.left = (-x + DragContainer.offsetWidth / 2) + "px";
      ScrollPane.style.top = (-y + DragContainer.offsetHeight / 2) + "px";

      updateTiles();
    }


    // This places the specified logical coordinate (parsec)
    // at the center of the viewport
    function centerAtLogical(x, y) {
      centerAtPhysical(x * Scale, -y * Scale);
    }


    function refreshDisplay() {
      clearTileCache();
      centerAtLogical(LogicalCenteredAtX, LogicalCenteredAtY);
      updateTiles();
    }


    function shouldAnimateToSectorHex(scale, sx, sy, hx, hy) {
      if (scale !== Scale) {
        return false;
      }

      var oTarget = sectorHexToLogical(sx, sy, hx, hy);

      return (
            (Math.abs(oTarget.x - LogicalCenteredAtX) < Astrometrics.SectorWidth * 2) &&
            (Math.abs(oTarget.y - LogicalCenteredAtY) < Astrometrics.SectorHeight * 2));
    }


    var animation = null;

    // Sets up an animation from the current view location to the
    // specified target view location.

    function animateToSectorHex(sx, sy, hx, hy) {
      if (animation) { animation.cancel(); animation = null; }

      var oTarget = sectorHexToLogical(sx, sy, hx, hy);

      var ox = LogicalCenteredAtX,
            oy = LogicalCenteredAtY,
            tx = oTarget.x,
            ty = oTarget.y;

      if (ox === tx && oy === ty) { return; }

      animation = new Animation(3.0, 40, function(p) {
        return smooth(p, 1.0, 0.1, 0.25);
      });
      animation.onanimate = function(p) {
        centerAtLogical(interpolate(ox, tx, p), interpolate(oy, ty, p));
      };
    }


    //----------------------------------------------------------------------
    // Constructor
    //----------------------------------------------------------------------

    DragContainer = mapContainer;
    DragContainer.style.cursor = "move";

    ScrollPane = document.createElement("div");
    ScrollPane.style.position = "absolute";
    ScrollPane.innerHTML = "";

    DragContainer.appendChild(ScrollPane);

    //----------------------------------------------------------------------
    // Event Handlers
    //----------------------------------------------------------------------


    DOMHelpers.addEvent(window, 'resize', function(e) {
      if (PhysicalCenteredAtX !== null && PhysicalCenteredAtY !== null) {
        centerAtPhysical(PhysicalCenteredAtX, PhysicalCenteredAtY, true);
      }
    });


    // Hover
    var hover_x = 0, hover_y = 0;
    DOMHelpers.addEvent(DragContainer, 'mousemove', function(e) {
      if (!self.OnHover) {
        return;
      }

      // Compute the physical coordinates
      var px = PhysicalCenteredAtX + e.clientX - DragContainer.offsetLeft - DragContainer.offsetWidth / 2;
      var py = PhysicalCenteredAtY + e.clientY - DragContainer.offsetTop - DragContainer.offsetHeight / 2;

      // Convert to logical coordinates
      var lx = px / Scale;
      var ly = -py / Scale;

      // Convert to hex coordinates
      var hx = Math.round((lx / Astrometrics.ParsecScaleX) + 0.5);
      var hy = Math.round((-ly / Astrometrics.ParsecScaleY) + ((hx % 2 === 0) ? 0.5 : 0));

      // Throttle the events
      if (hover_x !== hx || hover_y !== hy) {
        hover_x = hx;
        hover_y = hy;

        self.OnHover(hx, hy);
      }

      e.preventDefault();
      e.stopPropagation();
    });


    // Dragging
    var dragging, drag_x, drag_y;

    DOMHelpers.addEvent(DragContainer, 'mousedown', function(e) {
      DOMHelpers.focus(DragContainer);

      if (animation) { animation.cancel(); animation = null; }

      dragging = true;
      drag_x = e.clientX;
      drag_y = e.clientY;
      DOMHelpers.setCapture(document);

      e.preventDefault();
      e.stopPropagation();
    }, true);


    DOMHelpers.addEvent(document, 'mousemove', function(e) {
      if (dragging) {
        var dx = drag_x - e.clientX;
        var dy = drag_y - e.clientY;

        self.Scroll(dx, dy);

        drag_x = e.clientX;
        drag_y = e.clientY;
        e.preventDefault();
        e.stopPropagation();
      }
    }, true);

    DOMHelpers.addEvent(document, 'mouseup', function(e) {
      if (dragging) {
        dragging = false;
        DOMHelpers.releaseCapture(document);
        e.preventDefault();
        e.stopPropagation();
      }
    });


    DOMHelpers.addEvent(DragContainer, 'click', function(e) {
      e.preventDefault();
      e.stopPropagation();
    });


    DOMHelpers.addEvent(DragContainer, 'dblclick', function(e) {
      if (animation) { animation.cancel(); animation = null; }

      // Center the drag container offset within the map

      var coords = DOMHelpers.globalToLocal(e.clientX, e.clientY, DragContainer);      
      var dx = coords.x - DragContainer.offsetWidth / 2;
      var dy = coords.y - DragContainer.offsetHeight / 2;

      self.Scroll(dx, dy);

      if (e.altKey) {
        self.ZoomOut();
      }
      else if (Scale < 64) {
        // Don't zoom in past the "maximum detail" setting
        self.ZoomIn();
      }

      drag_x = e.clientX;
      drag_y = e.clientY;

      e.preventDefault();
      e.stopPropagation();
    });


    DOMHelpers.addEvent(DragContainer, 'mousewheel', function(e) {
      var delta = e.detail ? e.detail * -40 : e.wheelDelta;

      if (animation) { animation.cancel(); animation = null; }

      if (delta < 0) {
        self.ZoomOut();
      }
      else if (delta > 0) {
        self.ZoomIn();
      }

      e.preventDefault();
      e.stopPropagation();
    });


    DOMHelpers.addEvent(DragContainer, 'keydown', function(e) {
      if (e.ctrlKey || e.altKey || e.metaKey) {
        return;
      }

      var isMoz = navigator.userAgent.indexOf('Gecko/') !== -1,
            VK_I = 73,
            VK_J = 74,
            VK_K = 75,
            VK_L = 76,
            VK_SUBTRACT = isMoz ? 109 : 189,
            VK_EQUALS = isMoz ? 61 : 187;

      switch (e.keyCode) {
        case VK_I: self.Scroll(0, -10); break;
        case VK_J: self.Scroll(-10, 0); break;
        case VK_K: self.Scroll(0, 10); break;
        case VK_L: self.Scroll(10, 0); break;
        case VK_SUBTRACT: self.ZoomOut(); break;
        case VK_EQUALS: self.ZoomIn(); break;
        default: return;
      }

      e.preventDefault();
      e.stopPropagation();
    });


    //----------------------------------------------------------------------
    // Public Methods
    //----------------------------------------------------------------------

    this.GetHexX = function() { return HexCenteredAtX; };
    this.GetHexY = function() { return HexCenteredAtY; };
    this.GetX = function() { return LogicalCenteredAtX; };
    this.GetY = function() { return LogicalCenteredAtY; };
    this.GetScale = function() { return Scale; };
    this.GetOptions = function() { return Options; };
    this.SetOptions = function(options, refresh) {
      if (LEGACY_STYLES) {
        // Handy legacy styles specified in options bits
        if ((options & MapOptions.StyleMaskDeprecated) === MapOptions.PrintStyleDeprecated) {
          this.SetStyle("atlas", refresh);
        }
        else if ((options & MapOptions.StyleMaskDeprecated) === MapOptions.CandyStyleDeprecated) {
          this.SetStyle("candy", refresh);
        }
        options = options & ~MapOptions.StyleMaskDeprecated;
      }

      if (options === Options) {
        return;
      }

      Options = options & MapOptions.Mask;
      clearTileCache();

      if (this.OnOptionsChanged) {
        this.OnOptionsChanged(Options);
      }

      if (refresh) {
        refreshDisplay();
      }
    };

    this.GetStyle = function() { return Style; };
    this.SetStyle = function(style) {
      if (style === Style) {
        return;
      }

      Style = style;
      clearTileCache();

      if (this.OnStyleChanged) {
        this.OnStyleChanged(Style);
      }

      if (refreshDisplay) {
        refreshDisplay();
      }
    };


    // This places the specified Sector, Hex coordinates (parsec)
    // at the center of the viewport, with a specific scale.
    this.ScaleCenterAtSectorHex = function(scale, sx, sy, hx, hy) {
      if (animation) { animation.cancel(); animation = null; }
      //setYouAreHere( sx, sy, hx, hy );

      if (shouldAnimateToSectorHex(scale, sx, sy, hx, hy)) {
        animateToSectorHex(sx, sy, hx, hy);
      }
      else {
        // Clear cache on non-animated transition so we are not
        // boundlessly leaking memory
        // FUTURE: Put an upper limit on the cache size instead
        clearTileCache();

        this.SetScale(scale, false);
        this.CenterAtSectorHex(sx, sy, hx, hy);
      }
    };


    // This places the specified Sector, Hex coordinates (parsec)
    // at the center of the viewport
    this.CenterAtSectorHex = function(sx, sy, hx, hy) {
      var target = sectorHexToLogical(sx, sy, hx, hy);

      centerAtLogical(target.x, target.y);
    };


    // Set the selected scale in pixels/parsec.
    // This causes a refresh, but the currently centered
    // logical coordinates are retained.
    this.SetScale = function(scale, refresh) {
      if (scale < Astrometrics.MinScale) { scale = Astrometrics.MinScale; }
      if (scale > Astrometrics.MaxScale) { scale = Astrometrics.MaxScale; }

      if (scale === Scale) {
        return;
      }

      Scale = scale;
      clearTileCache();

      if (this.OnScaleChanged) {
        this.OnScaleChanged(Scale);
      }

      if (refresh) {
        refreshDisplay();
      }
    };


    // This places the specified coordinate (parsec)
    // at the center of the viewport
    this.SetPosition = function(x, y) {
      centerAtLogical(x, y);

    };


    // Scroll the map view by the specified dx/dy (in pixels)
    this.Scroll = function(dx, dy, fAnimate) {
      if (!fAnimate) {

        centerAtPhysical(PhysicalCenteredAtX + dx, PhysicalCenteredAtY + dy);
      }
      else {

        if (animation) { animation.cancel(); animation = null; }

        var ox = PhysicalCenteredAtX,
                oy = PhysicalCenteredAtY,
                tx = ox + dx,
                ty = oy + dy;

        animation = new Animation(1.0, 40, function(p) {
          return smooth(p, 1.0, 0.1, 0.25);
        });
        animation.onanimate = function(p) {
          centerAtPhysical(interpolate(ox, tx, p), interpolate(oy, ty, p));
        };
      }
    };


    // Zoom in to the next zoom level.
    this.ZoomIn = function() {
      this.SetScale(Scale * 2, true);

    };


    // Zoom out to the next zoom level.
    this.ZoomOut = function() {
      this.SetScale(Scale / 2, true);

    };


    // NOTE: This API is subject to change
    this.TEMP_AddMarker = function(id, sx, sy, hx, hy) {
      var marker = {
        "sx": sx,
        "sy": sy,
        "hx": hx,
        "hy": hy,

        "id": id,
        "z": 9
      };

      markers.push(marker);
      makeMarker(marker);

    };

    // NOTE: This API is subject to change
    this.TEMP_AddOverlay = function(x, y, w, h) {
      var overlay = {
        "x": x,
        "y": y,
        "w": w,
        "h": h,

        "id": "overlay",
        "z": 10
      };

      overlays.push(overlay);
      makeOverlay(overlay);

    };
  };

  // TODO: refactor out common logic

  function service(url, contentType, callback) {
    if (typeof callback !== 'function') { throw new TypeError(); }

    var xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.setRequestHeader("Accept", contentType);
    xhr.onreadystatechange = function() {
      if (xhr.readyState === XMLHttpRequest.DONE) {
        if (xhr.status === 0) {
          return; // aborted, no callback
        } else if (xhr.status === 200) {
          callback(contentType === "application/json" ? JSON.parse(xhr.responseText) : xhr.responseText);
        } else {
          callback(null);
        }
      }
    };
    xhr.send();
    return xhr;
  }

  MapService = {
    coordinates: function(sector, hex, callback) {
      return service(
        'coordinates.aspx?sector=' + encodeURIComponent(sector) + (hex ? '&hex=' + encodeURIComponent(hex) : ''),
        'application/json', callback);
    },

    credits: function(hexX, hexY, callback) {
      return service('Credits.aspx?x=' + encodeURIComponent(hexX) + '&y=' + encodeURIComponent(hexY),
      'application/json', callback);
    },

    search: function(query, callback) {
      return service('Search.aspx?q=' + encodeURIComponent(query),
        'application/json', callback);
    },

    sectorData: function(sector, callback) {
      return service('SEC.aspx?sector=' + encodeURIComponent(sector),
        'text/plain', callback);
    }
  };

} ());

