Seleccionar, almacenar en caché y mostrar fotos en el mapa

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) { // Use lock to avoid extracting from and adding to the cache more than once. lock (SyncObject) { photos = (List<Photo>)CachedAreas[hash]; if (photos == null) { photos = PhotoList.GetAllFromRect(latMin, lngMin, latMax, lngMax, count); // Adding information about photos to the cache with a storage time of 2 minutes with a high storage priority. CachedAreas.Add(hash, photos, null, DateTime.Now.AddSeconds(120), Cache.NoSlidingExpiration, CacheItemPriority.High, null); } } } // Further usage of CachedAreas[hash] 

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.


Determinar la geolocalización utilizando información del servidor


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; // a constant in Google's map projection var west = sw.lng(); var east = ne.lng(); var angle = east - west; if (angle < 0) { angle += 360; } return Math.round(Math.log($('#map_canvas').width() * 360 / angle / GLOBE_WIDTH) / Math.LN2); } 

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:


 // The initial window at which initMapSizeLat and initMapSizeLng were calculated var initDefaultDimX = 1000, var initDefaultDimY = 800; // The current default viewport which depends on the size of the areas. var currentDefaultDimX = 1080, var currentDefaultDimY = 500; var initMapSizeLat = 0.0003019; var initMapSizeLng = 0.00067055; // The coefficient of size reduction (increase). var initRatio = 0.75; // To calculate the size of the smallest caching area, the map was zoomed in to the maximum zoom level // Ie initMapSizeLat and initMapSizeLng were calculated empirically. var initZoomSize = new google.maps.Size( initMapSizeLat / initDefaultDimX * currentDefaultDimX * initRatio, initMapSizeLng / initDefaultDimY * currentDefaultDimY * initRatio); // All subsequent sizes of areas can be calculated based only on the smallest area (by multiplying each size by 2, because with increasing the zoom level by 1, the linear dimensions increase by 2 times, and the quadratic dimensions increase by 4 times). function initZoomSizes() { zoomSizes = []; var coef = 1; for (var i = 21; i >= 0; i--) { zoomSizes[i] = new google.maps.Size(initZoomSize.width * coef, initZoomSize.height * coef); coef *= 2; } } 

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) { // lat and normalizeLng(lng) coordinates are the coordinates of the overlapping rectangles. // Longitude normalization is used because the right boundary can be greater than 180 or the left boundary can be less than -180. loadIfNeeded(lat, normalizeLng(lng)); lng += s.height; } lat += s.width; } function normalizeLng(lng) { var rtn = lng % 360; if (rtn <= 0) rtn += 360; if (rtn > 180) rtn -= 360; return rtn; } 

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)) { // Send a query to the database and put this cell in the client cache. } else { // Do nothing. } } function calculateHash(lat, lng, zoom) { // lat: [-90..90] // lng: [-180..180] return (lat + 90) + ((lng + 180) * 180) + (zoom * 64800); } 

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:


  1. Extraer todas las fotos visibles del caché del cliente a la matriz visMarks . El cálculo de estas áreas con fotos se describió anteriormente.
  2. Ordenar los marcadores recibidos por popularidad.
  3. Encontrar marcadores superpuestos usando markerSize , SmallMarkerSize , minPhotoDistRatio y pixelDistance .
  4. Creación de matrices de marcadores grandes con maxBigVisPhotosCount y marcadores pequeños con maxSmlVisPhotosCount .
  5. Definir marcadores antiguos que deben ocultarse y agregarlos a smlMarksToHide y bigMarksToHide utilizando refreshMarkerArrays .
  6. Actualización del índice de visibilidad y zoom zIndex para los nuevos marcadores que deben mostrarse usando updateMarkersVis .
  7. 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; // Extracting all visible markers. while (lat <= latMax) { lng = beginLng; while (lng <= lngMax) { var hash = calcHash(lat, normLng(lng), zoom); if (!(hash in curItems)) { } else { var item = curItems[hash]; for (photo in item.photos) { if (bounds.contains(item.photos[photo].latLng)) { visMarks[i] = item.photos[photo]; visMarks[i].overlapCount = 0; i++; } } } k++; lng += s.height; } lat += s.width; } // Sorting markers by popularity. visMarks.sort(function (a, b) { if (b.priority !== a.priority) { return b.priority - a.priority; } else if (b.popularity !== a.popularity) { return b.popularity - a.popularity; } else { return b.id - a.id; } }); // Finding overlapping markers and markers that exceed a certain specified number. var curInd; var contains; var contains2; var dist; visBigMarks2 = []; visSmlMarks2 = []; for (i = 0; i < visMarks.length; i++) { contains = false; contains2 = false; visMarker = visMarks[i]; for (j = 0; j < visBigMarks2.length; j++) { dist = pixelDistance(visMarker.latLng, visBigMarks2[j].latLng, zoom); if (dist <= markerSize * minPhotoDistRatio) { contains = true; if (contains && contains2) break; } if (dist <= (markerSize + smallMarkerSize) / 2) { contains2 = true; if (contains && contains2) break; } } if (!contains) { if (visBigMarks2.length < maxBigVisPhotosCount) { smlMarksToHide[smlMarksToHide.length] = visMarker; visBigMarks2[visBigMarks2.length] = visMarker; } } else { bigMarksToHide[bigMarksToHide.length] = visMarker; if (!contains2 && visSmlMarks2.length < maxSmlVisPhotosCount) { visSmlMarks2[visSmlMarks2.length] = visMarker; } else { visBigMarks2[j].overlapCount++; } } } // Adding markers that should be hidden to smlMarksToHide and bigMarksToHide. refreshMarkerArrays(visibleSmallMarkers, visSmlMarks2, smlMarksToHide); refreshMarkerArrays(visibleBigMarkers, visBigMarks2, bigMarksToHide); // Hiding invisible markers and displaying visible markers when zIndex changes. var curZInd = maxBigVisPhotosCount + 1; curZInd = updateMarkersVis(visBigMarks2, bigMarksToHide, true, curZInd); curZInd = 0; curZInd = updateMarkersVis(visSmlMarks2, smlMarksToHide, false, curZInd); visibleBigMarkers = visBigMarks2; visibleSmallMarkers = visSmlMarks2; // Adding visible photos to the feed. trPhotosOnMap.innerHTML = ''; for (var marker in visBigMarks2) { addPhotoToRibbon(visBigMarks2[marker]); } isRedrawing = false; } function refreshMarkerArrays(oldArr, newArr, toHide) { for (var j = 0; j < oldArr.length; j++) { contains = false; var visMarker = oldArr[j]; for (i = 0; i < newArr.length; i++) { if (newArr[i].id === visMarker.id) { contains = true; break; } } if (!contains) { toHide[toHide.length] = visMarker; } } } function updateMarkersVis(showArr, hideArr, big, curZInd) { var marker; var bounds = map.getBounds(); for (var i = 0; i < showArr.length; i++) { var photo = showArr[i]; if (big) { marker = photo.bigMarker; $('#divOvlpCount' + photo.id).html(photo.overlapCount); } else { marker = photo.smlMarker; } marker.setZIndex(++curZInd); if (marker.getMap() === null) { marker.setMap(map); } } for (i = 0; i < hideArr.length; i++) { marker = big ? hideArr[i].bigMarker : hideArr[i].smlMarker; if (marker.getMap() !== null) { marker.setMap(null); marker.setZIndex(0); if (!bounds.contains(hideArr[i].latLng)) hideArr[i].priority = 0; } } return curZInd; } function addPhotoToRibbon(marker) { var td = createColumn(marker); if (isLatLngValid(marker.latLng)) { trPhotosOnMap.appendChild(td); } else { trPhotosNotOnMap.appendChild(td); if (photoViewMode == 'user') { var img = $("#photo" + marker.id).children()[0]; $('#photo' + marker.id).draggable({ helper: 'clone', appendTo: $('#map_canvas'), stop: function (e) { var mapBoundingRect = document.getElementById("map_canvas").getBoundingClientRect(); var point = new google.maps.Point(e.pageX - mapBoundingRect.left, e.pageY - mapBoundingRect.top); var latLng = overlay.getProjection().fromContainerPixelToLatLng(point); marker.latLng = latLng; marker.priority = ++curPriority; placeMarker(marker); }, containment: 'parent', distance: 5 }); } } } 

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.

Source: https://habr.com/ru/post/440410/


All Articles