Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

Реализация выборки, кэширования и отрисовки фотографий на карте в фото-сервисе gfranq.com

Anna | 18.06.2014 | нет комментариев

В данной статье я решил описать как была реализована функциональность выборки и отображения фотографий на определенном участке карты в нашем фото-сервисе, как и обещал в предыдущей статье.

Так как теперь фотографий на нашем сервисе дюже много и посылать запросы к БД при всяком изменении окна просмотра слишком ресурсозатратно, разумно было разбить карту на участки, в которых хранилась бы информация об теснее извлеченных данных. Эти участки по абсолютно внятным причинам имеют прямоугольную форму (правда вариант с гексагональной сеткой тоже рассматривался).

Выходит, в данной статье будут затронуты следующие задачи:

  1. Хранение и выборка фотографий из БД и помещение их в серверный кэш (SQL, C#).
  2. Загрузка нужных фотографий на стороне заказчика и помещение их в клиентский кэш (JavaScript).
  3. Перерасчет фотографий, которые необходимо спрятать либо отобразить при всяком изменении окна просмотра (JavaScript).

Серверная часть

Были придуманы следующие методы выборки и хранения геоинформации в БД:

  • Встроенных географические типы SQL Server.
  • Обыкновенная выборка с ограничениями.
  • Применение дополнительной таблицы.

Дальше эти методы будут рассмотрены детально.

Встроенные геотипы

Как вестимо, в SQL Server 2008 возникла помощь типов geography и geometry, которые разрешают задавать географическую (на сфере) и геометрическую (на плоскости) информацию, такие как точки, линии, многоугольники и т.д. И для того, Дабы получить все фотографии, арестанты прямоугольником с координатами (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 значения, а также комбинированный индекс не может содержать в себе колонку с типом geography, и данный вопрос был затронут на stackoverflow. Из-за этого эффективность таких запросов (без индексов) становится дюже низкой.

В качестве решения такой задачи дозволено предложить следующее:

  • Так как NULL значения применять невозможно, то по-умолчанию в этой колонке находятся координаты (0 0), которую указывают на точку в Атлантическом океане неподалеку от Африки (Оттуда начинается отсчет долготы и широты). Впрочем в этой точке, а также неподалеку могут располагаться и настоящие точки, а значит фотографии не на карте необходимо как-то игнорировать. Если же изменить точку (0 0) на (0 90), то все будет значительно отменнее, потому что широта 90 указывает реально на край карты, а значит при построении сетки исключать эту широту (т.е. строить до 89).
  • Метаморфоза версии SQL Server до 110 посредством команды
    ALTER DATABASE database_name SET COMPATIBILITY_LEVEL = 110

Невзирая на широкие вероятности географических типов (а они разрешают изготавливать не только такую примитивную выборку, указанную в примере выше, но и применять расстояния, разные многоугольники), они не были использованы у нас в плане.

Обыкновенная выборка

Выборку фотографий из области, ограниченной координатами (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 в данном случае дозволено создавать всякие индексы (в различие от первого варианта), от того что они являются обыкновенными типами float. Впрочем в данной выборке присутствует 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]

В данном разделе была описана серверная функциональность для выборки фотографий из БД и их сохранения в кэш. В дальнейшем разделе будет описано то, что происходит на стороне заказчика.

Клиентская часть

Для визуализации карты и фотографий на ней применялось Googla Maps API. Для начала карту у пользователя необходимо переместить в определенное подходящее место.

Инициализация карты

Существуют два метода определения области навигации при инициализации карты. 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);
}

Для агрегации всех координат границ для всякого региона применялся google geocoding api. (Правда применение такой информации в оффлайне не является закономерным, помимо того там есть лимитация на 2500 запросов в день). Для всякого города, области и страны из нашей базы данных формировался запрос дальнейшего типа, тот, что возвращал желанные границы viewport и bounds (Они кстати разны только для крупных областей, которые не могут всецело поместиться в окно просмотра). При этом если возвращался результат с оплошностью, то применялись другие запросы, в котором комбинировалось написание на родном для этого региона языке либо английском, убиралась часть {Населенный пункт} и т. д.

http://maps.googleapis.com/maps/api/geocode/xml?address={Страна},{Область/Штат},{Населенный пункт}&sensor=false

Скажем для такого запроса:
http://maps.googleapis.com/maps/api/geocode/xml?address=Россия, Ивановская%20область, Иваново&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;
    }
}

Таким образом, на всяком ярусе зума, размер прямоугольной области по площади составляет 0.75^2=0.5625 от нынешнего окна просмотра, если его ширина = 1080px и высота = 500px.

Пересчет видимых фотографий при изменении размеров окна просмотра с задержкой

Так как перерисовка всех фотографий на карте не дюже стремительная операция (как будет показано позднее), то решено было ее сделать с определенной задержкой позже пользовательского ввода:

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);
}
Перерисовка отображаемых фотографий (маркеров)

Позже того, как все фотографии загружены либо извлечены из кэша, часть из них необходимо перерисовать. При большом собрании фотографий в одном месте, часть из них желанно необходимо скрывать, впрочем становится непостижимо, сколько именно фотографий располагается в данном месте. Для решения этой задачи было решено сделать поддержку 2-х типов маркеров: маркеры, отображающие фотографии и маркеры, отображающие, что в данном месте есть фотографии. Также если все маркеры скрывать при изменении границ, а потом снова их отображать, то будет приметно мерцание. Для решения вышеописанных задач был разработан дальнейший алгорифм:

  • Извлечение всех видимых фотографий из клиентского кэша в массив visMarks. Расчет данных областей с фотографиями был описаны выше.
  • Сортировка полученных маркеров по популярности.
  • Определение перекрывающихся маркеров с применением markerSizesmallMarkerSizeminPhotoDistRatio и функции pixelDistance.
  • Создание массивов из крупных маркеров с числом maxBigVisPhotosCount и маленьких с числом maxSmlVisPhotosCount.
  • Определение ветхих маркеров, которые необходимо спрятать и занесение их в smlMarksToHide иbigMarksToHide c поддержкой refreshMarkerArrays
  • Обновление видимости и индекса глубины (zIndex) для новых маркеров, которых необходимо отобразить с поддержкой updateMarkersVis
  • Добавление фотографий, которые стали видимыми в нынешний момент времени в ленту сверху с поддержкой 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
            });
        }
    }
}
Расстояние между двумя точками на карте в пикселях.

Применяется при перерасчете видимых маркеров и вычисляется дальнейшим образом. Данная функция тоже была обнаружена на просторах stackoverflow.

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

Для стилизации маркеров (Дабы они выглядели как кружки с фотографиями как вконтакте) применялся плагин richmarker с добавление произвольного жанра элементу div.

Посмотреть как работает разработанная функциональность дозволено скажем по данной ссылке:http://gfranq.com/vlada#map

Если данный топик оказался увлекательным либо пригодным для вас, то в дальнейшей статья я постараюсь описать еще что-нибудь из работы нашего обслуживания, новой функциональности и т.д.

Источник: programmingmaster.ru

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB