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