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

a537967fc8810a469e9094743b54d52e.pngИногда в своих проектах мне хотелось прикрутить некоторую географическую базу, с помощью которой я бы разделял пользователей ресурса по их месту пребывания. Но постоянная занятость делами насущными никак не давала реализовать идею с базой регионов и мало-мальски удобным интерфейсом для ее визуализации.Волею судьбы и заказчика (или судьбы заказчика или заказчика судьбы) такая задача, наконец-то, возникла — необходимо создать базу регионов, городов и улиц для сегментирования пользователей и реализовать удобную 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 = $(»

»).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 (»

» + o.v + »
»); }); _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) тык (на гитхаб не коммитится, наверно размер велик)

© Habrahabr.ru