From 261025dbcf0f818ad78713337f7efa5a8a88bd07 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 5 Apr 2023 18:58:28 -0400 Subject: [PATCH] Fix bugs with constructing spatial queries Relates to #2069 --- src/js/collections/SolrResults.js | 7 +- src/js/collections/maps/Geohashes.js | 257 ++++++++++++-------- src/js/models/connectors/Filters-Map.js | 3 +- src/js/models/connectors/Filters-Search.js | 23 +- src/js/models/connectors/Map-Search.js | 10 +- src/js/models/filters/SpatialFilter.js | 259 ++++++++++++++------- src/js/models/maps/Geohash.js | 83 ++++--- src/js/models/maps/assets/CesiumGeohash.js | 74 ++++-- 8 files changed, 471 insertions(+), 245 deletions(-) diff --git a/src/js/collections/SolrResults.js b/src/js/collections/SolrResults.js index 5af0f4afa..6dd78be57 100644 --- a/src/js/collections/SolrResults.js +++ b/src/js/collections/SolrResults.js @@ -119,7 +119,7 @@ define(['jquery', 'underscore', 'backbone', 'models/SolrHeader', 'models/SolrRes this.header.set({"rows" : solr.responseHeader.params.rows}); //Get the facet counts and store them in this model - if( solr.facet_counts ){ + if (solr.facet_counts) { this.facetCounts = solr.facet_counts.facet_fields; } else { this.facetCounts = "nothing"; @@ -233,7 +233,10 @@ define(['jquery', 'underscore', 'backbone', 'models/SolrHeader', 'models/SolrRes this.trigger("change:sort"); }, - setFacet: function(fields) { + setFacet: function (fields) { + if (!Array.isArray(fields)) { + fields = [fields]; + } this.facet = fields; this.trigger("change:facet"); }, diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 3fde69b19..a83a55ef9 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -8,7 +8,8 @@ define([ "models/maps/Geohash", ], function ($, _, Backbone, nGeohash, Geohash) { /** - * @classdesc A Geohashes Collection represents a collection of Geohash models. + * @classdesc A collection of adjacent geohashes, potentially at mixed + * precision levels. * @classcategory Collections/Geohashes * @class Geohashes * @name Geohashes @@ -36,16 +37,15 @@ define([ * @returns {number} Length of the geohash. */ comparator: function (model) { - return model.get("geohash")?.length || 0; + return model.get("hashString")?.length || 0; }, /** - * Get the geohash level to use for a given height. - * @param {number} [height] - Altitude to use to calculate the geohash - * level/precision, in meters. - * @returns {number} Geohash level. + * Get the precision height map. + * @returns {Object} Precision height map, where the key is the geohash + * precision level and the value is the height in meters. */ - getLevelHeightMap: function () { + getPrecisionHeightMap: function () { return { 1: 6800000, 2: 2400000, @@ -57,55 +57,103 @@ define([ }, /** - * Get the geohash level to use for a given height. - * + * Get the geohash precision level to use for a given height. * @param {number} [height] - Altitude to use to calculate the geohash - * level/precision. + * precision, in meters. + * @returns {number} Geohash precision level. */ - heightToLevel: function (height) { + heightToPrecision: function (height) { try { - const levelHeightMap = this.getLevelHeightMap(); - return Object.keys(levelHeightMap).find( - (key) => height >= levelHeightMap[key] + const precisionHeightMap = this.getPrecisionHeightMap(); + let precision = Object.keys(precisionHeightMap).find( + (key) => height >= precisionHeightMap[key] ); + return precision ? parseInt(precision) : 1; } catch (e) { - console.log("Failed to get geohash level, returning 1" + e); + console.log("Failed to get geohash precision, returning 1" + e); return 1; } }, /** - * Retrieves the geohash IDs for the provided bounding boxes and level. - * + * Checks if the geohashes in this model are empty or if there are no + * models + * @returns {boolean} True if this collection is empty. + */ + isEmpty: function () { + return ( + this.length === 0 || this.models.every((model) => model.isEmpty()) + ); + }, + + /** + * Returns true if the set of geohashes in this model collection are the + * 32 geohashes at precision 1, i.e. [0-9a-v] + * @returns {boolean} True if there are 32 geohashes with one character + * each. + */ + isCompleteRootLevel: function () { + const hashStrings = this.getAllHashStrings(); + if (hashStrings.length !== 32) return false; + if (hashStrings.some((hash) => hash.length !== 1)) return false; + return true; + }, + + /** + * Returns true if the geohashes in this model cover the entire earth. + * @returns {boolean} True if the geohashes cover the entire earth. + */ + coversEarth: function () { + if (this.isEmpty()) return false; + if (this.isCompleteRootLevel()) return true; + return this.clone().consolidate().isCompleteRootLevel(); + }, + + /** + * Creates hashStrings for geohashes that are within the provided bounding + * boxes at the given precision. The returned hashStrings are not + * necessarily in the collection. * @param {Object} bounds - Bounding box with north, south, east, and west * properties. - * @param {number} level - Geohash level. - * @returns {string[]} Array of geohash IDs. + * @param {number} precision - Geohash precision level. + * @returns {string[]} Array of geohash hashStrings. */ - getGeohashIDs: function (bounds, level) { - let geohashIDs = []; + getHashStringsByExtent: function (bounds, precision) { + let hashStrings = []; bounds = this.splitBoundingBox(bounds); bounds.forEach(function (bb) { - geohashIDs = geohashIDs.concat( - nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, level) + hashStrings = hashStrings.concat( + nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, precision) ); }); - return geohashIDs; + return hashStrings; + }, + + /** + * Returns a list of hashStrings in this collection. Optionally provide a + * precision to only return hashes of that length. + * @param {Number} precision - Geohash precision level. + * @returns {string[]} Array of geohash hashStrings. + */ + getAllHashStrings: function (precision) { + const hashes = this.map((geohash) => geohash.get("hashString")); + if (precision) { + return hashes.filter((hash) => hash.length === precision); + } else { + return hashes; + } }, /** - * Splits the bounding box if it crosses the prime meridian. Returns an - * array of bounding boxes. - * + * Splits a given bounding box if it crosses the prime meridian. Returns + * an array of bounding boxes. * @param {Object} bounds - Bounding box object with north, south, east, * and west properties. - * @returns {Array} Array of bounding box objects. - * @since x.x.x + * @returns {Object[]} Array of bounding box objects. */ splitBoundingBox: function (bounds) { if (!bounds) return []; const { north, south, east, west } = bounds; - if (east < west) { return [ { north, south, east: 180, west }, @@ -117,29 +165,35 @@ define([ }, /** - * Add geohashes to the collection based on a bounding box and height. + * Add geohashes to the collection based on a bounding box and height. All + * geohashes within the bounding box at the corresponding precision will + * be added to the collection. * @param {Object} bounds - Bounding box with north, south, east, and west * properties. - * @param {number} height - Altitude to use to calculate the geohash - * level/precision. + * @param {number} height - Altitude in meters to use to calculate the + * geohash precision level. * @param {boolean} [overwrite=false] - Whether to overwrite the current * collection. */ addGeohashesByExtent: function (bounds, height, overwrite = false) { - const level = this.heightToLevel(height); - const geohashIDs = this.getGeohashIDs(bounds, level); - this.addGeohashesById(geohashIDs, overwrite); + const precision = this.heightToPrecision(height); + const hashStrings = this.getHashStringsByExtent(bounds, precision); + this.addGeohashesByHashString(hashStrings, overwrite); }, /** - * Add geohashes to the collection based on an array of geohash IDs. - * @param {string[]} geohashIDs - Array of geohash IDs. + * Add geohashes to the collection based on an array of geohash + * hashStrings. + * @param {string[]} hashStrings - Array of geohash hashStrings. * @param {boolean} [overwrite=false] - Whether to overwrite the current * collection. */ - addGeohashesById: function (geohashIDs, overwrite = false) { - if (overwrite) this.reset(); - this.add(geohashIDs.map((id) => ({ geohash: id }))); + addGeohashesByHashString: function (hashStrings, overwrite = false) { + const method = overwrite ? "reset" : "add"; + const geohashAttrs = hashStrings.map((gh) => { + return { hashString: gh }; + }); + this[method](geohashAttrs); }, /** @@ -150,81 +204,101 @@ define([ * @returns {Geohashes} Subset of geohashes. */ getSubsetByBounds: function (bounds) { - const levels = this.getLevels(); + const precisions = this.getPrecisions(); const hashes = []; - levels.forEach((level) => { - hashes = hashes.concat(this.getGeohashIDs(bounds, level)); + precisions.forEach((precision) => { + hashes = hashes.concat( + this.getHashStringsByExtent(bounds, precision) + ); }); const geohashes = this.filter((geohash) => { - return hashes.includes(geohash.get("geohash")); + return hashes.includes(geohash.get("hashString")); }); return new Geohashes(geohashes); }, /** * Check if a geohash is in the collection. This will only consider - * geohash IDs, not properties or any other attributes on the Geohash - * models. - * @param {Geohash} target - Geohash model or geohash hashstring. - * @returns {boolean} Whether the geohash is in the collection. + * geohash hashStrings, not properties or any other attributes on the + * Geohash models. + * @param {Geohash} target - Geohash model or geohash hashString. + * @returns {boolean} Whether the target is part of this collection. */ includes: function (geohash) { - const allHashes = this.getGeohashIDs(); - const geohashID = - geohash instanceof Geohash ? geohash.get("geohash") : geohash; - return allHashes.includes(geohashID); + const allHashes = this.getAllHashStrings(); + const targetHash = + geohash instanceof Geohash ? geohash.get("hashString") : geohash; + return allHashes.includes(targetHash); + }, + + /** + * Group the geohashes in the collection by their groupID. Their groupID + * is the hashString of the parent geohash, i.e. the hashString of the + * geohash with the last character removed. + * @returns {Object} Object with groupIDs as keys and arrays of Geohash + * models as values. + */ + getGroups: function () { + return this.groupBy((geohash) => { + return geohash.get("groupID"); + }); }, /** - * Determine if a set of geohashes can be merged into a single geohash. - * They can be merged if all of the child geohashes are in the collection. - * @param {Geohashes} geohashes - Geohashes collection. - * @param {Geohash} target - Geohash model. - * @returns {boolean} Whether the geohashes can be merged. + * Get the geohash groups in this collection that are complete, i.e. have + * 32 child geohashes. + * @returns {Object} Object with groupIDs as keys and arrays of Geohash + * models as values. */ - canMerge: function (geohashes, target) { - const children = target.getChildGeohashes(); - return children.every((child) => geohashes.includes(child)); + getCompleteGroups: function () { + const groups = this.getGroups(); + const completeGroups = {}; + Object.keys(groups).forEach((groupID) => { + if (groups[groupID].length === 32) { + completeGroups[groupID] = groups[groupID]; + } + }); + delete completeGroups[""]; + delete completeGroups[null]; + return completeGroups; }, /** - * Reduce the set of Geohashes to the minimal set of Geohashes that - * completely cover the same area as the current set. Warning: this will - * remove any properties or attributes from the returned Geohash models. - * @returns {Geohashes} A new Geohashes collection. + * Consolidate this collection: Merge complete groups of geohashes into a + * single, lower precision "parent" geohash. Groups are complete if all 32 + * "child" geohashes that make up the "parent" geohash are in the + * collection. Add and remove events will not be triggered during + * consolidation. */ - getMerged: function () { - // We will merge recursively, so we need to make a copy of the - // collection. - const geohashes = this.clone(); + consolidate: function () { let changed = true; while (changed) { changed = false; - geohashes.sort(); - for (let i = 0; i < geohashes.length; i++) { - const target = geohashes.at(i); - if (this.canMerge(geohashes, target)) { - const parent = target.getParentGeohash(); - const children = target.getChildGeohashes(); - geohashes.remove(children); - geohashes.add(parent); - changed = true; - break; - } - } + const toMerge = this.getCompleteGroups(); + let toRemove = []; + let toAdd = []; + Object.keys(toMerge).forEach((groupID) => { + const parent = new Geohash({ hashString: groupID }); + toRemove = toRemove.concat(toMerge[groupID]); + toAdd.push(parent); + changed = true; + }); + this.remove(toRemove, { silent: true }); + this.add(toAdd, { silent: true }); } - return geohashes; + return this; }, /** - * Get the unique geohash levels for all geohashes in the collection. + * Get the unique geohash precision levels present in the collection. */ - getLevels: function () { - return Array.from(new Set(this.map((geohash) => geohash.get("level")))); + getPrecisions: function () { + return Array.from(new Set(this.map((gh) => gh.get("precision")))); }, + /** - * Return the geohashes as a GeoJSON FeatureCollection, where each - * geohash is represented as a GeoJSON Polygon (rectangle). + * Return the geohashes as a GeoJSON FeatureCollection, where each geohash + * is represented as a GeoJSON Polygon (rectangle). * @returns {Object} GeoJSON FeatureCollection. */ toGeoJSON: function () { @@ -240,12 +314,3 @@ define([ return Geohashes; }); - -// TODO: consider adding this back in to optionally limit the number of geohashes -// const limit = this.get("maxGeohashes"); -// if (limit && geohashIDs.length > limit && level > 1) { -// while (geohashIDs.length > limit && level > 1) { -// level--; -// geohashIDs = this.getGeohashIDs(bounds, level); -// } -// } diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index adb1f6a4b..3ffcf0002 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -185,7 +185,8 @@ define([ } spatialFilters.forEach((spFilter) => { - spFilter.set(extent); + spFilter.set(extent, { silent: true }); + spFilter.trigger("change:height"); }); } catch (e) { console.log("Error updating spatial filters: ", e); diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 2e7f20c4a..2b5498ddc 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -82,19 +82,22 @@ define([ */ connect: function () { this.disconnect(); - const filters = this.get("filters"); + // const filters = this.get("filters"); const searchResults = this.get("searchResults"); // Listen to changes in the Filters to trigger a search - this.listenTo( - filters, - "add remove update reset change", - function () { - // Start at the first page when the filters change - MetacatUI.appModel.set("page", 0); - this.triggerSearch(); - } - ); + this.listenTo(this.get("filters"), "add remove reset", function () { + // Reset listeners so that we are not listening to the old filters + this.connect(); + MetacatUI.appModel.set("page", 0); + this.triggerSearch(); + }); + + this.listenTo(this.get("filters"), "change update", function () { + // Start at the first page when the filters change + MetacatUI.appModel.set("page", 0); + this.triggerSearch(); + }); this.listenTo( searchResults, diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 5ff803701..7be72ec58 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -95,7 +95,7 @@ define([ * Layers collection set on this model. * @fires Layers#add */ - createGeohash() { + createGeohash: function() { const map = this.get("map"); return map.addAsset({ type: "CesiumGeohash" }); }, @@ -216,7 +216,7 @@ define([ const counts = this.getGeohashCounts(); const modelAttrs = this.facetCountsToGeohashAttrs(counts); // const totalCount = this.getTotalNumberOfResults(); // TODO - geohashLayer.resetGeohashes(modelAttrs); + geohashLayer.replaceGeohashes(modelAttrs); }, /** @@ -227,9 +227,9 @@ define([ updateFacet: function () { const searchResults = this.get("searchResults"); const geohashLayer = this.get("geohashLayer"); - const geohashLevels = geohashLayer.getLevels(); - if (geohashLevels && geohashLevels.length) { - searchResults.setFacet(`geohash_${geohashLevels[0]}`); + const precision = geohashLayer.getPrecision(); + if (precision && typeof Number(precision) === "number") { + searchResults.setFacet([`geohash_${precision}`]); } else { searchResults.setFacet(null); } diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 94115a650..a9972b207 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -3,13 +3,11 @@ define([ "jquery", "backbone", "models/filters/Filter", - "collections/Filters", "collections/maps/Geohashes", -], function (_, $, Backbone, Filter, Filters, Geohashes) { +], function (_, $, Backbone, Filter, Geohashes) { /** - * @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. + * @classdesc A SpatialFilter represents a spatial constraint on the query to + * be executed. * @class SpatialFilter * @classcategory Models/Filters * @name SpatialFilter @@ -24,27 +22,24 @@ define([ type: "SpatialFilter", /** - * TODO: Fix these docs * 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} geohashLevel The default precision level of the geohash-based search - * // TODO update the above + * @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 */ defaults: function () { return _.extend(Filter.prototype.defaults(), { - geohashes: [], filterType: "SpatialFilter", - east: null, - west: null, - north: null, - south: null, - height: null, - fields: ["geohash_1"], + east: 180, + west: -180, + north: 90, + south: -90, + height: Infinity, + fields: [], + values: [], label: "Limit search to the map area", icon: "globe", operator: "OR", @@ -58,92 +53,187 @@ define([ */ initialize: function (attributes, options) { Filter.prototype.initialize.call(this, attributes, options); - this.setUpGeohashCollection(); - this.update(); + if (this.hasCoordinates()) this.updateFilterFromExtent(); this.setListeners(); }, - setUpGeohashCollection: function () { - this.set("geohashCollection", new Geohashes()); - }, - - setListeners: function () { - this.listenTo( - this, - "change:height change:north change:south change:east change:west", - this.update + /** + * Returns true if the filter has a valid set of coordinates + * @returns {boolean} True if the filter has coordinates + */ + hasCoordinates: function () { + return ( + typeof this.get("east") === "number" && + typeof this.get("west") === "number" && + typeof this.get("north") === "number" && + typeof this.get("south") === "number" ); }, - update: function () { - this.updateGeohashCollection(); - this.updateFilter(); + /** + * 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. + */ + validateCoordinates: function () { + if (!this.hasCoordinates()) return; + if (this.get("east") > 180) { + this.set("east", 180); + } + if (this.get("west") < -180) { + this.set("west", -180); + } + if (this.get("north") > 90) { + this.set("north", 90); + } + if (this.get("south") < -90) { + this.set("south", -90); + } + if (this.get("east") < this.get("west")) { + this.set("east", this.get("west")); + } + if (this.get("north") < this.get("south")) { + this.set("north", this.get("south")); + } }, - updateGeohashCollection: function () { - const gCollection = this.get("geohashCollection"); - gCollection.addGeohashesByExtent( - (bounds = { - north: this.get("north"), - south: this.get("south"), - east: this.get("east"), - west: this.get("west"), - }), - (height = this.get("height")), - (overwrite = true) - ); + /** + * Set a listener that updates the filter when the coordinates & height + * change + */ + setListeners: function () { + const extentEvents = + "change:height change:north change:south change:east change:west"; + this.stopListening(this, extentEvents); + this.listenTo(this, extentEvents, this.updateFilterFromExtent); }, /** - * Update the level, fields, geohashes, and values on the model, according - * to the current height, north, south and east attributes. + * 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 x.x.x */ - updateFilter: function () { + updateFilterFromExtent: function () { try { - const levels = this.getGeohashLevels().forEach((lvl) => { - return "geohash_" + lvl; + this.validateCoordinates(); + const geohashes = new Geohashes(); + geohashes.addGeohashesByExtent( + (bounds = { + north: this.get("north"), + south: this.get("south"), + east: this.get("east"), + west: this.get("west"), + }), + (height = this.get("height")), + (overwrite = true) + ); + geohashes.consolidate(); + this.set({ + fields: this.precisionsToFields(geohashes.getPrecisions()), + values: geohashes.getAllHashStrings(), }); - const IDs = this.getGeohashIDs(); - this.set("fields", levels); - this.set("values", IDs); } catch (e) { - console.log("Failed to update geohashes", e); + console.log("Error updating filter from extent", e); } }, - getGeohashLevels: function () { - const gCollection = this.get("geohashCollection"); - return gCollection.getLevels(); + /** + * 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 x.x.x + */ + precisionToField: function (precision) { + return precision && !isNaN(precision) ? "geohash_" + precision : null; }, - getGeohashIDs: function () { - const gCollection = this.get("geohashCollection"); - return gCollection.getGeohashIDs(); + /** + * 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 x.x.x + */ + 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 * @return {string} The query fragment + * @since x.x.x */ getQuery: function () { - const subset = this.get("geohashCollection").getMerged(); - const levels = subset.getLevels(); - if (levels.length <= 1) { - // We can use the prototype getQuery method if only one level of - // geohash is set on the fields - return Filter.prototype.getQuery.call(this); - } - // Otherwise, we will get a query from a collection of filters, each - // one representing a single level of geohash - const filters = new Filters(); - levels.forEach((lvl) => { - const filter = new SpatialFilter({ - fields: ["geohash_" + lvl], - values: subset[lvl], + try { + // Methods in the geohash collection allow us make efficient queries + const hashes = this.get("values"); + const 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.coversEarth() || geohashes.length === 0) { + return ""; + } + + // Merge into the minimal num. of geohashes to reduce query size + 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) + ) + ); + } }); - filters.add(filter); + 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 x.x.x + */ + 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"), }); - return filters.getQuery(); }, /** @@ -183,11 +273,20 @@ define([ }, /** + * // TODO: Do we need this? * @inheritdoc */ resetValue: function () { - this.set("fields", this.defaults().fields); - this.set("values", this.defaults().values); + const df = this.defaults(); + this.set({ + fields: df.fields, + values: df.values, + east: df.east, + west: df.west, + north: df.north, + south: df.south, + height: df.height, + }); }, } ); diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 613f35cfc..52d607ecf 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -24,23 +24,25 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( type: "Geohash", /** - * Default attributes for Geohash models + * Default attributes for Geohash models. Note that attributes like + * precision, bounds, etc. are all calculated on the fly during the get + * method. * @name Geohash#defaults * @type {Object} - * @property {string} geohash The geohash value/ID. + * @property {string} hashString The hashString of the geohash. * @property {Object} [properties] An object containing arbitrary * properties associated with the geohash. (e.g. count values from * SolrResults) */ defaults: function () { return { - geohash: "", // TODO: the proper name for a geohash ID is hashstring or hash. Rename this in all places it is used. Also rename "level" to precision. + hashString: "", properties: {}, }; }, /** - * Overwrite the get method to calculate bounds, point, level, and + * Overwrite the get method to calculate bounds, point, precision, and * arbitrary properties on the fly. * @param {string} attr The attribute to get the value of. * @returns {*} The value of the attribute. @@ -48,19 +50,21 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( get: function (attr) { if (attr === "bounds") return this.getBounds(); if (attr === "point") return this.getPoint(); - if (attr === "level") return this.getLevel(); + if (attr === "precision") return this.getPrecision(); if (attr === "geojson") return this.toGeoJSON(); + if (attr === "groupID") return this.getGroupID(); if (this.isProperty(attr)) return this.getProperty(attr); return Backbone.Model.prototype.get.call(this, attr); }, /** - * Checks if the geohash is empty. it is empty if it has no ID set. - * @returns {boolean} True if the geohash is empty, false otherwise. + * Checks if the geohash is empty. It is considered empty if it has no + * hashString set. + * @returns {boolean} true if the geohash is empty, false otherwise. */ isEmpty: function () { - const geohash = this.get("geohash"); - return !geohash || geohash.length === 0; + const hashString = this.get("hashString"); + return !hashString || hashString.length === 0; }, /** @@ -82,9 +86,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( */ getProperty: function (key) { if (!key) return null; - if (!this.isProperty(key)) { - return null; - } + if (!this.isProperty(key)) return null; return this.get("properties")[key]; }, @@ -117,7 +119,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( */ getBounds: function () { if (this.isEmpty()) return null; - return nGeohash.decode_bbox(this.get("geohash")); + return nGeohash.decode_bbox(this.get("hashString")); }, /** @@ -126,16 +128,16 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( */ getPoint: function () { if (this.isEmpty()) return null; - return nGeohash.decode(this.get("geohash")); + return nGeohash.decode(this.get("hashString")); }, /** - * Get the level of the geohash. - * @returns {number|null} The level of the geohash. + * Get the precision of the geohash. + * @returns {number|null} The precision of the geohash. */ - getLevel: function () { + getPrecision: function () { if (this.isEmpty()) return null; - return this.get("geohash").length; + return this.get("hashString").length; }, /** @@ -147,12 +149,14 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( getChildGeohashes: function (keepProperties = false) { if (this.isEmpty()) return null; const geohashes = []; - const geohash = this.get("geohash"); + const hashString = this.get("hashString"); for (let i = 0; i < 32; i++) { - geohashes.push(new Geohash({ - geohash: geohash + i.toString(32), - properties: keepProperties ? this.get("properties") : {} - })); + geohashes.push( + new Geohash({ + hashString: hashString + i.toString(32), + properties: keepProperties ? this.get("properties") : {}, + }) + ); } return geohashes; }, @@ -164,21 +168,42 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( * @returns {Geohash|null} A Geohash model or null if the geohash is empty. */ getParentGeohash: function (keepProperties = false) { - if (this.isEmpty()) return null; - const geohash = this.get("geohash"); - if (geohash.length === 0) return null; return new Geohash({ - geohash: geohash.slice(0, -1), - properties: keepProperties ? this.get("properties") : {} + hashString: this.getGroupID(), + properties: keepProperties ? this.get("properties") : {}, }); }, + /** + * Get all geohashes that belong in the same complete group of + * 32 geohashes. + */ + getGeohashGroup: function () { + if (this.isEmpty()) return null; + const parent = this.getParentGeohash(); + if (!parent) return null; + return parent.getChildGeohashes(); + }, + + /** + * Get the group ID of the geohash. The group ID is the hashString of the + * geohash without the last character, i.e. the hashString of the "parent" + * geohash. + * @returns {string} The group ID of the geohash. + */ + getGroupID: function () { + if (this.isEmpty()) return ""; + return this.get("hashString").slice(0, -1); + }, + /** * Get the geohash as a GeoJSON Feature. * @returns {Object} A GeoJSON Feature representing the geohash. */ toGeoJSON: function () { const bounds = this.getBounds(); + const properties = this.get("properties"); + properties["hashString"] = this.get("hashString"); if (!bounds) return null; return { type: "Feature", @@ -194,7 +219,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( ], ], }, - properties: this.get("properties"), + properties: properties, }; }, } diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 64d391483..4c76de13b 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -7,8 +7,7 @@ define([ "cesium", "models/maps/assets/CesiumVectorData", "models/maps/Geohash", - "collections/maps/Geohashes" - + "collections/maps/Geohashes", ], function ($, _, Backbone, Cesium, CesiumVectorData, Geohash, Geohashes) { /** * @classdesc A Geohash Model represents a geohash layer in a map. @@ -33,11 +32,12 @@ define([ * @name CesiumGeohash#defaults * @type {Object} * @extends CesiumVectorData#defaults - * @property {'CesiumGeohash'} type The format of the data. Must be - * 'CesiumGeohash'. - * // TODO + * @property {'CesiumGeohash'} type The format of the data. + * @property {string} label The label for the layer. + * @property {Geohashes} geohashes The collection of geohashes to display + * on the map. + * @property {number} opacity The opacity of the layer. */ - defaults: function () { return Object.assign(CesiumVectorData.prototype.defaults(), { type: "GeoJsonDataSource", @@ -66,11 +66,29 @@ define([ } }, - getLevels: function () { - return this.get("geohashes").getLevels(); + /** + * Get the associated precision level for the current camera height. + * Required that a mapModel be set on the model. If one is not set, then + * the minimum precision from the geohash collection will be returned. + * @returns {number} The precision level. + */ + getPrecision: function () { + try { + const height = this.get("mapModel").get("currentViewExtent").height; + return this.get("geohashes").heightToPrecision(height); + } catch (e) { + const precisions = this.get("geohashes").getPrecisions(); + return Math.min(...precisions); + } }, - resetGeohashes: function (geohashes) { + /** + * Replace the collection of geohashes to display on the map with a new + * set. + * @param {Geohash[]|Object[]} geohashes The new set of geohash models to + * display or attributes for the new geohash models. + */ + replaceGeohashes: function (geohashes) { this.get("geohashes").reset(geohashes); }, @@ -97,9 +115,13 @@ define([ this.listenTo(this.get("geohashes"), "change", function () { this.createCesiumModel(true); }); - this.listenTo(this.get("mapModel"), "change:currentExtent", function () { - this.createCesiumModel(true); - }); + this.listenTo( + this.get("mapModel"), + "change:currentExtent", + function () { + this.createCesiumModel(true); + } + ); } catch (error) { console.log("Failed to set listeners in CesiumGeohash", error); } @@ -107,11 +129,11 @@ define([ /** * Returns the GeoJSON representation of the geohashes. - * @param {Boolean} [limitToExtent = true] - Set to false to return - * the GeoJSON for all geohashes, not just those in the current extent. + * @param {Boolean} [limitToExtent = true] - Set to false to return the + * GeoJSON for all geohashes, not just those in the current extent. * @returns {Object} The GeoJSON representation of the geohashes. */ - getGeoJson: function (limitToExtent = true) { + getGeoJSON: function (limitToExtent = true) { if (!limitToExtent) { return this.get("geohashes")?.toGeoJSON(); } @@ -119,17 +141,17 @@ define([ // copy it and delete the height attr const bounds = Object.assign({}, extent); delete bounds.height; - return this.get("geohashes")?.getSubsetByBounds(bounds)?.toGeoJSON() + return this.get("geohashes")?.getSubsetByBounds(bounds)?.toGeoJSON(); }, /** * Creates a Cesium.DataSource model and sets it to this model's - * 'cesiumModel' attribute. This cesiumModel contains all the - * information required for Cesium to render the vector data. See + * 'cesiumModel' attribute. This cesiumModel contains all the information + * required for Cesium to render the vector data. See * {@link https://cesium.com/learn/cesiumjs/ref-doc/DataSource.html?classFilter=DataSource} - * @param {Boolean} [recreate = false] - Set recreate to true to force - * the function create the Cesium Model again. Otherwise, if a cesium - * model already exists, that is returned instead. + * @param {Boolean} [recreate = false] - Set recreate to true to force the + * function create the Cesium Model again. Otherwise, if a cesium model + * already exists, that is returned instead. */ createCesiumModel: function (recreate = false) { try { @@ -145,7 +167,7 @@ define([ } // Set the GeoJSON representing geohashes on the model const cesiumOptions = model.get("cesiumOptions"); - cesiumOptions["data"] = this.getGeoJson(); + cesiumOptions["data"] = this.getGeoJSON(); // TODO: outlines don't work when features are clamped to ground // cesiumOptions['clampToGround'] = true cesiumOptions["height"] = 0; @@ -163,3 +185,11 @@ define([ } ); }); + +// TODO: consider adding this back in to optionally limit the number of +// geohashes const limit = this.get("maxGeohashes"); if (limit && +// hashStrings.length > limit && level > 1) { while (hashStrings.length > limit +// && level > 1) { level--; hashStrings = this.getHashStringsByExtent(bounds, +// level); +// } +// }