Memilih, menyimpan dan menampilkan foto di peta

Dalam artikel ini, saya memutuskan untuk menjelaskan bagaimana fungsionalitas memilih dan menampilkan foto pada tempat tertentu di peta diimplementasikan dalam layanan foto kami gfranq.com . Layanan foto tidak berfungsi sekarang.



Karena kami memiliki banyak foto di layanan kami dan mengirimkan permintaan ke database setiap kali perubahan viewport terlalu intensif sumber daya, logis untuk membagi peta menjadi beberapa area yang berisi informasi tentang data yang diambil. Untuk alasan yang jelas, area ini memiliki bentuk persegi panjang (meskipun kotak heksagonal dianggap juga). Ketika area menjadi lebih bulat pada skala besar, elemen geometri bola dan alat untuknya juga dipertimbangkan.


Dalam artikel ini, masalah berikut ini muncul:


  • Menyimpan dan mengambil foto dari database dan menyimpannya di server (SQL, C #, ASP.NET).
  • Mengunduh foto-foto yang diperlukan di sisi klien dan menyimpannya ke cache klien (JavaScript).
  • Perhitungan ulang foto yang harus disembunyikan atau ditampilkan ketika viewport berubah.
  • Elemen geometri bola.

Isi



Bagian server


Metode berikut untuk memilih dan menyimpan informasi geoinformasi di dalam basis data dirancang:


  • Tipe data geografi bawaan SQL Server.
  • Seleksi normal dengan batasan.
  • Menggunakan tabel tambahan.

Selanjutnya, metode-metode ini akan dijelaskan secara rinci.


Geotipe bawaan


Seperti diketahui, SQL Server 2008 mendukung tipe data geografi dan geometri, yang memungkinkan untuk menentukan informasi geografis (di bola) dan geometri (di pesawat), seperti titik, garis, poligon, dll. . Untuk mengambil semua foto yang dilingkupi oleh persegi panjang dengan koordinat ( lngMin latMin ) dan ( latMax lngMax ), Anda dapat menggunakan kueri berikut:


 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 

Perhatikan bahwa poligon berorientasi berlawanan arah jarum jam dan indeks spasial IX_Photo_geoTag ditentukan oleh koordinat yang digunakan (selain itu, indeks spasial dibangun menggunakan B-tree ).


Namun, ternyata di Microsoft SQL Server 2008, indeks spasial tidak berfungsi jika kolom dengan geotipe dapat menerima nilai NULL , dan indeks komposit tidak dapat berisi kolom dengan tipe data geografi, dan pertanyaan ini dibahas di Stackoverflow . Itu sebabnya kinerja permintaan seperti itu (tanpa indeks) menjadi sangat rendah.


Pendekatan berikut dapat memecahkan masalah ini:


  • Karena nilai NULL tidak dapat digunakan, nilai default untuk kolom ini adalah koordinat (0, 0) yang menunjuk ke lokasi di Samudra Atlantik dekat Afrika (titik awal untuk mengukur lintang dan bujur). Namun, di tempat ini serta di dekat titik nyata dapat ditemukan, dan foto-foto yang bukan dari peta harus diabaikan. Jika Anda mengubah titik nol (0, 0) menjadi titik utara jauh (0, 90), maka semuanya akan jauh lebih baik, karena garis lintang 90 mengarah ke tepi peta, dan Anda harus mengabaikan nilai ini saat membangun kisi (mis. membangun hingga garis lintang 89).
  • Menggunakan SQL Server 2012 atau lebih tinggi dan mengubah tingkat kompatibilitas database menjadi 110 atau lebih tinggi dengan mengeksekusi ALTER DATABASE database_name SET COMPATIBILITY_LEVEL = 110 . Dalam versi SQL Server ini, bug dengan nilai NULL dari geotip diperbaiki dan dukungan poligon dengan orientasi yang berbeda (berlawanan arah jarum jam dan searah jarum jam) ditambahkan.

Terlepas dari berbagai kemungkinan geotipe (mereka memungkinkan Anda untuk membuat tidak hanya pilihan sederhana seperti yang ditunjukkan di atas, tetapi juga untuk menggunakan jarak dan poligon yang berbeda), kami tidak menggunakannya dalam proyek kami.


Seleksi normal


Untuk memilih foto dari area yang dibatasi oleh koordinat ( lngMin latMin ) dan ( latMax lngMax ), gunakan kueri berikut:


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

Perhatikan bahwa dalam hal ini Anda dapat membuat indeks untuk bidang latitude dan longitude (berbeda dengan metode pertama), karena tipe data float biasa digunakan. Namun, ada 4 operasi perbandingan dalam pemilihan ini.


Menggunakan tabel hash tambahan


Solusi paling optimal untuk masalah memilih foto dari area tertentu adalah dengan membuat Zooms tabel tambahan yang menyimpan string yang berisi hash area untuk setiap zoom, seperti yang ditunjukkan di bawah ini.



Query SQL berikut dapat digunakan ( zn - level zoom saat ini):


 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) 

Kerugian dari pendekatan ini adalah bahwa tabel tambahan menempati ruang memori tambahan.


Terlepas dari kelebihan metode terakhir, kami menggunakan metode kedua ( pemilihan normal ) di server, karena menunjukkan kinerja yang baik.


Menyimpan foto untuk akses multi-utas


Setelah mengekstraksi informasi dari database dengan satu atau lain cara, foto ditempatkan di cache server menggunakan objek sinkronisasi untuk mendukung multithreading sebagai berikut:


 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] 

Bagian ini menjelaskan fungsionalitas server untuk mengambil foto dari database dan menyimpannya ke cache. Bagian selanjutnya akan menjelaskan apa yang terjadi di sisi klien di browser.


Sisi klien


Untuk memvisualisasikan peta dan foto di dalamnya, Google Maps API digunakan. Pertama, peta pengguna harus dipindahkan ke tempat tertentu, sesuai dengan geolokasi foto.


Menginisialisasi peta


Ada dua cara untuk menentukan geolokasi saat menginisialisasi peta: untuk menggunakan kemampuan HTML5 atau menggunakan koordinat yang sudah dihitung sebelumnya untuk kawasan.


Menentukan geolokasi menggunakan 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)); } 

Kelemahan dari pendekatan ini adalah tidak semua browser mendukung fungsionalitas HTML5 ini dan pengguna mungkin tidak mengizinkan akses ke geoinformasi pada perangkatnya.


Menentukan geolokasi menggunakan informasi dari server


Peta diinisialisasi di bagian kode sumber di bawah ini, di mana bounds adalah koordinat wilayah (area yang dihuni, wilayah atau negara) yang dikembalikan oleh server. Level perkiraan zoom dihitung dalam fungsi getZoomFromBounds (diambil dari 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); } 

Di server, wilayah dihitung tergantung pada alamat IP pengguna. Untuk mengagregasi semua koordinat batas untuk setiap wilayah, google geocoding api digunakan, meskipun tidak sah untuk menggunakan informasi tersebut secara offline; selain itu, ada batas 2.500 permintaan per hari. Untuk setiap kota, wilayah, dan negara dari basis data kami, sebuah kueri dihasilkan yang mengembalikan batas-batas viewport dan bounds . Mereka berbeda hanya untuk area besar yang tidak dapat sepenuhnya masuk ke viewport. Jika server mengembalikan kesalahan, maka kueri lain digunakan di mana bahasa asli dari wilayah itu atau Inggris digunakan, bagian {Populated area} telah dihapus, dll. http://maps.googleapis.com/maps/api/geocode/xml?address={Country},{Region},{Populated area}&sensor=false


Misalnya, untuk permintaan berikut: http://maps.googleapis.com/maps/api/geocode/xml?address=Russia, wilayah Ivanovo% 20, Ivanovo & sensor = false


Koordinat berikut akan dikembalikan (fragmen)
 ... <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> ... 

Menghitung area persegi yang terlihat sebagian


Menghitung ukuran area caching


Jadi, seperti yang disebutkan sebelumnya, semua foto di sisi klien dan server di-cache oleh area persegi panjang, titik awal yang merupakan titik arbitrer (dalam kasus kami titik dengan koordinat (0, 0)), dan ukurannya dihitung tergantung pada tingkat zoom saat ini sebagai berikut:


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

Dengan demikian, pada setiap level zoom, ukuran area persegi panjang adalah 0.75^2=0.5625 dari ukuran viewport saat ini, jika memiliki lebar 1080px dan tinggi 500px.


Menggunakan penundaan saat menggambar ulang


Karena menggambar ulang semua foto di peta bukan operasi cepat (seperti yang akan ditampilkan nanti), kami memutuskan untuk melakukannya dengan beberapa penundaan setelah input pengguna:


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

Menghitung koordinat dan hash area yang terlihat sebagian


Perhitungan koordinat dan hash dari semua persegi panjang yang tumpang tindih dengan jendela terlihat dengan koordinat ( latMin , lngMin ) dan dimensi yang dihitung menggunakan algoritma yang dijelaskan sebelumnya dilakukan sebagai berikut:



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

Setelah itu, untuk setiap area, fungsi berikut dipanggil, yang mengirimkan permintaan ke server, jika perlu. Rumus perhitungan hash mengembalikan nilai unik untuk setiap area, karena titik awal dan dimensi ditetapkan.


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

Menggambar ulang foto yang ditampilkan


Setelah semua foto diunduh atau diekstrak dari cache, beberapa di antaranya perlu digambar ulang. Dengan sejumlah besar foto, atau spidol, di satu tempat, beberapa di antaranya harus disembunyikan, tetapi kemudian menjadi tidak jelas berapa banyak foto yang berada di tempat ini. Untuk mengatasi masalah ini, kami memutuskan untuk mendukung dua jenis penanda: penanda yang menampilkan foto dan penanda yang menunjukkan bahwa ada foto di tempat ini. Selain itu, jika semua penanda disembunyikan ketika batas diubah, dan kemudian ditampilkan kembali, maka pengguna dapat melihat kerlipan. Untuk mengatasi masalah ini, algoritma berikut dikembangkan:


  1. Mengekstrak semua foto yang terlihat dari cache klien ke array visMarks . Perhitungan area ini dengan foto dijelaskan di atas.
  2. Menyortir marker yang diterima berdasarkan popularitas.
  3. Menemukan marker yang tumpang tindih menggunakan markerSize , SmallMarkerSize , minPhotoDistRatio dan pixelDistance .
  4. Membuat array marker besar dengan maxBigVisPhotosCount dan marker kecil dengan maxSmlVisPhotosCount .
  5. Menentukan marker lama yang harus disembunyikan dan menambahkannya ke smlMarksToHide dan bigMarksToHide menggunakan refreshMarkerArrays .
  6. Memperbarui visibilitas dan indeks zoom zIndex untuk penanda baru yang harus ditampilkan menggunakan updateMarkersVis .
  7. Menambahkan foto, yang menjadi terlihat saat ini, ke umpan menggunakan addPhotoToRibbon .

Algoritma untuk perhitungan ulang marker yang terlihat
 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 }); } } } 

Jarak di peta


Untuk menghitung jarak antara dua titik pada peta dalam piksel , fungsi berikut digunakan:


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

Fungsi ini juga ditemukan pada stackoverflow.


Untuk membuat marker tampak seperti lingkaran dengan foto (seperti vkontakte), plugin RichMarker digunakan dengan penambahan gaya arbitrer ke elemen div.


Kesimpulan


Ternyata untuk menampilkan foto di peta dengan cepat dan benar, kami harus menyelesaikan masalah yang cukup menarik dan non-sepele terkait dengan caching dan geometri bola. Terlepas dari kenyataan bahwa tidak semua metode yang dijelaskan benar-benar digunakan dalam proyek kami, waktu tidak terbuang sia-sia, karena pengalaman yang kami dapatkan mungkin berguna dalam proyek lain, dan mungkin juga berguna bagi mereka yang telah membaca dan memahami artikel ini.

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


All Articles