Selecionando, armazenando em cache e exibindo fotos no mapa

Neste artigo, decidi descrever como a funcionalidade de selecionar e exibir fotos em um local específico no mapa foi implementada em nosso serviço de fotos gfranq.com . O serviço de foto não funciona agora.



Como tínhamos muitas fotos em nosso serviço e enviávamos solicitações para o banco de dados toda vez que a janela de visualização mudava muito, era lógico dividir o mapa em várias áreas que continham informações sobre os dados recuperados. Por razões óbvias, essas áreas têm uma forma retangular (embora a grade hexagonal também tenha sido considerada). À medida que as áreas se tornam mais esféricas em grandes escalas, também são considerados elementos de geometria esférica e ferramentas para isso.


Neste artigo, os seguintes problemas foram levantados:


  • Armazenando e recuperando fotos do banco de dados e armazenando em cache no servidor (SQL, C #, ASP.NET).
  • Fazendo o download das fotos necessárias no lado do cliente e salvando-as no cache do cliente (JavaScript).
  • Recálculo de fotos que devem ser ocultadas ou exibidas quando a janela de visualização é alterada.
  • Elementos de geometria esférica.

Conteúdo



Parte do servidor


Os seguintes métodos de seleção e armazenamento de informações geográficas no banco de dados foram projetados:


  • Tipo de dados geográficos interno do SQL Server.
  • Seleção normal com restrições.
  • Usando tabelas adicionais.

Além disso, esses métodos serão descritos em detalhes.


Geotipos incorporados


Como se sabe, o SQL Server 2008 oferece suporte a tipos de dados geográficos e geométricos, que permitem especificar informações geográficas (na esfera) e geométricas (no plano), como pontos, linhas, polígonos etc. . Para recuperar todas as fotos delimitadas por um retângulo com coordenadas ( lngMin latMin ) e ( latMax lngMax ), você pode usar a seguinte 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 

Observe que o polígono é orientado no sentido anti-horário e o índice espacial IX_Photo_geoTag definido pelas coordenadas é usado (além disso, os índices espaciais são construídos usando árvores B ).


Entretanto, no Microsoft SQL Server 2008, os índices espaciais não funcionam se a coluna com geotipos pode aceitar valores NULL e um índice composto não pode conter uma coluna com o tipo de dados geográficos, e essa questão foi discutida no Stackoverflow . É por isso que o desempenho dessas consultas (sem índices) se torna muito baixo.


As abordagens a seguir podem resolver esse problema:


  • Como os valores NULL não podem ser usados, os valores padrão desta coluna são as coordenadas (0, 0) que apontam para um local no Oceano Atlântico próximo a África (o ponto de partida para medir a latitude e longitude). No entanto, neste local e nas proximidades, os pontos reais podem ser localizados e as fotos que não são do mapa devem ser ignoradas. Se você alterar o ponto zero (0, 0) para o ponto norte (0, 90), tudo ficará muito melhor, porque a latitude 90 aponta para a borda do mapa e você deve ignorar esse valor ao criar a grade (ou seja, até latitude 89).
  • Usando o SQL Server 2012 ou superior e alterando o nível de compatibilidade do banco de dados para 110 ou superior, execute ALTER DATABASE database_name SET COMPATIBILITY_LEVEL = 110 . Nesta versão do SQL Server, o bug com valores NULL de geotipos foi corrigido e o suporte de polígonos de diferentes orientações (no sentido anti-horário e horário) foi adicionado.

Apesar das amplas possibilidades de geotipos (eles permitem que você faça não apenas uma seleção simples, como mostrado acima, mas também use distâncias e polígonos diferentes), nós não os usamos em nosso projeto.


Seleção normal


Para selecionar fotos da área delimitada pelas coordenadas ( lngMin latMin ) e ( latMax lngMax ), use a seguinte consulta:


 SELECT TOP @Count id, url, ... FROM Photo WHERE latitude > @latMin AND longitude > @lngMin AND latitude < @latMax AND longitude < @lngMax ORDER BY popularity DESC 

Observe que, nesse caso, você pode criar qualquer índice para os campos de latitude e longitude (em contraste com o primeiro método), porque um tipo de dados flutuante comum é usado. No entanto, existem 4 operações de comparação nesta seleção.


Usando tabela de hash adicional


A solução mais ideal para o problema de selecionar fotos de determinadas áreas é criar a tabela Zooms adicionais, que armazena seqüências contendo hashes de áreas para cada zoom, conforme mostrado abaixo.



A seguinte consulta SQL pode ser usada ( zn - nível de zoom atual):


 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) 

A desvantagem dessa abordagem é que a tabela adicional ocupa espaço adicional na memória.


Apesar das vantagens do último método, utilizamos o segundo método ( seleção Normal ) no servidor, pois apresentava bom desempenho.


Armazenando em cache fotos para acesso multiencadeado


Depois de extrair as informações do banco de dados de uma maneira ou de outra, as fotos são colocadas no cache do servidor usando o objeto de sincronização para suportar multithreading da seguinte maneira:


 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 seção descreveu a funcionalidade do servidor para recuperar fotos do banco de dados e salvá-las no cache. A próxima seção descreverá o que acontece no lado do cliente no navegador.


Lado do cliente


Para visualizar o mapa e as fotos, foi usada a API do Google Maps. Primeiro, o mapa do usuário deve ser movido para um determinado local, correspondendo à geolocalização das fotos.


Inicializando o mapa


Há duas maneiras de determinar a localização geográfica ao inicializar o mapa: usar os recursos do HTML5 ou usar coordenadas pré-calculadas para regiões.


Determinando a geolocalização 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)); } 

A desvantagem dessa abordagem é que nem todos os navegadores oferecem suporte a essa funcionalidade do HTML5 e o usuário pode não permitir o acesso às informações geográficas em seu dispositivo.


Determinando a geolocalização usando informações do servidor


O mapa é inicializado na seção do código fonte abaixo, onde bounds são as coordenadas da região (área preenchida, região ou país) retornada pelo servidor. O nível aproximado de zoom é calculado na função getZoomFromBounds (obtida do 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); } 

No servidor, as regiões são calculadas dependendo do endereço IP do usuário. Para agregar todas as coordenadas de limites de cada região, foi usada a API de geocodificação do Google , embora não seja legítimo usar essas informações offline; além disso, há um limite de 2500 solicitações por dia. Para cada cidade, região e país do nosso banco de dados, foi gerada uma consulta que retornava os limites necessários da viewport de viewport e dos bounds . Eles diferem apenas para grandes áreas que não cabem completamente na janela de exibição. Se o servidor retornou um erro, outras consultas foram usadas nas quais o idioma nativo dessa região ou inglês foi usado, a parte {Área povoada} foi removida etc. http://maps.googleapis.com/maps/api/geocode/xml?address={Country},{Region},{Populated area}&sensor=false


Por exemplo, para a seguinte consulta: http://maps.googleapis.com/maps/api/geocode/xml?address=Russia, área Ivanovo% 20, Ivanovo & sensor = false


As seguintes coordenadas serão retornadas (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> ... 

Cálculo de áreas retangulares parcialmente visíveis


Calculando o tamanho das áreas de armazenamento em cache


Portanto, como mencionado anteriormente, todas as fotos no lado do cliente e do servidor são armazenadas em cache por áreas retangulares, cujo ponto de partida é um ponto arbitrário (no nosso caso, o ponto com coordenadas (0, 0)) e o tamanho é calculado dependendo no nível de zoom atual da seguinte maneira:


 // 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; } } 

Portanto, em cada nível de zoom, o tamanho da área retangular é 0.75^2=0.5625 do tamanho da viewport atual, se tiver largura de 1080px e altura de 500px.


Usando atraso ao redesenhar


Como o redesenho de todas as fotos no mapa não é uma operação rápida (como será mostrado mais adiante), decidimos fazê-lo com algum atraso após a entrada do usuário:


 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 e hashes de áreas parcialmente visíveis


O cálculo de coordenadas e hashes de todos os retângulos que se sobrepõem à janela visível com coordenadas ( latMin , lngMin ) e dimensões calculadas usando o algoritmo descrito anteriormente é feito da seguinte maneira:



 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; } 

Depois disso, para cada área, é chamada a seguinte função, que envia a solicitação ao servidor, se necessário. A fórmula do cálculo de hash retorna um valor exclusivo para cada área, porque o ponto inicial e as dimensões são fixos.


 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); } 

Redesenhando as fotos exibidas


Depois que todas as fotos são baixadas ou extraídas do cache, algumas delas precisam ser redesenhadas. Com um grande número de fotos, ou marcadores, em um só lugar, algumas delas devem estar ocultas, mas depois fica claro quantas fotos estão localizadas nesse local. Para resolver esse problema, decidimos oferecer suporte a dois tipos de marcadores: marcadores exibindo fotos e marcadores mostrando que há fotos nesse local. Além disso, se todos os marcadores estiverem ocultos quando os limites forem alterados e forem exibidos novamente, o usuário poderá perceber tremulação. Para resolver esses problemas, o seguinte algoritmo foi desenvolvido:


  1. Extrair todas as fotos visíveis do cache do cliente para os visMarks da matriz. O cálculo dessas áreas com fotos foi descrito acima.
  2. Classificando os marcadores recebidos por popularidade.
  3. Localizando marcadores sobrepostos usando markerSize , SmallMarkerSize , minPhotoDistRatio e pixelDistance .
  4. Criando matrizes de marcadores grandes com maxBigVisPhotosCount e marcadores pequenos com maxSmlVisPhotosCount .
  5. Definir marcadores antigos que devem ser ocultados e adicioná-los a smlMarksToHide e bigMarksToHide usando refreshMarkerArrays .
  6. Atualizando o índice de visibilidade e zoom zIndex para novos marcadores que devem ser exibidos usando o updateMarkersVis .
  7. Adicionando fotos, que ficaram visíveis no momento, ao feed usando addPhotoToRibbon .

Algoritmo para recálculo de marcadores visíveis
 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 }); } } } 

Distância no mapa


Para calcular a distância entre dois pontos no mapa em pixels, a seguinte função é usada:


 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); } 

Essa função também foi encontrada no stackoverflow.


Para fazer os marcadores parecerem círculos com fotos (como vkontakte), o plug-in RichMarker foi usado com a adição de um estilo arbitrário ao elemento div.


Conclusão


Aconteceu que, para exibir fotos no mapa de maneira rápida e correta, precisávamos resolver problemas bastante interessantes e não triviais relacionados ao cache e à geometria esférica. Apesar do fato de que nem todos os métodos descritos foram realmente usados ​​em nosso projeto, o tempo não foi desperdiçado, pois a experiência que obtivemos pode ser útil em outros projetos e também para aqueles que leram e entenderam este artigo.

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


All Articles