Иногда в своих проектах мне хотелось прикрутить некоторую географическую базу, с помощью которой я бы разделял пользователей ресурса по их месту пребывания. Но постоянная занятость делами насущными никак не давала реализовать идею с базой регионов и мало-мальски удобным интерфейсом для ее визуализации.Волею судьбы и заказчика (или судьбы заказчика или заказчика судьбы) такая задача, наконец-то, возникла — необходимо создать базу регионов, городов и улиц для сегментирования пользователей и реализовать удобную 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 = $("<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) тык (на гитхаб не коммитится, наверно размер велик)
Актуальность базы данных 2014 год, I квартал. Новые регионы Крым и Севастополь, а также территории Байконура присутствуют
Бек-энд .Net 4.0, mvc 3
Файл базы (mdf, log) тык (на гитхаб не коммитится, наверно размер велик)
UPD
Спасибо WindDrop, AndriyanИсправлено \ добавлено:
1. Повторные нажатия на [ENTER], [TAB] или [RIGHT ARROW] после ручного ввода или автозаполнения
2. Автозаполенение с учетом регистра (case sensitivity)
3. Отображение окошка с результатами поиска при пустом поле
4. Произвольное заполнение поля регион («Республика Башко...» или «Башко....») будет иметь одинаковый резуьтат (без изменения БД)
Не исправлено:
Непонятно поведение библиотеки (нажатия клавиатуры) в FF
