📜 ⬆️ ⬇️

Targeting users: region, city, street

Sometimes in my projects I wanted to fasten a certain geographic base, with the help of which I would divide the users of the resource according to their place of stay. But constant employment with vital matters in no way allowed the idea to be implemented with a base of regions and a little bit convenient interface for its visualization.
By the will of fate and the customer (or the fate of the customer or the customer of fate) such a task finally arose - it is necessary to create a base of regions, cities and streets for user segmentation and implement a convenient web form, in fact, for its use. Fortunately, the customer focused his business on Russia, which dramatically simplified the task.



The search on the Internet for ready-made databases of subjects of the Russian Federation did not bring any special results - I found the KLADR database, but it turned out to be not very relevant. Having rummaged further, I stumbled upon the post KLADR died, long live the FIAS? . Thanks sergpenza , now there is much to dig!
')
The FIAS base really turned out to be as complete and relevant as possible, and even too much - there is a lot of unnecessary in it. Another disadvantage of the base is that it is “flat”: the main tablet is ADDROBJ.dbf, it contains areas, districts, cities and streets, and all this refers to itself. Another disadvantage is that there is no list of regions of the Russian Federation. But it is simple - they can easily be parsed from the site of the GNIVTS FTS RUSSIA.

I will not go into the process of sawing the base into a relational view - this is a routine, and the link to the ready base is at the bottom of my post.

Excellent base there. It is necessary to create an interface for its visualization under the web, I will dwell on this part in more detail.
The interface includes front and back-end.

  1. Front end is html, js, jQuery
  2. MS's MVC back-end (c #)


Front end


Task: the user must be able to fill in data about his place of stay, for this he consistently enters the region, city and street.
Given the number of cities (more than 160k) and the number of streets in each city, the task becomes more complicated - there is no drop-down list use, some quick search and filtering mechanism must be provided. Of course, the mechanism should be universal and cover not only the region, but also cities with streets.
This mechanism is best implemented in the form of a library that is connected in the right places on the site. Let's call the library jquery.locateme.js. By the name of the library it is clear that it is dependent on jQuery. Initially, I had the idea to write a plugin for jQuery in accordance with the ideology of the framework, but in the end I refused it.

The library should have the following functions:

Implementation (skeleton)
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; }; 


Public functions
 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() }; }; 


Designer control and internal functions
 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=\"" + ok + "\">" + ov + "</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); }); } 



Use control on the page should be so
 var region = new locateMe("field_wrapper", "field_name", "field_label", "search_URL", search_URL_DATA, applyHandler, cancelHandler); 

field_wrapper - class selector, shell in which control will be created
field_name - control field name
field_label is what will be written above the search field
search_URL - the URL that will be requested for the search (POST method)
[search_URL_DATA] - optional parameters passed to search_URL (object)
[applyHandler] - the function will be called after the search is completed in the field
[cancelHandler] - function, called when the field is changed (if, of course, the search was completed)

Example:
 var region = new locateMe("uloc_region", "region", "", "/region", null, function(selectedId){ alert(" ID:" + selectedId); }); 

The example creates a field called "region" in the div ".uloc_region". For the search, the url "/ region" with no parameters will be requested, and after finding the desired region, an alert will appear with the text "region ID:% regionID%".

Back end


Task: implement a selection from the database of fields that satisfy the user's search query for any database objects (region, city or street)
The typical behavior of a user who wants to find his city is to start typing his name in the text field, at this moment the front-end control starts to work, offering auto-completion as you type a search query.

The solution architecture (solution, .sln) consists of 4 libraries:



It does not make sense to describe BO, DC, DP, as they are typical (linq, DTO, database context). Link to the archive with the whole solution (solution) at the end of the post.
As for the UI, it can be considered in general terms. Namely search method signatures

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


It's simple: 3 methods for three database objects. Each accepts a searchquery string, which is a user's search query. In the last two methods, there is another parameter RegionId and CityId - they indicate in which region (or city) to search. Search results are limited to 5 entries. An anonymous type serialized into JSON is used as the returned object, where v is the name of the region \ city
or the streets, and k is their identifiers.

Demo right here

Project (in full) here (github)
Base (dump) in the same place

Links, descriptions, instructions
As a database server - MS SQL 2012
The relevance of the database in 2014, I quarter. New regions of Crimea and Sevastopol, as well as the territory of Baikonur are present
Back-end .Net 4.0, mvc 3

Base file (mdf, log) tyk (github will not commit, probably the size is large)


UPD
Thank you WindDrop , Andriyan
Fixed \ added:
1. Repeatedly pressing [ENTER], [TAB] or [RIGHT ARROW] after manual input or auto-complete.
2. Autocomplete case sensitive
3. Displaying the search results window with an empty field.
4. Arbitrary filling of the field region ("Republic of Bashko ..." or "Bashko ....") will have the same result (without changing the database)

Not fixed:
Unclear library behavior (keystrokes) in FF

Source: https://habr.com/ru/post/221989/


All Articles