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) {
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.
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;
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:
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) {
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)) {
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:
- 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. - Classificando os marcadores recebidos por popularidade.
- Localizando marcadores sobrepostos usando
markerSize
, SmallMarkerSize
, minPhotoDistRatio
e pixelDistance
. - Criando matrizes de marcadores grandes com
maxBigVisPhotosCount
e marcadores pequenos com maxSmlVisPhotosCount
. - Definir marcadores antigos que devem ser ocultados e adicioná-los a
smlMarksToHide
e bigMarksToHide
usando refreshMarkerArrays
. - Atualizando o índice de visibilidade e zoom
zIndex
para novos marcadores que devem ser exibidos usando o updateMarkersVis
. - 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;
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.