En este artículo, decidí describir cómo se implementó la funcionalidad de seleccionar y mostrar fotos en un lugar específico del mapa en nuestro servicio de fotos gfranq.com . El servicio de fotografía no funciona ahora.
Dado que teníamos muchas fotos en nuestro servicio y enviamos solicitudes a la base de datos cada vez que los cambios en la ventana gráfica requerían demasiados recursos, era lógico dividir el mapa en varias áreas que contienen información sobre los datos recuperados. Por razones obvias, estas áreas tienen una forma rectangular (aunque también se consideró la cuadrícula hexagonal). A medida que las áreas se vuelven más esféricas a grandes escalas, también se consideraron elementos de geometría esférica y herramientas para ello.
En este artículo, se plantearon los siguientes problemas:
- Almacenar y recuperar fotos de la base de datos y almacenarlas en caché en el servidor (SQL, C #, ASP.NET).
- Descargar fotos necesarias en el lado del cliente y guardarlas en la caché del cliente (JavaScript).
- Nuevo cálculo de fotos que deben ocultarse o mostrarse cuando cambia la ventana gráfica.
- Elementos de geometría esférica.
Contenido
Parte del servidor
Se diseñaron los siguientes métodos para seleccionar y almacenar información geográfica en la base de datos:
- Tipo de datos de geografía incorporado de SQL Server.
- Selección normal con restricciones.
- Usando tablas adicionales.
Además, estos métodos se describirán en detalle.
Geotipos incorporados
Como se sabe, SQL Server 2008 admite tipos de datos de geografía y geometría, que permiten especificar información geográfica (en la esfera) y geométrica (en el plano), como puntos, líneas, polígonos, etc. . Para recuperar todas las fotos encerradas por un rectángulo con coordenadas ( lngMin
latMin
) y ( latMax
lngMax
), puede usar la siguiente consulta:
DECLARE @h geography; DECLARE @p geography; SET @rect = geography::STGeomFromText('POLYGON((lngMin latMin, lngMax latMin, lngMax latMax, lngMin latMax, lngMin latMin))', 4326); SELECT TOP @cound id, image75Path, geoTag.Lat as Lat, geoTag.Long as Lng, popularity, width, height FROM Photo WITH (INDEX(IX_Photo_geoTag)) WHERE @rect.STContains(geoTag) = 1 ORDER BY popularity DESC
Tenga en cuenta que el polígono está orientado en sentido antihorario y se IX_Photo_geoTag
índice espacial IX_Photo_geoTag
definido por las coordenadas (además, los índices espaciales se construyen utilizando árboles B ).
Sin embargo, resultó que en Microsoft SQL Server 2008, los índices espaciales no funcionan si la columna con geotipos puede aceptar valores NULL
, y un índice compuesto no puede contener una columna con tipo de datos de geografía, y esta pregunta se discutió en Stackoverflow . Es por eso que el rendimiento de tales consultas (sin índices) se vuelve muy bajo.
Los siguientes enfoques pueden resolver este problema:
- Como los valores
NULL
no se pueden usar, los valores predeterminados para esta columna son las coordenadas (0, 0) que apuntan a una ubicación en el Océano Atlántico cerca de África (el punto de partida para medir la latitud y la longitud). Sin embargo, en este lugar y en los alrededores se pueden ubicar los puntos reales, y las fotos que no están en el mapa deben ignorarse. Si cambia el punto cero (0, 0) al punto norte lejano (0, 90), entonces todo será mucho mejor, porque la latitud 90 puntos al borde del mapa, y debe ignorar este valor al construir la cuadrícula (p. Ej. construir hasta la latitud 89). - Usar SQL Server 2012 o superior y cambiar el nivel de compatibilidad de la base de datos a 110 o superior ejecutando
ALTER DATABASE database_name SET COMPATIBILITY_LEVEL = 110
. En esta versión de SQL Server, se corrigió el error con valores NULL
de geotipos y se agregó el soporte de polígonos de diferentes orientaciones (en sentido antihorario y horario).
A pesar de las amplias posibilidades de los geotipos (le permiten hacer no solo una selección simple como se muestra arriba, sino también usar distancias y diferentes polígonos), no los usamos en nuestro proyecto.
Selección normal
Para seleccionar fotos del área delimitada por coordenadas ( lngMin
latMin
) y ( latMax
lngMax
), utilice la siguiente consulta:
SELECT TOP @Count id, url, ... FROM Photo WHERE latitude > @latMin AND longitude > @lngMin AND latitude < @latMax AND longitude < @lngMax ORDER BY popularity DESC
Tenga en cuenta que en este caso puede crear cualquier índice para los campos de latitude
y longitude
(en contraste con el primer método), porque se utiliza un tipo de datos flotante ordinario. Sin embargo, hay 4 operaciones de comparación en esta selección.
Usar tabla hash adicional
La solución más óptima al problema de seleccionar fotos de ciertas áreas es crear Zooms
tabla adicionales que almacene cadenas que contengan hashes de áreas para cada zoom, como se muestra a continuación.
Se puede utilizar la siguiente consulta SQL ( zn
- nivel de zoom actual):
DECLARE @hash float; SET @hash = (@latMin + 90) + (@lngMin + 180) * 180 + (@latMax + 90) * 64800 + (@lngMax + 180) * 11664000; SELECT TOP @Count id, url, ... FROM Photo WHERE id = (SELECT id FROM Zooms WHERE zn = @hash)
La desventaja de este enfoque es que la tabla adicional ocupa espacio de memoria adicional.
A pesar de las ventajas de este último método, utilizamos el segundo método ( Selección normal ) en el servidor, ya que mostró un buen rendimiento.
Almacenamiento en caché de fotos para acceso multiproceso
Después de extraer la información de la base de datos de una forma u otra, las fotos se colocan en la memoria caché del servidor utilizando un objeto de sincronización para admitir el subprocesamiento múltiple de la siguiente manera:
private static object SyncObject = new object(); ... List<Photo> photos = (List<Photo>)CachedAreas[hash]; if (photos == null) {
Esta sección describe la funcionalidad del servidor para recuperar fotos de la base de datos y guardarlas en la memoria caché. La siguiente sección describirá lo que sucede en el lado del cliente en el navegador.
Lado del cliente
Para visualizar el mapa y las fotos en él, se utilizó la API de Google Maps. Primero, el mapa del usuario debe moverse a un lugar determinado, correspondiente a la geolocalización de las fotos.
Inicializando el mapa
Hay dos formas de determinar la geolocalización al inicializar el mapa: usar las capacidades de HTML5 o usar coordenadas precalculadas para las regiones.
Determinando la geolocalización usando HTML5
function detectRegion() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(success); } else { map.setZoom(defaultZoom); map.setCenter(defaultPoint); } } function success(position) { ... map.setZoom(defaultZoom); map.setCenter(new google.maps.LatLng(position.coords.latitude, position.coords.longitude)); }
La desventaja de este enfoque es que no todos los navegadores admiten esta funcionalidad de HTML5 y el usuario puede no permitir el acceso a la información geográfica en su dispositivo.
El mapa se inicializa en la sección del código fuente a continuación, donde los bounds
son las coordenadas de la región (área poblada, región o país) devuelta por el servidor. El nivel de zoom aproximado se calcula en la función getZoomFromBounds
(tomada de stackoverflow ).
var northEast = bounds.getNorthEast(); var southWest = bounds.getSouthWest(); var myOptions = { zoom: getZoomFromBounds(northEast, southWest), center: new google.maps.LatLng((northEast.lat() + southWest.lat()) / 2, (northEast.lng() + southWest.lng()) / 2), mapTypeId: google.maps.MapTypeId.ROADMAP, minZoom: 3, maxZoom: 19 } map = new google.maps.Map(document.getElementById("map_canvas"), myOptions);
function getZoomFromBounds(ne, sw) { var GLOBE_WIDTH = 256;
En el servidor, las regiones se calculan en función de la dirección IP del usuario. Para agregar todas las coordenadas de los límites de cada región, se utilizó la API de geocodificación de Google , aunque no es legítimo utilizar dicha información sin conexión; Además, hay un límite de 2500 solicitudes por día. Para cada ciudad, región y país de nuestra base de datos, se generó una consulta que devolvió los límites requeridos de viewport
y bounds
. Difieren solo para grandes áreas que no pueden encajar completamente en la ventana gráfica. Si el servidor devolvió un error, se utilizaron otras consultas en las que se utilizó el idioma nativo de esa región o el inglés, se eliminó la parte {Área poblada}, etc. http://maps.googleapis.com/maps/api/geocode/xml?address={Country},{Region},{Populated area}&sensor=false
Por ejemplo, para la siguiente consulta: http://maps.googleapis.com/maps/api/geocode/xml?address=Russia, Ivanovo% 20 area, Ivanovo & sensor = false
Se devolverán las siguientes coordenadas (fragmento) ... <location> <lat>56.9951313</lat> <lng>40.9796047</lng> </location> <location_type>APPROXIMATE</location_type> <viewport> <southwest> <lat>56.9420231</lat> <lng>40.8765941</lng> </southwest> <northeast> <lat>57.0703221</lat> <lng>41.0876169</lng> </northeast> </viewport> <bounds> <southwest> <lat>56.9420231</lat> <lng>40.8765941</lng> </southwest> <northeast> <lat>57.0703221</lat> <lng>41.0876169</lng> </northeast> </bounds> ...
Calcular áreas rectangulares parcialmente visibles
Calcular el tamaño de las áreas de almacenamiento en caché
Entonces, como se mencionó anteriormente, todas las fotos en el lado del cliente y del servidor se almacenan en caché por áreas rectangulares, cuyo punto de partida es un punto arbitrario (en nuestro caso, el punto con coordenadas (0, 0)), y el tamaño se calcula dependiendo en el nivel de zoom actual de la siguiente manera:
Por lo tanto, en cada nivel de zoom, el tamaño del área rectangular es 0.75^2=0.5625
del tamaño de la ventana 0.75^2=0.5625
actual, si tiene un ancho de 1080px y una altura de 500px.
Usar retraso al volver a dibujar
Dado que volver a dibujar todas las fotos en el mapa no es una operación rápida (como se mostrará más adelante), decidimos hacerlo con cierto retraso después de la entrada del usuario:
google.maps.event.addListener(map, 'bounds_changed', function () { if (boundsChangedInverval != undefined) clearInterval(boundsChangedInverval); var zoom = map.getZoom(); boundsChangedInverval = setTimeout(function () { boundsChanged(); }, prevZoom === zoom ? moveUpdateDelay : zoomUpdateDelay); prevZoom = zoom; });
Cálculo de coordenadas y hashes de áreas parcialmente visibles.
El cálculo de coordenadas y hashes de todos los rectángulos que se superponen a la ventana visible con coordenadas ( latMin
, lngMin
) y dimensiones calculadas usando el algoritmo descrito anteriormente se realiza de la siguiente manera:
var s = zoomSizes[zoom]; var beginLat = Math.floor((latMin - initPoint.x) / s.width) * s.width + initPoint.x; var beginLng = Math.floor((lngMin - initPoint.y) / s.height) * s.height + initPoint.y; var lat = beginLat; var lng = beginLng; if (lngMax <= beginLng) beginLng = beginLng - 360; while (lat <= maxlat) { lng = beginLng; while (lng <= maxLng) {
Después de eso, para cada área, se llama a la siguiente función, que envía la solicitud al servidor, si es necesario. La fórmula del cálculo hash devuelve un valor único para cada área, porque el punto de partida y las dimensiones son fijos.
function loadIfNeeded(lat, lng) { var hash = calculateHash(lat, lng, zoom); if (!(hash in items)) {
Redibujando las fotos mostradas
Después de que todas las fotos se descargan o extraen del caché, algunas de ellas deben volver a dibujarse. Con una gran cantidad de fotos, o marcadores, en un lugar, algunas de ellas deberían estar ocultas, pero luego no queda claro cuántas fotos se encuentran en este lugar. Para resolver este problema, decidimos admitir dos tipos de marcadores: marcadores que muestran fotos y marcadores que muestran que hay fotos en este lugar. Además, si todos los marcadores están ocultos cuando se cambian los límites, y luego se vuelven a mostrar, el usuario puede notar un parpadeo. Para resolver estos problemas, se desarrolló el siguiente algoritmo:
- Extraer todas las fotos visibles del caché del cliente a la matriz
visMarks
. El cálculo de estas áreas con fotos se describió anteriormente. - Ordenar los marcadores recibidos por popularidad.
- Encontrar marcadores superpuestos usando
markerSize
, SmallMarkerSize
, minPhotoDistRatio
y pixelDistance
. - Creación de matrices de marcadores grandes con
maxBigVisPhotosCount
y marcadores pequeños con maxSmlVisPhotosCount
. - Definir marcadores antiguos que deben ocultarse y agregarlos a
smlMarksToHide
y bigMarksToHide
utilizando refreshMarkerArrays
. - Actualización del índice de visibilidad y zoom
zIndex
para los nuevos marcadores que deben mostrarse usando updateMarkersVis
. - Agregar fotos, que se hicieron visibles en el momento actual, al feed usando
addPhotoToRibbon
.
Algoritmo para el recálculo de marcadores visibles. function redraw() { isRedrawing = true; var visMarker; var visMarks = []; var visBigMarks2; var visSmlMarks2; var bigMarksToHide = []; var smlMarksToHide = []; var photo; var i, j; var bounds = map.getBounds(); var northEast = bounds.getNorthEast(); var southWest = bounds.getSouthWest(); var latMin = southWest.lat(); var lngMin = southWest.lng(); var latMax = northEast.lat(); var lngMax = northEast.lng(); var ratio = (latMax - latMin) / $("#map_canvas").height(); var zoom = map.getZoom(); visMarks = []; var k = 0; var s = zoomSizes[zoom]; var beginLat = Math.floor((latMin - initPoint.x) / s.width) * s.width + initPoint.x; var beginLng = Math.floor((lngMin - initPoint.y) / s.height) * s.height + initPoint.y; var lat = beginLat; var lng = beginLng; i = 0; if (lngMax <= beginLng) beginLng = beginLng - 360;
Distancia en el mapa
Para calcular la distancia entre dos puntos en el mapa en píxeles , se utiliza la siguiente función:
var Offset = 268435456; var Radius = 85445659.4471; function pixelDistance(latLng1, latLng2, zoom) { var x1 = lonToX(latLng1.lng()); var y1 = latToY(latLng1.lat()); var x2 = lonToX(latLng2.lng()); var y2 = latToY(latLng2.lat()); return Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)) >> (21 - zoom); } function lonToX(lng) { return Math.round(Offset + Radius * lng * Math.PI / 180); } function latToY(lat) { return Math.round(Offset - Radius * Math.log((1 + Math.sin(lat * Math.PI / 180)) / (1 - Math.sin(lat * Math.PI / 180))) / 2); }
Esta función también se encontró en stackoverflow.
Para hacer que los marcadores se vean como círculos con fotos (como vkontakte), se usó el complemento RichMarker con la adición de un estilo arbitrario al elemento div.
Conclusión
Resultó que para mostrar fotos en el mapa de forma rápida y correcta, necesitábamos resolver problemas bastante interesantes y no triviales relacionados con el almacenamiento en caché y la geometría esférica. A pesar del hecho de que no todos los métodos descritos se utilizaron realmente en nuestro proyecto, el tiempo no se desperdició, ya que la experiencia que obtenemos podría ser útil en otros proyectos, y también podría ser útil para aquellos que leyeron y entendieron este artículo.