在地图上选择,缓存和显示照片

在本文中,我决定描述如何在我们的照片服务gfranq.com中实现在地图上特定位置选择和显示照片的功能。 照片服务现在无法使用。



由于我们的服务中有很多照片,并且每次视口更改过于占用资源时都会向数据库发送请求,因此将地图划分为几个包含有关已检索数据的信息的区域是合乎逻辑的。 出于明显的原因,这些区域为矩形(尽管也考虑了六边形网格)。 随着区域在更大范围内变得越来越球形,还考虑了球形几何元素和用于它的工具。


本文中提出了以下问题:


  • 从数据库存储和检索照片,并将它们缓存在服务器(SQL,C#,ASP.NET)上。
  • 在客户端下载必要的照片并将其保存到客户端缓存(JavaScript)。
  • 重新计算视口更改时必须隐藏或显示的照片。
  • 球形几何元素。

目录内容



服务器部分


设计了以下选择地理信息并将其存储在数据库中的方法:


  • SQL Server内置的地理数据类型。
  • 正常选择有限制。
  • 使用其他表。

此外,将详细描述这些方法。


内置的地理类型


众所周知,SQL Server 2008支持地理和几何数据类型,这允许指定地理(在球上)和几何(在平面上)信息,例如点,线,多边形等。 。 为了检索由坐标为( lngMin latMin )和( latMax lngMax )的矩形包围的所有照片,可以使用以下查询:


 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 

注意,多边形的方向是逆时针方向,并且使用了由坐标定义的空间索引IX_Photo_geoTag (此外,空间索引是使用B树来构建的)。


但是,事实证明,在Microsoft SQL Server 2008中,如果具有地理类型的列可以接受NULL值,并且空间索引不能包含具有地理数据类型的列,那么空间索引将不起作用,并且在Stackoverflow上讨论了此问题。 这就是为什么这种查询(没有索引)的性能变得很低的原因。


以下方法可以解决此问题:


  • 由于无法使用NULL值,因此此列的默认值为坐标(0,0),该坐标指向非洲附近大西洋中的某个位置(用于测量纬度和经度的起点)。 但是,可以在此位置以及附近找到真实点,并且应该忽略非地图上的照片。 如果将零点(0,0)更改为最北点(0,90),那么一切都会好得多,因为纬度90指向地图的边缘,因此在构建网格时(例如,达到纬度89)。
  • 使用SQL Server 2012或更高版本,并通过执行ALTER DATABASE database_name SET COMPATIBILITY_LEVEL = 110将数据库的兼容性级别更改为110或更高。 在此版本的SQL Server中,固定了具有地理类型NULL值的错误,并添加了对不同方向(逆时针和顺时针)的多边形的支持。

尽管地理类型具有广泛的可能性(它们不仅使您可以如上所示进行简单选择,而且还可以使用距离和不同的多边形),但我们在项目中并未使用它们。


正常选择


要从坐标( lngMin latMin )和( latMax lngMax )限制的区域中选择照片,请使用以下查询:


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

请注意,在这种情况下,您可以为latitudelongitude字段创建任何索引(与第一种方法相反),因为使用了普通的float数据类型。 但是,此选择中有4个比较操作。


使用其他哈希表


对于从某些区域选择照片的问题,最理想的解决方案是创建额外的表Zoom,该表存储了包含每次缩放的区域哈希值的字符串,如下所示。



可以使用以下SQL查询( zn当前缩放级别):


 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) 

这种方法的缺点是额外的表占用了额外的内存空间。


尽管后一种方法有很多优点,但我们在服务器上仍使用了第二种方法(“ 正常选择” ),因为它表现出良好的性能。


缓存照片以进行多线程访问


在以一种或多种方式从数据库中提取信息之后,使用同步对象将照片放置在服务器缓存中,以支持多线程,如下所示:


 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] 

本节介绍了用于从数据库检索照片并将其保存到缓存的服务器功能。 下一部分将描述浏览器在客户端发生的情况。


客户端


为了可视化地图和上面的照片,使用了Google Maps API。 首先,必须将用户地图移动到与照片的地理位置相对应的特定位置。


初始化地图


初始化地图时,有两种方法可以确定地理位置:使用HTML5的功能或对区域使用预先计算的坐标。


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

这种方法的缺点是,并非所有浏览器都支持HTML5的此功能,并且用户可能不允许访问其设备上的地理信息。


使用服务器中的信息确定地理位置


该地图在下面的源代码部分中进行了初始化,其中的bounds是服务器返回的区域(填充区域,区域或国家/地区)的坐标。 近似缩放级别在函数getZoomFromBounds计算(从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); } 

在服务器上,将根据用户的IP地址来计算区域。 为了汇总每个区域的所有边界坐标,使用了Google地理编码api ,尽管离线使用此类信息是不合法的; 此外,每天最多有2500个请求。 对于我们数据库中的每个城市,地区和国家/地区,都会生成一个查询,该查询返回所需的viewport boundsbounds 。 它们仅针对无法完全放入视口的大区域而有所不同。 如果服务器返回错误,则使用其他查询,其中使用该地区的本地语言或英语,删除{Populated area}部分,依此类推。 http://maps.googleapis.com/maps/api/geocode/xml?address={Country},{Region},{Populated area}&sensor=false


例如,对于以下查询: http://maps.googleapis.com/maps/api/geocode/xml?address=Russia,Ivanovo% 20区域,Ivanovo&sensor = false


以下坐标将被返回(片段)
 ... <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> ... 

计算部分可见的矩形区域


计算缓存区域的大小


因此,如前所述,客户端和服务器端的所有照片均被矩形区域缓存,矩形区域的起点是任意点(在我们的示例中是坐标为(0,0)的点),其大小取决于在当前缩放级别上如下:


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

因此,在每个缩放级别,如果矩形区域的宽度为1080px,高度为500px,则矩形区域的大小为当前视口大小的0.75^2=0.5625


重绘时使用延迟


由于在地图上重新绘制所有照片并不是一项快速的操作(稍后将显示),因此我们决定在用户​​输入后进行一些延迟:


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

计算部分可见区域的坐标和哈希


使用可见的坐标( latMinlngMin )和尺寸(使用前面描述的算法计算)与可见窗口重叠的所有矩形的坐标和哈希的计算如下:



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

之后,对于每个区域,调用以下函数,如有必要,该函数会将请求发送到服务器。 哈希计算公式为每个区域返回唯一值,因为起点和尺寸是固定的。


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

重新绘制显示的照片


从高速缓存下载或提取所有照片后,其中一些照片需要重画。 在一个地方放有大量照片或标记时,其中一些应该被隐藏,但是现在不清楚在这个地方放置了多少张照片。 为了解决此问题,我们决定支持两种类型的标记:显示照片的标记和显示该位置有照片的标记。 另外,如果在更改边界时所有标记都被隐藏,然后重新显示它们,则用户可能会注意到闪烁。 为了解决这些问题,开发了以下算法:


  1. 将所有可见的照片从客户端缓存提取到visMarks数组。 上面描述了用照片计算这些区域的方法。
  2. 按受欢迎程度对收到的标记进行排序。
  3. 使用markerSizeSmallMarkerSizeminPhotoDistRatiopixelDistance查找重叠的标记。
  4. 使用maxBigVisPhotosCount创建大标记数组,并使用maxBigVisPhotosCount创建小标记maxSmlVisPhotosCount
  5. 定义应隐藏的旧标记,并使用refreshMarkerArrays将它们添加到smlMarksToHidebigMarksToHide中。
  6. 为应使用updateMarkersVis显示的新标记更新可见性和缩放索引zIndex
  7. 使用addPhotoToRibbon将当前可见的照片添加到feed中。

重新计算可见标记的算法
 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 }); } } } 

地图上的距离


要计算地图上两点之间的距离(以像素单位) ,请使用以下函数:


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

在stackoverflow上也发现了此功能。


为了使标记看起来像带有照片的圆圈(例如vkontakte),使用了RichMarker插件,并为div元素添加了任意样式。


结论


事实证明,为了在地图上快速正确地显示照片,我们需要解决与缓存和球形几何体相关的非常有趣且不平凡的问题。 尽管并非所有描述的方法实际上都在我们的项目中使用过,但并没有浪费时间,因为我们获得的经验可能对其他项目有用,并且对阅读和理解本文的人也可能有用。

Source: https://habr.com/ru/post/zh-CN440410/


All Articles