Как я уже упоминал в прошлой статье, почему я влюбился в игру! Фреймворк? Я был техническим руководителем нескольких проектов в сфере недвижимости для Fannie Mae, HUD, Foreclosure.com и т. Д .; Как вы знаете, недвижимость — это все о РАСПОЛОЖЕНИИ! МЕСТО НАХОЖДЕНИЯ! МЕСТО НАХОЖДЕНИЯ!
Я нахожусь в отпуске, но я все еще выродок (а моя жена все еще спит!), Поэтому я решил создать приложение на основе определения местоположения, используя все возможности Play Framework, Scala, Google Maps v3, PostgreSQL и Anorm; Вы знаете, ради старых времен! На стороне карты я покажу вам, как решить очень распространенную проблему « слишком много маркеров » (см. Изображение ниже), используя кластеризацию для группировки маркеров.
Посмотрите живую демоверсию на Heroku прямо здесь ! Полный исходный код доступен по адресу https://github.com/feliperazeek/where-is-felipe . Спасибо моему безумному французскому другу Паскалю (автору Сиены ) за обзор.
/** * 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])
/** * 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 } }
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) }
/** * 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 } } }
// "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!