lunes, 18 de abril de 2011

Proyectando grandes Datasets en Google Maps

Proyectar información georeferenciada de nuestro sistema con Google Maps es un proceso que se logra con relativa facilidad. Sin embargo cuando el volumen de información que se muestra comienza a crecer, el mapa se vuelve prácticamente inservible pues es imposible localizar algo con facilidad.

Es en este punto cuando se comienza a buscar alguna solución a este “agradable” problema. Rápidamente te das cuenta que necesitas de alguna forma “agrupar” (cluster en inglés) información y por ahí comienzas a trabajar.

Muchos métodos de agrupamiento en el fondo se parecen. Básicamente se trata de agrupar datos siguiendo algún criterio. Para esto se necesita alguna ecuación que nos indique cuán parecidos son 2 elementos, se define un umbral y los datos que no sobrepasen dicho umbral conforman el clúster. En nuestro caso, el criterio que se sigue es hallar la distancia que existe entre 2 puntos, si esa distancia es menor que un umbral que se defina entonces se agrupan. En la siguiente figura se muestra el resultado final, también se puede ver en la dirección http://www.chil.es/yellowpages
  GoogleMaps

Existe una biblioteca capaz de agrupar datos pero lo hace del lado del cliente lo cual no resulta beneficioso debido a la gran cantidad de datos que es necesario enviar en la respuesta HTTP. 

Pues bien, manos a la obra. Lo primero que se necesita es hallar la distancia entre los puntos, esto se consigue a través de la fórmula de Haversine. Sucede que en nuestro caso necesitamos la distancia pero en píxeles, para esto usamos la proyección de Mercator. La siguiente clase encapsula los métodos necesarios para trabajar con las fórmulas de distancia:
   1: public static class GeolocalizationUtils
   2: {
   3:     public const int Offset = 268435456; //It is half of the earth circumference in pixels at zoom level 21
   4:  
   5:     public const double Radius = 85445659.4471; // offset / pi()
   6:  
   7:     public const double EartRadiusInMiles = 3956.0;
   8:  
   9:     public static double ToRadian(double val)
  10:     {
  11:         return val * (Math.PI / 180);
  12:     }
  13:  
  14:     public static double DiffRadian(double val1, double val2)
  15:     {
  16:         return ToRadian(val2) - ToRadian(val1);
  17:     }
  18:  
  19:     public static double ConvertFromMetersToMiles(int meters)
  20:     {
  21:         return meters * 0.000621371192237334;
  22:     }
  23:  
  24:     /// <summary>
  25:     /// Evaluate the distance between two points on the Earth (miles)
  26:     /// </summary>
  27:     /// <param name="lat1">Latitude 1</param>
  28:     /// <param name="lng1">Longitude 1</param>
  29:     /// <param name="lat2">Latitude 2</param>
  30:     /// <param name="lng2">Longitude 2</param>
  31:     /// <returns></returns>
  32:     public static double CalcDistance(double lat1, double lng1, double lat2, double lng2)
  33:     {
  34:         return EartRadiusInMiles * 2 * (Math.Asin(
  35:                     Math.Min(1,
  36:                         Math.Sqrt(
  37:                             (
  38:                                 Math.Pow(Math.Sin((DiffRadian(lat1, lat2)) / 2.0), 2.0) +
  39:                                 Math.Cos(ToRadian(lat1)) * Math.Cos(ToRadian(lat2)) *
  40:                                 Math.Pow(Math.Sin((DiffRadian(lng1, lng2)) / 2.0), 2.0)
  41:                             )
  42:                         )
  43:                     )
  44:                 )
  45:             );
  46:     }
  47:  
  48:     public static int LonToX(double lon)
  49:     {
  50:         return (int)Math.Round(Offset + Radius * lon * Math.PI / 180);
  51:     }
  52:  
  53:     public static int LatToY(double lat)
  54:     {
  55:         return (int)Math.Round(Offset - Radius * Math.Log((1 + Math.Sin(lat * Math.PI / 180)) / (1 - Math.Sin(lat * Math.PI / 180))) / 2);
  56:     }
  57:  
  58:     /// <summary>
  59:     /// Evaluate the distance between two points (pixel)
  60:     /// </summary>
  61:     /// <param name="lat1">Latitude 1</param>
  62:     /// <param name="lng1">Longitude 1</param>
  63:     /// <param name="lat2">Latitude 2</param>
  64:     /// <param name="lng2">Longitude 1</param>
  65:     /// <param name="zoom">Zoom level</param>
  66:     /// <returns></returns>
  67:     public static int CalcPixelDistance(double lat1, double lng1, double lat2, double lng2, int zoom)
  68:     {
  69:         var x1 = LonToX(lng1);
  70:         var y1 = LatToY(lat1);
  71:  
  72:         var x2 = LonToX(lng2);
  73:         var y2 = LatToY(lat2);
  74:  
  75:         return (int)Math.Sqrt(Math.Pow(x1 - x2, 2) + Math.Pow(y1 - y2, 2)) >> (21 - zoom);
  76:     }
  77: }
Ahora definimos la interfaz que deberá implementar nuestro algoritmo de clúster:
   1: public interface IClusterProcessor
   2: {
   3:     /// <summary>
   4:     /// Cluster a list of items
   5:     /// </summary>
   6:     /// <typeparam name="E"></typeparam>
   7:     /// <param name="items">The list of items to cluster</param>
   8:     /// <param name="zoom">The zoom of the map</param>
   9:     /// <param name="pixelThreshold">Pixel threshold distance</param>
  10:     /// <param name="threshold">Threshold in meters for items in the same place</param>
  11:     /// <returns></returns>
  12:     ClusterResult<E> DoCluster<E>(IEnumerable<E> items, int zoom, int pixelThreshold = 40, int threshold = 5) where E : IGeolocalizable;
  13: }
 La interface solo consta de un método. A este método se le debe suministrar una colección de objetos que implementen la interface IGeolocalizable, la cual se define de la siguiente forma:
   1: public interface IGeolocalizable
   2: {
   3:     double Latitude { get; set; }
   4:  
   5:     double Longitude { get; set; }
   6: }
Así mismo el método retornará el resultado en una instancia de ClusterResult que se define de la siguiente manera:
   1: public class ClusterResult<T>
   2:     where T : IGeolocalizable
   3: {
   4:     public IEnumerable<T> Items { get; set; }
   5:  
   6:     public IEnumerable<Cluster<T>> Clusters { get; set; }
   7: }
En la propiedad Items quedarán los elementos que no se han agrupado y en la propiedad Clusters quedarán los distintos clústeres que se han de ir encontrando. De esta manera la clase Cluster se define de la siguiente manera:
   1: public class Cluster<T> : IGeolocalizable
   2: {
   3:     public double Latitude { get; set; }
   4:  
   5:     public double Longitude { get; set; }
   6:  
   7:     public IEnumerable<T> Items { get; set; }
   8:  
   9:     public bool IsClusterInOnePoint { get; set; }
  10:  
  11:     public object ToJson()
  12:     {
  13:         return new
  14:         {
  15:             lat = Latitude,
  16:             lng = Longitude,
  17:             itemsInCluster = Items.Count(),
  18:             isClusterInOnePoint = IsClusterInOnePoint
  19:         };
  20:     }
  21: }
Las propiedades Latitude y Longitude indican las coordenadas del mapa donde se encuentra el clúster. En la propiedad Items se almacenan los distintos elementos que hay en el clúster. Por último la propiedad IsClusterInOnePoint la utilizamos para indicar que los elementos están tan cerca (este umbral se específica cuando se invoca el clúster que por defecto es 5 metros) que pueden se considerados como un solo punto. La implementación de la interface IClusterProcessor queda de la siguiente forma:
   1: public ClusterResult<E> DoCluster<E>(IEnumerable<E> items, int zoom, int pixelThreshold = 40, int threshold = 5)
   2:     where E : IGeolocalizable
   3: {
   4:     var itemsWithoutCluster = new List<E>();
   5:     var clusters = new List<Cluster<E>>();
   6:     var list = items.ToList();
   7:     var thresholdInMiles = GeolocalizationUtils.ConvertFromMetersToMiles(threshold);
   8:     
   9:     while (list.Count > 0)
  10:     {
  11:         var item = list[0];
  12:         list.Remove(item);
  13:         if (item.Latitude <= 0 || item.Longitude <= 0)
  14:             continue;
  15:  
  16:         var count = 1;
  17:         var isClusterInOnePoint = true;
  18:         var clusteredItems = new List<E>();
  19:  
  20:         for (var i = 0; i < list.Count; i++)
  21:         {
  22:             var pixelDistance = GeolocalizationUtils.CalcPixelDistance(item.Latitude, item.Longitude, list[i].Latitude, list[i].Longitude, zoom);
  23:  
  24:             if (pixelThreshold > pixelDistance)
  25:             {
  26:                 if (isClusterInOnePoint)
  27:                 {
  28:                     var d = GeolocalizationUtils.CalcDistance(item.Latitude, item.Longitude, list[i].Latitude, list[i].Longitude);
  29:                     isClusterInOnePoint = d <= thresholdInMiles;
  30:                 }
  31:  
  32:                 clusteredItems.Add(item);
  33:                 count++;
  34:                 list.RemoveAt(i);
  35:                 i--;
  36:             }
  37:         }
  38:  
  39:         if (count == 1) //No Cluster was found...
  40:             itemsWithoutCluster.Add(item);
  41:         else
  42:             clusters.Add(new Cluster<E>
  43:             {
  44:                 Latitude = item.Latitude,
  45:                 Longitude = item.Longitude,
  46:                 IsClusterInOnePoint = isClusterInOnePoint,
  47:                 Items = clusteredItems
  48:             });
  49:     }
  50:  
  51:     return new ClusterResult<E> 
  52:     {
  53:         Clusters = clusters,
  54:         Items = itemsWithoutCluster
  55:     };
  56: }
El algoritmo es muy sencillo. Se va recorriendo la lista, se escoge el primer elemento (E1), se elimina de la lista y se halla la distancia que existe entre E1 y el resto de elementos de la lista (En). Si la distancia es menor que el umbral que se especifica en el parámetro pixelThresold  se elimina En y se conforma un clúster entre E1 y En, sucesivamente se le van añadiendo al clúster los elementos que no sobrepasan el umbral. Si la distancia entre todos los elementos que conforman el clúster es menor que el umbral especificado en el parámetro threshold entonces podemos decir que el clúster está solo en un punto, para esto se establece la propiedad IsClusterInOnePoint en “true”. El proceso continua hasta que la lista inicial esté totalmente vacía.

La lista que le debemos suministrar al método DoCluster debemos obtenerla de algún sitio, por ejemplo de una clase de servicio como se muestra a continuación:
   1: public IEnumerable<User> GetByBounds(CoordsBound bound)
   2: {
   3:     return repository.GetFilteredElements(u =>
   4:         (u.Longitude > bound.SouthWest.Longitude && u.Longitude < bound.NorthEast.Longitude) &&
   5:         (u.Latitude <= bound.NorthEast.Latitude && u.Latitude >= bound.SouthWest.Latitude));
   6: } 
 Ahora solo nos queda ponerlo todo en marcha, esto lo hacemos en un método de una clase controladora de ASP.NET MVC de la siguiente forma:
   1: public ActionResult GetGeolocalizedUsers(string northEast, string southWest, int zoom)
   2: {
   3:     var coordsBound = CoordsBound.Parse(southWest, northEast);
   4:     IEnumerable<User> users = userService.GetByBounds(coordsBound);
   5:     var clusterResult = clusterProcessor.DoCluster(users, zoom, 40, 5);
   6:     
   7:     var result = new ArrayList();
   8:     
   9:     foreach (var user in clusterResult.Items)
  10:     {
  11:         result.Add(user.ToJson());
  12:     }
  13:  
  14:     foreach (var cluster in clusterResult.Clusters)
  15:     {
  16:         result.Add(cluster.ToJson());
  17:     }
  18:  
  19:     return Json(result);
  20: }
 Primero se filtra la lista de usuarios según su posición geográfica. Luego se hace un clúster de esta selección y se envían los datos al cliente en formato JSON. Este código solo funciona en .NET 4.0 pues hace uso de la covarianza, nótese que la clase del servicio retorna una colección de User y necesitamos una colección de IGeolocalizable para aplicar el clúster, es ahí donde entra en juego la covarianza ya que User implementa IGeolocalizable.

Por ahora es suficiente, con esto quedaría solo proyectar la información en el mapa del lado del cliente lo cual queda fuera del alcance de esta entrada y que quizás en otro post comentemos.

Antes de finalizar comentar de otra técnica que se puede usar si no se quiere hacer clúster. Es posible obtener una serie de  imágenes con la información que se quiere proyectar en el mapa y superponerlas a las imágenes del mapa que nos proporciona Google, esto se consigue trabajando con los “tiles” de Google Maps. Es de esta última forma como trabaja Panoramio pero esto lo mostraré en otro post, por ahora ya es algo para empezar y comenzar a divertirse con Google Maps o Bing (todo lo aquí explicado se aplica también a Bing).

3 comentarios:

  1. Hector! Justo estoy trabajando en algo similar, tengo un dataset enorme (varias decenas de millones de registros) de tecnicos que reportan su posicion cada minuto; y se necesita reportar con umbrales variables de tiempo y diatancia para diferentes propositos.
    Por ejemplo, en ciertos casos necesitas ver solo el recorrido del tecnico reportando puntos cada X metros (ignorando el resto) y/o cada Y minutos.
    Estoy mirando el sorporte para spatial data en SQLServer 2008 con la esperanza de encontrar algo mas eficiente ahi, de lo contrario no me quedara otra que procesar todos los registros que entren en el marco de la consulta.
    Un abrazo bro! Y sigue posteando!

    ResponderEliminar
  2. Es raciel no????? sql server tiene buen soporte para datos geospatial y tiene muchos métodos implementados, de todas formas hay una librería q se llama SharpMap (o algo así) q tiene el mismo API (o bastante parecido, creo q es una recomendación estándar) q el de Sql Server, pero bueno... es cuestión de probar y tener suerte, jejeje, un abrazo h
    pd: lo de postear lo tengo q retomar en breve, jajaja

    ResponderEliminar
  3. Si! Soy yo. Sigue posteando, que de ultima no te perjudica, y te ayuda a poner las ideas mas en claro.
    Mira esto: http://channel9.msdn.com/Shows/HanselminutesOn9/Hanselminutes-on-9-Social-Networking-for-Developers-Part-1-Every-Developer-Needs-a-Blog

    Yo hace tiempo quiero publicar algunas cosas, pero soy muy vago... pero ya me lo puse como objetivo.

    Un abrazo, y sigue con el buen trabajo que estas haciendo!

    ResponderEliminar