define([
"underscore",
"jquery",
"models/filters/Filter",
"collections/maps/Geohashes",
], function (_, $, Filter, Geohashes) {
/**
* @classdesc A SpatialFilter represents a spatial constraint on the query to
* be executed.
* @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 {number} east The easternmost longitude of the search area
* @property {number} west The westernmost longitude of the search area
* @property {number} north The northernmost latitude of the search area
* @property {number} south The southernmost latitude of the search area
* @property {number} height The height at which to calculate the geohash
* precision for the search area
* @property {number} maxGeohashValues The maximum number of geohash
* values to use in the filter. If the number of geohashes exceeds this
* value, the precision will be reduced until the number of geohashes is
* less than or equal to this value.
*/
defaults: function () {
return _.extend(Filter.prototype.defaults(), {
filterType: "SpatialFilter",
east: 180,
west: -180,
north: 90,
south: -90,
height: Infinity,
fields: [],
values: [],
label: "Limit search to the map area",
icon: "globe",
operator: "OR",
fieldsOperator: "OR",
matchSubstring: false,
// 1024 is the default limit in Solr for boolean clauses, limit even
// more to allow for other filters
maxGeohashValues: 500,
});
},
/**
* Initialize the model, calling super
*/
initialize: function (attributes, options) {
Filter.prototype.initialize.call(this, attributes, options);
if (this.hasCoordinates()) this.updateFilterFromExtent();
this.setListeners();
},
/**
* Returns true if the filter has a valid set of coordinates
* @returns {boolean} True if the filter has coordinates
* @since 2.25.0
*/
hasCoordinates: function () {
return (
typeof this.get("east") === "number" &&
typeof this.get("west") === "number" &&
typeof this.get("north") === "number" &&
typeof this.get("south") === "number"
);
},
/**
* Validate the coordinates, ensuring that the east and west are not
* greater than 180 and that the north and south are not greater than 90.
* Coordinates will be adjusted if they are out of bounds.
* @param {boolean} [silent=true] - Whether to trigger a change event in
* the case where the coordinates are adjusted
* @since 2.25.0
*/
validateCoordinates: function (silent = true) {
if (!this.hasCoordinates()) return;
if (this.get("east") > 180) {
this.set("east", 180, { silent: silent });
}
if (this.get("west") < -180) {
this.set("west", -180, { silent: silent });
}
if (this.get("north") > 90) {
this.set("north", 90, { silent: silent });
}
if (this.get("south") < -90) {
this.set("south", -90), { silent: silent };
}
},
/**
* Remove the listeners.
* @since 2.27.0
*/
removeListeners: function () {
const extentEvents =
"change:height change:north change:south change:east change:west";
this.stopListening(this, extentEvents);
},
/**
* Set a listener that updates the filter when the coordinates & height
* change
* @since 2.25.0
*/
setListeners: function () {
this.removeListeners();
const extentEvents =
"change:height change:north change:south change:east change:west";
this.listenTo(this, extentEvents, this.updateFilterFromExtent);
},
/**
* Convert the coordinate attributes to a bounds object
* @param {string} [as="object"] - The format to return the bounds in.
* Defaults to "object". Can set to GeoBoundingBox to return a Backbone
* model instead.
* @returns {object|GeoBoundingBox} An object with north, south, east, and west props or
* a GeoBoundingBox model
* @since 2.25.0
*/
getBounds: function (as = "object") {
const coords = {
north: this.get("north"),
south: this.get("south"),
east: this.get("east"),
west: this.get("west"),
};
if (as === "GeoBoundingBox") {
const GeoBoundingBox = require("models/maps/GeoBoundingBox");
return new GeoBoundingBox(coords);
} else {
return coords;
}
},
/**
* Returns true if the bounds set on the filter covers the entire earth
* @returns {boolean}
* @since 2.25.0
*/
coversEarth: function () {
const bounds = this.getBounds("GeoBoundingBox").coversEarth();
},
/**
* Given the current coordinates and height set on the model, update the
* fields and values to match the geohashes that cover the area. This will
* set a consolidated set of geohashes that cover the area at the
* appropriate precision. It will also validate the coordinates to ensure
* that they are within the bounds of the map.
* @since 2.25.0
*/
updateFilterFromExtent: function () {
try {
this.validateCoordinates();
// If the bounds are global there is no spatial constraint
if (this.coversEarth()) {
this.set({ fields: [], values: [] });
return;
}
const geohashes = new Geohashes();
const bounds = this.getBounds("GeoBoundingBox");
const limit = this.get("maxGeohashValues");
geohashes.addGeohashesByBounds(bounds, true, limit, true);
this.set({
fields: this.precisionsToFields(geohashes.getPrecisions()),
values: geohashes.getAllHashStrings(),
});
} catch (e) {
console.log("Error updating filter from extent", e);
}
},
/**
* Coverts a geohash precision level to a field name for Solr
* @param {number} precision The geohash precision level, e.g. 4
* @returns {string} The corresponding field name, e.g. "geohash_4"
* @since 2.25.0
*/
precisionToField: function (precision) {
return precision && !isNaN(precision) ? "geohash_" + precision : null;
},
/**
* Converts an array of geohash precision levels to an array of field
* names for Solr
* @param {number[]} precisions The geohash precision levels, e.g. [4, 5]
* @returns {string[]} The corresponding field names, e.g. ["geohash_4",
* "geohash_5"]
* @since 2.25.0
*/
precisionsToFields: function (precisions) {
let fields = [];
if (precisions && precisions.length) {
fields = precisions
.map((lvl) => this.precisionToField(lvl))
.filter((f) => f);
}
return fields;
},
/**
* Builds a query string that represents this spatial filter
* @param {boolean} [consolidate=false] Whether to consolidate the set of
* geohashes to the smallest set that covers the same area (i.e. merges
* geohashes together when there are complete groups of 32). This is false
* by default because geohashes are already consolidated when they are
* added as the spatial filter is used right now.
* @return {string} The query fragment
* @since 2.25.0
*/
getQuery: function (consolidate = false) {
try {
// Methods in the geohash collection allow us make efficient queries
const hashes = this.get("values");
let geohashes = new Geohashes(hashes.map((h) => ({ hashString: h })));
// Don't spatially constrain the search if the geohahes covers the world
// or if there are no geohashes
if (geohashes.length === 0 || geohashes.coversEarth()) {
return "";
}
if (consolidate) geohashes = geohashes.consolidate();
const precisions = geohashes.getPrecisions();
// Just use a regular Filter if there is only one level of geohash
if (precisions.length === 1) {
return this.createBaseFilter(
precisions,
geohashes.getAllHashStrings(),
).getQuery();
}
// Make a query fragment that ORs together all the geohashes at each
// precision level
const Filters = require("collections/Filters");
const filters = new Filters();
precisions.forEach((precision) => {
if (precision) {
filters.add(
this.createBaseFilter(
[precision],
geohashes.getAllHashStrings(precision),
),
);
}
});
return filters.getQuery("OR");
} catch (e) {
console.log("Error in SpatialFilter.getQuery", e);
return "";
}
},
/**
* Creates a Filter model that represents the geohashes at a given
* precision level for a specific set of geohashes
* @param {number[]} precisions The geohash precision levels, e.g. [4, 5]
* @param {string[]} hashStrings The geohashes, e.g. ["9q8yy", "9q8yz"]
* @returns {Filter} The filter model
* @since 2.25.0
*/
createBaseFilter: function (precisions = [], hashStrings = []) {
return new Filter({
fields: this.precisionsToFields(precisions),
values: hashStrings,
operator: this.get("operator"),
fieldsOperator: this.get("fieldsOperator"),
matchSubstring: this.get("matchSubstring"),
});
},
/**
* @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 fieldsOperatorNode =
updatedDOM.ownerDocument.createElement("fieldsOperator");
fieldsOperatorNode.textContent = this.get("fieldsOperator");
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") || "";
}
},
/**
* @inheritdoc
*/
resetValue: function () {
// Need to stop listeners because otherwise changing the coords will
// update the filter values. This sometimes updates the values *after*
// the values are reset, preventing the reset from working.
this.removeListeners();
let df = this.defaults();
this.set({
values: df.values,
east: df.east,
west: df.west,
north: df.north,
south: df.south,
height: df.height,
});
// Reset the listeners
this.setListeners();
},
},
);
return SpatialFilter;
});