Как я уже упоминал в прошлой статье, почему я влюбился в игру! Фреймворк? Я был техническим руководителем нескольких проектов в сфере недвижимости для 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!
