Map Puzzle

More complex demo, showing a game with draggable polygons. See the draggable polygon demo for a simple demo with draggable polygons.

TypeScript

interface Country {
  bounds: number[][];
  name: string;
  start: string[];
  end: string[];
}

class PuzzleDemo {
  private map_: google.maps.Map;
  private polys_: google.maps.Polygon[] = [];
  private difficulty_ = "Easy";
  private count_ = 0;
  private pieceDiv_: HTMLElement;
  private timeDiv_: HTMLElement;
  private dataLoaded_ = false;
  private NUM_PIECES_ = 10;
  private countries_: Country[] = [];
  private timer_ = 0;
  private START_COLOR_ = "#3c79de";
  private END_COLOR_ = "#037e29";

  constructor(map: google.maps.Map) {
    this.map_ = map;
    this.pieceDiv_ = document.createElement("div");
    this.timeDiv_ = document.createElement("div");
    this.createMenu_();
    this.setDifficultyStyle_();
    this.loadData_();
  }

  private createMenu_() {
    const menuDiv = document.createElement("div");

    menuDiv.style.cssText =
      "margin: 40px 10px; border-radius: 8px; height: 320px; width: 180px;" +
      "background-color: white; font-size: 14px; font-family: Roboto;" +
      "text-align: center; color: grey;line-height: 32px; overflow: hidden";

    const titleDiv = document.createElement("div");

    titleDiv.style.cssText =
      "width: 100%; background-color: #4285f4; color: white; font-size: 20px;" +
      "line-height: 40px;margin-bottom: 24px";
    titleDiv.innerText = "Game Options";

    const pieceTitleDiv = document.createElement("div");

    pieceTitleDiv.innerText = "PIECE:";
    pieceTitleDiv.style.fontWeight = "800";

    const pieceDiv = this.pieceDiv_;

    pieceDiv.innerText = "0 / " + this.NUM_PIECES_;

    const timeTitleDiv = document.createElement("div");

    timeTitleDiv.innerText = "TIME:";
    timeTitleDiv.style.fontWeight = "800";

    const timeDiv = this.timeDiv_;

    timeDiv.innerText = "0.0 seconds";

    const difficultyTitleDiv = document.createElement("div");

    difficultyTitleDiv.innerText = "DIFFICULTY:";
    difficultyTitleDiv.style.fontWeight = "800";

    const difficultySelect = document.createElement("select");

    ["Easy", "Moderate", "Hard", "Extreme"].forEach((level) => {
      const option = document.createElement("option");

      option.value = level.toLowerCase();
      option.innerText = level;
      difficultySelect.appendChild(option);
    });
    difficultySelect.style.cssText =
      "border: 2px solid lightgrey; background-color: white; color: #4275f4;" +
      "padding: 6px;";

    difficultySelect.onchange = () => {
      this.setDifficulty_(difficultySelect.value);
      this.resetGame_();
    };

    const resetDiv = document.createElement("div");

    resetDiv.innerText = "Reset";
    resetDiv.style.cssText =
      "cursor: pointer; border-top: 1px solid lightgrey; margin-top: 18px;" +
      "color: #4275f4; line-height: 40px; font-weight: 800";
    resetDiv.onclick = this.resetGame_.bind(this);
    menuDiv.appendChild(titleDiv);
    menuDiv.appendChild(pieceTitleDiv);
    menuDiv.appendChild(pieceDiv);
    menuDiv.appendChild(timeTitleDiv);
    menuDiv.appendChild(timeDiv);
    menuDiv.appendChild(difficultyTitleDiv);
    menuDiv.appendChild(difficultySelect);
    menuDiv.appendChild(resetDiv);
    this.map_.controls[google.maps.ControlPosition.TOP_LEFT].push(menuDiv);
  }

  render() {
    if (!this.dataLoaded_) {
      return;
    }

    this.start_();
  }

  private loadData_() {
    const xmlhttpRequest = new XMLHttpRequest();

    xmlhttpRequest.onreadystatechange = () => {
      if (
        xmlhttpRequest.status != 200 ||
        xmlhttpRequest.readyState != XMLHttpRequest.DONE
      )
        return;

      this.loadDataComplete_(JSON.parse(xmlhttpRequest.responseText) as any);
    };

    xmlhttpRequest.open(
      "GET",
      "https://storage.googleapis.com/mapsdevsite/json/puzzle.json",
      true
    );
    xmlhttpRequest.send(null);
  }

  private loadDataComplete_(data: Country[]) {
    this.dataLoaded_ = true;
    this.countries_ = data;
    this.start_();
  }

  /**
   * @param {string} difficulty
   * @private
   */
  private setDifficulty_(difficulty: string) {
    this.difficulty_ = difficulty;

    if (this.map_) {
      this.setDifficultyStyle_();
    }
  }

  private setDifficultyStyle_() {
    const styles = {
      easy: [
        {
          stylers: [{ visibility: "off" }],
        },
        {
          featureType: "water",
          stylers: [{ visibility: "on" }, { color: "#d4d4d4" }],
        },
        {
          featureType: "landscape",
          stylers: [{ visibility: "on" }, { color: "#e5e3df" }],
        },
        {
          featureType: "administrative.country",
          elementType: "labels",
          stylers: [{ visibility: "on" }],
        },
        {
          featureType: "administrative.country",
          elementType: "geometry",
          stylers: [{ visibility: "on" }, { weight: 1.3 }],
        },
      ],
      moderate: [
        {
          stylers: [{ visibility: "off" }],
        },
        {
          featureType: "water",
          stylers: [{ visibility: "on" }, { color: "#d4d4d4" }],
        },
        {
          featureType: "landscape",
          stylers: [{ visibility: "on" }, { color: "#e5e3df" }],
        },
        {
          featureType: "administrative.country",
          elementType: "labels",
          stylers: [{ visibility: "on" }],
        },
      ],
      hard: [
        {
          stylers: [{ visibility: "off" }],
        },
        {
          featureType: "water",
          stylers: [{ visibility: "on" }, { color: "#d4d4d4" }],
        },
        {
          featureType: "landscape",
          stylers: [{ visibility: "on" }, { color: "#e5e3df" }],
        },
      ],
      extreme: [
        {
          elementType: "geometry",
          stylers: [{ visibility: "off" }],
        },
      ],
    };

    this.map_.set("styles", styles[this.difficulty_]);
  }

  private resetGame_() {
    this.removeCountries_();
    this.count_ = 0;
    this.setCount_();
    this.startClock_();

    this.addRandomCountries_();
  }

  private setCount_() {
    this.pieceDiv_.innerText = this.count_ + " / " + this.NUM_PIECES_;

    if (this.count_ == this.NUM_PIECES_) {
      this.stopClock_();
    }
  }

  private stopClock_() {
    window.clearInterval(this.timer_);
  }

  private startClock_() {
    this.stopClock_();

    const timeDiv = this.timeDiv_;

    if (timeDiv) timeDiv.textContent = "0.0 seconds";

    const t = new Date();

    this.timer_ = window.setInterval(() => {
      const diff = new Date().getTime() - t.getTime();

      if (timeDiv) timeDiv.textContent = (diff / 1000).toFixed(2) + " seconds";
    }, 100);
  }

  private addRandomCountries_() {
    // Shuffle countries
    this.countries_.sort(() => {
      return Math.round(Math.random()) - 0.5;
    });

    const countries = this.countries_.slice(0, this.NUM_PIECES_);

    for (let i = 0, country; (country = countries[i]); i++) {
      this.addCountry_(country);
    }
  }

  private addCountry_(country: Country) {
    const options = {
      strokeColor: this.START_COLOR_,
      strokeOpacity: 0.8,
      strokeWeight: 2,
      fillColor: this.START_COLOR_,
      fillOpacity: 0.35,
      geodesic: true,
      map: this.map_,
      draggable: true,
      zIndex: 2,
      paths: country.start.map(google.maps.geometry.encoding.decodePath),
    };

    const poly = new google.maps.Polygon(options);

    google.maps.event.addListener(poly, "dragend", () => {
      this.checkPosition_(poly, country);
    });

    this.polys_.push(poly);
  }

  /**
   * Checks that every point in the polygon is inside the bounds.
   */
  private boundsContainsPoly_(
    bounds: number[][],
    poly: google.maps.Polygon
  ): boolean {
    const b = new google.maps.LatLngBounds(
      new google.maps.LatLng(bounds[0][0], bounds[0][1]),
      new google.maps.LatLng(bounds[1][0], bounds[1][1])
    );
    const paths = poly.getPaths().getArray();

    for (let i = 0; i < paths.length; i++) {
      const p = paths[i].getArray();

      for (let j = 0; j < p.length; j++) {
        if (!b.contains(p[j])) {
          return false;
        }
      }
    }
    return true;
  }

  /**
   * Replace a poly with the correct 'end' position of the country.
   */
  private replacePiece_(poly: google.maps.Polygon, country: Country) {
    const options = {
      strokeColor: this.END_COLOR_,
      fillColor: this.END_COLOR_,
      draggable: false,
      zIndex: 1,
      paths: country.end.map(google.maps.geometry.encoding.decodePath),
    };

    poly.setOptions(options);
    this.count_++;
    this.setCount_();
  }

  private checkPosition_(poly: google.maps.Polygon, country: Country) {
    if (this.boundsContainsPoly_(country.bounds, poly)) {
      this.replacePiece_(poly, country);
    }
  }

  private start_() {
    this.setDifficultyStyle_();
    this.resetGame_();
  }

  private removeCountries_() {
    for (let i = 0, poly; (poly = this.polys_[i]); i++) {
      poly.setMap(null);
    }

    this.polys_ = [];
  }
}

function initMap(): void {
  const map = new google.maps.Map(
    document.getElementById("map") as HTMLElement,
    {
      disableDefaultUI: true,
      center: { lat: 10, lng: 60 },
      zoom: 2,
    }
  );

  new PuzzleDemo(map);
}

declare global {
  interface Window {
    initMap: () => void;
  }
}
window.initMap = initMap;

JavaScript

class PuzzleDemo {
  map_;
  polys_ = [];
  difficulty_ = "Easy";
  count_ = 0;
  pieceDiv_;
  timeDiv_;
  dataLoaded_ = false;
  NUM_PIECES_ = 10;
  countries_ = [];
  timer_ = 0;
  START_COLOR_ = "#3c79de";
  END_COLOR_ = "#037e29";
  constructor(map) {
    this.map_ = map;
    this.pieceDiv_ = document.createElement("div");
    this.timeDiv_ = document.createElement("div");
    this.createMenu_();
    this.setDifficultyStyle_();
    this.loadData_();
  }
  createMenu_() {
    const menuDiv = document.createElement("div");

    menuDiv.style.cssText =
      "margin: 40px 10px; border-radius: 8px; height: 320px; width: 180px;" +
      "background-color: white; font-size: 14px; font-family: Roboto;" +
      "text-align: center; color: grey;line-height: 32px; overflow: hidden";

    const titleDiv = document.createElement("div");

    titleDiv.style.cssText =
      "width: 100%; background-color: #4285f4; color: white; font-size: 20px;" +
      "line-height: 40px;margin-bottom: 24px";
    titleDiv.innerText = "Game Options";

    const pieceTitleDiv = document.createElement("div");

    pieceTitleDiv.innerText = "PIECE:";
    pieceTitleDiv.style.fontWeight = "800";

    const pieceDiv = this.pieceDiv_;

    pieceDiv.innerText = "0 / " + this.NUM_PIECES_;

    const timeTitleDiv = document.createElement("div");

    timeTitleDiv.innerText = "TIME:";
    timeTitleDiv.style.fontWeight = "800";

    const timeDiv = this.timeDiv_;

    timeDiv.innerText = "0.0 seconds";

    const difficultyTitleDiv = document.createElement("div");

    difficultyTitleDiv.innerText = "DIFFICULTY:";
    difficultyTitleDiv.style.fontWeight = "800";

    const difficultySelect = document.createElement("select");

    ["Easy", "Moderate", "Hard", "Extreme"].forEach((level) => {
      const option = document.createElement("option");

      option.value = level.toLowerCase();
      option.innerText = level;
      difficultySelect.appendChild(option);
    });
    difficultySelect.style.cssText =
      "border: 2px solid lightgrey; background-color: white; color: #4275f4;" +
      "padding: 6px;";
    difficultySelect.onchange = () => {
      this.setDifficulty_(difficultySelect.value);
      this.resetGame_();
    };

    const resetDiv = document.createElement("div");

    resetDiv.innerText = "Reset";
    resetDiv.style.cssText =
      "cursor: pointer; border-top: 1px solid lightgrey; margin-top: 18px;" +
      "color: #4275f4; line-height: 40px; font-weight: 800";
    resetDiv.onclick = this.resetGame_.bind(this);
    menuDiv.appendChild(titleDiv);
    menuDiv.appendChild(pieceTitleDiv);
    menuDiv.appendChild(pieceDiv);
    menuDiv.appendChild(timeTitleDiv);
    menuDiv.appendChild(timeDiv);
    menuDiv.appendChild(difficultyTitleDiv);
    menuDiv.appendChild(difficultySelect);
    menuDiv.appendChild(resetDiv);
    this.map_.controls[google.maps.ControlPosition.TOP_LEFT].push(menuDiv);
  }
  render() {
    if (!this.dataLoaded_) {
      return;
    }

    this.start_();
  }
  loadData_() {
    const xmlhttpRequest = new XMLHttpRequest();

    xmlhttpRequest.onreadystatechange = () => {
      if (
        xmlhttpRequest.status != 200 ||
        xmlhttpRequest.readyState != XMLHttpRequest.DONE
      )
        return;

      this.loadDataComplete_(JSON.parse(xmlhttpRequest.responseText));
    };

    xmlhttpRequest.open(
      "GET",
      "https://storage.googleapis.com/mapsdevsite/json/puzzle.json",
      true,
    );
    xmlhttpRequest.send(null);
  }
  loadDataComplete_(data) {
    this.dataLoaded_ = true;
    this.countries_ = data;
    this.start_();
  }
  /**
   * @param {string} difficulty
   * @private
   */
  setDifficulty_(difficulty) {
    this.difficulty_ = difficulty;
    if (this.map_) {
      this.setDifficultyStyle_();
    }
  }
  setDifficultyStyle_() {
    const styles = {
      easy: [
        {
          stylers: [{ visibility: "off" }],
        },
        {
          featureType: "water",
          stylers: [{ visibility: "on" }, { color: "#d4d4d4" }],
        },
        {
          featureType: "landscape",
          stylers: [{ visibility: "on" }, { color: "#e5e3df" }],
        },
        {
          featureType: "administrative.country",
          elementType: "labels",
          stylers: [{ visibility: "on" }],
        },
        {
          featureType: "administrative.country",
          elementType: "geometry",
          stylers: [{ visibility: "on" }, { weight: 1.3 }],
        },
      ],
      moderate: [
        {
          stylers: [{ visibility: "off" }],
        },
        {
          featureType: "water",
          stylers: [{ visibility: "on" }, { color: "#d4d4d4" }],
        },
        {
          featureType: "landscape",
          stylers: [{ visibility: "on" }, { color: "#e5e3df" }],
        },
        {
          featureType: "administrative.country",
          elementType: "labels",
          stylers: [{ visibility: "on" }],
        },
      ],
      hard: [
        {
          stylers: [{ visibility: "off" }],
        },
        {
          featureType: "water",
          stylers: [{ visibility: "on" }, { color: "#d4d4d4" }],
        },
        {
          featureType: "landscape",
          stylers: [{ visibility: "on" }, { color: "#e5e3df" }],
        },
      ],
      extreme: [
        {
          elementType: "geometry",
          stylers: [{ visibility: "off" }],
        },
      ],
    };

    this.map_.set("styles", styles[this.difficulty_]);
  }
  resetGame_() {
    this.removeCountries_();
    this.count_ = 0;
    this.setCount_();
    this.startClock_();
    this.addRandomCountries_();
  }
  setCount_() {
    this.pieceDiv_.innerText = this.count_ + " / " + this.NUM_PIECES_;
    if (this.count_ == this.NUM_PIECES_) {
      this.stopClock_();
    }
  }
  stopClock_() {
    window.clearInterval(this.timer_);
  }
  startClock_() {
    this.stopClock_();

    const timeDiv = this.timeDiv_;

    if (timeDiv) timeDiv.textContent = "0.0 seconds";

    const t = new Date();

    this.timer_ = window.setInterval(() => {
      const diff = new Date().getTime() - t.getTime();

      if (timeDiv) timeDiv.textContent = (diff / 1000).toFixed(2) + " seconds";
    }, 100);
  }
  addRandomCountries_() {
    // Shuffle countries
    this.countries_.sort(() => {
      return Math.round(Math.random()) - 0.5;
    });

    const countries = this.countries_.slice(0, this.NUM_PIECES_);

    for (let i = 0, country; (country = countries[i]); i++) {
      this.addCountry_(country);
    }
  }
  addCountry_(country) {
    const options = {
      strokeColor: this.START_COLOR_,
      strokeOpacity: 0.8,
      strokeWeight: 2,
      fillColor: this.START_COLOR_,
      fillOpacity: 0.35,
      geodesic: true,
      map: this.map_,
      draggable: true,
      zIndex: 2,
      paths: country.start.map(google.maps.geometry.encoding.decodePath),
    };
    const poly = new google.maps.Polygon(options);

    google.maps.event.addListener(poly, "dragend", () => {
      this.checkPosition_(poly, country);
    });
    this.polys_.push(poly);
  }
  /**
   * Checks that every point in the polygon is inside the bounds.
   */
  boundsContainsPoly_(bounds, poly) {
    const b = new google.maps.LatLngBounds(
      new google.maps.LatLng(bounds[0][0], bounds[0][1]),
      new google.maps.LatLng(bounds[1][0], bounds[1][1]),
    );
    const paths = poly.getPaths().getArray();

    for (let i = 0; i < paths.length; i++) {
      const p = paths[i].getArray();

      for (let j = 0; j < p.length; j++) {
        if (!b.contains(p[j])) {
          return false;
        }
      }
    }
    return true;
  }
  /**
   * Replace a poly with the correct 'end' position of the country.
   */
  replacePiece_(poly, country) {
    const options = {
      strokeColor: this.END_COLOR_,
      fillColor: this.END_COLOR_,
      draggable: false,
      zIndex: 1,
      paths: country.end.map(google.maps.geometry.encoding.decodePath),
    };

    poly.setOptions(options);
    this.count_++;
    this.setCount_();
  }
  checkPosition_(poly, country) {
    if (this.boundsContainsPoly_(country.bounds, poly)) {
      this.replacePiece_(poly, country);
    }
  }
  start_() {
    this.setDifficultyStyle_();
    this.resetGame_();
  }
  removeCountries_() {
    for (let i = 0, poly; (poly = this.polys_[i]); i++) {
      poly.setMap(null);
    }

    this.polys_ = [];
  }
}

function initMap() {
  const map = new google.maps.Map(document.getElementById("map"), {
    disableDefaultUI: true,
    center: { lat: 10, lng: 60 },
    zoom: 2,
  });

  new PuzzleDemo(map);
}

window.initMap = initMap;

CSS

/* 
 * Always set the map height explicitly to define the size of the div element
 * that contains the map. 
 */
#map {
  height: 100%;
}

/* 
 * Optional: Makes the sample page fill the window. 
 */
html,
body {
  height: 100%;
  margin: 0;
  padding: 0;
}

HTML

<html>
  <head>
    <title>Map Puzzle</title>
    <script src="https://polyfill.io/v3/polyfill.min.js?features=default"></script>

    <link rel="stylesheet" type="text/css" href="./style.css" />
    <script type="module" src="./index.js"></script>
  </head>
  <body>
    <div id="map"></div>

    <!-- 
      The `defer` attribute causes the callback to execute after the full HTML
      document has been parsed. For non-blocking uses, avoiding race conditions,
      and consistent behavior across browsers, consider loading using Promises.
      See https://developers.google.com/maps/documentation/javascript/load-maps-js-api
      for more information.
      -->
    <script
      src="https://maps.googleapis.com/maps/api/js?key=AIzaSyB41DRUbKWJHPxaFjMAwdrzWzbVKartNGg&callback=initMap&libraries=geometry&v=weekly"
      defer
    ></script>
  </body>
</html>

Try Sample

Clone Sample

Git and Node.js are required to run this sample locally. Follow these instructions to install Node.js and NPM. The following commands clone, install dependencies and start the sample application.

  git clone -b sample-map-puzzle https://github.com/googlemaps/js-samples.git
  cd js-samples
  npm i
  npm start

Other samples can be tried by switching to any branch beginning with sample-SAMPLE_NAME.

  git checkout sample-SAMPLE_NAME
  npm i
  npm start