User targeting: region, city, street

    Sometimes in my projects I would like to fasten some geographical base with which I would divide the users of the resource by their place of stay. But the constant preoccupation with vital affairs did not make it possible to realize the idea with a base of regions and a little more 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 was necessary to create a database of regions, cities and streets for segmenting users and implement a convenient web-form, in fact, for its use. Fortunately, the customer focused his business on Russia, which greatly simplified the task.



    An Internet search of ready-made databases of the constituent entities of the Russian Federation did not bring any particular results - found the KLADR database, but it turned out to be not very relevant. Looking further I came across a postKLADR died, long live the FIAS? . Thanks sergpenza , now there is where to dig!

    The FIAS database really turned out to be as complete and relevant as possible, and even too much - there is a lot of unnecessary in it. Another minus of the base is that it is “flat”: the main plate is ADDROBJ.dbf, it contains areas, districts, cities and streets, and all this refers to itself. Another minus - it does not have a list of regions of the Russian Federation. But it’s simple - they can be easily parsed from the website of the GNIVTS Federal Tax Service of Russia.

    I will not go into the process of re-parsing the database into a relational view - this is a routine, and there is a link to the finished database at the bottom of my post.

    Excellent base there. It is necessary to create an interface for visualizing it 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. Back-end MVC from MS (c #)


    Front end


    Task: the user should be able to fill out data about his place of stay, for this he will sequentially enter the region, city and street.
    Given the number of cities (more than 160k) and the number of streets in each city, the task is complicated - there’s no need to use drop-down lists, you need to provide some kind of quick search and filtering mechanism. Of course, the mechanism should be universal and cover not only the region, but also cities with streets.
    Such a mechanism is best implemented in the form of a library connected in the right places on the site. Let's name the jquery.locateme.js library. By the name of the library it is clear that it is dependent on jQuery. Initially, I had the idea of ​​writing a plug-in for jQuery in accordance with the ideology of the framework, but in the end I abandoned it.

    The library should have the following functions:
    • search for all database objects (regions, cities, streets)
    • display of search results (both list and autocomplete)
    • keyboard control (search results navigation)
    • handling all kinds of callbacks to scale functionality

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


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


    Control constructor 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("
    " + 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); }); }



    Use control on the page as follows
    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%».

    Бэк-энд


    Objective: to implement a selection from the database of fields that satisfy the user's search query for any database objects (region, city or street)
    Typical behavior of a user who wants to find his city is to start entering its name in the text field, at this moment the front-end control starts working offering autocomplete as you type your search query.

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

    • BO (BusinesObjects)
    • DC (DataContext)
    • DP (DataProvider)
    • LocateMe (UI, web interface)


    It makes no sense to describe BO, DC, DP, since they are typical (linq, DTO, database context). Link to the archive with the entire solution at the end of the post.
    As for the UI, it can be considered, in general terms. Namely signatures of search methods

            [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 the 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 in JSON is used as the returned object, where v is the name of the region \ city
    or street, and k is their identifiers.

    Demo here

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

    Links, descriptions, instructions
    As a database server - MS SQL 2012
    Database relevance 2014, I quarter. The 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 (does not commit to github, probably the size is large)


    UPD
    Thanks WindDrop , Andriyan
    Fixed \ added:
    1. Repeatedly pressing [ENTER], [TAB] or [RIGHT ARROW] after manual entry or autofill
    2. Case-sensitive autofill
    3. Displaying a window with search results with an empty field
    4. An arbitrary filling in the region field (“Republic of Bashko ...” or “Bashko ....”) will have the same result (without changing the database)

    Not fixed: The
    behavior of the library (pressing the keyboard) in FF is unclear


    Also popular now: