Source: src/js/models/filters/SpatialFilter.js

define(["underscore", "jquery", "backbone", "models/filters/Filter"],
    function(_, $, Backbone, Filter) {

        /**
        * @classdesc A SpatialFilter represents a spatial constraint on the query to be executed,
        * and stores the geohash strings for all of the geohash tiles that coincide with the
        * search bounding box at the given zoom level.
        * @class SpatialFilter
        * @classcategory Models/Filters
        * @name SpatialFilter
        * @constructs
        * @extends Filter
        */
        var SpatialFilter = Filter.extend(
          /** @lends SpatialFilter.prototype */{

            /**
             @inheritdoc
            */
            type: "SpatialFilter",

            /**
            * Inherits all default properties of {@link Filter}
            * @property {string[]} geohashes - The array of geohashes used to spatially constrain the search
            * @property {object} groupedGeohashes -The same geohash values, grouped by geohash level (e.g. 1,2,3...). Complete geohash groups (of 32) are consolidated to the level above.
            * @property {number} east The easternmost longitude of the represented bounding box
            * @property {number} west The westernmost longitude of the represented bounding box
            * @property {number} north The northernmost latitude of the represented bounding box
            * @property {number} south The southernmost latitude of the represented bounding box
            * @property {number} geohashLvel The default precision level of the geohash-based search
            */
            defaults: function() {
                return _.extend(Filter.prototype.defaults(), {
                    geohashes: [],
                    east: null,
                    west: null,
                    north: null,
                    south: null,
                    geohashLevel: null,
                    groupedGeohashes: {},
                    label: "Limit search to the map area",
                    icon: "globe",
                    operator: "OR",
                    matchSubstring: false
                });
            },

            /**
             * Initialize the model, calling super
             */
            initialize: function(attributes, options) {
                this.on("change:geohashes", this.groupGeohashes);
            },

            /**
             * Builds a query string that represents this spatial filter
             * @return queryFragment - the query string representing the geohash constraints
             */
            getQuery: function() {
                var queryFragment = "";
                var geohashes = this.get("geohashes");
                var groups = this.get("geohashGroups");
                var geohashList;

                // Only return geohash query fragments when they are enabled in the filter
                if ( (typeof geohashes !== "undefined") && geohashes.length > 0 ) {
                    if ( (typeof groups !== "undefined") &&
                        Object.keys(groups).length > 0
                    ) {
                        // Group the Solr query fragment
                        queryFragment += "+(";

                        // Append geohashes at each level up to a fixed query string length
                        _.each(Object.keys(groups), function(level) {
                            geohashList = groups[level];
                            queryFragment += "geohash_" + level + ":(";
                            _.each(geohashList, function(geohash) {
                                if ( queryFragment.length < 7900 ) {
                                    queryFragment += geohash + "%20OR%20";
                                }
                            });
                            // Remove the last OR
                            queryFragment =
                                queryFragment.substring(0, (queryFragment.length - 8));
                            queryFragment += ")%20OR%20";
                        });
                        // Remove the last OR
                        queryFragment = queryFragment.substring(0, (queryFragment.length - 8));
                        // Ungroup the Solr query fragment
                        queryFragment += ")";

                    }
                }
                return queryFragment;
            },

            /**
            * @inheritdoc
            */
            updateDOM: function(options){

              try{
                var updatedDOM = Filter.prototype.updateDOM.call(this, options),
                    $updatedDOM = $(updatedDOM);

                //Force the serialization of the "operator" node for SpatialFilters,
                // since the Filter model will skip default values
                var operatorNode = updatedDOM.ownerDocument.createElement("operator");
                operatorNode.textContent = this.get("operator");

                var matchSubstringNode = updatedDOM.ownerDocument.createElement("matchSubstring");
                matchSubstringNode.textContent = this.get("matchSubstring");

                //Insert the operator node
                $updatedDOM.children("field").last().after(operatorNode);

                //Insert the matchSubstring node
                $(matchSubstringNode).insertBefore($updatedDOM.children("value").first());

                //Return the updated DOM
                return updatedDOM;
              }
              catch(e){
                console.error("Unable to serialize a SpatialFilter.", e);
                return this.get("objectDOM") || "";
              }
            },

            /**
             *  Consolidates geohashes into groups based on their geohash level
             *  and updates the model with those groups. The fields and values attributes
             *  on this model are also updated with the geohashes.
             */
            groupGeohashes: function() {
                var geohashGroups = {};
                var sortedGeohashes = this.get("geohashes").sort();
                var groupedGeohashes = _.groupBy(sortedGeohashes, function(geohash) {
                    return geohash.substring(0, geohash.length - 1);
                });
                //Find groups of geohashes that makeup a complete geohash tile (32)
                // so we can shorten the query
                var completeGroups = _.filter(Object.keys(groupedGeohashes), function(group) {
                    return groupedGeohashes[group].length == 32;
                });

                // Find groups that fall short of 32 tiles
                var incompleteGroups = [];
                _.each(
                    _.filter(Object.keys(groupedGeohashes), function(group) {
                        return (groupedGeohashes[group].length < 32)
                    }), function(incomplete) {
                        incompleteGroups.push(groupedGeohashes[incomplete]);
                    }
                );
                incompleteGroups = _.flatten(incompleteGroups);

                // Add both complete and incomplete groups to the instance property
                if((typeof incompleteGroups !== "undefined") && (incompleteGroups.length > 0)) {
                    geohashGroups[incompleteGroups[0].length.toString()] = incompleteGroups;
                }
                if((typeof completeGroups !== "undefined") && (completeGroups.length > 0)) {
                    geohashGroups[completeGroups[0].length.toString()] = completeGroups;
                }
                this.set("geohashGroups", geohashGroups); // Triggers a change event

                //Determine the field and value attributes
                var fields = [],
                    values = [];
                _.each( Object.keys(geohashGroups), function(geohashLevel){
                  fields.push( "geohash_" + geohashLevel );
                  values = values.concat( geohashGroups[geohashLevel].slice() );
                }, this);

                this.set("fields", fields);
                this.set("values", values);
            },

            /**
            * @inheritdoc
            */
            resetValue: function(){
              this.set("fields", this.defaults().fields);
              this.set("values", this.defaults().values);
            }
        });
        return SpatialFilter;
});