Location-based Application with Play Framework, Scala, Google Maps Clustering, PostgreSQL, Heroku and Anorm
Join the DZone community and get the full member experience.
Join For Free
as i mentioned in a past article, why did i fall in love with play! framework? , i was the tech lead for a few real estate deployments for fannie mae, hud, foreclosure.com, etc; as you know, real estate is all about location! location! location!
i’m on vacation, but i am still a geek (and my wife is still sleeping!) so i decided to create a location-based application using the all-awesomeness of play framework, scala, google maps v3, postgresql and anorm; you know, for ol’ times’ sake! on the map-side i will show you how to solve the very common “ too many markers ” problem (see image below) using clustering to group the markers.
see a live demo running on heroku right here ! the complete source code is available at https://github.com/feliperazeek/where-is-felipe . thank you my crazy french friend pascal (author of siena ) for the review.
/** * 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!
Opinions expressed by DZone contributors are their own.
Comments