Таргетинг пользователей: регион, город, улица
Иногда в своих проектах мне хотелось прикрутить некоторую географическую базу, с помощью которой я бы разделял пользователей ресурса по их месту пребывания. Но постоянная занятость делами насущными никак не давала реализовать идею с базой регионов и мало-мальски удобным интерфейсом для ее визуализации.Волею судьбы и заказчика (или судьбы заказчика или заказчика судьбы) такая задача, наконец-то, возникла — необходимо создать базу регионов, городов и улиц для сегментирования пользователей и реализовать удобную web-форму, собственно, для ее использования. Благо заказчик ориентировал свой бизнес на Россию, что резко упростило задачу.Поиск по интернету готовых баз субъектов РФ особых результатов не принес — нашел базу КЛАДР, но она оказалась не очень-то актуальной. Порыскав дальше я наткнулся на пост КЛАДР умер, да здравствует ФИАС?. Спасибо sergpenza, теперь есть куда копать!
База ФИАС действительно оказалась максимально полной и актуальной, и даже слишком — в ней очень много ненужного. Еще один минус базы — она «плоская»: основная табличка — ADDROBJ.dbf, в ней содержатся области, районы, города и улицы и все это ссылается само на себя. Еще один минус — в ней нет списка регионов РФ. Но это просто — их можно с легкостью спарсить с сайта ГНИВЦ ФНС РОССИИ.
Не буду вдаваться в процесс перепиливания базы в реляционный вид — это рутина, да и ссылка на уже готовую базу есть внизу моего поста.
Отлично база есть. Нужно создать интерфейс для ее визуализации под web, на этой части я остановлюсь более подробно.Интерфейс включает в себя фронт и бек-энд.
Фронт-энд это html, js, jQuery Бек-энд 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 = $(»»).addClass («label»).attr («id», fieldName + »_label»).html (fieldLabel);
this.SearchInput = $(»»).addClass («input_search»).attr («id», fieldName).attr («type», «text»);
this.SearchInputTip = $(»»).addClass («input_search_tip»).attr («id», fieldName + »_tip»).attr («type», «text»);
this.SearchResultsTipId = $(»»).attr («id», fieldName + »_tip_id»).attr («type», «hidden»);
this.SearchResults = $(» 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 (» _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) тык (на гитхаб не коммитится, наверно размер велик)