...

четверг, 8 мая 2014 г.

Таргетинг пользователей: регион, город, улица

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

Волею судьбы и заказчика (или судьбы заказчика или заказчика судьбы) такая задача, наконец-то, возникла — необходимо создать базу регионов, городов и улиц для сегментирования пользователей и реализовать удобную web-форму, собственно, для ее использования. Благо заказчик ориентировал свой бизнес на Россию, что резко упростило задачу.

Поиск по интернету готовых баз субъектов РФ особых результатов не принес — нашел базу КЛАДР, но она оказалась не очень-то актуальной. Порыскав дальше я наткнулся на пост КЛАДР умер, да здравствует ФИАС?. Спасибо sergpenza, теперь есть куда копать!


База ФИАС действительно оказалась максимально полной и актуальной, и даже слишком — в ней очень много ненужного. Еще один минус базы — она «плоская»: основная табличка — ADDROBJ.dbf, в ней содержатся области, районы, города и улицы и все это ссылается само на себя. Еще один минус — в ней нет списка регионов РФ. Но это просто — их можно с легкостью спарсить с сайта ГНИВЦ ФНС РОССИИ.


Не буду вдаваться в процесс перепиливания базы в реляционный вид — это рутина, да и ссылка на уже готовую базу есть внизу моего поста.


Отлично база есть. Нужно создать интерфейс для ее визуализации под web, на этой части я остановлюсь более подробно.

Интерфейс включает в себя фронт и бек-энд.



  1. Фронт-энд это html, js, jQuery

  2. Бек-энд MVC от MS (c#)


Фронт-энд




Задача: пользователь должен иметь возможность заполнить данные о своем месте пребывания, для этого он последовательно вводит регион, город и улицу.

Учитывая количество городов (более 160к) и количество улиц в каждом городе задача усложняется — использовать выпадающие списки отпадает, нужно предусмотреть какой-то механизм быстрого поиска и фильтрации. Конечно же, механизм должен быть универсальным и охватывать не только регион, но и города с улицами.

Такой механизм лучше всего реализовать в виде библиотеки, подключаемой в нужных местах на сайте. Назовем библиотеку jquery.locateme.js. По названию библиотеки понятно, что она зависима от jQuery. Изначально у меня была мысль написать плагин для jQuery в соответствии с идеологией фреймворка, но в итоге я от нее отказался.

Библиотека должна обладать следующими функциями:



  • поиск по все объектам БД (регионы, города, улицы)

  • вывод результатов поиска (как списком так и автозаполнением)

  • управление с клавиатуры (навигация по результатам поиска)

  • обработка всевозможных callback для масштабирования функционала




Реализация (скелет)


var locateMe = function (wrapperName, fieldName, fieldLabel, url, urlData, applyHandler, cancelHandler) {
var _this = this,
_urlData = urlData;

this.isApplied = false;
this.SearchInputLabel = $("<span>").addClass("label").attr("id", fieldName + "_label").html(fieldLabel);
this.SearchInput = $("<input/>").addClass("input_search").attr("id", fieldName).attr("type", "text");
this.SearchInputTip = $("<input/>").addClass("input_search_tip").attr("id", fieldName + "_tip").attr("type", "text");
this.SearchResultsTipId = $("<input/>").attr("id", fieldName + "_tip_id").attr("type", "hidden");
this.SearchResults = $("<div>").addClass("results").attr("id", fieldName + "_results");
this.SearchUrl = url;

return this;
};







Публичные функции


this.Reload = function (reloadValues) {
if (reloadValues) {
_this.SearchInput.val("");
_this.SearchInputTip.val("");
_this.SearchResultsTipId.val("");
_this.SearchResults.hide().empty();
}

_methods.setResultsPosition();
};
this.Dispose = function () {
this.isApplied = false;
this.SearchInputLabel.remove();
this.SearchInput.unbind().remove();
this.SearchInputTip.remove();
this.SearchResultsTipId.remove();
this.SearchResults.unbind().remove();

_this = null;
}
this.Disable = function (setDisabled) {
if (setDisabled) {
this.SearchInput.val("").attr("disabled", "disabled");
this.SearchInputTip.val("").attr("disabled", "disabled");
this.SearchResultsTipId.val("");
this.SearchResults.empty().hide();
}
else {
this.SearchInput.removeAttr("disabled");
this.SearchInputTip.removeAttr("disabled");
}

return this;
};
this.AjaxRequestParameters = function (data) {
_urlData = data;

return _urlData;
};
this.DefaultValue = function (id, val) {
this.SearchResultsTipId.val(id);
this.SearchInput.val(val);

return this;
};
this.Value = function () {
return { k: _this.SearchResultsTipId.val(), v: _this.SearchInput.val() };
};







Конструктор контрола и внутренние функции


var _methods = {
setResultsPosition: function () {
var inputOffset = _this.SearchInput.offset(),
inputSize = _methods.objectWH(_this.SearchInput);

_this.SearchResults
.css("left", inputOffset.left)
.css("top", inputOffset.top + inputSize.height - 2)
.css("width", inputSize.width - 2);
},
retrieveResults: function (query) {

if (query && query.length > 0) {
var _data = {};
if (_urlData &&
typeof (_urlData) === "object") {

_data = _urlData,
_data.searchquery = query;
}
else _data = { searchquery: query };

$.ajax({
async: true,
url: _this.SearchUrl,
type: "POST",
data: _data,
success: function (response) {
_methods.fillResults(response);
}
});
}
},
fillResults: function (arr) {
_this.SearchResults.empty().hide();
_this.SearchInputTip.val("");
if (arr && arr.length > 1) {
$(arr).each(function (i, o) {
_this.SearchResults.append("<div class=\"row\" id=\"" + o.k + "\">" + o.v + "</div>");
});
_this.SearchResults
.find("div")
.unbind()
.click(function () {
$(this).addClass("selected");
_methods.resultsApply();
}).end()
.css("height", arr.length * 19).show();
}
else if (arr && arr.length == 1) {
var searchInputValue = _this.SearchInput.val().length,
arrayValue = arr[0].v,
arrayKey = arr[0].k,
tip = _this.SearchInput.val() + arrayValue.substring(searchInputValue, arrayValue.length);

_this.SearchResultsTipId.val(arrayKey);
_this.SearchInputTip.val(tip);
}
},
resultsMove: function (direction) {
var currentPosition = -1,
resultsCount = _this.SearchResults.find(".row").length - 1;

$(_this.SearchResults.children()).each(function (i, o) {
if ($(o).hasClass("selected")) {
currentPosition = i;
return;
}
});

if (direction == "up") {
if (currentPosition > 0) {
currentPosition--;
_this.SearchResults
.find("div.selected").removeClass("selected").end()
.find("div:eq(" + currentPosition + ")").addClass("selected");
}
}
else {
if (currentPosition < resultsCount) {
currentPosition++;
_this.SearchResults
.find("div.selected").removeClass("selected").end()
.find("div:eq(" + currentPosition + ")").addClass("selected");

}
}
},
resultsApply: function () {
var selectedId = 0;
if (_this.SearchResultsTipId.val() != "" ||
_this.SearchResults.find("div").length > 0) {
if (_this.SearchResults.is(":visible")) {
selectedId = _this.SearchResults.find(".selected").attr("id");
_this.SearchInput.val(_this.SearchResults.find(".selected").html());
_this.SearchInputTip.val("");
_this.SearchResultsTipId.val(selectedId);
_this.SearchResults.empty().hide();
}
else {
selectedId = _this.SearchResultsTipId.val();
_this.SearchInput.val(_this.SearchInputTip.val());
_this.SearchInputTip.val("");
}

if (!_this.isApplied) {
if (applyHandler &&
typeof (applyHandler) === "function") {
applyHandler(selectedId);
}
_this.isApplied = true;
}
}
return selectedId;
},
objectWH: function (obj) {
var r = { width: 0, height: 0 };

r.height += obj.css("height").replace("px", "") * 1;
r.height += obj.css("padding-top").replace("px", "") * 1;
r.height += obj.css("padding-bottom").replace("px", "") * 1;
r.height += obj.css("margin-top").replace("px", "") * 1;
r.height += obj.css("margin-bottom").replace("px", "") * 1;
r.height += obj.css("border-top-width").replace("px", "") * 1;
r.height += obj.css("border-bottom-width").replace("px", "") * 1;

r.width += obj.css("width").replace("px", "") * 1;
r.width += obj.css("padding-left").replace("px", "") * 1;
r.width += obj.css("padding-right").replace("px", "") * 1;
r.width += obj.css("margin-left").replace("px", "") * 1;
r.width += obj.css("margin-right").replace("px", "") * 1;
r.width += obj.css("border-left-width").replace("px", "") * 1;
r.width += obj.css("border-right-width").replace("px", "") * 1;

return r;
}
};

var target = $("." + wrapperName);
if (target.length > 0) {

target
.append(this.SearchInputLabel)
.append(this.SearchInput)
.append(this.SearchInputTip)
.append(this.SearchResultsTipId)
.append(this.SearchResults);

$(window)
.resize(function () { _methods.setResultsPosition(); })
.trigger("resize");

this.SearchInput
.keydown(function (e) {
var val = _this.SearchInput.val(),
valLength = val.length;

if (e.which > 32 &&
e.which != 40 &&
e.which != 38 &&
e.which != 9 &&
e.which != 39 &&
e.which != 46 &&
e.which != 13) {

return true;
}
else if (e.which == 8 || // [Backspace]
e.which == 46) { // [DELETE]
if ((valLength - 1) > 0) {
_methods.retrieveResults(val.substring(0, valLength - 1));
}

if (_this.isApplied) {
_this.isApplied = false;
_this.SearchResultsTipId.val("");

if (cancelHandler && typeof (cancelHandler) === "function") {
cancelHandler();
}
}
}
else if (e.which == 40) { //▼
_methods.resultsMove("down");
}
else if (e.which == 38) { //▲
_methods.resultsMove("up");
}
else if (e.which == 39) { //→
_methods.resultsApply();
}
else if (e.which == 9) { //TAB
_methods.resultsApply();
return false;
}
else if (e.which == 13) { //ENTER
_methods.resultsApply();
}
})
.keypress(function (e) {
var text = _this.SearchInput.val(),
pressedChar = String.fromCharCode(e.which || e.keyCode),
query = text + pressedChar;

_methods.retrieveResults(query);
});
}





Использовать контрол на странице надо так



var region = new locateMe("field_wrapper", "field_name", "field_label", "search_URL", search_URL_DATA, applyHandler, cancelHandler);




field_wrapper — селектор по классу, оболочка, в которой будет создан контрол

field_name — название поля контрола

field_label — то, что будет написано над полем с поиском

search_URL — URL, который будет запрашиваться для поиска (метод POST)

[search_URL_DATA] — опциональные параметры, передаваемые в search_URL (объект)

[applyHandler] — функция, будет вызвана после завершения поиска в поле

[cancelHandler] — функция, вызывается при изменении поля (если конечно, поиск был завершен)

Пример:



var region = new locateMe("uloc_region", "region", "Регион", "/region", null, function(selectedId){ alert("регион ID:" + selectedId); });




В примере создается поле с именем «region» в div ".uloc_region". Для поиска будет запрашиваться url "/region" без параметров, а после нахождения нужного региона появится алерт с текстом «регион ID:%regionID%».

Бэк-энд




Задача: реализовать выборку из базы данных полей, удовлетворяющих поисковому запросу пользователя, для любых объектов БД (регион, город или улица)

Типичное поведение пользователя желающего найти свой город — начать вводить его название в текстовое поле, в этот момент начинает работать фронт-энд контрол, предлагающий автозаполнение по мере ввода поискового запроса.

Архитектура решения (solution, .sln) состоит из 4 библиотек:



  • BO (BusinesObjects)

  • DC (DataContext)

  • DP (DataProvider)

  • LocateMe (UI, web-интерфейс)


Описывать BO,DC,DP не имеет смысла, так как они типичны (linq, DTO, database context). Ссылка на архив со всем решением (solution) в конце поста.

Что касается UI, то его можно рассмотреть, в общих чертах. А именно сигнатуры методов поиска



[HttpPost]
public JsonResult Region(string searchquery)
{
return Json(from i in Database.SearchRegions(searchquery, 5) select new { k = i.Id, v = i.Region });
}

[HttpPost]
public JsonResult City(int regionId, string searchquery)
{
return Json(from i in Database.SearchCities(regionId, searchquery, 5) select new { k = i.Id, v = i.City });
}

[HttpPost]
public JsonResult Street(long cityId, string searchquery)
{
return Json(from i in Database.SearchStreets(cityId, searchquery, 5) select new { k = i.Id, v = i.Street });
}


Все просто: 3 метода на три объекта БД. Каждый принимает строку searchquery, которая является поисковым запросом пользователя. В двух последних методах присутствует еще один параметр RegionId и CityId — они указывают в каком именно регионе (или городе) производить поиск. Результаты поиска ограничены 5 записями. В качестве возвращаемого объекта используется сериализованный в JSON анонимный тип, где v — это название региона\города

или улицы, а k — это их индефикаторы.


Демо вот тут


Проект (полностью) тут (github)

База (дамп) там же


Ссылки, описания, инструкции
В качестве сервера БД — MS SQL 2012

Актуальность базы данных 2014 год, I квартал. Новые регионы Крым и Севастополь, а также территории Байконура присутствуют

Бек-энд .Net 4.0, mvc 3

Файл базы (mdf, log) тык (на гитхаб не коммитится, наверно размер велик)






This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий