この記事では、マップの特定のセクションで写真をサンプリングして表示する機能が、 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
、空間インデックスIX_Photo_geoTag
ことに注意してください。これは座標によって構築されます(ところで、空間インデックスはBツリーの原理によって機能します )。
ただし、Microsoft SQL Server 2008では、ジオタイプを持つ列がNULL
値を受け入れることができる場合、空間インデックスは機能せず、複合インデックスにタイプgeographyの列を含めることができないため、この質問はstackoverflowで発生しました 。 このため、このようなクエリ(インデックスなし)のパフォーマンスは非常に低くなります。
この問題の解決策として、以下を提案できます。
-
NULL
値は使用できないため、デフォルトでは、この列には、アフリカ近くの大西洋のポイントを指す座標(0 0)が含まれます(そこから、経度と緯度がカウントされます)。 ただし、この時点とその近くでは、実際の地点も特定できます。つまり、地図上にない写真はどうにかして無視する必要があります。 ポイント(0 0)を(0 90)に変更すると、緯度90は実際に地図の端を示すため、すべてがより良くなります。つまり、グリッドを除外するとこの緯度が除外されます(つまり、89にビルドされます)。 -
ALTER DATABASE database_name SET COMPATIBILITY_LEVEL = 110
を使用して、SQL Serverを110にアップグレードします。 このバージョンでは、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
この場合、通常の浮動小数点型であるため、 latitude
とlongitude
フィールドのインデックスを作成できることに注意してください(最初のオプションとは異なります)。 ただし、このサンプルには4つの比較があります。
ハッシュを持つ追加のテーブルを使用する
特定の領域から写真を選択する問題に対する最も最適な解決策は、次の図に示すように、各ズームの領域のハッシュを含む線をZooms
する追加のズームテーブルを作成することです。
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)
このアプローチの欠点は、追加のテーブルが追加のメモリ領域を占有することです。
後者の方法の利点にもかかわらず、通常の選択を行う2番目のオプションは、非常に優れたパフォーマンスを示したため、サーバーに実装されました。
マルチスレッドの写真キャッシュ
何らかの方法でデータベースから情報が抽出された後、マルチスレッドをサポートする同期オブジェクトを使用して、写真が次のようにサーバーキャッシュに配置されます。
private static object SyncObject = new object(); ... List<Photo> photos = (List<Photo>)CachedAreas[hash]; if (photos == null) { // , 1 . lock (SyncObject) { photos = (List<Photo>)CachedAreas[hash]; if (photos == null) { photos = PhotoList.GetAllFromRect(latMin, lngMin, latMax, lngMax, count); // 2 . CachedAreas.Add(hash, photos, null, DateTime.Now.AddSeconds(120), Cache.NoSlidingExpiration, CacheItemPriority.High, null); } } } // CachedAreas[hash]
このセクションでは、データベースから写真を取得してキャッシュに保存するためのサーバー機能について説明しました。 次のセクションでは、ブラウザでクライアント側で何が起こるかを説明します。
クライアント部
地図と地図上の写真を視覚化するために、Google Maps APIが使用されました。 最初に、ユーザーの地図を地理位置に対応する特定の適切な場所に移動する必要があります。
マップの初期化
マップを初期化するときに場所を決定するには、2つの方法があります。 1つ目はHTML5の機能を使用することであり、2つ目はすべての領域に対して事前に計算された座標を使用することです。
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 geocoding apiが使用されましたが 、このような情報をオフラインで使用することは正当ではありません。さらに、1日あたり2500件のリクエストという制限があります。 都市、地域、国ごとに、データベースから要求が生成され、目的のviewport
とbounds
境界が返されました。 ビューポートに完全に収まらない大きな領域でのみ異なります。 同時に、回答がエラーで返された場合は、地域の母国語または英語での記述を組み合わせた他のリクエストを使用し、{Settlement}などの部分を削除しましたhttp://maps.googleapis.com/maps/api/geocode/xml?address={},{/},{ }&sensor=false
たとえば、そのようなリクエストの場合: http : //maps.googleapis.com/maps/api/geocode/xml?address=Russia、Ivanovo%20 area、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)の点)であり、サイズは現在の近似レベルに応じて計算されます(ズーム)次のように:
// , initMapSizeLat initMapSizeLng var initDefaultDimX = 1000, var initDefaultDimY = 800; // , . var currentDefaultDimX = 1080, var currentDefaultDimY = 500; var initMapSizeLat = 0.0003019; var initMapSizeLng = 0.00067055; // () . var initRatio = 0.75; // // .. initMapSizeLat initMapSizeLng . var initZoomSize = new google.maps.Size( initMapSizeLat / initDefaultDimX * currentDefaultDimX * initRatio, initMapSizeLng / initDefaultDimY * currentDefaultDimY * initRatio); // , ( 2, 1, 2 , - 4). 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および高さheight = 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; });
部分的に見える領域の座標とハッシュの計算
前述のアルゴリズムに従って計算された座標( latMin
、 lngMin
)と次元を持つ可視ウィンドウに重なるすべての長方形の座標とハッシュの計算は次のとおりです。
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 normalizeLng(lng) . // - , 180 -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)) { // . } else { // . } } function calculateHash(lat, lng, zoom) { // lat: [-90..90] // lng: [-180..180] return (lat + 90) + ((lng + 180) * 180) + (zoom * 64800); }
表示された写真の再描画
すべての写真をロードまたはキャッシュから抽出した後、それらの一部を再描画する必要があります。 1か所に大量の写真、またはマーカーが蓄積されているため、それらのいくつかを非表示にすることが望まれますが、この場所にいくつの写真があるのかが不明確になります。 この問題を解決するため、写真を表示するマーカーと、この場所に写真があることを示すマーカーの2種類のマーカーをサポートすることにしました。 また、境界線を変更するときにすべてのマーカーが非表示になってから再表示されると、ちらつきが目立ちます。 上記の問題を解決するために、次のアルゴリズムが開発されました。
- すべての表示可能な写真をクライアントキャッシュから
visMarks
配列に抽出します。 写真を使用したこれらの領域の計算については、前述のとおりです。 - 受信したマーカーを人気順に並べ替えます。
-
markerSize
、smallMarkerSize
、minPhotoDistRatio
およびpixelDistance
関数を使用して、オーバーラップマーカーを定義します。 -
maxBigVisPhotosCount
の数で大きなマーカーの配列を作成し、maxBigVisPhotosCount
の数で小さなマーカーの配列を作成します。 -
bigMarksToHide
を使用して、非表示にする古いマーカーを定義し、smlMarksToHide
およびbigMarksToHide
にそれらを入力します。 -
updateMarkersVis
を使用して表示する必要がある新しいマーカーの可視性とzIndex
深度インデックスを更新します。 - addPhotoToRibbonを使用して、現在トップリボンに表示されている写真を追加します。
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; // . 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; } // . 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; } }); // , . 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++; } } } // , smlMarksToHide bigMarksToHide . refreshMarkerArrays(visibleSmallMarkers, visSmlMarks2, smlMarksToHide); refreshMarkerArrays(visibleBigMarkers, visBigMarks2, bigMarksToHide); // zIndex. var curZInd = maxBigVisPhotosCount + 1; curZInd = updateMarkersVis(visBigMarks2, bigMarksToHide, true, curZInd); curZInd = 0; curZInd = updateMarkersVis(visSmlMarks2, smlMarksToHide, false, curZInd); visibleBigMarkers = visBigMarks2; visibleSmallMarkers = visSmlMarks2; // . 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 }); } } }
地図距離
マップ上の2点間の距離をピクセル単位で取得するには、次の関数を使用します。
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); }
この関数は、膨大なスタックオーバーフローでも見られました。
VKontakteのような写真で円のようにマーカーをスタイル設定するために、RichMarkerプラグインが使用され、div要素に任意のスタイルが追加されました。
おわりに
判明したように、マップ上の写真を迅速かつ正確に表示するためには、キャッシングと球体ジオメトリに関連するかなり面白くて自明でないタスクを解決する必要がありました。 使用されているすべてのメソッドが実際にプロジェクトで使用されているわけではないという事実にもかかわらず、時間は無駄になりませんでした。 経験を積むことは、他のプロジェクトで役立ちます。また、この記事を読んで掘り下げた人にとっても役に立つかもしれません。