Статьи

Основанное на местоположении приложение с Play Framework, Scala, Google Maps Clustering, PostgreSQL, Heroku и Anorm

Как я уже упоминал в прошлой статье, почему я влюбился в игру! Фреймворк? Я был техническим руководителем нескольких проектов в сфере недвижимости для Fannie Mae, HUD, Foreclosure.com и т. Д .; Как вы знаете, недвижимость — это все о РАСПОЛОЖЕНИИ! МЕСТО НАХОЖДЕНИЯ! МЕСТО НАХОЖДЕНИЯ!

Я нахожусь в отпуске, но я все еще выродок (а моя жена все еще спит!), Поэтому я решил создать приложение на основе определения местоположения, используя все возможности Play Framework, Scala, Google Maps v3, PostgreSQL и Anorm; Вы знаете, ради старых времен! На стороне карты я покажу вам, как решить очень распространенную проблему « слишком много маркеров » (см. Изображение ниже), используя кластеризацию для группировки маркеров.

Посмотрите живую демоверсию на Heroku прямо здесь ! Полный исходный код доступен по адресу https://github.com/feliperazeek/where-is-felipe . Спасибо моему безумному французскому другу Паскалю (автору Сиены ) за обзор.

  • Сначала давайте создадим классы передачи данных, возвращаемые контроллером в форме JSON и используемые Google Maps (маркеры и кластеры). Это очень простые классы, которые будут содержать данные, используемые тонким слоем RESTful.
  • /**
     * Map Marker
     *
     * @author Felipe Oliveira [@_felipera]
     */
    case class MapMarker(id: String, latitude: Double, longitude: Double, address: Option[String], city: Option[String], state: Option[String], zip: Option[String], county: Option[String])
    
    /**
     * Map Cluster
     *
     * @author Felipe Oliveira [@_felipera]
     */
    case class MapCluster(geohash: String, count: Long, latitude: Double, longitude: Double)
    
    /**
     * Map Cluster Companion
     *
     * @author Felipe Oliveira [@_felipera]
     */
    object MapCluster {
    
        /**
         * Constructor
         */
        def apply(geohash: String, count: Long): MapCluster = {
            val coords = GeoHashUtils decode geohash
            new MapCluster(geohash, count, coords(0), coords(1))
        }
    
    }
    
    /**
     * Map Overlay
     *
     * @author Felipe Oliveira [@_felipera]
     */
    case class MapOverlay(markers: List[MapMarker], clusters: List[MapCluster])
  • Во-вторых, давайте определим классы модели, используя Anorm в Play Framework. Определена только одна модель — она ​​называется Site. Это очень простая модель, которая содержит только основные поля, такие как идентификатор, адрес, город, штат, город, почтовый индекс, округ, широта, долгота и геохэш. Geohash уникально идентифицирует географические области, систему, созданную Густаво Немье , и имеет много ограничений по сравнению с настоящими инструментами ГИС, такими как PostGIS PostgreSQL или поисковыми индексами с гео-функциями (такими как мой любимый ElasticSearch ). Хотя геохэш имеет свои ограничения, это очень простой способ добавить географические возможности, используя хранилища данных без GeoSpatialтакие возможности, как MySQL. В этом случае я использую геохэш для группировки маркеров, так называемых кластеров маркеров (удары в пучки, детка!). Нажмите здесь, чтобы найти хорошее представление о том, как работают геохэш. Я использую кодировщик и декодер геохэш от Lucene Spatial , очень прост в использовании.
    /**
     * To String Trait
     *
     * @author Felipe Oliveira [@_felipera]
     */
    trait ToString {
        self: Entity =>
    
        /**
         * Reflection-Based ToString
         */
        override def toString = ToStringBuilder.reflectionToString(this)
    
    }
    
    /**
     * Entity Base Class
     *
     * @author Felipe Oliveira [@_felipera]
     */
    trait Entity extends ToString
    
    /**
     * Site Model
     *
     * @author Felipe Oliveira [@_felipera]
     */
    case class Site(
            var id: Pk[Long],
            @Required var name: Option[String],
            @Required var address: Option[String],
            @Required var city: Option[String],
            @Required var state: Option[String],
            @Required var zipcode: Option[String],
            @Required var county: Option[String],
            @Required var latitude: Option[Double],
            @Required var longitude: Option[Double]) extends Entity {
    
        /**
         * Constructor
         */
        def this(address: Option[String], city: Option[String], state: Option[String], zipcode: Option[String], county: Option[String], latitude: Option[Double], longitude: Option[Double]) {
            this(NotAssigned, None, address, city, state, zipcode, county, latitude, longitude)
        }
    
    }
    
    /**
     * Site Companion Object
     *
     * @author Felipe Oliveira [@_felipera]
     */
    object Site extends Magic[Site] {
    
        /**
         * Count
         */
        def count(implicit filters: SearchFilters): Long = statement("select count(1) as count from Site").as(scalar[Long])
    
        /**
         * Map Overlay
         */
        def mapOverlay(implicit filters: SearchFilters): MapOverlay = {
            // Get Map Clusters
            val clusters = mapClusters
    
            // Get Markers
            filters.geohashes = Option(clusters.filter(_.count == 1).map(_.geohash).toList)
            val markers = mapMarkers
    
            // Define Map Overlay
            new MapOverlay(markers, clusters.filter(_.count > 1))
        }
    
        /**
         * Map Clusters
         */
        def mapClusters(implicit filters: SearchFilters): List[MapCluster] = {
            // Get Query
            val query = statement("select " + geohashExpression + " as geohash, count(1) as count from Site", "group by " + geohashExpression + " order by count desc")
    
            // Get Results
            val list: List[MapCluster] = query().filter(_.data(0) != null).map {
                row =>
                    {
                        // Get Fields
                        val fields = row.data
    
                        // Geohash
                        Option(fields(0)) match {
                            case Some(geohash: String) => {
                                // Count
                                val count: Long = fields(1).toString.toLong
    
                                // Map Cluster
                                MapCluster(geohash, count)
                            }
                            case _ => null
                        }
                    }
            } toList
    
            // Log Debug
            please log "Map Clusters: " + list.size
    
            // Return List
            list.filter(_ != null)
        }
    
        /**
         * Map Markers
         */
        def mapMarkers(implicit filters: SearchFilters): List[MapMarker] = {
            // Get Query
            val query = statement("select site.* from Site site", "order by id")
    
            // Get Results
            val list: List[MapMarker] = query().map {
                row =>
                    try {
                        // Id
                        val id = row[String]("id")
    
                        // Fields
                        val address = row[Option[String]]("address")
                        val city = row[Option[String]]("city")
                        val state = row[Option[String]]("state")
                        val zip = row[Option[String]]("zip")
                        val county = row[Option[String]]("county")
                        val latitude = row[Option[Double]]("latitude")
                        val longitude = row[Option[Double]]("longitude")
    
                        // Map Marker (coord required)
                        (latitude, longitude) match {
                            case (lat: Some[Double], lng: Some[Double]) => new MapMarker(id, lat.get, lng.get, address, city, state, zip, county)
                            case _ => null
                        }
    
                    } catch {
                        case error: Throwable => {
                            please report error
                            null
                        }
                    }
            } toList
    
            // Log Debug
            please log "Map Markers: " + list.size
    
            // Return List
            list
        }
    
        /**
         * Statement
         */
        def statement(prefix: String, suffix: Option[String] = None)(implicit filterBy: SearchFilters) = {
            // Params
            val geohashes = filterBy geohashes
            val zoom = filterBy zoom
            val geohashPrecision = filterBy geohashPrecision
    
            // Params will contain the list of name/value pairs that need to be bound to the query
            val params = new HashMap[String, Any]
    
            // This is gonna define the statement that we'll use on the find method
            val terms = new StringBuffer
            terms.append(prefix)
            terms.append(" where ")
    
            // Boundary
            filterBy.hasBounds match {
                case true => {
                    params += "nw" -> filterBy.nw.get
                    params += "ne" -> filterBy.ne.get
                    params += "se" -> filterBy.se.get
                    params += "sw" -> filterBy.sw.get
                    terms.append("latitude between {nw} and {ne} and longitude between {sw} and {se} and ")
                }
                case _ => please log "Ignoring Map Bounds!"
            }
    
            // Geohashes
            geohashes match {
                case Some(list) => {
                    if (!list.isEmpty) {
                        val values = list.map(_.substring(0, geohashPrecision)).toSet
                        terms.append(geohashExpression + " in (" + multiValues(values) + ") and ")
                        terms.append("geohash is not null and ")
                    }
                }
                case _ => please log "Not including geohashes in query!"
            }
    
            // Final one just in case
            terms.append("1 = {someNumber} ")
    
            // Suffix
            terms.append(suffix.getOrElse(""))
    
            // Define SQL
            val sql = terms.toString.trim
    
            // Define Query
            var query = SQL(sql).on("someNumber" -> 1)
            for (param <- params) {             please log "Bind - " + param._1 + ": " + param._2             query = query.on(param._1 -> param._2)
            }
    
            // Return Query
            query
        }
    
        /**
         * Geohash Expression
         */
        def geohashExpression(implicit filterBy: SearchFilters): String = filterBy.zoom match {
            case Some(z: Int) if z > 0 => "substring(geohash from 1 for " + filterBy.geohashPrecision.toString + ")"
            case _ => "geohash"
        }
    }
  • Тогда давайте определим поисковые фильтры.
    /**
     * Search Filters
     *
     * @author Felipe Oliveira [@_felipera]
     */
    case class SearchFilters(var ne: Option[Double], var sw: Option[Double], var nw: Option[Double], var se: Option[Double], var geohashes: Option[List[String]] = None, var zoom: Option[Int] = None) {
    
        /**
         * Log Debug
         */
        please log "NE: " + ne
        please log "SW: " + sw
        please log "NW: " + nw
        please log "SE: " + se
        please log "Geohashes: " + geohashes
        please log "Zoom: " + zoom
    
        /**
         * Format Date
         */
        def format(date: Date) = dateFormat format date
    
        /**
         * Geohash Precision
         */
        def geohashPrecision: Int = zoom match {
            case Some(z: Int) if z > 0 => 22 - z
            case _ => 1
        }
    
        /**
         * Geohash Suffix
         */
        def geohashSuffix: String = geohashPrecision toString
    
        /**
         * Has Bounds
         */
        def hasBounds: Boolean = {
            please log "Has Bounds? NE: " + ne + ", SW: " + sw + ", NW: " + nw + ", SE: " + se
            if (ne.valid && nw.valid && nw.valid && se.valid) {
                please log "Yes!"
                true
            } else {
                please log "No!"
                false
            }
        }
    
        /**
         * To Query String
         */
        def toQueryString = {
            // Start
            val queryString = new StringBuilder
    
            // Map Bounds
            if (hasBounds) {
                queryString append "ne=" append ne append "&"
                queryString append "sw=" append sw append "&"
                queryString append "nw=" append nw append "&"
                queryString append "se=" append se append "&"
            }
    
            // Zoom
            zoom match {
                case Some(s) => queryString append "zoom=" append s append "&"
                case _ => please log "No zoom level defined!"
            }
    
            // Log Debug
            please log "Query String: " + queryString
            queryString.toString
        }
    
    }
  • And a bunch of sugar!
    package pretty
    
    import play.Logger
    import play.mvc.Controller
    import org.apache.commons.lang.exception.ExceptionUtils
    import net.liftweb.json.Printer._
    import net.liftweb.json.JsonAST._
    import net.liftweb.json.Extraction._
    import java.util.{ Calendar, Date }
    import org.joda.time.Months
    import java.net.URLEncoder
    import java.text.SimpleDateFormat
    import org.apache.commons.lang.StringUtils
    import play.Logger
    import org.joda.time.DateTime
    import java.util.Date
    import java.sql.Timestamp
    
    /**
     * Helper Object
     *
     * @author Felipe Oliveira [@_felipera]
     */
    object please {
    
        /**
         * JSON Formats
         */
        implicit val formats = PlayParameterReader.Formats.formats
    
        /**
         * Compress
         */
        def compress[A](ls: List[A]): List[A] = {
            ls.foldRight(List[A]()) {
                (h, r) =>
                    if (r.isEmpty || r.head != h) {
                        h :: r
                    } else {
                        r
                    }
            }
        }
    
        /**
         * Time Execution
         */
        def time[T](identifier: String = "n/a")(runnable: => T): T = {
            throwOnError {
                // Define Search Start Time
                val started = new Date
    
                // Execute Action
                val results: T = runnable
    
                // Define Duration Time
                logOnError[Unit] {
                    val ended = new Date
                    val duration = ended.getTime - started.getTime
                    Logger.info("Runnable: " + identifier + ", Duration: " + duration + "ms (" + (duration / 1000.0) + "s)")
                }
    
                // Return Results
                results
            }
        }
    
        /**
         * Now
         */
        def now = new Date()
    
        /**
         * Current Time
         */
        def currentTime = now.getTime
    
        /**
         * Log
         */
        def log(any: String) = Logger info any
    
        /**
         * Report Exception
         */
        def report(error: Throwable) = Logger error ExceptionUtils.getStackTrace(error)
    
        /**
         * Dummy Controller
         */
        private val _dummy = new Controller {}
    
        /**
         * Conf
         */
        def conf(name: String) = play.Play.configuration.getProperty(name)
    
        /**
         * Multi Values
         */
        def multiValues(values: Iterable[String]) = values.mkString("'", "','", "'")
    
        /**
         * Log If Error
         */
        def logOnError[T](runnable: => T): Option[T] = {
            try {
                Some(runnable)
            } catch {
                case error: Throwable =>
                    please report error
                    None
            }
        }
    
        /**
         * Throw if Error
         */
        def throwOnError[T](runnable: => T): T = {
            try {
                runnable
            } catch {
                case error: Throwable => {
                    please report error
                    throw new RuntimeException(error fillInStackTrace)
                }
            }
        }
    
        /**
         * URL Encode
         */
        def encode(value: String) = URLEncoder.encode(value, conf("encoding"))
    
        /**
         * Automatically generate a standard perks json response.
         */
        def jsonify(runnable: => Any) = {
            _dummy.Json(pretty(render(decompose(
                try {
                    val data = runnable match {
                        // Lift-json has a cow if you pass it a mutable map to render.
                        case mmapResult: Map[Any, Any] => mmapResult.toMap
                        case result => result
                    }
                    Map("status" -> 200, "data" -> data)
                } catch {
                    case error: Throwable =>
                        please report error
                        Map("status" -> 409, "errors" -> Map(error.hashCode.toString -> error.getMessage))
                }
            ))))
        }
    
        /**
         * Option[Date] to Pimp Date Implicit Conversion
         */
        implicit def date2PimpDate(date: Option[Date]) = new PimpDate(date.getOrElse(new Date))
    
        /**
         * Option[Date] to Date Implicit Conversion
         */
        implicit def optionDate2Date(date: Option[Date]) = date.getOrElse(new Date)
    
        /**
         * Int to Pimp Int
         */
        implicit def int2PimpInt(i: Int) = new PimpInt(i)
    
        /**
         * String to Date
         */
        implicit def stringToDate(string: String): Date = dateFormat parse string
    
        /**
         * Date to String
         */
        implicit def dateToString(date: Date): String = dateFormat format date
    
        /**
         * Option Date to String
         */
        implicit def optionDate2String(date: Option[Date]): String = date match {
            case Some(d: Date) => d
            case _ => "n/a"
        }
    
        /**
         * Option Double to Pimp Option Double
         */
        implicit def optionDoubleToPimpOptionDouble(value: Option[Double]): PimpOptionDouble = new PimpOptionDouble(value)
    
        /**
         * String to Double
         */
        implicit def stringToDouble(str: Option[String]): Double = {
            str match {
                case Some(s) => {
                    try {
                        s.toDouble
                    } catch {
                        case ne: NumberFormatException => 0.0
                        case error: Throwable => 0.0
                    }
                }
                case _ => 0.0
            }
        }
    
        /**
         * Date Format
         */
        def dateFormat = new SimpleDateFormat("MM/dd/yyyy")
    
        /**
         * String to Option String
         */
        implicit def string2OptionString(value: String): Option[String] = Option(value)
    
        /**
         * Option String to String
         */
        implicit def optionString2String(value: Option[String]): String = value.getOrElse("n/a")
    
        /**
         * String to Option Double
         */
        implicit def string2OptionDouble(value: String): Option[Double] = try {
            if (StringUtils.isNotBlank(value)) {
                Option(value.toDouble)
            } else {
                None
            }
        } catch {
            case error: Throwable => {
                please report error
                None
            }
        }
    
        /**
         * Dinossaur Birth Date
         */
        def dinoBirthDate = {
            val c = Calendar.getInstance()
            c.set(0, 0, 0)
            c.getTime
        }
    
    }
    
    /**
     * Pimp Int
     *
     * @author Felipe Oliveira [@_felipera]
     */
    case class PimpInt(value: Int) {
    
        /**
         * Months Ago
         */
        def monthsAgo = {
            val c = Calendar.getInstance()
            c.setTime(new Date)
            c.add(Calendar.MONTH, (value * -1))
            c.getTime
        }
    
    }
    
    /**
     * Pimp Option Double
     *
     * @author Felipe Oliveira [@_felipera
     */
    case class PimpOptionDouble(value: Option[Double]) {
    
        /**
         * Is Valid
         */
        def valid: Boolean = value.getOrElse(0.0) != 0.0
    
    }
    
    /**
     * Pimp Date
     *
     * @author Felipe Oliveira [@_felipera]
     */
    class PimpDate(date: Date) {
    
        /**
         * Is?
         */
        def is = {
            Option(date) match {
                case Some(d) => d
                case _ => new Date
            }
        }
    
        /**
         * Before?
         */
        def before(other: Date) = date.before(other)
    
        /**
         * After?
         */
        def after(other: Date) = date.after(other)
    
        /**
         * Past?
         */
        def past = date.before(new Date)
    
        /**
         * Future?
         */
        def future = date.after(new Date)
    
        /**
         * Subtract
         */
        def -(months: Int) = {
            val c = Calendar.getInstance()
            c.setTime(date)
            c.add(Calendar.MONTH, (months * -1))
            c.getTime
        }
    
    }
    
    /**
     * Clockman Object
     *
     * @author Felipe Oliveira [@_felipera]
     */
    object Clockman {
    
        /**
         * Pimp Date
         */
        def is(date: Date) = new PimpDate(date)
    
    }
  • Then let’s define the controllers.
    /**
     * Filters Trait
     *
     * @author Felipe Oliveira [@_felipera]
     */
    trait Filters {
        self: Controller =>
    
        /**
         * Search Filters
         */
        def filters = new SearchFilters(ne, sw, nw, se, zoom = zoom)
    
        /**
         * Zoom
         */
        def zoom: Option[Int] = Option(params.get("zoom")) match {
            case Some(o: String) => Option(o.toInt)
            case _ => Option(1)
        }
    
        /**
         * NE Bound
         */
        def ne = boundParam("ne")
    
        /**
         * SW Bound
         */
        def sw = boundParam("sw")
    
        /**
         * NW Bound
         */
        def nw = boundParam("nw")
    
        /**
         * SE Bound
         */
        def se = boundParam("se")
    
        /**
         * Bound Param
         */
        def boundParam(name: String): String = Option(params.get(name)).getOrElse("")
    
    }
    
    /**
     * Main Controller
     *
     * @author Felipe Oliveira [@_felipera]
     */
    object Application extends Controller with controllers.Filters {
    
        /**
         * Index Action
         */
        def index = time("index") {
            views.Application.html.index(filters)
        }
    
    }
    
    /**
     * Geo Controller
     *
     * @author Felipe Oliveira [@_felipera]
     */
    object Geo extends Controller with controllers.Filters {
    
        /**
         * Map Overlay
         */
        def mapOverlay = time("mapOverlay") {
            jsonify {
                implicit val searchWith = filters
                Site mapOverlay
            }
        }
    
    }
  • Now it’s time for the frontend code, it’s mostly sugar so I am just gonna show you the JavaScript part of it, the important piece. Basically it defined the map, calls the RESTful MapOverlay API, grabs the markers and clusters and bind them to the map!
    // "Da" Map
    var map;
    
    // Global Markers Array
    var markers = [];
    
    // Global Debug Flag
    var debug = true;
    if (console == null) {
        debug = false;
    }
    
    // Log Function
    function log(msg) {
        if (debug) {
            console.log(msg);
        }
    }
    
    // Initialize Map
    function initialize() {
        // Map Options
        var myOptions = {
              zoom: 8,
              mapTypeId: google.maps.MapTypeId.ROADMAP
        };
    
        // Define Map
        map = new google.maps.Map(document.getElementById('map_canvas'), myOptions);
    
        // Set Map Position
        map.setCenter(new google.maps.LatLng(40.710623, -74.006605));
    
        // Load Markers
        loadMarkers(map, null);
    
        // Listen for Map Movements
        google.maps.event.addListener(map, 'idle', function(ev) {
                log("Idle Listener!");
                var bounds = map.getBounds();
                var ne = bounds.getNorthEast(); // LatLng of the north-east corner
                var sw = bounds.getSouthWest(); // LatLng of the south-west corner
                var nw = new google.maps.LatLng(ne.lat(), sw.lng());
                var se = new google.maps.LatLng(sw.lat(), ne.lng());
                var q = "&ne=" + ne.lat() + "&sw=" + sw.lng() + "&nw=" + sw.lat() + "&se=" + ne.lng();
                log("Map Bounds: " + q);
                clearOverlays();
                loadMarkers(map, q);
            });
    }
    
    // Clear Markers
    function clearOverlays() {
        log("Clear Overlays!");
        if (markers != null) {
            for (i in markers) {
                markers[i].setMap(null);
            }
        }
        markers = new Array();
    }
    
    // Cap Words
    function capWords(str){
        if (str == null) {
            return "";
        }
        str = str.toLowerCase();
       words = str.split(" ");
       for (i=0 ; i < words.length ; i++){
          testwd = words[i];
          firLet = testwd.substr(0,1); //lop off first letter
          rest = testwd.substr(1, testwd.length -1)
          words[i] = firLet.toUpperCase() + rest
       }
       return words.join(" ");
    }
    
    // Load Markers
    function loadMarkers(map, extra) {
        $(document).ready(function() {
            zoomLevel = map.getZoom();
            if (zoomLevel == null) {
                zoomLevel = 1;
            }
            var url = '/mapOverlay';
            if (document.location.search) {
                url = url + document.location.search;
            } else {
                url = url + "?1=1"
            }
            if (extra != null) {
                url = url + extra;
            }
            url = url + "&zoom=" + zoomLevel;
            log('URL: ' + url);
            $.getJSON(url, function(json) {
                markers = new Array(json.data.markers.length);
                for (i = 0; i < json.data.markers.length; i++) {
                      // Get Marker
                      var marker = json.data.markers[i];
    
                      // Customer Logo
                      var icon = "http://geeks.aretotally.in/wp-content/uploads/2011/03/html5_geek_matt_16.png";
                      var logo = "http://geeks.aretotally.in/wp-content/uploads/2011/03/html5_geek_matt_32.png";
                      var width = 32;
                      var height = 32;
                      var customer = marker.customer;
    
                      // console.log('Marker: ' + marker + ', Lat: ' + marker.latitude + ', Lng: ' + marker.longitude);
                      var contentString = marker.address + ' ' + marker.city + ', ' + marker.state + ' ' + marker.zip;
    
                      var position = new google.maps.LatLng(marker.latitude, marker.longitude);
    
                      var m = new google.maps.Marker({
                          position: position,
                          icon: icon,
                          html: contentString
                      });
    
                      markers[i] = m;
                }
    
                // Clusters
                for (i = 0; i < json.data.clusters.length; i++) {
                    var cluster = json.data.clusters[i];
                    log('Cluster: ' + cluster);
    
                    var position = new google.maps.LatLng(cluster.latitude, cluster.longitude);
    
                    for (c = 0; c < cluster.count; c++) {
                      log('Cluster Item: ' + c);
                      var m = new google.maps.Marker({
                          position: position
                      });
    
                      markers[i] = m;
                    }
                }
    
                // Marker Cluster
                var clusterOptions = { zoomOnClick: true }
                var markerCluster = new MarkerClusterer(map, markers, clusterOptions);
    
                // Info Window
                var infowindow = new google.maps.InfoWindow({
                    content: 'Loading...'
                });
    
                // Info Window Listener for Markers
                for (var i = 0; i < markers.length; i++) {
                    var marker = markers[i];
                    google.maps.event.addListener(marker, 'click', function () {
                        // Log Debug
                        log('Marker Click!');
    
                        // Set Info Window Marker Content
                        infowindow.close();
                        infowindow.setContent(this.html);
    
                        // Set Current Marker
                        var currentMarker = this;
    
                        // Get Map Position
                        var mapLatLng = map.getCenter();
                        var markerLatLng = currentMarker.getPosition();
    
                        // Check Coordinate
                        if (!markerLatLng.equals(mapLatLng)) {
                            // Map will need to pan
                            map.panTo(markerLatLng);
                            google.maps.event.addListenerOnce(map, 'idle', function() {
                                // Open Info Window
                                infowindow.open(map, currentMarker);
                                setTimeout(function () { infowindow.close(); }, 5000);
                            });
                        } else {
                            // Map won't be panning, which wouldn't trigger 'idle' listener so just open info window
                            infowindow.open(map, currentMarker);
                            setTimeout(function () { infowindow.close(); }, 5000);
                        }
                    });
                }
    
            });
        });
    }
    
    // Initialize Map
    google.maps.event.addDomListener(window, 'load', initialize);
  • Happy New Year! 2011 was a great year, I can’t wait to see what 2012 has in store!