From 7dffff2c0459d0f836c8c652fb13ab021b5785ee Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 2 Dec 2022 16:00:31 -0500 Subject: [PATCH 01/79] Update arctic router to render CatalogSearchView When it's configured to use it instead of the older version Fixes #2064 --- src/js/themes/arctic/routers/router.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/js/themes/arctic/routers/router.js b/src/js/themes/arctic/routers/router.js index 99e215cf2..7af708d96 100644 --- a/src/js/themes/arctic/routers/router.js +++ b/src/js/themes/arctic/routers/router.js @@ -118,7 +118,16 @@ function ($, _, Backbone) { else if(page == 0) MetacatUI.appModel.set('page', 0); else - MetacatUI.appModel.set('page', page-1); + MetacatUI.appModel.set('page', page - 1); + + //Check if we are using the new CatalogSearchView + if(!MetacatUI.appModel.get("useDeprecatedDataCatalogView")){ + require(["views/search/CatalogSearchView"], function(CatalogSearchView){ + MetacatUI.appView.catalogSearchView = new CatalogSearchView(); + MetacatUI.appView.showView(MetacatUI.appView.catalogSearchView); + }); + return; + } //Check for a query URL parameter if((typeof query !== "undefined") && query){; From a7d650609f5b599a728f00a45560ef20db4b0217 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 2 Dec 2022 16:22:26 -0500 Subject: [PATCH 02/79] Remove references to dataCatalogMap option Fixes #2075 --- src/js/views/DataCatalogView.js | 36 +--------------------- src/js/views/DataCatalogViewWithFilters.js | 3 -- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/src/js/views/DataCatalogView.js b/src/js/views/DataCatalogView.js index 225d078f0..05b7b3f7e 100644 --- a/src/js/views/DataCatalogView.js +++ b/src/js/views/DataCatalogView.js @@ -163,9 +163,6 @@ define(["jquery", // and event handling to sub views render: function () { - // Which type of map are we rendering, Google maps or Cesium maps? - this.mapType = MetacatUI.appModel.get("dataCatalogMap") || "google"; - // Use the global models if there are no other models specified at time of render if ((MetacatUI.appModel.get("searchHistory").length > 0) && (!this.searchModel || Object.keys(this.searchModel).length == 0) @@ -1843,7 +1840,7 @@ define(["jquery", renderMap: function () { // If gmaps isn't enabled or loaded with an error, use list mode - if (this.mapType === "google" && (!gmaps || this.mode == "list")) { + if (!gmaps || this.mode == "list") { this.ready = true; this.mode = "list"; return; @@ -1855,31 +1852,6 @@ define(["jquery", $("body").addClass("mapMode"); } - // Render Cesium maps, if that is the map type rendered. - if (this.mapType == "cesium") { - var mapContainer = $("#map-container").append("
"); - - var mapView = new CesiumWidgetView({ - el: mapContainer - }); - mapView.render(); - this.map = mapView; - - this.mapModel.set("map", this.map); - - this.map.showGeohashes() - - // Mark the view as ready to start a search - this.ready = true; - this.triggerSearch(); - this.allowSearch = false; - - // TODO / WIP : Implement the rest of the map search features... - return - } - - // Continue with rendering Google maps, if that is configured mapType - // Get the map options and create the map gmaps.visualRefresh = true; var mapOptions = this.mapModel.get("mapOptions"); @@ -2169,12 +2141,6 @@ define(["jquery", **/ drawTiles: function () { - // This function is for Google maps only. The CesiumWidgetView draws its - // own tiles. - if (this.mapType !== "google") { - return - } - // Exit if maps are not in use if ((this.mode != "map") || (!gmaps)) { return false; diff --git a/src/js/views/DataCatalogViewWithFilters.js b/src/js/views/DataCatalogViewWithFilters.js index 161952eae..138f5285d 100644 --- a/src/js/views/DataCatalogViewWithFilters.js +++ b/src/js/views/DataCatalogViewWithFilters.js @@ -105,9 +105,6 @@ define(["jquery", MetacatUI.appModel.set("searchMode", this.mode); } - // Which type of map are we rendering, Google maps or Cesium maps? - this.mapType = MetacatUI.appModel.get("dataCatalogMap") || "google"; - if(!this.statsModel){ this.statsModel = new Stats(); } From d74d6aa74266de93ba3add1e15f1a00a801a8774 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 12 Dec 2022 11:25:13 -0500 Subject: [PATCH 03/79] Remove duplicated code in Cesium view - Replace similar calculations with the view's getDegreesFromCartesian function - Make the getDegreesFromCartesian less repetitive by iterating through the position keys - Also standardize formatting in the view Relates to #1720 --- src/js/views/maps/CesiumWidgetView.js | 64 +++++++++++---------------- 1 file changed, 26 insertions(+), 38 deletions(-) diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 2c12fa29d..c73caeb38 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -697,18 +697,7 @@ define( */ getCameraPosition: function () { try { - var camera = this.camera - var cameraPosition = Cesium.Cartographic.fromCartesian(camera.position) - - return { - longitude: Cesium.Math.toDegrees(cameraPosition.longitude), - latitude: Cesium.Math.toDegrees(cameraPosition.latitude), - height: cameraPosition.height, - heading: Cesium.Math.toDegrees(camera.heading), - pitch: Cesium.Math.toDegrees(camera.pitch), - roll: Cesium.Math.toDegrees(camera.roll) - } - + return this.getDegreesFromCartesian(this.camera.position) } catch (error) { console.log( @@ -821,11 +810,16 @@ define( */ getDegreesFromCartesian: function (cartesian) { const cartographic = Cesium.Cartographic.fromCartesian(cartesian); - return { - longitude: Cesium.Math.toDegrees(cartographic.longitude), - latitude: Cesium.Math.toDegrees(cartographic.latitude), + const degrees = { height: cartographic.height } + const coordinates = ['longitude', 'latitude', 'heading', 'pitch', 'roll'] + coordinates.forEach(function (coordinate) { + if (Cesium.defined(cartographic[coordinate])) { + degrees[coordinate] = Cesium.Math.toDegrees(cartographic[coordinate]) + } + }); + return degrees }, /** @@ -1005,13 +999,7 @@ define( var pickRay = view.camera.getPickRay(mousePosition); var cartesian = view.scene.globe.pick(pickRay, view.scene); if (cartesian) { - // Use globe.ellipsoid.cartesianToCartographic ? - var cartographic = Cesium.Cartographic.fromCartesian(cartesian); - view.model.set('currentPosition', { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - height: cartographic.height, - }) + view.model.set('currentPosition', view.getDegreesFromCartesian(cartesian)) } } @@ -1139,7 +1127,7 @@ define( * @param {MapAsset} mapAsset A MapAsset layer to render in the map, such as a * Cesium3DTileset or a CesiumImagery model. */ - addAsset: function(mapAsset) { + addAsset: function (mapAsset) { try { if (!mapAsset) { return @@ -1224,27 +1212,27 @@ define( /** * Renders a CesiumGeohash map asset on the map - * */ + */ addGeohashes: function () { - let view = this; - - require(["views/maps/CesiumGeohashes"], (CesiumGeohashes)=>{ - //Create a CesiumGeohashes view - let cg = new CesiumGeohashes(); - cg.cesiumViewer = view; - - //Get the CesiumGeohash MapAsset and save a reference in the view - let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash"); - cg.cesiumGeohash = cesiumGeohashAsset; - - cg.render(); - }) + let view = this; + + require(["views/maps/CesiumGeohashes"], (CesiumGeohashes) => { + //Create a CesiumGeohashes view + let cg = new CesiumGeohashes(); + cg.cesiumViewer = view; + + //Get the CesiumGeohash MapAsset and save a reference in the view + let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash"); + cg.cesiumGeohash = cesiumGeohashAsset; + + cg.render(); + }) }, /** * Renders imagery in the Map. * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to render - */ + */ addImagery: function (cesiumModel) { this.scene.imageryLayers.add(cesiumModel) this.sortImagery() From 2b9765bac50650c7850ba15fdda4ce2986039620 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 12 Jan 2023 15:52:30 -0500 Subject: [PATCH 04/79] Use the CesiumVectorData model for Geohashes - Make CesiumGeohash an extension of CesiumVectorData instead of MapAsset - Add Geohash specific properties to the CesiumGeohash model (e.g. precisionAltMap, bounds, level, geohashes, etc.) - Add a ToJSON function to the CesiumGeohash model that converts geohash & search result information to a JSON object - Create listeners for updating Geohashes when the bounds & altitude change - Add ability to update the data source in the CesiumVectorData model - Always set ClampToGround to true for geohashes Relates to #1720, #2063, #2070, #2076 --- src/js/models/AppModel.js | 34 +- src/js/models/connectors/Geohash-Search.js | 36 +- src/js/models/maps/Map.js | 15 +- src/js/models/maps/assets/CesiumGeohash.js | 314 +++++++++++++----- src/js/models/maps/assets/CesiumVectorData.js | 37 ++- src/js/models/maps/assets/MapAsset.js | 3 +- src/js/views/maps/CesiumWidgetView.js | 26 +- src/js/views/search/CatalogSearchView.js | 23 +- 8 files changed, 310 insertions(+), 178 deletions(-) diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index a2c98e990..9206994a3 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -107,23 +107,23 @@ define(['jquery', 'underscore', 'backbone'], catalogSearchMapOptions: { showToolbar: false, layers: [ - { - "type": "CesiumGeohash", - "opacity": 1, - "hue": 205 //blue - }, - { - "label": "Satellite imagery", - "icon": "urn:uuid:4177c2e1-3037-4964-bf00-5f13182308d9", - "type": "IonImageryProvider", - "description": "Global satellite imagery down to 15 cm resolution in urban areas", - "attribution": "Data provided by Bing Maps © 2021 Microsoft Corporation", - "moreInfoLink": "https://www.microsoft.com/maps", - "opacity": 1, - "cesiumOptions": { - "ionAssetId": "2" - } - }] + { + "type": "CesiumGeohash", + "opacity": 0.7, + }, + { + "label": "Satellite imagery", + "icon": "urn:uuid:4177c2e1-3037-4964-bf00-5f13182308d9", + "type": "IonImageryProvider", + "description": "Global satellite imagery down to 15 cm resolution in urban areas", + "attribution": "Data provided by Bing Maps © 2021 Microsoft Corporation", + "moreInfoLink": "https://www.microsoft.com/maps", + "opacity": 1, + "cesiumOptions": { + "ionAssetId": "2" + } + } + ] }, /** diff --git a/src/js/models/connectors/Geohash-Search.js b/src/js/models/connectors/Geohash-Search.js index 3f3c54d50..f26e999d8 100644 --- a/src/js/models/connectors/Geohash-Search.js +++ b/src/js/models/connectors/Geohash-Search.js @@ -20,7 +20,7 @@ define(['backbone', "models/maps/assets/CesiumGeohash", "collections/SolrResults * @property {CesiumGeohash} cesiumGeohash */ defaults: function(){ - return{ + return { searchResults: null, cesiumGeohash: null } @@ -32,24 +32,26 @@ define(['backbone', "models/maps/assets/CesiumGeohash", "collections/SolrResults * geohash level in the SolrResults so that it can be used by the next query. * @since 2.22.0 */ - startListening: function(){ - this.listenTo(this.get("searchResults"), "reset", function(){ - //Set the new geohash facet counts on the CesiumGeohash MapAsset - let level = this.get("cesiumGeohash").get("geohashLevel"); - this.get("cesiumGeohash").set("geohashCounts", this.get("searchResults").facetCounts["geohash_"+level] ); - this.get("cesiumGeohash").set("totalCount", this.get("searchResults").getNumFound() ); - - //Set the status of the CesiumGeohash MapAsset to 'ready' so that it is re-rendered - if(this.get("cesiumGeohash").get("status") == "ready"){ - this.get("cesiumGeohash").trigger("change:status"); - } - else{ - this.get("cesiumGeohash").set("status", "ready"); - } + startListening: function () { + + const geohashLayer = this.get("cesiumGeohash") + const searchResults = this.get("searchResults") + + this.listenTo(searchResults, "reset", function(){ + + const level = geohashLayer.get("level") || 1; + const facetCounts = searchResults.facetCounts["geohash_" + level] + const totalFound = searchResults.getNumFound() + + // Set the new geohash facet counts on the CesiumGeohash MapAsset + geohashLayer.set("counts", facetCounts); + geohashLayer.set("totalCount", totalFound); + }); - this.listenTo(this.get("cesiumGeohash"), "change:geohashLevel", function(){ - this.get("searchResults").setFacet(["geohash_"+this.get("cesiumGeohash").get("geohashLevel")]); + this.listenTo(geohashLayer, "change:geohashLevel", function () { + const level = geohashLayer.get("level") || 1; + searchResults.setFacet(["geohash_" + level]); }); } diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index c3e193559..6e69fde00 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -171,9 +171,9 @@ define( roll: 0 }, layers: new MapAssets([{ - type: 'NaturalEarthII', - label: 'Base layer' - }]), + type: 'NaturalEarthII', + label: 'Base layer' + }]), terrains: new MapAssets(), selectedFeatures: new Features(), showToolbar: true, @@ -208,12 +208,15 @@ define( try { if (config) { - if (config.layers && config.layers.length && Array.isArray(config.layers)) { + function isNonEmptyArray(a) { + return a && a.length && Array.isArray(a) + } + + if (isNonEmptyArray(config.layers)) { this.set('layers', new MapAssets(config.layers)) this.get('layers').setMapModel(this) } - - if (config.terrains && config.terrains.length && Array.isArray(config.terrains)) { + if (isNonEmptyArray(config.terrains)) { this.set('terrains', new MapAssets(config.terrains)) } diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 9b04cde69..de84b7ef8 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -7,7 +7,7 @@ define( 'backbone', 'cesium', 'nGeohash', - 'models/maps/assets/MapAsset', + 'models/maps/assets/CesiumVectorData', ], function ( $, @@ -15,18 +15,18 @@ define( Backbone, Cesium, nGeohash, - MapAsset + CesiumVectorData ) { /** * @classdesc A Geohash Model represents a geohash layer in a map. * @classcategory Models/Maps/Assets * @class CesiumGeohash * @name CesiumGeohash - * @extends MapAsset + * @extends CesiumVectorData * @since 2.18.0 * @constructor */ - return MapAsset.extend( + return CesiumVectorData.extend( /** @lends Geohash.prototype */ { /** @@ -36,118 +36,258 @@ define( type: 'CesiumGeohash', /** - * This function will return the appropriate geohash level to use for mapping - * geohash tiles on the map at the specified altitude (zoom level). - * @param {Number} altitude The distance from the surface of the earth in meters - * @returns The geohash level, an integer between 0 and 9. + * Default attributes for Geohash models + * @name CesiumGeohash#defaults + * @type {Object} + * @extends CesiumVectorData#defaults + * @property {'CesiumGeohash'} type The format of the data. Must be + * 'CesiumGeohash'. + * @property {boolean} isGeohashLayer A flag to indicate that this is a + * Geohash layer, since we change the type to CesiumVectorData. Used by + * the Catalog Search View to find this layer so it can be connected to + * search results. + * @property {object} precisionAltMap Map of precision integer to + * minimum altitude (m) + * @property {Number} altitude The current distance from the surface of + * the earth in meters + * @property {Number} level The geohash level, an integer between 0 and + * 9. + * @property {object} bounds The current bounding box (south, west, + * north, east) within which to render geohashes (in longitude/latitude + * coordinates). + * @property {string[]} counts An array of geohash strings followed by + * their associated count. e.g. ["a", 123, "f", 8] + * @property {Number} totalCount The total number of results that were + * just fetched + * @property {Number} geohashes + */ + + defaults: function () { + return Object.assign( + CesiumVectorData.prototype.defaults(), + { + type: 'GeoJsonDataSource', + label: 'Geohashes', + isGeohashLayer: true, + precisionAltMap: { + 1: 6000000, + 2: 4000000, + 3: 1000000, + 4: 100000, + 5: 0 + }, + altitude: null, + level: 1, + bounds: { + north: null, + east: null, + south: null, + west: null + }, + level: 1, + counts: [], + totalCount: 0, + geohashes: [] + } + ) + }, + + /** + * Executed when a new CesiumGeohash model is created. + * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of + * the attributes, which will be set on the model. */ - setGeohashLevel: function (altitude) { + initialize: function (assetConfig) { try { - // map of precision integer to minimum altitude - const precisionAltMap = { - '1': 6000000, - '2': 4000000, - '3': 1000000, - '4': 100000, - '5': 0 - } - const precision = _.findKey(precisionAltMap, function (minAltitude) { - return altitude >= minAltitude - }) - this.set("geohashLevel", Number(precision)); + this.setGeohashListeners() + this.set('type', 'GeoJsonDataSource') + CesiumVectorData.prototype.initialize.call(this, assetConfig); } catch (error) { console.log( - 'There was an error getting the geohash level from altitude in a Geohash ' + - 'Returning level 1 by default. ' + - 'model. Error details: ' + error + 'There was an error initializing a CesiumVectorData model' + + '. Error details: ' + error ); - return 1 } }, /** - * - * @param {Number} south The south-most coordinate of the area to get geohashes - * for - * @param {Number} west The west-most coordinate of the area to get geohashes for - * @param {Number} north The north-most coordinate of the area to get geohashes - * for - * @param {Number} east The east-most coordinate of the area to get geohashes for - * @param {Number} precision An integer between 1 and 9 representing the geohash - * @param {Boolean} boundingBoxes Set to true to return the bounding box for each - * geohash level to show + * Connect this layer to the map to get updates on the current view + * extent (bounds) and altitude. Update the Geohashes when the altitude + * or bounds in the model change. */ - getGeohashes: function (south, west, north, east, precision, boundingBoxes = false) { + setGeohashListeners: function () { try { - // Get all the geohash tiles contained in the map bounds - var geohashes = nGeohash.bboxes( - south, west, north, east, precision - ) - // If the boundingBoxes option is set to false, then just return the list of - // geohashes - if (!boundingBoxes) { - return geohashes - } - // Otherwise, return the bounding box for each geohash as well - var boundingBoxes = [] - geohashes.forEach(function (geohash) { - boundingBoxes[geohash] = nGeohash.decode_bbox(geohash) + const model = this + + // Update the geohashes when the bounds or altitude change + model.stopListening(model, + 'change:level change:bounds change:altitude change:geohashes') + model.listenTo(model, 'change:altitude', model.setGeohashLevel) + model.listenTo(model, 'change:bounds change:level', model.setGeohashes) + model.listenTo(model, 'change:geohashes', function () { + model.createCesiumModel(true) }) - return boundingBoxes + + // Connect this layer to the map to get current bounds and altitude + function setMapListeners() { + const mapModel = model.get('mapModel') + if (!mapModel) { return } + model.listenTo(mapModel, 'change:currentViewExtent', + function (map, newExtent) { + model.set('bounds', newExtent) + }) + model.listenTo(mapModel, 'change:currentPosition', + function (model, newPosition) { + // TODO: This is the estimated elevation at the cursor. + // Get calculation for "camera" altitude instead. + // const alt = newPosition['height'] + // model.set('altitude', alt) + }) + } + setMapListeners.call(model) + model.stopListening(model, 'change:mapModel', setMapListeners) + model.listenTo(model, 'change:mapModel', setMapListeners) } catch (error) { console.log( - 'There was an error getting geohashes in a Geohash model' + - '. Error details: ' + error + 'There was an error setting listeners in a CesiumGeohash' + + '. Error details: ', error ); } }, /** - * Default attributes for Geohash models - * @name CesiumGeohash#defaults - * @type {Object} - * @property {number} geohashLevel The level of geohash currently used by this Cesium Map Asset - * @property {number[]|string[]} geohashCounts An array of geohash strings followed by their associated count. e.g. ["a", 123, "f", 8] - */ - defaults: function (){ return Object.assign(MapAsset.prototype.defaults(), { - type: "CesiumGeohash", - status: "", - hue: 205, //blue - geohashLevel: 2, - geohashCounts: [], - totalCount: 0 - }) + * Given the geohashes set on the model, return as geoJSON + * @returns {object} GeoJSON representing the geohashes with counts + */ + toGeoJSON: function () { + try { + // The base GeoJSON format + const geojson = { + "type": "FeatureCollection", + "features": [] + } + const geohashes = this.get('geohashes') + if (!geohashes) { + return geojson + } + const features = [] + // Format for geohashes: + // { geohashID: [minlat, minlon, maxlat, maxlon] }. + for (const [id, bb] of Object.entries(geohashes)) { + const minlat = bb[0] + const minlon = bb[1] + const maxlat = bb[2] + const maxlon = bb[3] + const feature = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [minlon, minlat], + [maxlon, minlat], + [maxlon, maxlat], + [minlon, maxlat], + [minlon, minlat] + ] + ] + }, + "properties": { + // "count": 0, // TODO - add counts + "geohash": id + } + } + features.push(feature) + } + geojson['features'] = features + return geojson + } + catch (error) { + console.log( + 'There was an error converting geohashes to GeoJSON ' + + 'in a CesiumGeohash model. Error details: ', error + ); + } }, /** - * - * Creates a Cesium `CustomDataSource` {@link https://cesium.com/learn/cesiumjs/ref-doc/CustomDataSource.html} object - * that is used to add entities to the Cesium map. It is set on the `cesiumModel` attribute of the attached `CesiumGeohash` model. + * 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 + * {@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. */ - createCesiumModel: function(){ - // If the cesium model already exists, don't create it again unless specified - if (this.get('cesiumModel')) { - return this.get('cesiumModel') - } - - let cesiumModel = new Cesium.CustomDataSource('geohashes'); - this.set('cesiumModel', cesiumModel); - - let model = this; + createCesiumModel: function (recreate = false) { + try { + const model = this; + // Set the GeoJSON representing geohashes on the model + const cesiumOptions = model.get('cesiumOptions') + cesiumOptions['data'] = model.toGeoJSON() + cesiumOptions['clampToGround'] = true + model.set('cesiumOptions', cesiumOptions) + // Create the model like a regular GeoJSON data source + CesiumVectorData.prototype.createCesiumModel.call(this, recreate) + } + catch (error) { + console.log( + 'There was an error creating a CesiumGeohash model' + + '. Error details: ', error + ); + } + }, + /** + * Reset the geohash level set on the model, given the altitude that is + * currently set on the model. + */ + setGeohashLevel: function () { + try { + const precisionAltMap = this.get('precisionAltMap') + const altitude = this.get('altitude') + const precision = Object.keys(precisionAltMap) + .find(key => altitude >= precisionAltMap[key]); + this.set('level', precision); + } + catch (error) { + console.log( + 'There was an error getting the geohash level from altitude in ' + + 'a Geohash mode. Setting to level 1 by default. ' + + 'Error details: ' + error + ); + this.set('level', 1); + } }, /** - * Executed when a new Geohash model is created. - * @param {Object} [attributes] The initial values of the attributes, which - will - * be set on the model. - * @param {Object} [options] Options for the initialize function. - */ - initialize: function (attributes, options) { - this.createCesiumModel(); + * Update the geohash property with geohashes for the current + * altitude/precision and bounding box. + */ + setGeohashes: function () { + try { + const bb = this.get('bounds') + const precision = this.get('level') + // Get all the geohash tiles contained in the current bounds + var geohashID = nGeohash.bboxes( + bb['south'], bb['west'], bb['north'], bb['east'], precision + ) + var geohashes = [] + geohashID.forEach(function (id) { + geohashes[id] = nGeohash.decode_bbox(id) + + }) + this.set('geohashes', geohashes) + console.log(geohashes) + } + catch (error) { + console.log( + 'There was an error getting geohashes in a Geohash model' + + '. Error details: ' + error + ); + } }, // /** diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 7779cd518..a3adf2351 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -79,7 +79,7 @@ define( * specific to each type of asset. */ defaults: function () { - return _.extend( + return Object.assign( this.constructor.__super__.defaults(), { type: 'GeoJsonDataSource', @@ -123,12 +123,12 @@ define( /** * 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 - 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) { @@ -140,9 +140,19 @@ define( const label = model.get('label') || '' const dataSourceFunction = Cesium[type] + // If the cesium model already exists, don't create it again unless specified - if (!recreate && model.get('cesiumModel')) { - return model.get('cesiumModel') + let dataSource = model.get('cesiumModel') + if (dataSource) { + if (!recreate) { + return dataSource + } else { + // If we are recreating the model, remove all entities first. + // see https://stackoverflow.com/questions/31426796/loading-updated-data-with-geojsondatasource-in-cesium-js + dataSource.entities.removeAll(); + // Make sure the CesiumWidgetView re-renders the data + model.set('displayReady', false); + } } model.resetStatus(); @@ -154,7 +164,10 @@ define( } if (dataSourceFunction && typeof dataSourceFunction === 'function') { - let dataSource = new dataSourceFunction(label) + + if (!recreate) { + dataSource = new dataSourceFunction(label) + } const data = cesiumOptions.data; delete cesiumOptions.data @@ -162,7 +175,9 @@ define( dataSource.load(data, cesiumOptions) .then(function (loadedData) { model.set('cesiumModel', loadedData) - model.setListeners() + if (!recreate) { + model.setListeners() + } model.updateFeatureVisibility() model.updateAppearance() model.set('status', 'ready') @@ -393,7 +408,7 @@ define( } cesiumModel.entities.resumeEvents() - + // Let the map and/or other parent views know that a change has been made that // requires the map to be re-rendered model.trigger('appearanceChanged') diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index 3d597bb48..7d0fbb3a5 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -348,7 +348,6 @@ define( if (typeof this.updateAppearance === 'function') { const setSelectFeaturesListeners = function () { - const mapModel = this.get('mapModel') if (!mapModel) { return } const selectedFeatures = mapModel.get('selectedFeatures') @@ -364,7 +363,7 @@ define( } setSelectFeaturesListeners.call(this) - this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) + this.stopListening(this, 'change:mapModel', setSelectFeaturesListeners) this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) } } diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index c73caeb38..8034d1926 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -92,10 +92,6 @@ define( { types: ['CesiumTerrainProvider'], renderFunction: 'updateTerrain' - }, - { - types: ['CesiumGeohash'], - renderFunction: 'addGeohashes' } ], @@ -332,6 +328,7 @@ define( updateDataSourceDisplay: function () { try { const view = this; + const layers = view.model.get('layers') var dataSources = view.dataSourceDisplay.dataSources; if (!dataSources || !dataSources.length) { @@ -347,7 +344,7 @@ define( const dataSource = dataSources.get(i); const visualizers = dataSource._visualizers; - const assetModel = view.model.get('layers').findWhere({ + const assetModel = layers.findWhere({ cesiumModel: dataSource }) const displayReadyBefore = assetModel.get('displayReady') @@ -1210,25 +1207,6 @@ define( this.dataSourceCollection.add(cesiumModel) }, - /** - * Renders a CesiumGeohash map asset on the map - */ - addGeohashes: function () { - let view = this; - - require(["views/maps/CesiumGeohashes"], (CesiumGeohashes) => { - //Create a CesiumGeohashes view - let cg = new CesiumGeohashes(); - cg.cesiumViewer = view; - - //Get the CesiumGeohash MapAsset and save a reference in the view - let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash"); - cg.cesiumGeohash = cesiumGeohashAsset; - - cg.render(); - }) - }, - /** * Renders imagery in the Map. * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to render diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index a21d5c0a9..3fe528ec0 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -384,20 +384,13 @@ function($, Backbone, MapAssets, FilterGroup, FiltersSearchConnector, GeohashSea * @since 2.22.0 */ createMap: function(){ - let mapOptions = Object.assign({}, MetacatUI.appModel.get("catalogSearchMapOptions") || {}); - let map = new Map(mapOptions); + const mapOptions = Object.assign({}, MetacatUI.appModel.get("catalogSearchMapOptions") || {}); + const map = new Map(mapOptions); - //Add a CesiumGeohash layer to the map - /* let geohashLayer = new CesiumGeohash(); - geohashLayer. - let assets = map.get("layers"); - assets.add(geohashLayer); -*/ - - let geohashLayer = map.get("layers").findWhere({type: "CesiumGeohash"}) + const geohashLayer = map.get("layers").findWhere({isGeohashLayer: true}) //Connect the CesiumGeohash to the SolrResults - let connector = new GeohashSearchConnector({ + const connector = new GeohashSearchConnector({ cesiumGeohash: geohashLayer, searchResults: this.searchResultsView.searchResults }); @@ -405,10 +398,12 @@ function($, Backbone, MapAssets, FilterGroup, FiltersSearchConnector, GeohashSea this.geohashSearchConnector = connector; //Set the geohash level for the search - if( Array.isArray(this.searchResultsView.searchResults.facet) ) - this.searchResultsView.searchResults.facet.push("geohash_" + geohashLayer.get("geohashLevel")); + const searchFacet = this.searchResultsView.searchResults.facet + const newLevel = "geohash_" + geohashLayer.get("level") + if( Array.isArray(searchFacet) ) + searchFacet.push(newLevel); else - this.searchResultsView.searchResults.facet = "geohash_" + geohashLayer.get("geohashLevel"); + searchFacet = newLevel; //Create the Map model and view this.mapView = new MapView({ model: map }); From 8b85d2b47563185c3a492374a58261a6f7e6c0f7 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 17 Jan 2023 17:35:24 -0500 Subject: [PATCH 05/79] Set min latitude to -89.99999 for Geohashes Cesium throws an error when the latitude is -90 Relates to #1720 --- src/js/models/maps/assets/CesiumGeohash.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index de84b7ef8..a4b49d00d 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -176,7 +176,7 @@ define( // Format for geohashes: // { geohashID: [minlat, minlon, maxlat, maxlon] }. for (const [id, bb] of Object.entries(geohashes)) { - const minlat = bb[0] + const minlat = bb[0] <= -90 ? -89.99999 : bb[0] const minlon = bb[1] const maxlat = bb[2] const maxlon = bb[3] @@ -187,9 +187,9 @@ define( "coordinates": [ [ [minlon, minlat], - [maxlon, minlat], - [maxlon, maxlat], [minlon, maxlat], + [maxlon, maxlat], + [maxlon, minlat], [minlon, minlat] ] ] From 4e07636f8384d8a6c392166aff45d94143370916 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 18 Jan 2023 16:03:16 -0500 Subject: [PATCH 06/79] Render geohashes at p. meridian & all precisions - add height to the map model's currentViewExtent property on camera change (use height to get geohash precision) - tweak altitude-geohash precision map - fix issue where no geohashes were rendered when the view extent crossed the prime meridian Relates to #1720, #2076 --- src/js/models/maps/Map.js | 6 +- src/js/models/maps/assets/CesiumGeohash.js | 90 ++++++++++++++-------- src/js/views/maps/CesiumWidgetView.js | 14 +++- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 6e69fde00..10ce089ed 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -158,7 +158,8 @@ define( * equal the number of meters on the map/globe. * @property {Object} [currentViewExtent={ north: null, east: null, south: null, west: null }] * An object updated by the map widget that gives the extent of the current - * visible area as a bounding box in longitude/latitude coordinates. + * visible area as a bounding box in longitude/latitude coordinates, as well + * as the height/altitude in meters. */ defaults: function () { return { @@ -193,7 +194,8 @@ define( north: null, east: null, south: null, - west: null + west: null, + height: null } }; }, diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index a4b49d00d..fed79e52b 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -70,11 +70,12 @@ define( label: 'Geohashes', isGeohashLayer: true, precisionAltMap: { - 1: 6000000, - 2: 4000000, - 3: 1000000, - 4: 100000, - 5: 0 + 1: 6800000, + 2: 2400000, + 3: 550000, + 4: 120000, + 5: 7000, + 6: 0 }, altitude: null, level: 1, @@ -121,13 +122,18 @@ define( const model = this // Update the geohashes when the bounds or altitude change - model.stopListening(model, - 'change:level change:bounds change:altitude change:geohashes') - model.listenTo(model, 'change:altitude', model.setGeohashLevel) - model.listenTo(model, 'change:bounds change:level', model.setGeohashes) - model.listenTo(model, 'change:geohashes', function () { - model.createCesiumModel(true) - }) + + // TODO: Determine best way to set listeners, without re-creating + // the cesium model twice when both bounds and altitude change + // simultaneously + + // model.stopListening(model, + // 'change:level change:bounds change:altitude change:geohashes') + // model.listenTo(model, 'change:altitude', model.setGeohashLevel) + // model.listenTo(model, 'change:bounds change:level', model.setGeohashes) + // model.listenTo(model, 'change:geohashes', function () { + // model.createCesiumModel(true) + // }) // Connect this layer to the map to get current bounds and altitude function setMapListeners() { @@ -135,15 +141,15 @@ define( if (!mapModel) { return } model.listenTo(mapModel, 'change:currentViewExtent', function (map, newExtent) { + const altitude = newExtent.height + delete newExtent.height model.set('bounds', newExtent) - }) - model.listenTo(mapModel, 'change:currentPosition', - function (model, newPosition) { - // TODO: This is the estimated elevation at the cursor. - // Get calculation for "camera" altitude instead. - // const alt = newPosition['height'] - // model.set('altitude', alt) - }) + model.set('altitude', altitude) + model.setGeohashLevel() + model.setGeohashes() + model.createCesiumModel(true) + } + ) } setMapListeners.call(model) model.stopListening(model, 'change:mapModel', setMapListeners) @@ -227,7 +233,9 @@ define( // Set the GeoJSON representing geohashes on the model const cesiumOptions = model.get('cesiumOptions') cesiumOptions['data'] = model.toGeoJSON() - cesiumOptions['clampToGround'] = true + // TODO: outlines don't work when features are clamped to ground + // cesiumOptions['clampToGround'] = true + cesiumOptions['height'] = 0 model.set('cesiumOptions', cesiumOptions) // Create the model like a regular GeoJSON data source CesiumVectorData.prototype.createCesiumModel.call(this, recreate) @@ -268,19 +276,41 @@ define( */ setGeohashes: function () { try { - const bb = this.get('bounds') + const bounds = this.get('bounds') const precision = this.get('level') - // Get all the geohash tiles contained in the current bounds - var geohashID = nGeohash.bboxes( - bb['south'], bb['west'], bb['north'], bb['east'], precision - ) - var geohashes = [] - geohashID.forEach(function (id) { - geohashes[id] = nGeohash.decode_bbox(id) + const all_bounds = [] + let geohashIDs = [] + // Get all the geohash tiles contained in the current bounds. + if (bounds.east < bounds.west) { + // If the bounding box crosses the prime meridian, then we need to + // search for geohashes on both sides. Otherwise nGeohash returns + // 0 geohashes. + all_bounds.push({ + north: bounds.north, + south: bounds.south, + east: 180, + west: bounds.west + }) + all_bounds.push({ + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: -180 + }) + } else { + all_bounds.push(bounds) + } + all_bounds.forEach(function (bb) { + geohashIDs = geohashIDs.concat(nGeohash.bboxes( + bb.south, bb.west, bb.north, bb.east, precision + )) + }) + const geohashes = [] + geohashIDs.forEach(function (id) { + geohashes[id] = nGeohash.decode_bbox(id) }) this.set('geohashes', geohashes) - console.log(geohashes) } catch (error) { console.log( diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 8034d1926..d424482e7 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -505,7 +505,9 @@ define( attrs.mapAsset && typeof attrs.mapAsset.getPropertiesFromFeature === 'function' ) { - attrs.properties = attrs.mapAsset.getPropertiesFromFeature(attrs.featureObject) + attrs.properties = attrs.mapAsset.getPropertiesFromFeature( + attrs.featureObject + ) } featuresAttrs.push(attrs) @@ -707,7 +709,8 @@ define( /** * Update the 'currentViewExtent' attribute in the Map model with the north, * south, east, and west-most lat/long that define a bounding box around the - * currently visible area of the map. + * currently visible area of the map. Also gives the height/altitude of the + * camera in meters. */ updateViewExtent: function () { try { @@ -715,8 +718,13 @@ define( const camera = view.camera; const scene = view.scene; + // Get the height in meters + const height = camera.positionCartographic.height + // This will be the bounding box of the visible area - let coords = { north: null, south: null, east: null, west: null } + let coords = { + north: null, south: null, east: null, west: null, height: height + } // First try getting the visible bounding box using the simple method if (!view.scratchRectangle) { From 7785fe45e4875442aa2f7d8f86eb03b05f276dab Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 19 Jan 2023 12:29:13 -0500 Subject: [PATCH 07/79] Limit number of geohashes to render in Cesium map To improve performance when map is focused on poles or zoomed in and at ground level perspective Relates to #1720 --- src/js/models/maps/assets/CesiumGeohash.js | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index fed79e52b..964e3b599 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -48,6 +48,13 @@ define( * search results. * @property {object} precisionAltMap Map of precision integer to * minimum altitude (m) + * @property {Number} maxNumGeohashes The maximum number of geohashes + * allowed. Set to null to remove the limit. If the given bounds + + * altitude/level result in more geohashes than the max limit, then the + * level will be reduced by one until the number of geohashes is under + * the limit. This improves rendering performance, especially when the + * map is focused on either pole, or is tilted in a "street view" like + * perspective. * @property {Number} altitude The current distance from the surface of * the earth in meters * @property {Number} level The geohash level, an integer between 0 and @@ -77,6 +84,7 @@ define( 5: 7000, 6: 0 }, + maxNumGeohashes: 1000, altitude: null, level: 1, bounds: { @@ -276,10 +284,14 @@ define( */ setGeohashes: function () { try { + const bounds = this.get('bounds') const precision = this.get('level') + const limit = this.get('maxNumGeohashes') + const all_bounds = [] let geohashIDs = [] + const geohashes = [] // Get all the geohash tiles contained in the current bounds. if (bounds.east < bounds.west) { @@ -306,7 +318,18 @@ define( bb.south, bb.west, bb.north, bb.east, precision )) }) - const geohashes = [] + + // When the map is centered on the poles or is zoomed in and tilted, + // the bounds + level result in too many geohashes. Reduce the + // number of geohashes to the model's limit by reducing the + // precision. + if (limit && geohashIDs.length > limit && precision > 1) { + this.set('level', (precision - 1)) + this.setGeohashes(limit=limit) + return + } + + // Get the bounds for each of the geohashes geohashIDs.forEach(function (id) { geohashes[id] = nGeohash.decode_bbox(id) }) From fc8912ebf7fc79ba492e81fb0b6ba7c6c7988bbd Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 2 Dec 2022 16:00:31 -0500 Subject: [PATCH 08/79] Update arctic router to render CatalogSearchView When it's configured to use it instead of the older version Fixes #2064 --- src/js/themes/arctic/routers/router.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/js/themes/arctic/routers/router.js b/src/js/themes/arctic/routers/router.js index 99e215cf2..7af708d96 100644 --- a/src/js/themes/arctic/routers/router.js +++ b/src/js/themes/arctic/routers/router.js @@ -118,7 +118,16 @@ function ($, _, Backbone) { else if(page == 0) MetacatUI.appModel.set('page', 0); else - MetacatUI.appModel.set('page', page-1); + MetacatUI.appModel.set('page', page - 1); + + //Check if we are using the new CatalogSearchView + if(!MetacatUI.appModel.get("useDeprecatedDataCatalogView")){ + require(["views/search/CatalogSearchView"], function(CatalogSearchView){ + MetacatUI.appView.catalogSearchView = new CatalogSearchView(); + MetacatUI.appView.showView(MetacatUI.appView.catalogSearchView); + }); + return; + } //Check for a query URL parameter if((typeof query !== "undefined") && query){; From 9288e969cbd9d59246b9d2ec89a529d686f023df Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 2 Dec 2022 16:22:26 -0500 Subject: [PATCH 09/79] Remove references to dataCatalogMap option Fixes #2075 --- src/js/views/DataCatalogView.js | 36 +--------------------- src/js/views/DataCatalogViewWithFilters.js | 3 -- 2 files changed, 1 insertion(+), 38 deletions(-) diff --git a/src/js/views/DataCatalogView.js b/src/js/views/DataCatalogView.js index 225d078f0..05b7b3f7e 100644 --- a/src/js/views/DataCatalogView.js +++ b/src/js/views/DataCatalogView.js @@ -163,9 +163,6 @@ define(["jquery", // and event handling to sub views render: function () { - // Which type of map are we rendering, Google maps or Cesium maps? - this.mapType = MetacatUI.appModel.get("dataCatalogMap") || "google"; - // Use the global models if there are no other models specified at time of render if ((MetacatUI.appModel.get("searchHistory").length > 0) && (!this.searchModel || Object.keys(this.searchModel).length == 0) @@ -1843,7 +1840,7 @@ define(["jquery", renderMap: function () { // If gmaps isn't enabled or loaded with an error, use list mode - if (this.mapType === "google" && (!gmaps || this.mode == "list")) { + if (!gmaps || this.mode == "list") { this.ready = true; this.mode = "list"; return; @@ -1855,31 +1852,6 @@ define(["jquery", $("body").addClass("mapMode"); } - // Render Cesium maps, if that is the map type rendered. - if (this.mapType == "cesium") { - var mapContainer = $("#map-container").append("
"); - - var mapView = new CesiumWidgetView({ - el: mapContainer - }); - mapView.render(); - this.map = mapView; - - this.mapModel.set("map", this.map); - - this.map.showGeohashes() - - // Mark the view as ready to start a search - this.ready = true; - this.triggerSearch(); - this.allowSearch = false; - - // TODO / WIP : Implement the rest of the map search features... - return - } - - // Continue with rendering Google maps, if that is configured mapType - // Get the map options and create the map gmaps.visualRefresh = true; var mapOptions = this.mapModel.get("mapOptions"); @@ -2169,12 +2141,6 @@ define(["jquery", **/ drawTiles: function () { - // This function is for Google maps only. The CesiumWidgetView draws its - // own tiles. - if (this.mapType !== "google") { - return - } - // Exit if maps are not in use if ((this.mode != "map") || (!gmaps)) { return false; diff --git a/src/js/views/DataCatalogViewWithFilters.js b/src/js/views/DataCatalogViewWithFilters.js index 161952eae..138f5285d 100644 --- a/src/js/views/DataCatalogViewWithFilters.js +++ b/src/js/views/DataCatalogViewWithFilters.js @@ -105,9 +105,6 @@ define(["jquery", MetacatUI.appModel.set("searchMode", this.mode); } - // Which type of map are we rendering, Google maps or Cesium maps? - this.mapType = MetacatUI.appModel.get("dataCatalogMap") || "google"; - if(!this.statsModel){ this.statsModel = new Stats(); } From 98662cbd858397498856d880a7af5d65d4a8dbed Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 12 Dec 2022 11:25:13 -0500 Subject: [PATCH 10/79] Remove duplicated code in Cesium view - Replace similar calculations with the view's getDegreesFromCartesian function - Make the getDegreesFromCartesian less repetitive by iterating through the position keys - Also standardize formatting in the view Relates to #1720 --- src/js/views/maps/CesiumWidgetView.js | 64 +++++++++++---------------- 1 file changed, 26 insertions(+), 38 deletions(-) diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 2c12fa29d..c73caeb38 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -697,18 +697,7 @@ define( */ getCameraPosition: function () { try { - var camera = this.camera - var cameraPosition = Cesium.Cartographic.fromCartesian(camera.position) - - return { - longitude: Cesium.Math.toDegrees(cameraPosition.longitude), - latitude: Cesium.Math.toDegrees(cameraPosition.latitude), - height: cameraPosition.height, - heading: Cesium.Math.toDegrees(camera.heading), - pitch: Cesium.Math.toDegrees(camera.pitch), - roll: Cesium.Math.toDegrees(camera.roll) - } - + return this.getDegreesFromCartesian(this.camera.position) } catch (error) { console.log( @@ -821,11 +810,16 @@ define( */ getDegreesFromCartesian: function (cartesian) { const cartographic = Cesium.Cartographic.fromCartesian(cartesian); - return { - longitude: Cesium.Math.toDegrees(cartographic.longitude), - latitude: Cesium.Math.toDegrees(cartographic.latitude), + const degrees = { height: cartographic.height } + const coordinates = ['longitude', 'latitude', 'heading', 'pitch', 'roll'] + coordinates.forEach(function (coordinate) { + if (Cesium.defined(cartographic[coordinate])) { + degrees[coordinate] = Cesium.Math.toDegrees(cartographic[coordinate]) + } + }); + return degrees }, /** @@ -1005,13 +999,7 @@ define( var pickRay = view.camera.getPickRay(mousePosition); var cartesian = view.scene.globe.pick(pickRay, view.scene); if (cartesian) { - // Use globe.ellipsoid.cartesianToCartographic ? - var cartographic = Cesium.Cartographic.fromCartesian(cartesian); - view.model.set('currentPosition', { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - height: cartographic.height, - }) + view.model.set('currentPosition', view.getDegreesFromCartesian(cartesian)) } } @@ -1139,7 +1127,7 @@ define( * @param {MapAsset} mapAsset A MapAsset layer to render in the map, such as a * Cesium3DTileset or a CesiumImagery model. */ - addAsset: function(mapAsset) { + addAsset: function (mapAsset) { try { if (!mapAsset) { return @@ -1224,27 +1212,27 @@ define( /** * Renders a CesiumGeohash map asset on the map - * */ + */ addGeohashes: function () { - let view = this; - - require(["views/maps/CesiumGeohashes"], (CesiumGeohashes)=>{ - //Create a CesiumGeohashes view - let cg = new CesiumGeohashes(); - cg.cesiumViewer = view; - - //Get the CesiumGeohash MapAsset and save a reference in the view - let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash"); - cg.cesiumGeohash = cesiumGeohashAsset; - - cg.render(); - }) + let view = this; + + require(["views/maps/CesiumGeohashes"], (CesiumGeohashes) => { + //Create a CesiumGeohashes view + let cg = new CesiumGeohashes(); + cg.cesiumViewer = view; + + //Get the CesiumGeohash MapAsset and save a reference in the view + let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash"); + cg.cesiumGeohash = cesiumGeohashAsset; + + cg.render(); + }) }, /** * Renders imagery in the Map. * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to render - */ + */ addImagery: function (cesiumModel) { this.scene.imageryLayers.add(cesiumModel) this.sortImagery() From 0800f2eb05e35e1e8cac74d49fa6b01e9537ba35 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 12 Jan 2023 15:52:30 -0500 Subject: [PATCH 11/79] Use the CesiumVectorData model for Geohashes - Make CesiumGeohash an extension of CesiumVectorData instead of MapAsset - Add Geohash specific properties to the CesiumGeohash model (e.g. precisionAltMap, bounds, level, geohashes, etc.) - Add a ToJSON function to the CesiumGeohash model that converts geohash & search result information to a JSON object - Create listeners for updating Geohashes when the bounds & altitude change - Add ability to update the data source in the CesiumVectorData model - Always set ClampToGround to true for geohashes Relates to #1720, #2063, #2070, #2076 --- src/js/models/AppModel.js | 34 +- src/js/models/connectors/Geohash-Search.js | 36 +- src/js/models/maps/Map.js | 15 +- src/js/models/maps/assets/CesiumGeohash.js | 314 +++++++++++++----- src/js/models/maps/assets/CesiumVectorData.js | 37 ++- src/js/models/maps/assets/MapAsset.js | 3 +- src/js/views/maps/CesiumWidgetView.js | 26 +- src/js/views/search/CatalogSearchView.js | 23 +- 8 files changed, 310 insertions(+), 178 deletions(-) diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index a2c98e990..9206994a3 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -107,23 +107,23 @@ define(['jquery', 'underscore', 'backbone'], catalogSearchMapOptions: { showToolbar: false, layers: [ - { - "type": "CesiumGeohash", - "opacity": 1, - "hue": 205 //blue - }, - { - "label": "Satellite imagery", - "icon": "urn:uuid:4177c2e1-3037-4964-bf00-5f13182308d9", - "type": "IonImageryProvider", - "description": "Global satellite imagery down to 15 cm resolution in urban areas", - "attribution": "Data provided by Bing Maps © 2021 Microsoft Corporation", - "moreInfoLink": "https://www.microsoft.com/maps", - "opacity": 1, - "cesiumOptions": { - "ionAssetId": "2" - } - }] + { + "type": "CesiumGeohash", + "opacity": 0.7, + }, + { + "label": "Satellite imagery", + "icon": "urn:uuid:4177c2e1-3037-4964-bf00-5f13182308d9", + "type": "IonImageryProvider", + "description": "Global satellite imagery down to 15 cm resolution in urban areas", + "attribution": "Data provided by Bing Maps © 2021 Microsoft Corporation", + "moreInfoLink": "https://www.microsoft.com/maps", + "opacity": 1, + "cesiumOptions": { + "ionAssetId": "2" + } + } + ] }, /** diff --git a/src/js/models/connectors/Geohash-Search.js b/src/js/models/connectors/Geohash-Search.js index 3f3c54d50..f26e999d8 100644 --- a/src/js/models/connectors/Geohash-Search.js +++ b/src/js/models/connectors/Geohash-Search.js @@ -20,7 +20,7 @@ define(['backbone', "models/maps/assets/CesiumGeohash", "collections/SolrResults * @property {CesiumGeohash} cesiumGeohash */ defaults: function(){ - return{ + return { searchResults: null, cesiumGeohash: null } @@ -32,24 +32,26 @@ define(['backbone', "models/maps/assets/CesiumGeohash", "collections/SolrResults * geohash level in the SolrResults so that it can be used by the next query. * @since 2.22.0 */ - startListening: function(){ - this.listenTo(this.get("searchResults"), "reset", function(){ - //Set the new geohash facet counts on the CesiumGeohash MapAsset - let level = this.get("cesiumGeohash").get("geohashLevel"); - this.get("cesiumGeohash").set("geohashCounts", this.get("searchResults").facetCounts["geohash_"+level] ); - this.get("cesiumGeohash").set("totalCount", this.get("searchResults").getNumFound() ); - - //Set the status of the CesiumGeohash MapAsset to 'ready' so that it is re-rendered - if(this.get("cesiumGeohash").get("status") == "ready"){ - this.get("cesiumGeohash").trigger("change:status"); - } - else{ - this.get("cesiumGeohash").set("status", "ready"); - } + startListening: function () { + + const geohashLayer = this.get("cesiumGeohash") + const searchResults = this.get("searchResults") + + this.listenTo(searchResults, "reset", function(){ + + const level = geohashLayer.get("level") || 1; + const facetCounts = searchResults.facetCounts["geohash_" + level] + const totalFound = searchResults.getNumFound() + + // Set the new geohash facet counts on the CesiumGeohash MapAsset + geohashLayer.set("counts", facetCounts); + geohashLayer.set("totalCount", totalFound); + }); - this.listenTo(this.get("cesiumGeohash"), "change:geohashLevel", function(){ - this.get("searchResults").setFacet(["geohash_"+this.get("cesiumGeohash").get("geohashLevel")]); + this.listenTo(geohashLayer, "change:geohashLevel", function () { + const level = geohashLayer.get("level") || 1; + searchResults.setFacet(["geohash_" + level]); }); } diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index c3e193559..6e69fde00 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -171,9 +171,9 @@ define( roll: 0 }, layers: new MapAssets([{ - type: 'NaturalEarthII', - label: 'Base layer' - }]), + type: 'NaturalEarthII', + label: 'Base layer' + }]), terrains: new MapAssets(), selectedFeatures: new Features(), showToolbar: true, @@ -208,12 +208,15 @@ define( try { if (config) { - if (config.layers && config.layers.length && Array.isArray(config.layers)) { + function isNonEmptyArray(a) { + return a && a.length && Array.isArray(a) + } + + if (isNonEmptyArray(config.layers)) { this.set('layers', new MapAssets(config.layers)) this.get('layers').setMapModel(this) } - - if (config.terrains && config.terrains.length && Array.isArray(config.terrains)) { + if (isNonEmptyArray(config.terrains)) { this.set('terrains', new MapAssets(config.terrains)) } diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 9b04cde69..de84b7ef8 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -7,7 +7,7 @@ define( 'backbone', 'cesium', 'nGeohash', - 'models/maps/assets/MapAsset', + 'models/maps/assets/CesiumVectorData', ], function ( $, @@ -15,18 +15,18 @@ define( Backbone, Cesium, nGeohash, - MapAsset + CesiumVectorData ) { /** * @classdesc A Geohash Model represents a geohash layer in a map. * @classcategory Models/Maps/Assets * @class CesiumGeohash * @name CesiumGeohash - * @extends MapAsset + * @extends CesiumVectorData * @since 2.18.0 * @constructor */ - return MapAsset.extend( + return CesiumVectorData.extend( /** @lends Geohash.prototype */ { /** @@ -36,118 +36,258 @@ define( type: 'CesiumGeohash', /** - * This function will return the appropriate geohash level to use for mapping - * geohash tiles on the map at the specified altitude (zoom level). - * @param {Number} altitude The distance from the surface of the earth in meters - * @returns The geohash level, an integer between 0 and 9. + * Default attributes for Geohash models + * @name CesiumGeohash#defaults + * @type {Object} + * @extends CesiumVectorData#defaults + * @property {'CesiumGeohash'} type The format of the data. Must be + * 'CesiumGeohash'. + * @property {boolean} isGeohashLayer A flag to indicate that this is a + * Geohash layer, since we change the type to CesiumVectorData. Used by + * the Catalog Search View to find this layer so it can be connected to + * search results. + * @property {object} precisionAltMap Map of precision integer to + * minimum altitude (m) + * @property {Number} altitude The current distance from the surface of + * the earth in meters + * @property {Number} level The geohash level, an integer between 0 and + * 9. + * @property {object} bounds The current bounding box (south, west, + * north, east) within which to render geohashes (in longitude/latitude + * coordinates). + * @property {string[]} counts An array of geohash strings followed by + * their associated count. e.g. ["a", 123, "f", 8] + * @property {Number} totalCount The total number of results that were + * just fetched + * @property {Number} geohashes + */ + + defaults: function () { + return Object.assign( + CesiumVectorData.prototype.defaults(), + { + type: 'GeoJsonDataSource', + label: 'Geohashes', + isGeohashLayer: true, + precisionAltMap: { + 1: 6000000, + 2: 4000000, + 3: 1000000, + 4: 100000, + 5: 0 + }, + altitude: null, + level: 1, + bounds: { + north: null, + east: null, + south: null, + west: null + }, + level: 1, + counts: [], + totalCount: 0, + geohashes: [] + } + ) + }, + + /** + * Executed when a new CesiumGeohash model is created. + * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of + * the attributes, which will be set on the model. */ - setGeohashLevel: function (altitude) { + initialize: function (assetConfig) { try { - // map of precision integer to minimum altitude - const precisionAltMap = { - '1': 6000000, - '2': 4000000, - '3': 1000000, - '4': 100000, - '5': 0 - } - const precision = _.findKey(precisionAltMap, function (minAltitude) { - return altitude >= minAltitude - }) - this.set("geohashLevel", Number(precision)); + this.setGeohashListeners() + this.set('type', 'GeoJsonDataSource') + CesiumVectorData.prototype.initialize.call(this, assetConfig); } catch (error) { console.log( - 'There was an error getting the geohash level from altitude in a Geohash ' + - 'Returning level 1 by default. ' + - 'model. Error details: ' + error + 'There was an error initializing a CesiumVectorData model' + + '. Error details: ' + error ); - return 1 } }, /** - * - * @param {Number} south The south-most coordinate of the area to get geohashes - * for - * @param {Number} west The west-most coordinate of the area to get geohashes for - * @param {Number} north The north-most coordinate of the area to get geohashes - * for - * @param {Number} east The east-most coordinate of the area to get geohashes for - * @param {Number} precision An integer between 1 and 9 representing the geohash - * @param {Boolean} boundingBoxes Set to true to return the bounding box for each - * geohash level to show + * Connect this layer to the map to get updates on the current view + * extent (bounds) and altitude. Update the Geohashes when the altitude + * or bounds in the model change. */ - getGeohashes: function (south, west, north, east, precision, boundingBoxes = false) { + setGeohashListeners: function () { try { - // Get all the geohash tiles contained in the map bounds - var geohashes = nGeohash.bboxes( - south, west, north, east, precision - ) - // If the boundingBoxes option is set to false, then just return the list of - // geohashes - if (!boundingBoxes) { - return geohashes - } - // Otherwise, return the bounding box for each geohash as well - var boundingBoxes = [] - geohashes.forEach(function (geohash) { - boundingBoxes[geohash] = nGeohash.decode_bbox(geohash) + const model = this + + // Update the geohashes when the bounds or altitude change + model.stopListening(model, + 'change:level change:bounds change:altitude change:geohashes') + model.listenTo(model, 'change:altitude', model.setGeohashLevel) + model.listenTo(model, 'change:bounds change:level', model.setGeohashes) + model.listenTo(model, 'change:geohashes', function () { + model.createCesiumModel(true) }) - return boundingBoxes + + // Connect this layer to the map to get current bounds and altitude + function setMapListeners() { + const mapModel = model.get('mapModel') + if (!mapModel) { return } + model.listenTo(mapModel, 'change:currentViewExtent', + function (map, newExtent) { + model.set('bounds', newExtent) + }) + model.listenTo(mapModel, 'change:currentPosition', + function (model, newPosition) { + // TODO: This is the estimated elevation at the cursor. + // Get calculation for "camera" altitude instead. + // const alt = newPosition['height'] + // model.set('altitude', alt) + }) + } + setMapListeners.call(model) + model.stopListening(model, 'change:mapModel', setMapListeners) + model.listenTo(model, 'change:mapModel', setMapListeners) } catch (error) { console.log( - 'There was an error getting geohashes in a Geohash model' + - '. Error details: ' + error + 'There was an error setting listeners in a CesiumGeohash' + + '. Error details: ', error ); } }, /** - * Default attributes for Geohash models - * @name CesiumGeohash#defaults - * @type {Object} - * @property {number} geohashLevel The level of geohash currently used by this Cesium Map Asset - * @property {number[]|string[]} geohashCounts An array of geohash strings followed by their associated count. e.g. ["a", 123, "f", 8] - */ - defaults: function (){ return Object.assign(MapAsset.prototype.defaults(), { - type: "CesiumGeohash", - status: "", - hue: 205, //blue - geohashLevel: 2, - geohashCounts: [], - totalCount: 0 - }) + * Given the geohashes set on the model, return as geoJSON + * @returns {object} GeoJSON representing the geohashes with counts + */ + toGeoJSON: function () { + try { + // The base GeoJSON format + const geojson = { + "type": "FeatureCollection", + "features": [] + } + const geohashes = this.get('geohashes') + if (!geohashes) { + return geojson + } + const features = [] + // Format for geohashes: + // { geohashID: [minlat, minlon, maxlat, maxlon] }. + for (const [id, bb] of Object.entries(geohashes)) { + const minlat = bb[0] + const minlon = bb[1] + const maxlat = bb[2] + const maxlon = bb[3] + const feature = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [minlon, minlat], + [maxlon, minlat], + [maxlon, maxlat], + [minlon, maxlat], + [minlon, minlat] + ] + ] + }, + "properties": { + // "count": 0, // TODO - add counts + "geohash": id + } + } + features.push(feature) + } + geojson['features'] = features + return geojson + } + catch (error) { + console.log( + 'There was an error converting geohashes to GeoJSON ' + + 'in a CesiumGeohash model. Error details: ', error + ); + } }, /** - * - * Creates a Cesium `CustomDataSource` {@link https://cesium.com/learn/cesiumjs/ref-doc/CustomDataSource.html} object - * that is used to add entities to the Cesium map. It is set on the `cesiumModel` attribute of the attached `CesiumGeohash` model. + * 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 + * {@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. */ - createCesiumModel: function(){ - // If the cesium model already exists, don't create it again unless specified - if (this.get('cesiumModel')) { - return this.get('cesiumModel') - } - - let cesiumModel = new Cesium.CustomDataSource('geohashes'); - this.set('cesiumModel', cesiumModel); - - let model = this; + createCesiumModel: function (recreate = false) { + try { + const model = this; + // Set the GeoJSON representing geohashes on the model + const cesiumOptions = model.get('cesiumOptions') + cesiumOptions['data'] = model.toGeoJSON() + cesiumOptions['clampToGround'] = true + model.set('cesiumOptions', cesiumOptions) + // Create the model like a regular GeoJSON data source + CesiumVectorData.prototype.createCesiumModel.call(this, recreate) + } + catch (error) { + console.log( + 'There was an error creating a CesiumGeohash model' + + '. Error details: ', error + ); + } + }, + /** + * Reset the geohash level set on the model, given the altitude that is + * currently set on the model. + */ + setGeohashLevel: function () { + try { + const precisionAltMap = this.get('precisionAltMap') + const altitude = this.get('altitude') + const precision = Object.keys(precisionAltMap) + .find(key => altitude >= precisionAltMap[key]); + this.set('level', precision); + } + catch (error) { + console.log( + 'There was an error getting the geohash level from altitude in ' + + 'a Geohash mode. Setting to level 1 by default. ' + + 'Error details: ' + error + ); + this.set('level', 1); + } }, /** - * Executed when a new Geohash model is created. - * @param {Object} [attributes] The initial values of the attributes, which - will - * be set on the model. - * @param {Object} [options] Options for the initialize function. - */ - initialize: function (attributes, options) { - this.createCesiumModel(); + * Update the geohash property with geohashes for the current + * altitude/precision and bounding box. + */ + setGeohashes: function () { + try { + const bb = this.get('bounds') + const precision = this.get('level') + // Get all the geohash tiles contained in the current bounds + var geohashID = nGeohash.bboxes( + bb['south'], bb['west'], bb['north'], bb['east'], precision + ) + var geohashes = [] + geohashID.forEach(function (id) { + geohashes[id] = nGeohash.decode_bbox(id) + + }) + this.set('geohashes', geohashes) + console.log(geohashes) + } + catch (error) { + console.log( + 'There was an error getting geohashes in a Geohash model' + + '. Error details: ' + error + ); + } }, // /** diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 7779cd518..a3adf2351 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -79,7 +79,7 @@ define( * specific to each type of asset. */ defaults: function () { - return _.extend( + return Object.assign( this.constructor.__super__.defaults(), { type: 'GeoJsonDataSource', @@ -123,12 +123,12 @@ define( /** * 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 - 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) { @@ -140,9 +140,19 @@ define( const label = model.get('label') || '' const dataSourceFunction = Cesium[type] + // If the cesium model already exists, don't create it again unless specified - if (!recreate && model.get('cesiumModel')) { - return model.get('cesiumModel') + let dataSource = model.get('cesiumModel') + if (dataSource) { + if (!recreate) { + return dataSource + } else { + // If we are recreating the model, remove all entities first. + // see https://stackoverflow.com/questions/31426796/loading-updated-data-with-geojsondatasource-in-cesium-js + dataSource.entities.removeAll(); + // Make sure the CesiumWidgetView re-renders the data + model.set('displayReady', false); + } } model.resetStatus(); @@ -154,7 +164,10 @@ define( } if (dataSourceFunction && typeof dataSourceFunction === 'function') { - let dataSource = new dataSourceFunction(label) + + if (!recreate) { + dataSource = new dataSourceFunction(label) + } const data = cesiumOptions.data; delete cesiumOptions.data @@ -162,7 +175,9 @@ define( dataSource.load(data, cesiumOptions) .then(function (loadedData) { model.set('cesiumModel', loadedData) - model.setListeners() + if (!recreate) { + model.setListeners() + } model.updateFeatureVisibility() model.updateAppearance() model.set('status', 'ready') @@ -393,7 +408,7 @@ define( } cesiumModel.entities.resumeEvents() - + // Let the map and/or other parent views know that a change has been made that // requires the map to be re-rendered model.trigger('appearanceChanged') diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index 3d597bb48..7d0fbb3a5 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -348,7 +348,6 @@ define( if (typeof this.updateAppearance === 'function') { const setSelectFeaturesListeners = function () { - const mapModel = this.get('mapModel') if (!mapModel) { return } const selectedFeatures = mapModel.get('selectedFeatures') @@ -364,7 +363,7 @@ define( } setSelectFeaturesListeners.call(this) - this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) + this.stopListening(this, 'change:mapModel', setSelectFeaturesListeners) this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) } } diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index c73caeb38..8034d1926 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -92,10 +92,6 @@ define( { types: ['CesiumTerrainProvider'], renderFunction: 'updateTerrain' - }, - { - types: ['CesiumGeohash'], - renderFunction: 'addGeohashes' } ], @@ -332,6 +328,7 @@ define( updateDataSourceDisplay: function () { try { const view = this; + const layers = view.model.get('layers') var dataSources = view.dataSourceDisplay.dataSources; if (!dataSources || !dataSources.length) { @@ -347,7 +344,7 @@ define( const dataSource = dataSources.get(i); const visualizers = dataSource._visualizers; - const assetModel = view.model.get('layers').findWhere({ + const assetModel = layers.findWhere({ cesiumModel: dataSource }) const displayReadyBefore = assetModel.get('displayReady') @@ -1210,25 +1207,6 @@ define( this.dataSourceCollection.add(cesiumModel) }, - /** - * Renders a CesiumGeohash map asset on the map - */ - addGeohashes: function () { - let view = this; - - require(["views/maps/CesiumGeohashes"], (CesiumGeohashes) => { - //Create a CesiumGeohashes view - let cg = new CesiumGeohashes(); - cg.cesiumViewer = view; - - //Get the CesiumGeohash MapAsset and save a reference in the view - let cesiumGeohashAsset = view.model.get('layers').find(mapAsset => mapAsset.get("type") == "CesiumGeohash"); - cg.cesiumGeohash = cesiumGeohashAsset; - - cg.render(); - }) - }, - /** * Renders imagery in the Map. * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to render diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index a21d5c0a9..3fe528ec0 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -384,20 +384,13 @@ function($, Backbone, MapAssets, FilterGroup, FiltersSearchConnector, GeohashSea * @since 2.22.0 */ createMap: function(){ - let mapOptions = Object.assign({}, MetacatUI.appModel.get("catalogSearchMapOptions") || {}); - let map = new Map(mapOptions); + const mapOptions = Object.assign({}, MetacatUI.appModel.get("catalogSearchMapOptions") || {}); + const map = new Map(mapOptions); - //Add a CesiumGeohash layer to the map - /* let geohashLayer = new CesiumGeohash(); - geohashLayer. - let assets = map.get("layers"); - assets.add(geohashLayer); -*/ - - let geohashLayer = map.get("layers").findWhere({type: "CesiumGeohash"}) + const geohashLayer = map.get("layers").findWhere({isGeohashLayer: true}) //Connect the CesiumGeohash to the SolrResults - let connector = new GeohashSearchConnector({ + const connector = new GeohashSearchConnector({ cesiumGeohash: geohashLayer, searchResults: this.searchResultsView.searchResults }); @@ -405,10 +398,12 @@ function($, Backbone, MapAssets, FilterGroup, FiltersSearchConnector, GeohashSea this.geohashSearchConnector = connector; //Set the geohash level for the search - if( Array.isArray(this.searchResultsView.searchResults.facet) ) - this.searchResultsView.searchResults.facet.push("geohash_" + geohashLayer.get("geohashLevel")); + const searchFacet = this.searchResultsView.searchResults.facet + const newLevel = "geohash_" + geohashLayer.get("level") + if( Array.isArray(searchFacet) ) + searchFacet.push(newLevel); else - this.searchResultsView.searchResults.facet = "geohash_" + geohashLayer.get("geohashLevel"); + searchFacet = newLevel; //Create the Map model and view this.mapView = new MapView({ model: map }); From af7a432c5cb296a2e36a5ceb13eef51f55c33e30 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 17 Jan 2023 17:35:24 -0500 Subject: [PATCH 12/79] Set min latitude to -89.99999 for Geohashes Cesium throws an error when the latitude is -90 Relates to #1720 --- src/js/models/maps/assets/CesiumGeohash.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index de84b7ef8..a4b49d00d 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -176,7 +176,7 @@ define( // Format for geohashes: // { geohashID: [minlat, minlon, maxlat, maxlon] }. for (const [id, bb] of Object.entries(geohashes)) { - const minlat = bb[0] + const minlat = bb[0] <= -90 ? -89.99999 : bb[0] const minlon = bb[1] const maxlat = bb[2] const maxlon = bb[3] @@ -187,9 +187,9 @@ define( "coordinates": [ [ [minlon, minlat], - [maxlon, minlat], - [maxlon, maxlat], [minlon, maxlat], + [maxlon, maxlat], + [maxlon, minlat], [minlon, minlat] ] ] From bab25a42a748fdb9a379a7c54ecefbeb97a3c512 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 18 Jan 2023 16:03:16 -0500 Subject: [PATCH 13/79] Render geohashes at p. meridian & all precisions - add height to the map model's currentViewExtent property on camera change (use height to get geohash precision) - tweak altitude-geohash precision map - fix issue where no geohashes were rendered when the view extent crossed the prime meridian Relates to #1720, #2076 --- src/js/models/maps/Map.js | 6 +- src/js/models/maps/assets/CesiumGeohash.js | 90 ++++++++++++++-------- src/js/views/maps/CesiumWidgetView.js | 14 +++- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 6e69fde00..10ce089ed 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -158,7 +158,8 @@ define( * equal the number of meters on the map/globe. * @property {Object} [currentViewExtent={ north: null, east: null, south: null, west: null }] * An object updated by the map widget that gives the extent of the current - * visible area as a bounding box in longitude/latitude coordinates. + * visible area as a bounding box in longitude/latitude coordinates, as well + * as the height/altitude in meters. */ defaults: function () { return { @@ -193,7 +194,8 @@ define( north: null, east: null, south: null, - west: null + west: null, + height: null } }; }, diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index a4b49d00d..fed79e52b 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -70,11 +70,12 @@ define( label: 'Geohashes', isGeohashLayer: true, precisionAltMap: { - 1: 6000000, - 2: 4000000, - 3: 1000000, - 4: 100000, - 5: 0 + 1: 6800000, + 2: 2400000, + 3: 550000, + 4: 120000, + 5: 7000, + 6: 0 }, altitude: null, level: 1, @@ -121,13 +122,18 @@ define( const model = this // Update the geohashes when the bounds or altitude change - model.stopListening(model, - 'change:level change:bounds change:altitude change:geohashes') - model.listenTo(model, 'change:altitude', model.setGeohashLevel) - model.listenTo(model, 'change:bounds change:level', model.setGeohashes) - model.listenTo(model, 'change:geohashes', function () { - model.createCesiumModel(true) - }) + + // TODO: Determine best way to set listeners, without re-creating + // the cesium model twice when both bounds and altitude change + // simultaneously + + // model.stopListening(model, + // 'change:level change:bounds change:altitude change:geohashes') + // model.listenTo(model, 'change:altitude', model.setGeohashLevel) + // model.listenTo(model, 'change:bounds change:level', model.setGeohashes) + // model.listenTo(model, 'change:geohashes', function () { + // model.createCesiumModel(true) + // }) // Connect this layer to the map to get current bounds and altitude function setMapListeners() { @@ -135,15 +141,15 @@ define( if (!mapModel) { return } model.listenTo(mapModel, 'change:currentViewExtent', function (map, newExtent) { + const altitude = newExtent.height + delete newExtent.height model.set('bounds', newExtent) - }) - model.listenTo(mapModel, 'change:currentPosition', - function (model, newPosition) { - // TODO: This is the estimated elevation at the cursor. - // Get calculation for "camera" altitude instead. - // const alt = newPosition['height'] - // model.set('altitude', alt) - }) + model.set('altitude', altitude) + model.setGeohashLevel() + model.setGeohashes() + model.createCesiumModel(true) + } + ) } setMapListeners.call(model) model.stopListening(model, 'change:mapModel', setMapListeners) @@ -227,7 +233,9 @@ define( // Set the GeoJSON representing geohashes on the model const cesiumOptions = model.get('cesiumOptions') cesiumOptions['data'] = model.toGeoJSON() - cesiumOptions['clampToGround'] = true + // TODO: outlines don't work when features are clamped to ground + // cesiumOptions['clampToGround'] = true + cesiumOptions['height'] = 0 model.set('cesiumOptions', cesiumOptions) // Create the model like a regular GeoJSON data source CesiumVectorData.prototype.createCesiumModel.call(this, recreate) @@ -268,19 +276,41 @@ define( */ setGeohashes: function () { try { - const bb = this.get('bounds') + const bounds = this.get('bounds') const precision = this.get('level') - // Get all the geohash tiles contained in the current bounds - var geohashID = nGeohash.bboxes( - bb['south'], bb['west'], bb['north'], bb['east'], precision - ) - var geohashes = [] - geohashID.forEach(function (id) { - geohashes[id] = nGeohash.decode_bbox(id) + const all_bounds = [] + let geohashIDs = [] + // Get all the geohash tiles contained in the current bounds. + if (bounds.east < bounds.west) { + // If the bounding box crosses the prime meridian, then we need to + // search for geohashes on both sides. Otherwise nGeohash returns + // 0 geohashes. + all_bounds.push({ + north: bounds.north, + south: bounds.south, + east: 180, + west: bounds.west + }) + all_bounds.push({ + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: -180 + }) + } else { + all_bounds.push(bounds) + } + all_bounds.forEach(function (bb) { + geohashIDs = geohashIDs.concat(nGeohash.bboxes( + bb.south, bb.west, bb.north, bb.east, precision + )) + }) + const geohashes = [] + geohashIDs.forEach(function (id) { + geohashes[id] = nGeohash.decode_bbox(id) }) this.set('geohashes', geohashes) - console.log(geohashes) } catch (error) { console.log( diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 8034d1926..d424482e7 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -505,7 +505,9 @@ define( attrs.mapAsset && typeof attrs.mapAsset.getPropertiesFromFeature === 'function' ) { - attrs.properties = attrs.mapAsset.getPropertiesFromFeature(attrs.featureObject) + attrs.properties = attrs.mapAsset.getPropertiesFromFeature( + attrs.featureObject + ) } featuresAttrs.push(attrs) @@ -707,7 +709,8 @@ define( /** * Update the 'currentViewExtent' attribute in the Map model with the north, * south, east, and west-most lat/long that define a bounding box around the - * currently visible area of the map. + * currently visible area of the map. Also gives the height/altitude of the + * camera in meters. */ updateViewExtent: function () { try { @@ -715,8 +718,13 @@ define( const camera = view.camera; const scene = view.scene; + // Get the height in meters + const height = camera.positionCartographic.height + // This will be the bounding box of the visible area - let coords = { north: null, south: null, east: null, west: null } + let coords = { + north: null, south: null, east: null, west: null, height: height + } // First try getting the visible bounding box using the simple method if (!view.scratchRectangle) { From c84e73705aa840a0c85af75817ab570087671ec4 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 19 Jan 2023 12:29:13 -0500 Subject: [PATCH 14/79] Limit number of geohashes to render in Cesium map To improve performance when map is focused on poles or zoomed in and at ground level perspective Relates to #1720 --- src/js/models/maps/assets/CesiumGeohash.js | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index fed79e52b..964e3b599 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -48,6 +48,13 @@ define( * search results. * @property {object} precisionAltMap Map of precision integer to * minimum altitude (m) + * @property {Number} maxNumGeohashes The maximum number of geohashes + * allowed. Set to null to remove the limit. If the given bounds + + * altitude/level result in more geohashes than the max limit, then the + * level will be reduced by one until the number of geohashes is under + * the limit. This improves rendering performance, especially when the + * map is focused on either pole, or is tilted in a "street view" like + * perspective. * @property {Number} altitude The current distance from the surface of * the earth in meters * @property {Number} level The geohash level, an integer between 0 and @@ -77,6 +84,7 @@ define( 5: 7000, 6: 0 }, + maxNumGeohashes: 1000, altitude: null, level: 1, bounds: { @@ -276,10 +284,14 @@ define( */ setGeohashes: function () { try { + const bounds = this.get('bounds') const precision = this.get('level') + const limit = this.get('maxNumGeohashes') + const all_bounds = [] let geohashIDs = [] + const geohashes = [] // Get all the geohash tiles contained in the current bounds. if (bounds.east < bounds.west) { @@ -306,7 +318,18 @@ define( bb.south, bb.west, bb.north, bb.east, precision )) }) - const geohashes = [] + + // When the map is centered on the poles or is zoomed in and tilted, + // the bounds + level result in too many geohashes. Reduce the + // number of geohashes to the model's limit by reducing the + // precision. + if (limit && geohashIDs.length > limit && precision > 1) { + this.set('level', (precision - 1)) + this.setGeohashes(limit=limit) + return + } + + // Get the bounds for each of the geohashes geohashIDs.forEach(function (id) { geohashes[id] = nGeohash.decode_bbox(id) }) From b36a55a99dcc8fd3f077740eaf358350e8827244 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 17 Mar 2023 17:40:56 -0400 Subject: [PATCH 15/79] Fix some CSS issues with new data catalog view Relates to #2071, #2065, #1720, #1520 --- src/css/metacatui-common.css | 28 +++++++++++++++---------- src/js/themes/dataone/css/metacatui.css | 11 ++++++++++ src/js/themes/knb/css/metacatui.css | 16 ++++++++++++-- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index d849f6506..edb94ca83 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -547,15 +547,22 @@ text-shadow: none; /****************************************** ** CatalogSearchView *** ******************************************/ -.catalog-search-view .catalog-search-inner{ + +.catalog-search-view { + height: 100%; +} +.catalog-search-inner{ + height: 100%; display: grid; justify-content: stretch; align-items: stretch; grid-template-columns: auto 1fr 1fr; + grid-template-rows: 100%; } .catalog-search-view .filter-groups-container { width: 215px; - padding: var(--pad) + padding: var(--pad); + overflow: scroll; } .catalog-search-view .search-results-container, .catalog-search-view .map-container { @@ -569,17 +576,16 @@ text-shadow: none; justify-content: stretch; overflow: hidden; } -.catalog-search-body.mapMode #Content{ - padding: 40px 0px 0px 0px; -} -.catalog-search-body.mapMode .search-results-view .result-row:last-child{ - margin-bottom: 100px; +.catalog-search-body #Content{ + padding: 0; + } -.catalog-search-body.mapMode .search-results-view { +.search-results-container { overflow-y: scroll; - height: 100vh; - padding-bottom: 200px; /* Leaving room for the last row to show */ - padding-right: 15px; /* Padding for the scrollbar */ + height: 100%; +} +.search-results-panel-container{ + display: grid; } .catalog-search-body.mapMode .search-results-panel-container .map-toggle-container{ display: none; diff --git a/src/js/themes/dataone/css/metacatui.css b/src/js/themes/dataone/css/metacatui.css index bea6a7138..401e9648d 100644 --- a/src/js/themes/dataone/css/metacatui.css +++ b/src/js/themes/dataone/css/metacatui.css @@ -97,6 +97,17 @@ article, aside, figure, footer, header, hgroup, menu, nav, section { margin-bottom: 0px; } +/* Hide the footer in the new data catalog view when in map mode */ +.catalog-search-body.mapMode #Footer, +.catalog-search-body.mapMode #map-mode-extra-padding.auto-height-member { + display: none; +} + +/* when NOTE in map mode, add padding for the footer */ +.catalog-search-body:not(.mapMode) .search-results-panel-container { + margin-bottom: 15rem; +} + .container { width: 100%; } diff --git a/src/js/themes/knb/css/metacatui.css b/src/js/themes/knb/css/metacatui.css index 89862093d..61267103a 100644 --- a/src/js/themes/knb/css/metacatui.css +++ b/src/js/themes/knb/css/metacatui.css @@ -1,3 +1,11 @@ +/* KNB CSS vars +-------------------------------------------------- */ + +/* Footer height when it is fixed in place in the data catalog */ +:root { + --fixed-footer-height: 1.2em; +} + @font-face { font-family: "Lato"; src: url('../../../../font/Lato-Light.ttf') format('truetype'); /* Safari, Android, iOS */ @@ -39,6 +47,9 @@ .mapMode > section > article{ padding-bottom: 0px; } + .catalog-search-body #Content { + height: calc(100% - var(--fixed-footer-height)); + } a { color: #006699; @@ -328,12 +339,13 @@ height: 250px; /* Keeps footer down */ } - .mapMode #Footer{ + .catalog-search-body #Footer{ position: fixed; bottom: 0; background-color: #3F3F3F; color: #FFF; - height: 1.2em; + min-height: var(--fixed-footer-height); + height: var(--fixed-footer-height); transition: min-height 1s ease-out; -webkit-transition: min-height 1s ease-out; transition-delay: .5s; From aebd756590742ea15f5fe3253085b198039690d3 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 20 Mar 2023 12:31:32 -0400 Subject: [PATCH 16/79] Fix more CSS issues with new data catalog view Relates to #2071, #2065, #1720, #1520 --- src/css/metacatui-common.css | 39 ++++++++++++++++++++------ src/js/themes/arctic/css/metacatui.css | 5 ++-- src/js/themes/knb/css/metacatui.css | 10 +++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index edb94ca83..ec1e22a6a 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -562,11 +562,12 @@ text-shadow: none; .catalog-search-view .filter-groups-container { width: 215px; padding: var(--pad); + padding-bottom: 60px; overflow: scroll; } -.catalog-search-view .search-results-container, +/* .catalog-search-view .search-results-container, .catalog-search-view .map-container { -} +} */ .catalog-search-body.mapMode{ height: 100vh; width: 100vw; @@ -576,17 +577,38 @@ text-shadow: none; justify-content: stretch; overflow: hidden; } -.catalog-search-body #Content{ - padding: 0; - +.mapMode #Content{ + padding: 40px 0 0 0; } +.catalog-search-body #Content { + padding-top: 0; +} + .search-results-container { overflow-y: scroll; height: 100%; } .search-results-panel-container{ - display: grid; -} + display: grid; + grid-auto-columns: 1fr; + grid-template-columns: max-content 1fr; + grid-template-rows: min-content min-content min-content 1fr; + gap: 0px 0px; + grid-template-areas: + "map-toggle-container map-toggle-container" + "title-container title-container" + "pager-container sorter-container" + "search-results-container search-results-container"; +} +.search-results-container { grid-area: search-results-container; } +.pager-container { grid-area: pager-container; } +.sorter-container { + grid-area: sorter-container; + justify-self: end; + padding-right: var(--pad); +} +.title-container { grid-area: title-container; } +.map-toggle-container { grid-area: map-toggle-container; } .catalog-search-body.mapMode .search-results-panel-container .map-toggle-container{ display: none; } @@ -613,8 +635,7 @@ text-shadow: none; ******************************************/ .result-row{ padding: 10px 20px; - width: 95%; - width: calc(100% - 15px); + width: auto; border-top: 1px solid #EEE; } .result-row-loading .citation-loading{ diff --git a/src/js/themes/arctic/css/metacatui.css b/src/js/themes/arctic/css/metacatui.css index 4c4a64bfb..33ee66a4d 100644 --- a/src/js/themes/arctic/css/metacatui.css +++ b/src/js/themes/arctic/css/metacatui.css @@ -858,7 +858,7 @@ img[src*="gstatic.com/"], img[src*="googleapis.com/"] { #Navbar{ position: fixed; width: 100%; - z-index: 1; + z-index: 3; background-color: #FFF; } .center { @@ -945,7 +945,8 @@ img[src*="gstatic.com/"], img[src*="googleapis.com/"] { /* margin-left: auto; margin-right: auto; */ } - .mapMode #Content{ + .catalog-search-body.mapMode #Content, + .mapMode #Content{ padding-top: 112px; } .navbar .nav { diff --git a/src/js/themes/knb/css/metacatui.css b/src/js/themes/knb/css/metacatui.css index 61267103a..e659e548a 100644 --- a/src/js/themes/knb/css/metacatui.css +++ b/src/js/themes/knb/css/metacatui.css @@ -354,6 +354,16 @@ padding-top: 3px; } + .DataCatalog #Content { + padding-top: 0; + } + .DataCatalog:not(.mapMode) #Content { + padding-top: 2rem; + } + .catalog-search-body #Content { + padding-top: 0; + } + footer#Footer div#FooterHeading { max-width: 490px; } From 8b91b72af22b6d4d71fc226b01899a494c69dc9a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 21 Mar 2023 17:26:57 -0400 Subject: [PATCH 17/79] Fix remaining (?) CSS issues with new data catalog Relates to #2071, #2065, #1720, #1520 --- src/css/metacatui-common.css | 21 +++++++++------------ src/js/themes/arctic/css/metacatui.css | 9 ++------- src/js/themes/default/css/metacatui.css | 9 +++------ src/js/themes/knb/css/metacatui.css | 1 + 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index ec1e22a6a..6413e085c 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -123,7 +123,7 @@ article.container { width: 96%; } #Content{ - padding: 40px 40px 40px 40px; + padding: 2.5rem; min-height: 200px; } .DataCatalog #Content{ @@ -131,8 +131,9 @@ article.container { } .mapMode #Content{ width: 100%; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; + padding-top: var(--header-height, 0); } footer{ background-color: #FFFFFF; @@ -562,12 +563,10 @@ text-shadow: none; .catalog-search-view .filter-groups-container { width: 215px; padding: var(--pad); - padding-bottom: 60px; + padding-bottom: 3rem; overflow: scroll; } -/* .catalog-search-view .search-results-container, -.catalog-search-view .map-container { -} */ + .catalog-search-body.mapMode{ height: 100vh; width: 100vw; @@ -577,11 +576,9 @@ text-shadow: none; justify-content: stretch; overflow: hidden; } -.mapMode #Content{ - padding: 40px 0 0 0; -} -.catalog-search-body #Content { - padding-top: 0; + +.catalog-search-body.mapMode .search-results-view .result-row:last-child{ + margin-bottom: 100px; } .search-results-container { diff --git a/src/js/themes/arctic/css/metacatui.css b/src/js/themes/arctic/css/metacatui.css index 33ee66a4d..4edb1810b 100644 --- a/src/js/themes/arctic/css/metacatui.css +++ b/src/js/themes/arctic/css/metacatui.css @@ -61,6 +61,7 @@ :root { --footer-height: 320px; + --header-height: 112px; } /* Body CSS @@ -941,13 +942,7 @@ img[src*="gstatic.com/"], img[src*="googleapis.com/"] { white-space: nowrap; } #Content{ - padding-top: 118px; - /* margin-left: auto; - margin-right: auto; */ - } - .catalog-search-body.mapMode #Content, - .mapMode #Content{ - padding-top: 112px; + padding-top: var(--header-height); } .navbar .nav { margin-top: 8px; diff --git a/src/js/themes/default/css/metacatui.css b/src/js/themes/default/css/metacatui.css index f5d40e934..21d022dc5 100644 --- a/src/js/themes/default/css/metacatui.css +++ b/src/js/themes/default/css/metacatui.css @@ -1,4 +1,6 @@ - +:root { + --header-height: 40px; +} /* Body CSS -------------------------------------------------- */ html { @@ -1376,11 +1378,6 @@ li.ui-menu-item > a:hover, /* SEARCH PAGE CSS -------------------------------------------------- */ - .mapMode #Content{ - max-width: 100%; - padding-top: 57px; - padding-left: 0px; - } /*-- Results header --*/ .result-header{ diff --git a/src/js/themes/knb/css/metacatui.css b/src/js/themes/knb/css/metacatui.css index e659e548a..e513de2d3 100644 --- a/src/js/themes/knb/css/metacatui.css +++ b/src/js/themes/knb/css/metacatui.css @@ -4,6 +4,7 @@ /* Footer height when it is fixed in place in the data catalog */ :root { --fixed-footer-height: 1.2em; + --header-height: 0; } @font-face { From 50ef61c952f23f4bc1f528669a08d58821d42d96 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 21 Mar 2023 17:44:36 -0400 Subject: [PATCH 18/79] Minor updates to filters display in new Catalog Relates to #1520 --- src/css/metacatui-common.css | 13 +++++++++---- src/js/models/AppModel.js | 8 ++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 6413e085c..3f233dda1 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -6663,7 +6663,7 @@ body.mapMode{ padding-right: 0px; min-width: 0px; max-width: 100%; - margin-bottom: 10px; + margin-bottom: 1rem; } .filter-groups .filter .btn:not(.btn-filter-editor){ box-shadow: none; @@ -6684,10 +6684,13 @@ body.mapMode{ } .filter-groups .filter label{ cursor: default; - margin-bottom: 13px; display: flex; align-items: center; } +.filter-groups .filter > label{ + font-weight: bold; + margin-bottom: 0.5rem; +} .filter-group-links{ border-top: 1px solid #DDD; clear: both; @@ -6791,6 +6794,7 @@ body.mapMode{ font-weight: normal; padding: 5px; margin-right: 10px; + line-height: 1.1; } .filter-groups.vertical .applied-filters .applied-filter{ display: block; @@ -6859,8 +6863,9 @@ body.mapMode{ } .filter.boolean input{ height: auto; - font-size: 3em; + font-size: 3.2em; margin-right: 10px; + margin-top: 0; } .filter.boolean span{ display: inline; @@ -6926,7 +6931,7 @@ body.mapMode{ .filter-groups.vertical .filters-header{ margin: 0px; border-bottom: 1px dashed #CCC; - margin-bottom: 10px; + margin-bottom: 1.5rem; padding-bottom: 10px; } #portal-filters{ diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index b8866cd7e..a228ad2f8 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -1672,7 +1672,7 @@ define(['jquery', 'underscore', 'backbone'], */ defaultFilterGroups: [ { - label: "Search for: ", + label: "", filters: [ { fields: ["attribute"], @@ -1691,9 +1691,9 @@ define(['jquery', 'underscore', 'backbone'], { filterType: "ToggleFilter", fields: ["documents"], - label: "Show only results with data", - trueLabel: null, - falseLabel: null, + label: "Only results with data files", + trueLabel: "True", + falseLabel: "False", trueValue: "*", matchSubstring: false, icon: "table", From 7f46f3a32161c83f976d0cd7a1acb67d8ab2e318 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 21 Mar 2023 18:05:46 -0400 Subject: [PATCH 19/79] Standardize formatting in new Catalog views --- src/js/views/DataCatalogViewWithFilters.js | 1364 ++++++++++---------- src/js/views/search/CatalogSearchView.js | 808 ++++++------ 2 files changed, 1137 insertions(+), 1035 deletions(-) diff --git a/src/js/views/DataCatalogViewWithFilters.js b/src/js/views/DataCatalogViewWithFilters.js index 138f5285d..e4b6ba02e 100644 --- a/src/js/views/DataCatalogViewWithFilters.js +++ b/src/js/views/DataCatalogViewWithFilters.js @@ -1,677 +1,729 @@ -define(["jquery", - "underscore", - "backbone", - "gmaps", - "collections/Filters", - "collections/SolrResults", - "models/filters/FilterGroup", - "models/filters/SpatialFilter", - "models/Stats", - "views/DataCatalogView", - "views/filters/FilterGroupsView", - "text!templates/dataCatalog.html", - "nGeohash" - ], - function($, _, Backbone, gmaps, Filters, SearchResults, FilterGroup, SpatialFilter, Stats, - DataCatalogView, FilterGroupsView, - template, nGeohash) { - - /** - * @class DataCatalogViewWithFilters - * @classdesc A DataCatalogView that uses the Search collection - * and the Filter models for managing queries rather than the - * Search model and the filter literal objects used in the - * parent DataCatalogView. This accommodates custom portal filters. - * This view is deprecated and will eventually be removed in a future version (likely 3.0.0) - * @classcategory Views - * @extends DataCatalogView - * @constructor - * @deprecated - */ - var DataCatalogViewWithFilters = DataCatalogView.extend( - /** @lends DataCatalogViewWithFilters.prototype */{ - - el: null, - - /** - * The HTML tag name for this view element - * @type {string} - */ - tagName: "div", - - /** - * The HTML class names for this view element - * @type {string} - */ - className: "data-catalog", - - /** - * The primary HTML template for this view - * @type {Underscore.template} - */ - template: _.template(template), - - /** - * A reference to the PortalEditorView - * @type {PortalEditorView} - */ - editorView: undefined, - - /** - * The sort order for the Solr query - * @type {string} - */ - sortOrder: "dateUploaded+desc", - - /** - * The jQuery selector for the FilterGroupsView container - * @type {string} - */ - filterGroupsContainer: ".filter-groups-container", - - /** - * The Search model to use for creating and storing Filters and constructing - * query strings. This property is a Search model instead of a Filters - * collection in order to be quickly compatible with the superclass/superview, - * DataCatalogView, which was created with the (eventually to be deprecated) - * SearchModel. A Filters collection is set on the Search model and does most - * of the work for creating queries. - * @type (Search) - */ - searchModel: undefined, - - /** - * Override DataCatalogView.render() to render this view with filters - * from the Filters collection - */ - render: function() { - var loadingHTML; - var templateVars; - var compiledEl; - var tooltips; - var groupedTooltips; - var forFilterLabel = true; - var forOtherElements = false; - // TODO: Do we really need to cache the filters collection? - // Reconcile this from DataCatalogView.render() - // See https://github.com/NCEAS/metacatui/blob/19d608df9cc17ac2abee76d35feca415137c09d7/src/js/views/DataCatalogView.js#L122-L145 - - //Get the search mode - either "map" or "list" - if ((typeof this.mode === "undefined") || !this.mode) { - this.mode = MetacatUI.appModel.get("searchMode"); - if ((typeof this.mode === "undefined") || !this.mode) { - this.mode = "map"; - } - MetacatUI.appModel.set("searchMode", this.mode); - } - - if(!this.statsModel){ - this.statsModel = new Stats(); - } - - if( !this.searchResults ){ - this.searchResults = new SearchResults(); - } - - // Use map mode on tablets and browsers only - if ($(window).outerWidth() <= 600) { - this.mode = "list"; - MetacatUI.appModel.set("searchMode", "list"); - gmaps = null; - } - - // If this is a subview, don't set the headerType - if (!this.isSubView) { - MetacatUI.appModel.set("headerType", "default"); - $("body").addClass("DataCatalog"); - } else { - this.$el.addClass("DataCatalog"); - } - //Populate the search template with some model attributes - loadingHTML = this.loadingTemplate({ - msg: "Loading entries ..." - }); - - templateVars = { - gmaps: gmaps, - mode: MetacatUI.appModel.get("searchMode"), - useMapBounds: this.searchModel.get("useGeohash"), - username: MetacatUI.appUserModel.get("username"), - isMySearch: (_.indexOf(this.searchModel.get("username"), MetacatUI.appUserModel.get("username")) > -1), - loading: loadingHTML, - searchModelRef: this.searchModel, - searchResultsRef: this.searchResults, - dataSourceTitle: (MetacatUI.theme == "dataone") ? "Member Node" : "Data source" - } - compiledEl = - this.template(_.extend(this.searchModel.toJSON(), templateVars)); - this.$el.html(compiledEl); - - //Create and render the FilterGroupsView - this.createFilterGroups(); - - // Store some references to key views that we use repeatedly - this.$resultsview = this.$("#results-view"); - this.$results = this.$("#results"); - - //Update stats - this.updateStats(); - - //Render the Google Map - this.renderMap(); - //Initialize the tooltips - tooltips = $(".tooltip-this"); - - //Find the tooltips that are on filter labels - add a slight delay to those - groupedTooltips = _.groupBy(tooltips, function(t) { - return ((($(t).prop("tagName") == "LABEL") || - ($(t).parent().prop("tagName") == "LABEL")) && - ($(t).parents(".filter-container").length > 0)) - }); - - $(groupedTooltips[forFilterLabel]).tooltip({ - delay: { - show: "800" - } - }); - $(groupedTooltips[forOtherElements]).tooltip(); - - //Initialize all popover elements - $(".popover-this").popover(); - - //Initialize the resizeable content div - $("#content").resizable({ - handles: "n,s,e,w" - }); - - // Register listeners; this is done here in render because the HTML - // needs to be bound before the listenTo call can be made - this.stopListening(this.searchResults); - this.stopListening(this.searchModel); - this.stopListening(MetacatUI.appModel); - this.listenTo(this.searchResults, "reset", this.cacheSearch); - this.listenTo(this.searchResults, "add", this.addOne); - this.listenTo(this.searchResults, "reset", this.addAll); - this.listenTo(this.searchResults, "reset", this.checkForProv); - this.listenTo(this.searchResults, "error", this.showError); - - // Listen to changes in the Search model Filters to trigger a search - this.stopListening(this.searchModel.get("filters"), "add remove update reset change"); - this.listenTo(this.searchModel.get("filters"), "add remove update reset change", this.triggerSearch); - - // Listen to the MetacatUI.appModel for the search trigger - this.listenTo(MetacatUI.appModel, "search", this.getResults); - - this.listenTo(MetacatUI.appUserModel, "change:loggedIn", this.triggerSearch); - - // and go to a certain page if we have it - this.getResults(); - - //Set a custom height on any elements that have the .auto-height class - if ($(".auto-height").length > 0 && !this.fixedHeight) { - //Readjust the height whenever the window is resized - $(window).resize(this.setAutoHeight); - $(".auto-height-member").resize(this.setAutoHeight); - } - - this.addAnnotationFilter(); - - return this; - }, - - /** - * Creates UI Filter Groups and renders them in this view. UI Filter Groups - * are custom, interactive search filter elements, grouped together in one - * panel, section, tab, etc. - */ - createFilterGroups: function(){ - - //If it was already created, then exit - if( this.filterGroupsView ){ - return; - } - - //Start an array for the FilterGroups and the individual Filter models - var filterGroups = [], - allFilters = []; - - //Iterate over each default FilterGroup in the app config and create a FilterGroup model - _.each( MetacatUI.appModel.get("defaultFilterGroups"), function(filterGroupJSON){ - - //Create the FilterGroup model - var filterGroup = new FilterGroup(filterGroupJSON); - - //Add to the array - filterGroups.push(filterGroup); - - //Add the Filters to the array - allFilters = _.union(allFilters, filterGroup.get("filters").models); - - }, this); - - //Add the filters to the Search model - this.searchModel.get("filters").add(allFilters); - - //Create a FilterGroupsView - var filterGroupsView = new FilterGroupsView({ - filterGroups: filterGroups, - filters: this.searchModel.get("filters"), - vertical: true, - parentView: this, - editorView: this.editorView - }); - - //Add the FilterGroupsView element to this view - this.$(this.filterGroupsContainer).html(filterGroupsView.el); - - //Render the FilterGroupsView - filterGroupsView.render(); - - //Save a reference to the FilterGroupsView - this.filterGroupsView = filterGroupsView; - - }, - - /* - * Get Results from the Solr index by combining the Filter query string fragments - * in each Filter instance in the Search collection and querying Solr. - * - * Overrides DataCatalogView.getResults(). - */ - getResults: function() { - var sortOrder = this.searchModel.get("sortOrder"); - var query; // The full query string - var geohashLevel; // The geohash level to search - var page; // The page of search results to render - var position; // The geohash level position in the facet array - - // Get the Solr query string from the Search filter collection - query = this.searchModel.get("filters").getQuery(); - - //If the query hasn't changed since the last query that was sent, don't do anything. - //This function may have been triggered by a change event on a filter that doesn't - //affect the query at all - if( query == this.searchResults.getLastQuery()){ - return; - } - - if ( sortOrder ) { - this.searchResults.setSort(sortOrder); - } - - //Specify which fields to retrieve - var fields = ["id", - "seriesId", - "title", - "origin", - "pubDate", - "dateUploaded", - "abstract", - "resourceMap", - "beginDate", - "endDate", - "read_count_i", - "geohash_9", - "datasource", - "isPublic", - "project", - "documents", - "label", - "logo", - "formatId"]; - // Add spatial fields if the map is present - if ( gmaps ) { - fields.push("northBoundCoord", "southBoundCoord", "eastBoundCoord", "westBoundCoord"); - } - //Set the field list on the SolrResults collection as a comma-separated string - this.searchResults.setfields(fields.join(",")); - - // Specify which geohash level is used to return tile counts - if ( gmaps && this.map ) { - geohashLevel = "geohash_" + - this.mapModel.determineGeohashLevel(this.map.zoom); - // Does it already exist as a facet field? - position = this.searchResults.facet.indexOf(geohashLevel); - if ( position == -1) { - this.searchResults.facet.push(geohashLevel); - } - } - - // Set the query on the SolrResults collection - this.searchResults.setQuery(query); - - // Get the page number - if ( this.isSubView ) { - page = 0; - } else { - page = MetacatUI.appModel.get("page"); - if ( page == null ) { - page = 0; - } - } - this.searchResults.start = page * this.searchResults.rows; - - // go to the page, which triggers a search - this.showPage(page); - - // don't want to follow links - return false; - }, - - /** - * Toggle the map filter to include or exclude it from the Solr query - */ - toggleMapFilter: function(event) { - var toggleInput = this.$("input" + this.mapFilterToggle); - if ((typeof toggleInput === "undefined") || !toggleInput) return; - - var isOn = $(toggleInput).prop("checked"); - - // If the user clicked on the label, then change the checkbox for them - if (event && event.target.tagName != "INPUT") { - isOn = !isOn; - toggleInput.prop("checked", isOn); - } - - var spatialFilter = _.findWhere(this.searchModel.get("filters").models, {type: "SpatialFilter"}); - - if (isOn) { - this.searchModel.set("useGeohash", true); - - if( this.filterGroupsView && spatialFilter ){ - - this.filterGroupsView.addCustomAppliedFilter(spatialFilter); - - } - - } else { - this.searchModel.set("useGeohash", false); - // Remove the spatial filter from the collection - this.searchModel.get("filters").remove(spatialFilter); - - if( this.filterGroupsView && spatialFilter ){ - this.filterGroupsView.removeCustomAppliedFilter(spatialFilter); - } - } - - // Tell the map to trigger a new search and redraw tiles - this.allowSearch = true; - google.maps.event.trigger(this.mapModel.get("map"), "idle"); - - // Send this event to Google Analytics - if (MetacatUI.appModel.get("googleAnalyticsKey") && (typeof ga !== "undefined")) { - var action = isOn ? "on" : "off"; - ga("send", "event", "map", action); - } - }, - - /** - * Overload this function with an empty function since the Clear button - * has been moved to the FilterGroupsView - */ - toggleClearButton: function(){}, - - /** - * Overload this function with an empty function since the Clear button - * has been moved to the FilterGroupsView - */ - hideClearButton: function(){}, - - /** - * Overload this function with an empty function since the Clear button - * has been moved to the FilterGroupsView - */ - showClearButton: function(){}, - - - /** - * Toggle between map and list mode - * - * @param(Event) the event passed by clicking the toggle-map class button - */ - toggleMapMode: function(event) { - - // Block the event from bubbling - if (typeof event === "object") { - event.preventDefault(); - } - - if (gmaps) { - $(".mapMode").toggleClass("mapMode"); - } - - // Toggle the mode - if (this.mode == "map") { - MetacatUI.appModel.set("searchMode", "list"); - this.mode = "list"; - this.$("#map-canvas").detach(); - this.setAutoHeight(); - this.getResults(); - } else if (this.mode == "list") { - MetacatUI.appModel.set("searchMode", "map"); - this.mode = "map"; - this.renderMap(); - this.setAutoHeight(); - this.getResults(); - } - }, - - /** - * Reset the map to the defaults - */ - resetMap: function() { - - // The spatial models registered in the filters collection - var spatialModels; - - if (!gmaps) { - return; - } - - // Remove the SpatialFilter from the collection silently - // so we don't immediately trigger a new search - spatialModels = - _.where(this.searchModel.get("filters").models, {type: "SpatialFilter"}); - this.searchModel.get("filters").remove(spatialModels, {"silent": true}); - - // Reset the map options to defaults - this.mapModel.set("mapOptions", this.mapModel.defaults().mapOptions); - this.allowSearch = false; - }, - - /** - * Render the map based on the mapModel properties and search results - */ - renderMap: function() { - - // If gmaps isn't enabled or loaded with an error, use list mode - if (!gmaps || this.mode == "list") { - this.ready = true; - this.mode = "list"; - return; - } - - // The spatial filter instance used to constrain the search by zoom and extent - var spatialFilter; - - // The map's configuration - var mapOptions; - - // The map extent - var boundingBox; - - // The map bounding coordinates - var north; - var west; - var south; - var east; - - // The map zoom level - var zoom; - - // The map geohash precision based on the zoom level - var precision; - - // The geohash boxes associated with the map extent and zoom - var geohashBBoxes; - - // References to the map and catalog view instances for callbacks - var mapRef; - var viewRef; - - if (this.isSubView) { - this.$el.addClass("mapMode"); - } else { - $("body").addClass("mapMode"); - } - - // Get the map options and create the map - gmaps.visualRefresh = true; - mapOptions = this.mapModel.get("mapOptions"); - var defaultZoom = mapOptions.zoom; - $("#map-container").append("
"); - this.map = new gmaps.Map($("#map-canvas")[0], mapOptions); - this.mapModel.set("map", this.map); - this.hasZoomed = false; - this.hasDragged = false; - - // Hide the map filter toggle element - this.$(this.mapFilterToggle).hide(); - - // Get the existing spatial filter if it exists - if (this.searchModel.get("filters") && - this.searchModel.get("filters") - .where({type: "SpatialFilter"}).length > 0) { - spatialFilter = this.searchModel.get("filters") - .where({type: "SpatialFilter"})[0]; - } else { - spatialFilter = new SpatialFilter(); - } - - // Store references - mapRef = this.map; - viewRef = this; - - // Listen to idle events on the map (at rest), and render content as needed - google.maps.event.addListener(mapRef, "idle", function() { - // Remove all markers from the map - for (var i = 0; i < viewRef.resultMarkers.length; i++) { - viewRef.resultMarkers[i].setMap(null); - } - viewRef.resultMarkers = new Array(); - - //Check if the user has interacted with the map just now, and if so, we - // want to alter the geohash filter (changing the geohash values or resetting it completely) - var alterGeohashFilter = viewRef.allowSearch || viewRef.hasZoomed || viewRef.hasDragged; - if( !alterGeohashFilter ){ - return; - } - - //Determine if the map needs to be recentered. The map only needs to be - // recentered if it is not at the default lat,long center point AND it - // is not zoomed in or dragged to a new center point - var setGeohashFilter = viewRef.hasZoomed && viewRef.isMapFilterEnabled(); - - //If we are using the geohash filter defined by this map, then - // apply the filter and trigger a new search - if( setGeohashFilter ){ - // Get the Google map bounding box - boundingBox = mapRef.getBounds(); - - // Set the search model's spatial filter properties - // Encode the Google Map bounding box into geohash - if ( typeof boundingBox !== "undefined") { - north = boundingBox.getNorthEast().lat(); - west = boundingBox.getSouthWest().lng(); - south = boundingBox.getSouthWest().lat(); - east = boundingBox.getNorthEast().lng(); - } - - // Save the center position and zoom level of the map - viewRef.mapModel.get("mapOptions").center = mapRef.getCenter(); - viewRef.mapModel.get("mapOptions").zoom = mapRef.getZoom(); - - // Determine the precision of geohashes to search for - zoom = mapRef.getZoom(); +define([ + "jquery", + "underscore", + "backbone", + "gmaps", + "collections/Filters", + "collections/SolrResults", + "models/filters/FilterGroup", + "models/filters/SpatialFilter", + "models/Stats", + "views/DataCatalogView", + "views/filters/FilterGroupsView", + "text!templates/dataCatalog.html", + "nGeohash", +], function ( + $, + _, + Backbone, + gmaps, + Filters, + SearchResults, + FilterGroup, + SpatialFilter, + Stats, + DataCatalogView, + FilterGroupsView, + template, + nGeohash +) { + /** + * @class DataCatalogViewWithFilters + * @classdesc A DataCatalogView that uses the Search collection and the Filter + * models for managing queries rather than the Search model and the filter + * literal objects used in the parent DataCatalogView. This accommodates + * custom portal filters. This view is deprecated and will eventually be + * removed in a future version (likely 3.0.0) + * @classcategory Views + * @extends DataCatalogView + * @constructor + * @deprecated + */ + var DataCatalogViewWithFilters = DataCatalogView.extend( + /** @lends DataCatalogViewWithFilters.prototype */ { + el: null, + + /** + * The HTML tag name for this view element + * @type {string} + */ + tagName: "div", + + /** + * The HTML class names for this view element + * @type {string} + */ + className: "data-catalog", + + /** + * The primary HTML template for this view + * @type {Underscore.template} + */ + template: _.template(template), + + /** + * A reference to the PortalEditorView + * @type {PortalEditorView} + */ + editorView: undefined, + + /** + * The sort order for the Solr query + * @type {string} + */ + sortOrder: "dateUploaded+desc", + + /** + * The jQuery selector for the FilterGroupsView container + * @type {string} + */ + filterGroupsContainer: ".filter-groups-container", + + /** + * The Search model to use for creating and storing Filters and + * constructing query strings. This property is a Search model instead of + * a Filters collection in order to be quickly compatible with the + * superclass/superview, DataCatalogView, which was created with the + * (eventually to be deprecated) SearchModel. A Filters collection is set + * on the Search model and does most of the work for creating queries. + * @type (Search) + */ + searchModel: undefined, + + /** + * Override DataCatalogView.render() to render this view with filters from + * the Filters collection + */ + render: function () { + var loadingHTML; + var templateVars; + var compiledEl; + var tooltips; + var groupedTooltips; + var forFilterLabel = true; + var forOtherElements = false; + // TODO: Do we really need to cache the filters collection? Reconcile + // this from DataCatalogView.render() See + // https://github.com/NCEAS/metacatui/blob/19d608df9cc17ac2abee76d35feca415137c09d7/src/js/views/DataCatalogView.js#L122-L145 + + // Get the search mode - either "map" or "list" + if (typeof this.mode === "undefined" || !this.mode) { + this.mode = MetacatUI.appModel.get("searchMode"); + if (typeof this.mode === "undefined" || !this.mode) { + this.mode = "map"; + } + MetacatUI.appModel.set("searchMode", this.mode); + } + + if (!this.statsModel) { + this.statsModel = new Stats(); + } + + if (!this.searchResults) { + this.searchResults = new SearchResults(); + } + + // Use map mode on tablets and browsers only + if ($(window).outerWidth() <= 600) { + this.mode = "list"; + MetacatUI.appModel.set("searchMode", "list"); + gmaps = null; + } + + // If this is a subview, don't set the headerType + if (!this.isSubView) { + MetacatUI.appModel.set("headerType", "default"); + $("body").addClass("DataCatalog"); + } else { + this.$el.addClass("DataCatalog"); + } + // Populate the search template with some model attributes + loadingHTML = this.loadingTemplate({ + msg: "Loading entries ...", + }); - precision = viewRef.mapModel.getSearchPrecision(zoom); + templateVars = { + gmaps: gmaps, + mode: MetacatUI.appModel.get("searchMode"), + useMapBounds: this.searchModel.get("useGeohash"), + username: MetacatUI.appUserModel.get("username"), + isMySearch: + _.indexOf( + this.searchModel.get("username"), + MetacatUI.appUserModel.get("username") + ) > -1, + loading: loadingHTML, + searchModelRef: this.searchModel, + searchResultsRef: this.searchResults, + dataSourceTitle: + MetacatUI.theme == "dataone" ? "Member Node" : "Data source", + }; + compiledEl = this.template( + _.extend(this.searchModel.toJSON(), templateVars) + ); + this.$el.html(compiledEl); + + // Create and render the FilterGroupsView + this.createFilterGroups(); + + // Store some references to key views that we use repeatedly + this.$resultsview = this.$("#results-view"); + this.$results = this.$("#results"); + + // Update stats + this.updateStats(); + + // Render the Google Map + this.renderMap(); + // Initialize the tooltips + tooltips = $(".tooltip-this"); + + // Find the tooltips that are on filter labels - add a slight delay to + // those + groupedTooltips = _.groupBy(tooltips, function (t) { + return ( + ($(t).prop("tagName") == "LABEL" || + $(t).parent().prop("tagName") == "LABEL") && + $(t).parents(".filter-container").length > 0 + ); + }); - // Get all the geohash tiles contained in the map bounds - if ( south && west && north && east && precision ) { - geohashBBoxes = nGeohash.bboxes(south, west, north, east, precision); - } + $(groupedTooltips[forFilterLabel]).tooltip({ + delay: { + show: "800", + }, + }); + $(groupedTooltips[forOtherElements]).tooltip(); - // Save our geohash search settings - spatialFilter.set({ - "geohashes": geohashBBoxes, - "geohashLevel": precision, - "north": north, - "west": west, - "south": south, - "east": east, - }); + // Initialize all popover elements + $(".popover-this").popover(); - // Add the spatial filter to the filters collection if enabled - if ( viewRef.searchModel.get("useGeohash") ) { + // Initialize the resizeable content div + $("#content").resizable({ + handles: "n,s,e,w", + }); - viewRef.searchModel.get("filters").add(spatialFilter); + // Register listeners; this is done here in render because the HTML + // needs to be bound before the listenTo call can be made + this.stopListening(this.searchResults); + this.stopListening(this.searchModel); + this.stopListening(MetacatUI.appModel); + this.listenTo(this.searchResults, "reset", this.cacheSearch); + this.listenTo(this.searchResults, "add", this.addOne); + this.listenTo(this.searchResults, "reset", this.addAll); + this.listenTo(this.searchResults, "reset", this.checkForProv); + this.listenTo(this.searchResults, "error", this.showError); + + // Listen to changes in the Search model Filters to trigger a search + this.stopListening( + this.searchModel.get("filters"), + "add remove update reset change" + ); + this.listenTo( + this.searchModel.get("filters"), + "add remove update reset change", + this.triggerSearch + ); + + // Listen to the MetacatUI.appModel for the search trigger + this.listenTo(MetacatUI.appModel, "search", this.getResults); + + this.listenTo( + MetacatUI.appUserModel, + "change:loggedIn", + this.triggerSearch + ); + + // and go to a certain page if we have it + this.getResults(); + + // Set a custom height on any elements that have the .auto-height class + if ($(".auto-height").length > 0 && !this.fixedHeight) { + // Readjust the height whenever the window is resized + $(window).resize(this.setAutoHeight); + $(".auto-height-member").resize(this.setAutoHeight); + } + + this.addAnnotationFilter(); + + return this; + }, + + /** + * Creates UI Filter Groups and renders them in this view. UI Filter + * Groups are custom, interactive search filter elements, grouped together + * in one panel, section, tab, etc. + */ + createFilterGroups: function () { + // If it was already created, then exit + if (this.filterGroupsView) { + return; + } + + // Start an array for the FilterGroups and the individual Filter models + var filterGroups = [], + allFilters = []; + + // Iterate over each default FilterGroup in the app config and create a + // FilterGroup model + _.each( + MetacatUI.appModel.get("defaultFilterGroups"), + function (filterGroupJSON) { + // Create the FilterGroup model + var filterGroup = new FilterGroup(filterGroupJSON); + + // Add to the array + filterGroups.push(filterGroup); + + // Add the Filters to the array + allFilters = _.union(allFilters, filterGroup.get("filters").models); + }, + this + ); + + // Add the filters to the Search model + this.searchModel.get("filters").add(allFilters); + + // Create a FilterGroupsView + var filterGroupsView = new FilterGroupsView({ + filterGroups: filterGroups, + filters: this.searchModel.get("filters"), + vertical: true, + parentView: this, + editorView: this.editorView, + }); - if( viewRef.filterGroupsView && spatialFilter ){ - viewRef.filterGroupsView.addCustomAppliedFilter(spatialFilter); + // Add the FilterGroupsView element to this view + this.$(this.filterGroupsContainer).html(filterGroupsView.el); + + // Render the FilterGroupsView + filterGroupsView.render(); + + // Save a reference to the FilterGroupsView + this.filterGroupsView = filterGroupsView; + }, + + /* + * Get Results from the Solr index by combining the Filter query string + * fragments in each Filter instance in the Search collection and querying + * Solr. + * + * Overrides DataCatalogView.getResults(). + */ + getResults: function () { + var sortOrder = this.searchModel.get("sortOrder"); + var query; // The full query string + var geohashLevel; // The geohash level to search + var page; // The page of search results to render + var position; // The geohash level position in the facet array + + // Get the Solr query string from the Search filter collection + query = this.searchModel.get("filters").getQuery(); + + // If the query hasn't changed since the last query that was sent, don't + // do anything. This function may have been triggered by a change event + // on a filter that doesn't affect the query at all + if (query == this.searchResults.getLastQuery()) { + return; + } + + if (sortOrder) { + this.searchResults.setSort(sortOrder); + } + + // Specify which fields to retrieve + var fields = [ + "id", + "seriesId", + "title", + "origin", + "pubDate", + "dateUploaded", + "abstract", + "resourceMap", + "beginDate", + "endDate", + "read_count_i", + "geohash_9", + "datasource", + "isPublic", + "project", + "documents", + "label", + "logo", + "formatId", + ]; + // Add spatial fields if the map is present + if (gmaps) { + fields.push( + "northBoundCoord", + "southBoundCoord", + "eastBoundCoord", + "westBoundCoord" + ); + } + // Set the field list on the SolrResults collection as a comma-separated + // string + this.searchResults.setfields(fields.join(",")); + + // Specify which geohash level is used to return tile counts + if (gmaps && this.map) { + geohashLevel = + "geohash_" + this.mapModel.determineGeohashLevel(this.map.zoom); + // Does it already exist as a facet field? + position = this.searchResults.facet.indexOf(geohashLevel); + if (position == -1) { + this.searchResults.facet.push(geohashLevel); + } + } + + // Set the query on the SolrResults collection + this.searchResults.setQuery(query); + + // Get the page number + if (this.isSubView) { + page = 0; + } else { + page = MetacatUI.appModel.get("page"); + if (page == null) { + page = 0; + } + } + this.searchResults.start = page * this.searchResults.rows; + + // go to the page, which triggers a search + this.showPage(page); + + // don't want to follow links + return false; + }, + + /** + * Toggle the map filter to include or exclude it from the Solr query + */ + toggleMapFilter: function (event) { + var toggleInput = this.$("input" + this.mapFilterToggle); + if (typeof toggleInput === "undefined" || !toggleInput) return; + + var isOn = $(toggleInput).prop("checked"); + + // If the user clicked on the label, then change the checkbox for them + if (event && event.target.tagName != "INPUT") { + isOn = !isOn; + toggleInput.prop("checked", isOn); + } + + var spatialFilter = _.findWhere( + this.searchModel.get("filters").models, + { type: "SpatialFilter" } + ); + + if (isOn) { + this.searchModel.set("useGeohash", true); + + if (this.filterGroupsView && spatialFilter) { + this.filterGroupsView.addCustomAppliedFilter(spatialFilter); + } + } else { + this.searchModel.set("useGeohash", false); + // Remove the spatial filter from the collection + this.searchModel.get("filters").remove(spatialFilter); + + if (this.filterGroupsView && spatialFilter) { + this.filterGroupsView.removeCustomAppliedFilter(spatialFilter); + } + } + + // Tell the map to trigger a new search and redraw tiles + this.allowSearch = true; + google.maps.event.trigger(this.mapModel.get("map"), "idle"); + + // Send this event to Google Analytics + if ( + MetacatUI.appModel.get("googleAnalyticsKey") && + typeof ga !== "undefined" + ) { + var action = isOn ? "on" : "off"; + ga("send", "event", "map", action); + } + }, + + /** + * Overload this function with an empty function since the Clear button + * has been moved to the FilterGroupsView + */ + toggleClearButton: function () {}, + + /** + * Overload this function with an empty function since the Clear button + * has been moved to the FilterGroupsView + */ + hideClearButton: function () {}, + + /** + * Overload this function with an empty function since the Clear button + * has been moved to the FilterGroupsView + */ + showClearButton: function () {}, + + /** + * Toggle between map and list mode + * + * @param(Event) the event passed by clicking the toggle-map class button + */ + toggleMapMode: function (event) { + // Block the event from bubbling + if (typeof event === "object") { + event.preventDefault(); + } + + if (gmaps) { + $(".mapMode").toggleClass("mapMode"); + } + + // Toggle the mode + if (this.mode == "map") { + MetacatUI.appModel.set("searchMode", "list"); + this.mode = "list"; + this.$("#map-canvas").detach(); + this.setAutoHeight(); + this.getResults(); + } else if (this.mode == "list") { + MetacatUI.appModel.set("searchMode", "map"); + this.mode = "map"; + this.renderMap(); + this.setAutoHeight(); + this.getResults(); + } + }, + + /** + * Reset the map to the defaults + */ + resetMap: function () { + // The spatial models registered in the filters collection + var spatialModels; + + if (!gmaps) { + return; + } + + // Remove the SpatialFilter from the collection silently so we don't + // immediately trigger a new search + spatialModels = _.where(this.searchModel.get("filters").models, { + type: "SpatialFilter", + }); + this.searchModel.get("filters").remove(spatialModels, { silent: true }); + + // Reset the map options to defaults + this.mapModel.set("mapOptions", this.mapModel.defaults().mapOptions); + this.allowSearch = false; + }, + + /** + * Render the map based on the mapModel properties and search results + */ + renderMap: function () { + // If gmaps isn't enabled or loaded with an error, use list mode + if (!gmaps || this.mode == "list") { + this.ready = true; + this.mode = "list"; + return; + } + + // The spatial filter instance used to constrain the search by zoom and + // extent + var spatialFilter; + + // The map's configuration + var mapOptions; + + // The map extent + var boundingBox; + + // The map bounding coordinates + var north; + var west; + var south; + var east; + + // The map zoom level + var zoom; + + // The map geohash precision based on the zoom level + var precision; + + // The geohash boxes associated with the map extent and zoom + var geohashBBoxes; + + // References to the map and catalog view instances for callbacks + var mapRef; + var viewRef; + + if (this.isSubView) { + this.$el.addClass("mapMode"); + } else { + $("body").addClass("mapMode"); + } + + // Get the map options and create the map + gmaps.visualRefresh = true; + mapOptions = this.mapModel.get("mapOptions"); + var defaultZoom = mapOptions.zoom; + $("#map-container").append("
"); + this.map = new gmaps.Map($("#map-canvas")[0], mapOptions); + this.mapModel.set("map", this.map); + this.hasZoomed = false; + this.hasDragged = false; + + // Hide the map filter toggle element + this.$(this.mapFilterToggle).hide(); + + // Get the existing spatial filter if it exists + if ( + this.searchModel.get("filters") && + this.searchModel.get("filters").where({ type: "SpatialFilter" }) + .length > 0 + ) { + spatialFilter = this.searchModel + .get("filters") + .where({ type: "SpatialFilter" })[0]; + } else { + spatialFilter = new SpatialFilter(); + } + + // Store references + mapRef = this.map; + viewRef = this; + + // Listen to idle events on the map (at rest), and render content as + // needed + google.maps.event.addListener(mapRef, "idle", function () { + // Remove all markers from the map + for (var i = 0; i < viewRef.resultMarkers.length; i++) { + viewRef.resultMarkers[i].setMap(null); + } + viewRef.resultMarkers = new Array(); + + // Check if the user has interacted with the map just now, and if so, + // we want to alter the geohash filter (changing the geohash values or + // resetting it completely) + var alterGeohashFilter = + viewRef.allowSearch || viewRef.hasZoomed || viewRef.hasDragged; + if (!alterGeohashFilter) { + return; + } + + // Determine if the map needs to be recentered. The map only needs to + // be recentered if it is not at the default lat,long center point AND + // it is not zoomed in or dragged to a new center point + var setGeohashFilter = + viewRef.hasZoomed && viewRef.isMapFilterEnabled(); + + // If we are using the geohash filter defined by this map, then apply + // the filter and trigger a new search + if (setGeohashFilter) { + // Get the Google map bounding box + boundingBox = mapRef.getBounds(); + + // Set the search model's spatial filter properties Encode the + // Google Map bounding box into geohash + if (typeof boundingBox !== "undefined") { + north = boundingBox.getNorthEast().lat(); + west = boundingBox.getSouthWest().lng(); + south = boundingBox.getSouthWest().lat(); + east = boundingBox.getNorthEast().lng(); + } - //When the custom spatial filter is removed in the UI, toggle the map filter - viewRef.listenTo( viewRef.filterGroupsView, "customAppliedFilterRemoved", function(removedFilter){ + // Save the center position and zoom level of the map + viewRef.mapModel.get("mapOptions").center = mapRef.getCenter(); + viewRef.mapModel.get("mapOptions").zoom = mapRef.getZoom(); - if( removedFilter.type == "SpatialFilter" ){ + // Determine the precision of geohashes to search for + zoom = mapRef.getZoom(); - //Uncheck the map filter on the map itself - viewRef.$(".toggle-map-filter").prop("checked", false); - viewRef.toggleMapFilter(); + precision = viewRef.mapModel.getSearchPrecision(zoom); - } + // Get all the geohash tiles contained in the map bounds + if (south && west && north && east && precision) { + geohashBBoxes = nGeohash.bboxes( + south, + west, + north, + east, + precision + ); + } - }); - } + // Save our geohash search settings + spatialFilter.set({ + geohashes: geohashBBoxes, + geohashLevel: precision, + north: north, + west: west, + south: south, + east: east, + }); + + // Add the spatial filter to the filters collection if enabled + if (viewRef.searchModel.get("useGeohash")) { + viewRef.searchModel.get("filters").add(spatialFilter); + + if (viewRef.filterGroupsView && spatialFilter) { + viewRef.filterGroupsView.addCustomAppliedFilter(spatialFilter); + + // When the custom spatial filter is removed in the UI, toggle + // the map filter + viewRef.listenTo( + viewRef.filterGroupsView, + "customAppliedFilterRemoved", + function (removedFilter) { + if (removedFilter.type == "SpatialFilter") { + // Uncheck the map filter on the map itself + viewRef.$(".toggle-map-filter").prop("checked", false); + viewRef.toggleMapFilter(); } } - else{ - - //Reset the map filter - viewRef.resetMap(); - - //Start back at page 0 - MetacatUI.appModel.set("page", 0); + ); + } + } + } else { + // Reset the map filter + viewRef.resetMap(); - //Mark the view as ready to start a search - viewRef.ready = true; + // Start back at page 0 + MetacatUI.appModel.set("page", 0); - // Trigger a new search - viewRef.triggerSearch(); + // Mark the view as ready to start a search + viewRef.ready = true; - viewRef.allowSearch = false; + // Trigger a new search + viewRef.triggerSearch(); - return; - } + viewRef.allowSearch = false; - }); + return; + } + }); - google.maps.event.addListener(mapRef, "zoom_changed", function() { - // If the map is zoomed in further than the default zoom level, - // than we want to mark the map as zoomed in - if(viewRef.map.getZoom() > defaultZoom){ - viewRef.hasZoomed = true; - } - //If we are at the default zoom level or higher, than do not mark the map - // as zoomed in - else{ - viewRef.hasZoomed = false; - } - }); + google.maps.event.addListener(mapRef, "zoom_changed", function () { + // If the map is zoomed in further than the default zoom level, than + // we want to mark the map as zoomed in + if (viewRef.map.getZoom() > defaultZoom) { + viewRef.hasZoomed = true; + } + // If we are at the default zoom level or higher, than do not mark the + // map as zoomed in + else { + viewRef.hasZoomed = false; + } + }); - google.maps.event.addListener(mapRef, "dragend", function() { - viewRef.hasDragged = true; - }); - } + google.maps.event.addListener(mapRef, "dragend", function () { + viewRef.hasDragged = true; }); - return DataCatalogViewWithFilters; - }); + }, + } + ); + return DataCatalogViewWithFilters; +}); diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 3fe528ec0..f560a6976 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -1,345 +1,393 @@ /*global define */ -define(["jquery", - "backbone", - "collections/maps/MapAssets", - "models/filters/FilterGroup", - "models/connectors/Filters-Search", - "models/connectors/Geohash-Search", - "models/maps/assets/CesiumGeohash", - "models/maps/Map", - "views/search/SearchResultsView", - "views/filters/FilterGroupsView", - "views/maps/MapView", - "views/search/SearchResultsPagerView", - "views/search/SorterView" - ], -function($, Backbone, MapAssets, FilterGroup, FiltersSearchConnector, GeohashSearchConnector, CesiumGeohash, Map, SearchResultsView, FilterGroupsView, MapView, PagerView, SorterView){ - - "use strict"; - - /** - * @class CatalogSearchView - * @name CatalogSearchView - * @classcategory Views - * @extends Backbone.View - * @constructor - * @since 2.22.0 - */ - return Backbone.View.extend( - /** @lends CatalogSearchView.prototype */ { - - /** - * The type of View this is - * @type {string} - */ - type: "CatalogSearch", - - /** - * The HTML tag to use for this view's element - * @type {string} - */ - tagName: "section", - - /** - * The HTML classes to use for this view's element - * @type {string} - */ - className: "catalog-search-view", - - template: ` +define([ + "jquery", + "backbone", + "collections/maps/MapAssets", + "models/filters/FilterGroup", + "models/connectors/Filters-Search", + "models/connectors/Geohash-Search", + "models/maps/assets/CesiumGeohash", + "models/maps/Map", + "views/search/SearchResultsView", + "views/filters/FilterGroupsView", + "views/maps/MapView", + "views/search/SearchResultsPagerView", + "views/search/SorterView", +], function ( + $, + Backbone, + MapAssets, + FilterGroup, + FiltersSearchConnector, + GeohashSearchConnector, + CesiumGeohash, + Map, + SearchResultsView, + FilterGroupsView, + MapView, + PagerView, + SorterView +) { + "use strict"; + + /** + * @class CatalogSearchView + * @name CatalogSearchView + * @classcategory Views + * @extends Backbone.View + * @constructor + * @since 2.22.0 + */ + return Backbone.View.extend( + /** @lends CatalogSearchView.prototype */ { + /** + * The type of View this is + * @type {string} + */ + type: "CatalogSearch", + + /** + * The HTML tag to use for this view's element + * @type {string} + */ + tagName: "section", + + /** + * The HTML classes to use for this view's element + * @type {string} + */ + className: "catalog-search-view", + + template: `
`, - /** - * The search mode to use. This can be set to either `map` or `list`. List mode will hide all map features. - * @type string - * @since 2.22.0 - * @default "map" - */ - mode: "map", - - searchResults: null, - - filterGroupsView: null, - - /** - * @type {PagerView} - */ - pagerView: null, - - /** - * @type {SorterView} - */ - sorterView: null, - - searchModel: null, - - allFilters: null, - - filterGroups: null, - - /** - * An array of literal objects to transform into FilterGroup models. These FilterGroups will be displayed in this view and used for searching. If not provided, the {@link AppConfig#defaultFilterGroups} will be used. - * @type {FilterGroup#defaults[]} - */ - filterGroupsJSON: null, - - - /** - * The jQuery selector for the FilterGroupsView container - * @type {string} - */ - filterGroupsContainer: ".filter-groups-container", - - /** - * The query selector for the SearchResultsView container - * @type {string} - */ - searchResultsContainer: ".search-results-container", - - /** - * The query selector for the CesiumWidgetView container - * @type {string} - */ - mapContainer: ".map-container", - - /** - * The query selector for the PagerView container - * @type {string} - */ - pagerContainer: ".pager-container", - - /** - * The query selector for the SorterView container - * @type {string} - */ - sorterContainer: ".sorter-container", - - /** - * The query selector for the title container - * @type {string} - */ - titleContainer: ".title-container", - - /** - * The events this view will listen to and the associated function to call. - * @type {Object} - */ - events: { - "click .map-toggle-container" : "toggleMode" - }, - - render: function(){ - - //Set the search mode - either map or list + /** + * The search mode to use. This can be set to either `map` or `list`. List + * mode will hide all map features. + * @type string + * @since 2.22.0 + * @default "map" + */ + mode: "map", + + searchResults: null, + + filterGroupsView: null, + + /** + * @type {PagerView} + */ + pagerView: null, + + /** + * @type {SorterView} + */ + sorterView: null, + + searchModel: null, + + allFilters: null, + + filterGroups: null, + + /** + * An array of literal objects to transform into FilterGroup models. These + * FilterGroups will be displayed in this view and used for searching. If + * not provided, the {@link AppConfig#defaultFilterGroups} will be used. + * @type {FilterGroup#defaults[]} + */ + filterGroupsJSON: null, + + /** + * The jQuery selector for the FilterGroupsView container + * @type {string} + */ + filterGroupsContainer: ".filter-groups-container", + + /** + * The query selector for the SearchResultsView container + * @type {string} + */ + searchResultsContainer: ".search-results-container", + + /** + * The query selector for the CesiumWidgetView container + * @type {string} + */ + mapContainer: ".map-container", + + /** + * The query selector for the PagerView container + * @type {string} + */ + pagerContainer: ".pager-container", + + /** + * The query selector for the SorterView container + * @type {string} + */ + sorterContainer: ".sorter-container", + + /** + * The query selector for the title container + * @type {string} + */ + titleContainer: ".title-container", + + /** + * The events this view will listen to and the associated function to + * call. + * @type {Object} + */ + events: { + "click .map-toggle-container": "toggleMode", + }, + + render: function () { + // Set the search mode - either map or list this.setMode(); - //Set up the view for styling and layout + // Set up the view for styling and layout this.setupView(); - //Set up the search and search result models + // Set up the search and search result models this.setupSearch(); - //Render the search components + // Render the search components this.renderComponents(); + }, - }, + /** + * Sets up the basic components of this view + */ + setupView: function () { + document + .querySelector("body") + .classList.add(`catalog-search-body`, `${this.mode}Mode`); - /** - * Sets up the basic components of this view - */ - setupView: function(){ - document.querySelector("body").classList.add(`catalog-search-body`, `${this.mode}Mode`); - - //Add LinkedData to the page + // Add LinkedData to the page this.addLinkedData(); this.$el.html(this.template); - }, - - /** - * Sets the search mode (map or list) - * @since 2.22.0 - */ - setMode: function(){ - //Get the search mode - either "map" or "list" - if (((typeof this.mode === "undefined") || !this.mode) && (MetacatUI.appModel.get("enableCesium"))) { - this.mode = "map"; + }, + + /** + * Sets the search mode (map or list) + * @since 2.22.0 + */ + setMode: function () { + // Get the search mode - either "map" or "list" + if ( + (typeof this.mode === "undefined" || !this.mode) && + MetacatUI.appModel.get("enableCesium") + ) { + this.mode = "map"; } // Use map mode on tablets and browsers only if ($(window).outerWidth() <= 600) { - this.mode = "list"; + this.mode = "list"; } - }, + }, - renderComponents: function(){ + renderComponents: function () { this.renderFilters(); - //Render the list of search results + // Render the list of search results this.renderSearchResults(); - //Render the Title + // Render the Title this.renderTitle(); - this.listenTo(this.searchResultsView.searchResults, "reset", this.renderTitle); + this.listenTo( + this.searchResultsView.searchResults, + "reset", + this.renderTitle + ); - //Render Pager + // Render Pager this.renderPager(); - //Render Sorter + // Render Sorter this.renderSorter(); - //Render Cesium + // Render Cesium this.renderMap(); - }, - - /** - * Renders the search filters - * @since 2.22.0 - */ - renderFilters: function(){ - //Render FilterGroups + }, + + /** + * Renders the search filters + * @since 2.22.0 + */ + renderFilters: function () { + // Render FilterGroups this.filterGroupsView = new FilterGroupsView({ - filterGroups: this.filterGroups, - filters: this.connector?.get("filters"), - vertical: true, - parentView: this + filterGroups: this.filterGroups, + filters: this.connector?.get("filters"), + vertical: true, + parentView: this, }); - //Add the FilterGroupsView element to this view + // Add the FilterGroupsView element to this view this.$(this.filterGroupsContainer).html(this.filterGroupsView.el); - //Render the FilterGroupsView + // Render the FilterGroupsView this.filterGroupsView.render(); - }, - - /** - * Creates the SearchResultsView and saves a reference to the SolrResults collection - * @since 2.22.0 - */ - createSearchResults: function(){ + }, + + /** + * Creates the SearchResultsView and saves a reference to the SolrResults + * collection + * @since 2.22.0 + */ + createSearchResults: function () { this.searchResultsView = new SearchResultsView(); - if( this.connector ){ - this.searchResultsView.searchResults = this.connector.get("searchResults"); + if (this.connector) { + this.searchResultsView.searchResults = + this.connector.get("searchResults"); } - }, + }, - /** - * Renders the search result list - * @since 2.22.0 - */ - renderSearchResults: function(){ - if(!this.searchResultsView) return; + /** + * Renders the search result list + * @since 2.22.0 + */ + renderSearchResults: function () { + if (!this.searchResultsView) return; - //Add the view element to this view + // Add the view element to this view this.$(this.searchResultsContainer).html(this.searchResultsView.el); - //Render the view + // Render the view this.searchResultsView.render(); - }, + }, - /** - * Creates a PagerView and adds it to the page. - * @since 2.22.0 - */ - renderPager: function(){ + /** + * Creates a PagerView and adds it to the page. + * @since 2.22.0 + */ + renderPager: function () { this.pagerView = new PagerView(); - - //Give the PagerView the SearchResults to listen to and update + + // Give the PagerView the SearchResults to listen to and update this.pagerView.searchResults = this.searchResultsView.searchResults; - //Add the pager view to the page - this.el.querySelector(this.pagerContainer).replaceChildren(this.pagerView.el); + // Add the pager view to the page + this.el + .querySelector(this.pagerContainer) + .replaceChildren(this.pagerView.el); - //Render the pager view + // Render the pager view this.pagerView.render(); - }, + }, - /** - * Creates a SorterView and adds it to the page. - * @since 2.22.0 - */ - renderSorter: function(){ + /** + * Creates a SorterView and adds it to the page. + * @since 2.22.0 + */ + renderSorter: function () { this.sorterView = new SorterView(); - - //Give the SorterView the SearchResults to listen to and update + + // Give the SorterView the SearchResults to listen to and update this.sorterView.searchResults = this.searchResultsView.searchResults; - //Add the sorter view to the page - this.el.querySelector(this.sorterContainer).replaceChildren(this.sorterView.el); + // Add the sorter view to the page + this.el + .querySelector(this.sorterContainer) + .replaceChildren(this.sorterView.el); - //Render the sorter view + // Render the sorter view this.sorterView.render(); - }, - - /** - * Constructs an HTML string of the title of this view - * @param {number} start - * @param {number} end - * @param {number} numFound - * @returns {string} - * @since 2.22.0 - */ - titleTemplate: function(start, end, numFound){ - let html = `
${MetacatUI.appView.commaSeparateNumber(start)} to ${MetacatUI.appView.commaSeparateNumber(end)}`; - - if(typeof numFound == "number"){ - html += ` of ${MetacatUI.appView.commaSeparateNumber(numFound)}`; + }, + + /** + * Constructs an HTML string of the title of this view + * @param {number} start + * @param {number} end + * @param {number} numFound + * @returns {string} + * @since 2.22.0 + */ + titleTemplate: function (start, end, numFound) { + let html = ` +
+
+ + ${MetacatUI.appView.commaSeparateNumber(start)} + to + ${MetacatUI.appView.commaSeparateNumber(end)} + `; + + if (typeof numFound == "number") { + html += ` of + ${MetacatUI.appView.commaSeparateNumber(numFound)} + `; } html += `
`; return html; - }, - - /** - * Updates the view title using the {@link CatalogSearchView#searchResults} data. - * @since 2.22.0 - */ - renderTitle: function(){ + }, + + /** + * Updates the view title using the + * {@link CatalogSearchView#searchResults} data. + * @since 2.22.0 + */ + renderTitle: function () { let titleEl = this.el.querySelector(this.titleContainer); - - if(!titleEl){ - titleEl = document.createElement("div"); - titleEl.classList.add("title-container"); - this.el.prepend(titleEl); - } - titleEl.innerHTML=""; - - let title = this.titleTemplate(this.searchResultsView.searchResults.getStart()+1, this.searchResultsView.searchResults.getEnd()+1, this.searchResultsView.searchResults.getNumFound()); - - titleEl.insertAdjacentHTML("beforeend", title); + if (!titleEl) { + titleEl = document.createElement("div"); + titleEl.classList.add("title-container"); + this.el.prepend(titleEl); + } - }, + titleEl.innerHTML = ""; - /** - * Creates the Filter models and SolrResults that will be used for searches - * @since 2.22.0 - */ - setupSearch: function(){ + let title = this.titleTemplate( + this.searchResultsView.searchResults.getStart() + 1, + this.searchResultsView.searchResults.getEnd() + 1, + this.searchResultsView.searchResults.getNumFound() + ); - //Get an array of all Filter models + titleEl.insertAdjacentHTML("beforeend", title); + }, + + /** + * Creates the Filter models and SolrResults that will be used for + * searches + * @since 2.22.0 + */ + setupSearch: function () { + // Get an array of all Filter models let allFilters = []; this.filterGroups = this.createFilterGroups(); - this.filterGroups.forEach(group => { - allFilters = allFilters.concat(group.get("filters")?.models); + this.filterGroups.forEach((group) => { + allFilters = allFilters.concat(group.get("filters")?.models); }); - //Connect the filters to the search and search results + // Connect the filters to the search and search results let connector = new FiltersSearchConnector({ filtersList: allFilters }); this.connector = connector; connector.startListening(); @@ -347,157 +395,159 @@ function($, Backbone, MapAssets, FilterGroup, FiltersSearchConnector, GeohashSea this.createSearchResults(); this.createMap(); - }, - - /** - * Creates UI Filter Groups. UI Filter Groups - * are custom, interactive search filter elements, grouped together in one - * panel, section, tab, etc. - * @param {FilterGroup#defaults[]} filterGroupsJSON An array of literal objects to transform into FilterGroup models. These FilterGroups will be displayed in this view and used for searching. If not provided, the {@link AppConfig#defaultFilterGroups} will be used. - * @since 2.22.0 - */ - createFilterGroups: function(filterGroupsJSON=this.filterGroupsJSON){ - - try{ - //Start an array for the FilterGroups and the individual Filter models - let filterGroups = []; - - //Iterate over each default FilterGroup in the app config and create a FilterGroup model - (filterGroupsJSON || MetacatUI.appModel.get("defaultFilterGroups")).forEach( filterGroupJSON => { - - //Create the FilterGroup model - //Add to the array - filterGroups.push(new FilterGroup(filterGroupJSON)); - - }); - - return filterGroups; - } - catch(e){ - console.error("Couldn't create Filter Groups in search. ", e) + }, + + /** + * Creates UI Filter Groups. UI Filter Groups are custom, interactive + * search filter elements, grouped together in one panel, section, tab, + * etc. + * @param {FilterGroup#defaults[]} filterGroupsJSON An array of literal + * objects to transform into FilterGroup models. These FilterGroups will + * be displayed in this view and used for searching. If not provided, the + * {@link AppConfig#defaultFilterGroups} will be used. + * @since 2.22.0 + */ + createFilterGroups: function (filterGroupsJSON = this.filterGroupsJSON) { + try { + // Start an array for the FilterGroups and the individual Filter models + let filterGroups = []; + + // Iterate over each default FilterGroup in the app config and create a + // FilterGroup model + ( + filterGroupsJSON || MetacatUI.appModel.get("defaultFilterGroups") + ).forEach((filterGroupJSON) => { + // Create the FilterGroup model Add to the array + filterGroups.push(new FilterGroup(filterGroupJSON)); + }); + + return filterGroups; + } catch (e) { + console.error("Couldn't create Filter Groups in search. ", e); } - - }, - - /** - * Create the models and views associated with the map and map search - * @since 2.22.0 - */ - createMap: function(){ - const mapOptions = Object.assign({}, MetacatUI.appModel.get("catalogSearchMapOptions") || {}); + }, + + /** + * Create the models and views associated with the map and map search + * @since 2.22.0 + */ + createMap: function () { + const mapOptions = Object.assign( + {}, + MetacatUI.appModel.get("catalogSearchMapOptions") || {} + ); const map = new Map(mapOptions); - const geohashLayer = map.get("layers").findWhere({isGeohashLayer: true}) + const geohashLayer = map + .get("layers") + .findWhere({ isGeohashLayer: true }); - //Connect the CesiumGeohash to the SolrResults + // Connect the CesiumGeohash to the SolrResults const connector = new GeohashSearchConnector({ - cesiumGeohash: geohashLayer, - searchResults: this.searchResultsView.searchResults + cesiumGeohash: geohashLayer, + searchResults: this.searchResultsView.searchResults, }); connector.startListening(); this.geohashSearchConnector = connector; - //Set the geohash level for the search - const searchFacet = this.searchResultsView.searchResults.facet - const newLevel = "geohash_" + geohashLayer.get("level") - if( Array.isArray(searchFacet) ) - searchFacet.push(newLevel); - else - searchFacet = newLevel; - - //Create the Map model and view + // Set the geohash level for the search + const searchFacet = this.searchResultsView.searchResults.facet; + const newLevel = "geohash_" + geohashLayer.get("level"); + if (Array.isArray(searchFacet)) searchFacet.push(newLevel); + else searchFacet = newLevel; + + // Create the Map model and view this.mapView = new MapView({ model: map }); - }, - - /** - * Renders the Cesium map with a geohash layer - * @since 2.22.0 - */ - renderMap: function(){ - //Add the map to the page and render it + }, + + /** + * Renders the Cesium map with a geohash layer + * @since 2.22.0 + */ + renderMap: function () { + // Add the map to the page and render it this.$(this.mapContainer).empty().append(this.mapView.el); - this.mapView.render(); - }, - - /** - * Linked Data Object for appending the jsonld into the browser DOM - * @since 2.22.0 - * */ - addLinkedData: function() { - + this.mapView.render(); + }, + + /** + * Linked Data Object for appending the jsonld into the browser DOM + * @since 2.22.0 + * */ + addLinkedData: function () { // JSON Linked Data Object let elJSON = { - "@context": { - "@vocab": "http://schema.org/" - }, - "@type": "DataCatalog", - } + "@context": { + "@vocab": "http://schema.org/", + }, + "@type": "DataCatalog", + }; // Find the MN info from the CN Node list let members = MetacatUI.nodeModel.get("members"), - nodeModelObject; + nodeModelObject; for (let i = 0; i < members.length; i++) { - if (members[i].identifier == MetacatUI.nodeModel.get("currentMemberNode")) { - nodeModelObject = members[i]; - } + if ( + members[i].identifier == + MetacatUI.nodeModel.get("currentMemberNode") + ) { + nodeModelObject = members[i]; + } } if (nodeModelObject) { - // "keywords": "", - // "provider": "", - let conditionalData = { - "description": nodeModelObject.description, - "identifier": nodeModelObject.identifier, - "image": nodeModelObject.logo, - "name": nodeModelObject.name, - "url": nodeModelObject.url - } - $.extend(elJSON, conditionalData); + // "keywords": "", "provider": "", + let conditionalData = { + description: nodeModelObject.description, + identifier: nodeModelObject.identifier, + image: nodeModelObject.logo, + name: nodeModelObject.name, + url: nodeModelObject.url, + }; + $.extend(elJSON, conditionalData); } - - // Check if the jsonld already exists from the previous data view - // If not create a new script tag and append otherwise replace the text for the script + // Check if the jsonld already exists from the previous data view If not + // create a new script tag and append otherwise replace the text for the + // script if (!document.getElementById("jsonld")) { - var el = document.createElement("script"); - el.type = "application/ld+json"; - el.id = "jsonld"; - el.text = JSON.stringify(elJSON); - document.querySelector("head").appendChild(el); + var el = document.createElement("script"); + el.type = "application/ld+json"; + el.id = "jsonld"; + el.text = JSON.stringify(elJSON); + document.querySelector("head").appendChild(el); } else { - var script = document.getElementById("jsonld"); - script.text = JSON.stringify(elJSON); + var script = document.getElementById("jsonld"); + script.text = JSON.stringify(elJSON); } - }, - - /** - * Toggles between map and list search mode - * @since 2.22.0 - */ - toggleMode: function(){ + }, + /** + * Toggles between map and list search mode + * @since 2.22.0 + */ + toggleMode: function () { let classList = document.querySelector("body").classList; - if(this.mode == "map"){ - this.mode = "list"; - classList.remove("mapMode"); - classList.add("listMode") - } - else{ - this.mode = "map"; - classList.remove("listMode"); - classList.add("mapMode") + if (this.mode == "map") { + this.mode = "list"; + classList.remove("mapMode"); + classList.add("listMode"); + } else { + this.mode = "map"; + classList.remove("listMode"); + classList.add("mapMode"); } + }, - }, + onClose: function () { + document + .querySelector("body") + .classList.remove(`catalog-search-body`, `${this.mode}Mode`); - onClose: function(){ - document.querySelector("body").classList.remove(`catalog-search-body`, `${this.mode}Mode`); - - //Remove the JSON-LD from the page + // Remove the JSON-LD from the page document.getElementById("jsonld")?.remove(); + }, } - + ); }); - -}); \ No newline at end of file From 21dfb62bf6a181cbadd73e23ba79690df5b5e574 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 22 Mar 2023 12:40:42 -0400 Subject: [PATCH 20/79] Add JSdocs and error handling to catalogSearch Also externalize the main template Relates to #1520 --- src/js/templates/search/catalogSearch.html | 24 + src/js/views/search/CatalogSearchView.js | 649 +++++++++++++-------- 2 files changed, 437 insertions(+), 236 deletions(-) create mode 100644 src/js/templates/search/catalogSearch.html diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html new file mode 100644 index 000000000..7394583a6 --- /dev/null +++ b/src/js/templates/search/catalogSearch.html @@ -0,0 +1,24 @@ +
+
+
+ +
+
+
+
+
+ +
diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index f560a6976..eda1ba7aa 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -2,31 +2,29 @@ define([ "jquery", "backbone", - "collections/maps/MapAssets", "models/filters/FilterGroup", "models/connectors/Filters-Search", "models/connectors/Geohash-Search", - "models/maps/assets/CesiumGeohash", "models/maps/Map", "views/search/SearchResultsView", "views/filters/FilterGroupsView", "views/maps/MapView", "views/search/SearchResultsPagerView", "views/search/SorterView", + "text!templates/search/catalogSearch.html", ], function ( $, Backbone, - MapAssets, FilterGroup, FiltersSearchConnector, GeohashSearchConnector, - CesiumGeohash, Map, SearchResultsView, FilterGroupsView, MapView, PagerView, - SorterView + SorterView, + Template ) { "use strict"; @@ -37,50 +35,48 @@ define([ * @extends Backbone.View * @constructor * @since 2.22.0 + * TODO: Add screenshot and description */ return Backbone.View.extend( /** @lends CatalogSearchView.prototype */ { /** * The type of View this is * @type {string} + * @since 2.22.0 */ type: "CatalogSearch", /** * The HTML tag to use for this view's element * @type {string} + * @since 2.22.0 */ tagName: "section", /** * The HTML classes to use for this view's element * @type {string} + * @since 2.22.0 */ className: "catalog-search-view", - template: ` -
-
-
- -
-
-
-
-
-
- -
-
-
- `, + /** + * The template to use for this view's element + * @type {underscore.template} + * @since 2.22.0 + */ + template: _.template(Template), + + /** + * The template to use in case there is a major error in rendering the + * view. + * @type {string} + * @since 2.x.x + */ + errorTemplate: ``, /** * The search mode to use. This can be set to either `map` or `list`. List @@ -91,24 +87,74 @@ define([ */ mode: "map", - searchResults: null, + /** + * The View that displays the search results. The render method will be + * attach the search results view to the + * {@link CatalogSearchView#searchResultsContainer} element and will add + * the view reference to this property. + * @type {SearchResultsView} + * @since 2.22.0 + */ + searchResultsView: null, + /** + * The view that shows the search filters. The render method will attach + * the filter groups view to the + * {@link CatalogSearchView#filterGroupsContainer} element and will add + * the view reference to this property. + * @type {FilterGroupsView} + * @since 2.22.0 + */ filterGroupsView: null, /** + * The view that shows the number of pages and allows the user to navigate + * between them. The render method will attach the pager view to the + * {@link CatalogSearchView#pagerContainer} element and will add the view + * reference to this property. * @type {PagerView} + * @since 2.22.0 */ pagerView: null, /** + * The view that handles sorting the search results. The render method + * will attach the sorter view to the + * {@link CatalogSearchView#sorterContainer} element and will add the view + * reference to this property. * @type {SorterView} + * @since 2.22.0 */ sorterView: null, + /** + * The model that retrieves the search results. + * @type {SearchModel} + * @since 2.22.0 + */ searchModel: null, + /** + * An array of Filter models, outside of their parent FilterGroup, + * that can be used to filter the search results. These models are passed + * to the {@link FiltersSearchConnector} to be used in the search. This + * property is added to the view by the {@link CatalogSearchView#setupSearch} + * method. + * @type {Filter[]} + * @since 2.22.0 + */ allFilters: null, + /** + * An array of FilterGroup models created by the + * {@link CatalogSearchView#createFilterGroups} method, using the + * {@link CatalogSearchView#filterGroupsJSON} property. These FilterGroups + * will be displayed in this view and used for searching. This property is + * added to the view by the {@link CatalogSearchView#createFilterGroups} + * method. + * @type {FilterGroup[]} + * @since 2.22.0 + */ filterGroups: null, /** @@ -116,42 +162,57 @@ define([ * FilterGroups will be displayed in this view and used for searching. If * not provided, the {@link AppConfig#defaultFilterGroups} will be used. * @type {FilterGroup#defaults[]} + * @since 2.22.0 */ filterGroupsJSON: null, + /** + * The CSS class to add to the body of the CatalogSearch. + * @type {string} + * @since 2.22.0 + * @default "catalog-search-body" + */ + bodyClass: "catalog-search-body", + /** * The jQuery selector for the FilterGroupsView container * @type {string} + * @since 2.22.0 */ filterGroupsContainer: ".filter-groups-container", /** * The query selector for the SearchResultsView container * @type {string} + * @since 2.22.0 */ searchResultsContainer: ".search-results-container", /** * The query selector for the CesiumWidgetView container * @type {string} + * @since 2.22.0 */ mapContainer: ".map-container", /** * The query selector for the PagerView container * @type {string} + * @since 2.22.0 */ pagerContainer: ".pager-container", /** * The query selector for the SorterView container * @type {string} + * @since 2.22.0 */ sorterContainer: ".sorter-container", /** * The query selector for the title container * @type {string} + * @since 2.22.0 */ titleContainer: ".title-container", @@ -159,11 +220,16 @@ define([ * The events this view will listen to and the associated function to * call. * @type {Object} + * @since 2.22.0 */ events: { "click .map-toggle-container": "toggleMode", }, + /** + * Initializes the view + * @since 2.22.0 + */ render: function () { // Set the search mode - either map or list this.setMode(); @@ -179,17 +245,10 @@ define([ }, /** - * Sets up the basic components of this view + * Indicates that there was a problem rendering this view. */ - setupView: function () { - document - .querySelector("body") - .classList.add(`catalog-search-body`, `${this.mode}Mode`); - - // Add LinkedData to the page - this.addLinkedData(); - - this.$el.html(this.template); + renderError: function () { + this.$el.html(this.errorTemplate); }, /** @@ -197,42 +256,85 @@ define([ * @since 2.22.0 */ setMode: function () { - // Get the search mode - either "map" or "list" - if ( - (typeof this.mode === "undefined" || !this.mode) && - MetacatUI.appModel.get("enableCesium") - ) { - this.mode = "map"; - } + try { + // Get the search mode - either "map" or "list" + if ( + (typeof this.mode === "undefined" || !this.mode) && + MetacatUI.appModel.get("enableCesium") + ) { + this.mode = "map"; + } - // Use map mode on tablets and browsers only - if ($(window).outerWidth() <= 600) { + // Use map mode on tablets and browsers only + // TODO: should we set a listener for window resize? + if ($(window).outerWidth() <= 600) { + this.mode = "list"; + } + } catch (e) { + console.error( + "Error setting the search mode, defaulting to list:" + e + ); this.mode = "list"; } }, + /** + * Sets up the basic components of this view + * @since 2.22.0 + */ + setupView: function () { + try { + document + .querySelector("body") + .classList.add(this.bodyClass, `${this.mode}Mode`); + + // Add LinkedData to the page + this.addLinkedData(); + + // Render the template + this.$el.html(this.template({})); + } catch (e) { + console.log( + "There was an error setting up the CatalogSearchView:" + e + ); + this.this.renderError(); + } + }, + + /** + * Calls other methods that insert the sub-views into the DOM and render + * them. + * @since 2.22.0 + */ renderComponents: function () { - this.renderFilters(); + try { + this.renderFilters(); - // Render the list of search results - this.renderSearchResults(); + // Render the list of search results + this.renderSearchResults(); - // Render the Title - this.renderTitle(); - this.listenTo( - this.searchResultsView.searchResults, - "reset", - this.renderTitle - ); + // Render the Title + this.renderTitle(); + this.listenTo( + this.searchResultsView.searchResults, + "reset", + this.renderTitle + ); - // Render Pager - this.renderPager(); + // Render Pager + this.renderPager(); - // Render Sorter - this.renderSorter(); + // Render Sorter + this.renderSorter(); - // Render Cesium - this.renderMap(); + // Render Cesium + this.renderMap(); + } catch (e) { + console.log( + "There was an error rendering the CatalogSearchView:" + e + ); + this.renderError(); + } }, /** @@ -240,19 +342,23 @@ define([ * @since 2.22.0 */ renderFilters: function () { - // Render FilterGroups - this.filterGroupsView = new FilterGroupsView({ - filterGroups: this.filterGroups, - filters: this.connector?.get("filters"), - vertical: true, - parentView: this, - }); - - // Add the FilterGroupsView element to this view - this.$(this.filterGroupsContainer).html(this.filterGroupsView.el); - - // Render the FilterGroupsView - this.filterGroupsView.render(); + try { + // Render FilterGroups + this.filterGroupsView = new FilterGroupsView({ + filterGroups: this.filterGroups, + filters: this.connector?.get("filters"), + vertical: true, + parentView: this, + }); + + // Add the FilterGroupsView element to this view + this.$(this.filterGroupsContainer).html(this.filterGroupsView.el); + + // Render the FilterGroupsView + this.filterGroupsView.render(); + } catch (e) { + console.log("There was an error rendering the FilterGroupsView:" + e); + } }, /** @@ -261,11 +367,15 @@ define([ * @since 2.22.0 */ createSearchResults: function () { - this.searchResultsView = new SearchResultsView(); + try { + this.searchResultsView = new SearchResultsView(); - if (this.connector) { - this.searchResultsView.searchResults = - this.connector.get("searchResults"); + if (this.connector) { + this.searchResultsView.searchResults = + this.connector.get("searchResults"); + } + } catch (e) { + console.log("There was an error creating the SearchResultsView:" + e); } }, @@ -274,13 +384,19 @@ define([ * @since 2.22.0 */ renderSearchResults: function () { - if (!this.searchResultsView) return; + try { + if (!this.searchResultsView) return; - // Add the view element to this view - this.$(this.searchResultsContainer).html(this.searchResultsView.el); + // Add the view element to this view + this.$(this.searchResultsContainer).html(this.searchResultsView.el); - // Render the view - this.searchResultsView.render(); + // Render the view + this.searchResultsView.render(); + } catch (e) { + console.log( + "There was an error rendering the SearchResultsView:" + e + ); + } }, /** @@ -288,18 +404,22 @@ define([ * @since 2.22.0 */ renderPager: function () { - this.pagerView = new PagerView(); + try { + this.pagerView = new PagerView(); - // Give the PagerView the SearchResults to listen to and update - this.pagerView.searchResults = this.searchResultsView.searchResults; + // Give the PagerView the SearchResults to listen to and update + this.pagerView.searchResults = this.searchResultsView.searchResults; - // Add the pager view to the page - this.el - .querySelector(this.pagerContainer) - .replaceChildren(this.pagerView.el); + // Add the pager view to the page + this.el + .querySelector(this.pagerContainer) + .replaceChildren(this.pagerView.el); - // Render the pager view - this.pagerView.render(); + // Render the pager view + this.pagerView.render(); + } catch (e) { + console.log("There was an error rendering the PagerView:" + e); + } }, /** @@ -307,18 +427,22 @@ define([ * @since 2.22.0 */ renderSorter: function () { - this.sorterView = new SorterView(); + try { + this.sorterView = new SorterView(); - // Give the SorterView the SearchResults to listen to and update - this.sorterView.searchResults = this.searchResultsView.searchResults; + // Give the SorterView the SearchResults to listen to and update + this.sorterView.searchResults = this.searchResultsView.searchResults; - // Add the sorter view to the page - this.el - .querySelector(this.sorterContainer) - .replaceChildren(this.sorterView.el); + // Add the sorter view to the page + this.el + .querySelector(this.sorterContainer) + .replaceChildren(this.sorterView.el); - // Render the sorter view - this.sorterView.render(); + // Render the sorter view + this.sorterView.render(); + } catch (e) { + console.log("There was an error rendering the SorterView:" + e); + } }, /** @@ -330,7 +454,8 @@ define([ * @since 2.22.0 */ titleTemplate: function (start, end, numFound) { - let html = ` + try { + let html = `
@@ -339,14 +464,18 @@ define([ ${MetacatUI.appView.commaSeparateNumber(end)} `; - if (typeof numFound == "number") { - html += ` of + if (typeof numFound == "number") { + html += ` of ${MetacatUI.appView.commaSeparateNumber(numFound)} `; - } + } - html += `
`; - return html; + html += `
`; + return html; + } catch (e) { + console.log("There was an error creating the title template:" + e); + return ""; + } }, /** @@ -355,23 +484,27 @@ define([ * @since 2.22.0 */ renderTitle: function () { - let titleEl = this.el.querySelector(this.titleContainer); + try { + let titleEl = this.el.querySelector(this.titleContainer); - if (!titleEl) { - titleEl = document.createElement("div"); - titleEl.classList.add("title-container"); - this.el.prepend(titleEl); - } + if (!titleEl) { + titleEl = document.createElement("div"); + titleEl.classList.add("title-container"); + this.el.prepend(titleEl); + } - titleEl.innerHTML = ""; + titleEl.innerHTML = ""; - let title = this.titleTemplate( - this.searchResultsView.searchResults.getStart() + 1, - this.searchResultsView.searchResults.getEnd() + 1, - this.searchResultsView.searchResults.getNumFound() - ); + let title = this.titleTemplate( + this.searchResultsView.searchResults.getStart() + 1, + this.searchResultsView.searchResults.getEnd() + 1, + this.searchResultsView.searchResults.getNumFound() + ); - titleEl.insertAdjacentHTML("beforeend", title); + titleEl.insertAdjacentHTML("beforeend", title); + } catch (e) { + console.log("There was an error rendering the title:" + e); + } }, /** @@ -380,21 +513,27 @@ define([ * @since 2.22.0 */ setupSearch: function () { - // Get an array of all Filter models - let allFilters = []; - this.filterGroups = this.createFilterGroups(); - this.filterGroups.forEach((group) => { - allFilters = allFilters.concat(group.get("filters")?.models); - }); + try { + // Get an array of all Filter models + let allFilters = []; + this.filterGroups = this.createFilterGroups(); + this.filterGroups.forEach((group) => { + allFilters = allFilters.concat(group.get("filters")?.models); + }); - // Connect the filters to the search and search results - let connector = new FiltersSearchConnector({ filtersList: allFilters }); - this.connector = connector; - connector.startListening(); + // Connect the filters to the search and search results + let connector = new FiltersSearchConnector({ + filtersList: allFilters, + }); + this.connector = connector; + connector.startListening(); - this.createSearchResults(); + this.createSearchResults(); - this.createMap(); + this.createMap(); + } catch (e) { + console.log("There was an error setting up the search:" + e); + } }, /** @@ -409,19 +548,23 @@ define([ */ createFilterGroups: function (filterGroupsJSON = this.filterGroupsJSON) { try { - // Start an array for the FilterGroups and the individual Filter models - let filterGroups = []; - - // Iterate over each default FilterGroup in the app config and create a - // FilterGroup model - ( - filterGroupsJSON || MetacatUI.appModel.get("defaultFilterGroups") - ).forEach((filterGroupJSON) => { - // Create the FilterGroup model Add to the array - filterGroups.push(new FilterGroup(filterGroupJSON)); - }); - - return filterGroups; + try { + // Start an array for the FilterGroups and the individual Filter models + let filterGroups = []; + + // Iterate over each default FilterGroup in the app config and create a + // FilterGroup model + ( + filterGroupsJSON || MetacatUI.appModel.get("defaultFilterGroups") + ).forEach((filterGroupJSON) => { + // Create the FilterGroup model Add to the array + filterGroups.push(new FilterGroup(filterGroupJSON)); + }); + + return filterGroups; + } catch (e) { + console.error("Couldn't create Filter Groups in search. ", e); + } } catch (e) { console.error("Couldn't create Filter Groups in search. ", e); } @@ -432,32 +575,37 @@ define([ * @since 2.22.0 */ createMap: function () { - const mapOptions = Object.assign( - {}, - MetacatUI.appModel.get("catalogSearchMapOptions") || {} - ); - const map = new Map(mapOptions); - - const geohashLayer = map - .get("layers") - .findWhere({ isGeohashLayer: true }); - - // Connect the CesiumGeohash to the SolrResults - const connector = new GeohashSearchConnector({ - cesiumGeohash: geohashLayer, - searchResults: this.searchResultsView.searchResults, - }); - connector.startListening(); - this.geohashSearchConnector = connector; - - // Set the geohash level for the search - const searchFacet = this.searchResultsView.searchResults.facet; - const newLevel = "geohash_" + geohashLayer.get("level"); - if (Array.isArray(searchFacet)) searchFacet.push(newLevel); - else searchFacet = newLevel; - - // Create the Map model and view - this.mapView = new MapView({ model: map }); + try { + const mapOptions = Object.assign( + {}, + MetacatUI.appModel.get("catalogSearchMapOptions") || {} + ); + const map = new Map(mapOptions); + + const geohashLayer = map + .get("layers") + .findWhere({ isGeohashLayer: true }); + + // Connect the CesiumGeohash to the SolrResults + const connector = new GeohashSearchConnector({ + cesiumGeohash: geohashLayer, + searchResults: this.searchResultsView.searchResults, + }); + connector.startListening(); + this.geohashSearchConnector = connector; + + // Set the geohash level for the search + const searchFacet = this.searchResultsView.searchResults.facet; + const newLevel = "geohash_" + geohashLayer.get("level"); + if (Array.isArray(searchFacet)) searchFacet.push(newLevel); + else searchFacet = newLevel; + + // Create the Map model and view + this.mapView = new MapView({ model: map }); + } catch (e) { + console.error("Couldn't create map in search. ", e); + this.toggleMode("list"); + } }, /** @@ -465,88 +613,117 @@ define([ * @since 2.22.0 */ renderMap: function () { - // Add the map to the page and render it - this.$(this.mapContainer).empty().append(this.mapView.el); - this.mapView.render(); + try { + // Add the map to the page and render it + this.$(this.mapContainer).empty().append(this.mapView.el); + this.mapView.render(); + } catch (e) { + console.error("Couldn't render map in search. ", e); + this.toggleMode("list"); + } }, /** * Linked Data Object for appending the jsonld into the browser DOM * @since 2.22.0 - * */ + */ addLinkedData: function () { - // JSON Linked Data Object - let elJSON = { - "@context": { - "@vocab": "http://schema.org/", - }, - "@type": "DataCatalog", - }; - - // Find the MN info from the CN Node list - let members = MetacatUI.nodeModel.get("members"), - nodeModelObject; - - for (let i = 0; i < members.length; i++) { - if ( - members[i].identifier == - MetacatUI.nodeModel.get("currentMemberNode") - ) { - nodeModelObject = members[i]; - } - } - if (nodeModelObject) { - // "keywords": "", "provider": "", - let conditionalData = { - description: nodeModelObject.description, - identifier: nodeModelObject.identifier, - image: nodeModelObject.logo, - name: nodeModelObject.name, - url: nodeModelObject.url, + try { + // JSON Linked Data Object + let elJSON = { + "@context": { + "@vocab": "http://schema.org/", + }, + "@type": "DataCatalog", }; - $.extend(elJSON, conditionalData); - } - // Check if the jsonld already exists from the previous data view If not - // create a new script tag and append otherwise replace the text for the - // script - if (!document.getElementById("jsonld")) { - var el = document.createElement("script"); - el.type = "application/ld+json"; - el.id = "jsonld"; - el.text = JSON.stringify(elJSON); - document.querySelector("head").appendChild(el); - } else { - var script = document.getElementById("jsonld"); - script.text = JSON.stringify(elJSON); + // Find the MN info from the CN Node list + let members = MetacatUI.nodeModel.get("members"), + nodeModelObject; + + for (let i = 0; i < members.length; i++) { + if ( + members[i].identifier == + MetacatUI.nodeModel.get("currentMemberNode") + ) { + nodeModelObject = members[i]; + } + } + if (nodeModelObject) { + // "keywords": "", "provider": "", + let conditionalData = { + description: nodeModelObject.description, + identifier: nodeModelObject.identifier, + image: nodeModelObject.logo, + name: nodeModelObject.name, + url: nodeModelObject.url, + }; + $.extend(elJSON, conditionalData); + } + + // Check if the jsonld already exists from the previous data view If not + // create a new script tag and append otherwise replace the text for the + // script + if (!document.getElementById("jsonld")) { + var el = document.createElement("script"); + el.type = "application/ld+json"; + el.id = "jsonld"; + el.text = JSON.stringify(elJSON); + document.querySelector("head").appendChild(el); + } else { + var script = document.getElementById("jsonld"); + script.text = JSON.stringify(elJSON); + } + } catch (e) { + console.error("Couldn't add linked data to search. ", e); } }, /** * Toggles between map and list search mode + * @param {string} newMode - Optionally provide the desired new mode to + * switch to. If not provided, the opposite of the current mode will be + * used. * @since 2.22.0 */ - toggleMode: function () { - let classList = document.querySelector("body").classList; - - if (this.mode == "map") { - this.mode = "list"; - classList.remove("mapMode"); - classList.add("listMode"); - } else { - this.mode = "map"; - classList.remove("listMode"); - classList.add("mapMode"); + toggleMode: function (newMode) { + try { + let classList = document.querySelector("body").classList; + + // If the new mode is not provided, the new mode is the opposite of the + // current mode + newMode = newMode != "map" && newMode != "list" ? null : newMode; + newMode = newMode || (this.mode == "map" ? "list" : "map"); + + if (newMode == "list") { + this.mode = "list"; + classList.remove("mapMode"); + classList.add("listMode"); + } else { + this.mode = "map"; + classList.remove("listMode"); + classList.add("mapMode"); + } + } catch (e) { + console.error("Couldn't toggle search mode. ", e); } }, + /** + * Tasks to perform when the view is closed + * @since 2.22.0 + */ onClose: function () { - document - .querySelector("body") - .classList.remove(`catalog-search-body`, `${this.mode}Mode`); + try { + document + .querySelector("body") + .classList.remove(this.bodyClass, `${this.mode}Mode`); - // Remove the JSON-LD from the page - document.getElementById("jsonld")?.remove(); + // Remove the JSON-LD from the page + document.getElementById("jsonld")?.remove(); + } catch (e) { + console.error("Couldn't close search view. ", e); + } }, } ); From f1aaa5505f977788a76a7367a7b6fb4b3adc7f17 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 22 Mar 2023 14:33:24 -0400 Subject: [PATCH 21/79] Fix format & add JSdocs to CatalogSearch subviews Add some error handling as well Relates to #1520, #2106 --- src/js/views/search/CatalogSearchView.js | 2 +- src/js/views/search/SearchResultView.js | 896 +++++++++++------- src/js/views/search/SearchResultsPagerView.js | 410 ++++---- src/js/views/search/SearchResultsView.js | 364 +++---- src/js/views/search/SorterView.js | 46 +- 5 files changed, 997 insertions(+), 721 deletions(-) diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index eda1ba7aa..35174e3ca 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -297,7 +297,7 @@ define([ console.log( "There was an error setting up the CatalogSearchView:" + e ); - this.this.renderError(); + this.renderError(); } }, diff --git a/src/js/views/search/SearchResultView.js b/src/js/views/search/SearchResultView.js index 5e649ce5f..44a3c14f0 100644 --- a/src/js/views/search/SearchResultView.js +++ b/src/js/views/search/SearchResultView.js @@ -1,378 +1,544 @@ /*global define */ -define(['jquery', 'underscore', 'backbone', 'models/SolrResult', 'models/PackageModel', 'views/CitationView', 'text!templates/resultsItem.html'], - function($, _, Backbone, SolrResult, Package, CitationView, ResultItemTemplate) { - - 'use strict'; - - // SearchResult View - // -------------- - - // The DOM element for a SearchResult item... - var SearchResultView = Backbone.View.extend({ - tagName: 'div', - className: 'row-fluid result-row', - - // Cache the template function for a single item. - //template: _.template($('#result-template').html()), - template: _.template(ResultItemTemplate), - //Templates - metricStatTemplate: _.template( " <%=metricValue%> "), - loadingTemplate: `
-
-
-
-
-
`, - - // The DOM events specific to an item. - events: { - 'click .result-selection' : 'toggleSelected', - 'click .download' : 'download' - }, - - // The SearchResultView listens for changes to its model, re-rendering. Since there's - // a one-to-one correspondence between a **SolrResult** and a **SearchResultView** in this - // app, we set a direct reference on the model for convenience. - initialize: function (options) { - - if(typeof options !== "object"){ - var options = {} - } - - this.listenTo(this.model, 'change', this.render); - this.listenTo(this.model, 'reset', this.render); - //this.listenTo(this.model, 'destroy', this.remove); - //this.listenTo(this.model, 'visible', this.toggleVisible); - - if(typeof options.metricsModel !== "undefined") - this.metricsModel = options.metricsModel; - - - if(typeof options.className !== "undefined") { - this.className = options.className; - } - - if(typeof options.template !== "undefined") { - this.template = options.template; - } - }, - - // Re-render the citation of the result item. - render: function () { - - if( !this.model ){ - return; - } - - //Convert the model to JSON and create the result row from the template - var json = this.model.toJSON(); - - /* Add other attributes to the JSON to send to the template */ - //Determine if there is a prov trace - json.hasProv = this.model.hasProvTrace(); - - // Add flag to add in annotation icon in results item view if appropriate - json.hasAnnotations = json.sem_annotation && - json.sem_annotation.length && - json.sem_annotation.length > 0; - - json.showAnnotationIndicator = MetacatUI.appModel.get("showAnnotationIndicator"); - - //Find the member node object - json.memberNode = _.findWhere(MetacatUI.nodeModel.get("members"), {identifier: this.model.get("datasource")}); - - //Figure out if this objbect is a collection or portal - var isCollection = this.model.getType() == "collection" || this.model.getType() == "portal"; - - //Determine if this metadata doc documents any data files - if( Array.isArray(json.documents) && json.documents.length ){ - var dataFileIDs = _.without(json.documents, this.model.get("id"), this.model.get("seriesId"), this.model.get("resourceMap")); - json.numDataFiles = dataFileIDs.length; - json.dataFilesMessage = "This dataset contains " + json.numDataFiles + " data file"; - if(json.numDataFiles > 1){ - json.dataFilesMessage += "s"; - } - } - else{ - json.numDataFiles = 0; - json.dataFilesMessage = "This dataset doesn't contain any data files"; - } - - if( MetacatUI.appModel.get("displayRepoLogosInSearchResults") ){ - //If this result has a logo and it is not a URL, assume it is an ID and create a full URL - if( json.logo && !json.logo.startsWith("http") ){ - json.logo = MetacatUI.appModel.get("objectServiceUrl") + json.logo; +define([ + "jquery", + "underscore", + "backbone", + "models/PackageModel", + "views/CitationView", + "text!templates/resultsItem.html", +], function ($, _, Backbone, Package, CitationView, ResultItemTemplate) { + "use strict"; + + // SearchResult View + // -------------- + + // The DOM element for a SearchResult item... + return Backbone.View.extend( + /** + * @lends SearchResultView.prototype + */ { + /** + * The tag name for the view's element + * @type {string} + */ + tagName: "div", + + /** + * The HTML classes to use for this view's element + * @type {string} + */ + className: "row-fluid result-row", + + /** + * The HTML template to use for this view's main element + * @type {underscore.template} + */ + template: _.template(ResultItemTemplate), + + /** + * The HTML template to use for the metrics statics + * @type {underscore.template} + */ + metricStatTemplate: _.template( + ` + <%=metricValue%> + ` + ), + + /** + * The HTML template to use when showing that a result item is loading + * @type {string} + */ + loadingTemplate: ` +
+
+
+
+
+
+
+
+
`, + + /** + * The events this view will listen to and the associated function to + * call. + * @type {Object} + */ + events: { + "click .result-selection": "toggleSelected", + "click .download": "download", + }, + + /** + * Initialize the view. The SearchResultView listens for changes to its + * model, re-rendering. Since there's a one-to-one correspondence between + * a **SolrResult** and a **SearchResultView** in this app, we set a + * direct reference on the model for convenience. + * @param {*} options + */ + initialize: function (options) { + if (typeof options !== "object") { + var options = {}; } - var datasourceId = json.memberNode? json.memberNode.identifier : json.datasource, - currentMemberNode = MetacatUI.appModel.get("nodeId") || datasourceId; + this.stopListening(this.model, "change", this.render); + this.stopListening(this.model, "reset", this.render); + this.listenTo(this.model, "change", this.render); + this.listenTo(this.model, "reset", this.render); + // this.listenTo(this.model, 'destroy', this.remove); + // this.listenTo(this.model, 'visible', this.toggleVisible); + + if (typeof options.metricsModel !== "undefined") + this.metricsModel = options.metricsModel; - //Construct a URL to the profile of this repository - json.profileURL = (datasourceId == currentMemberNode)? - MetacatUI.root + "/profile" : - MetacatUI.appModel.get("dataoneSearchUrl") + "/portals/" + datasourceId.replace("urn:node:", ""); + if (typeof options.className !== "undefined") { + this.className = options.className; + } + + if (typeof options.template !== "undefined") { + this.template = options.template; + } + }, + + /** + * Render or re-render the result item. + * @returns {SearchResultView} Returns this view + */ + render: function () { + if (!this.model) { + return; + } - } + // Convert the model to JSON and create the result row from the template + var json = this.model.toJSON(); + + // Add other attributes to the JSON to send to the template + + // Determine if there is a prov trace + json.hasProv = this.model.hasProvTrace(); + + // Add flag to add in annotation icon in results item view if + // appropriate + json.hasAnnotations = + json.sem_annotation && + json.sem_annotation.length && + json.sem_annotation.length > 0; + + json.showAnnotationIndicator = MetacatUI.appModel.get( + "showAnnotationIndicator" + ); + + // Find the member node object + json.memberNode = _.findWhere(MetacatUI.nodeModel.get("members"), { + identifier: this.model.get("datasource"), + }); + + // Figure out if this object is a collection or portal + var isCollection = + this.model.getType() == "collection" || + this.model.getType() == "portal"; + + // Determine if this metadata doc documents any data files + if (Array.isArray(json.documents) && json.documents.length) { + var dataFileIDs = _.without( + json.documents, + this.model.get("id"), + this.model.get("seriesId"), + this.model.get("resourceMap") + ); + json.numDataFiles = dataFileIDs.length; + json.dataFilesMessage = + "This dataset contains " + json.numDataFiles + " data file"; + if (json.numDataFiles > 1) { + json.dataFilesMessage += "s"; + } + } else { + json.numDataFiles = 0; + json.dataFilesMessage = "This dataset doesn't contain any data files"; + } - //Create a URL that leads to a view of this object - json.viewURL = this.model.createViewURL(); + if (MetacatUI.appModel.get("displayRepoLogosInSearchResults")) { + // If this result has a logo and it is not a URL, assume it is an ID + // and create a full URL + if (json.logo && !json.logo.startsWith("http")) { + json.logo = MetacatUI.appModel.get("objectServiceUrl") + json.logo; + } + + var datasourceId = json.memberNode + ? json.memberNode.identifier + : json.datasource, + currentMemberNode = + MetacatUI.appModel.get("nodeId") || datasourceId; + + // Construct a URL to the profile of this repository + json.profileURL = + datasourceId == currentMemberNode + ? MetacatUI.root + "/profile" + : MetacatUI.appModel.get("dataoneSearchUrl") + + "/portals/" + + datasourceId.replace("urn:node:", ""); + } - var resultRow = this.template(json); - this.$el.html(resultRow); + //Create a URL that leads to a view of this object + json.viewURL = this.model.createViewURL(); + + var resultRow = this.template(json); + this.$el.html(resultRow); + + //Create the citation + var citation = new CitationView({ metadata: this.model }).render().el; + var placeholder = this.$(".citation"); + if (placeholder.length < 1) this.$el.append(citation); + else $(placeholder).replaceWith(citation); + + //Create the OpenURL COinS + var span = this.getOpenURLCOinS(); + this.$el.append(span); + + if ( + MetacatUI.appModel.get("displayDatasetMetrics") && + this.metricsModel + ) { + if (this.metricsModel.get("views") !== null) { + // Display metrics if the model has already been fetched + this.displayMetrics(); + } else if (this.metricsModel) { + // waiting for the fetch() call to succeed. + this.listenTo(this.metricsModel, "sync", this.displayMetrics); + } + } - //Create the citation - var citation = new CitationView({metadata: this.model}).render().el; - var placeholder = this.$(".citation"); - if(placeholder.length < 1) this.$el.append(citation); - else $(placeholder).replaceWith(citation); + if (isCollection) { + this.$el.addClass("collection"); + } - //Create the OpenURL COinS - var span = this.getOpenURLCOinS(); - this.$el.append(span); + //Save the id in the DOM for later use + var id = json.id; + this.$el.attr("data-id", id); + + if (this.model.get("abstract")) { + var abridgedAbstract = + this.model.get("abstract").indexOf(" ", 250) < 0 + ? this.model.get("abstract") + : this.model + .get("abstract") + .substring(0, this.model.get("abstract").indexOf(" ", 250)) + + "..."; + var content = $(document.createElement("div")).append( + $(document.createElement("p")).text(abridgedAbstract) + ); + + this.$(".popover-this.abstract").popover({ + trigger: "hover", + html: true, + content: content, + title: "Abstract", + placement: "top", + container: this.el, + }); + } else { + this.$(".popover-this.abstract").addClass("inactive"); + this.$(".icon.abstract").addClass("inactive"); + } - if( MetacatUI.appModel.get("displayDatasetMetrics") && this.metricsModel ){ - if (this.metricsModel.get("views") !== null) { - // Display metrics if the model has already been fetched - this.displayMetrics(); + return this; + }, + + displayMetrics: function () { + try { + // If metrics for this object should be hidden, exit the function + if (this.model.hideMetrics()) { + return; + } + + var datasets = this.metricsModel.get("datasets"); + var downloads = this.metricsModel.get("downloads"); + var views = this.metricsModel.get("views"); + var citations = this.metricsModel.get("citations"); + + // Initializing the metric counts + var viewCount = 0; + var downloadCount = 0; + var citationCount = 0; + + // Get the individual dataset metics only if the response from Metrics + // Service API has non-zero array sizes + if (datasets && datasets.length > 0) { + var index = datasets.indexOf(this.model.get("id")); + viewCount = views[index]; + downloadCount = downloads[index]; + citationCount = citations[index]; + } + + // Generating tool-tip title Citations + if (citationCount == 1) { + var citationToolTip = citationCount + " citation"; + } else { + var citationToolTip = + MetacatUI.appView.numberAbbreviator(citationCount, 1) + + " citations"; + } + + // Downloads + if (downloadCount == 1) { + var downloadToolTip = downloadCount + " download"; + } else { + var downloadToolTip = + MetacatUI.appView.numberAbbreviator(downloadCount, 1) + + " downloads"; + } + + // Views + if (viewCount == 1) { + var viewToolTip = viewCount + " view"; + } else { + var viewToolTip = + MetacatUI.appView.numberAbbreviator(viewCount, 1) + " views"; + } + + // Replacing the metric total count with the spinning icon. + this.$(".resultItem-CitationCount") + .html( + this.metricStatTemplate({ + metricValue: MetacatUI.appView.numberAbbreviator( + citationCount, + 1 + ), + metricIcon: "icon-quote-right", + }) + ) + .tooltip({ + placement: "top", + trigger: "hover", + delay: 800, + container: this.el, + title: citationToolTip, + }); + + this.$(".resultItem-DownloadCount") + .html( + this.metricStatTemplate({ + metricValue: MetacatUI.appView.numberAbbreviator( + downloadCount, + 1 + ), + metricIcon: "icon-cloud-download", + }) + ) + .tooltip({ + placement: "top", + trigger: "hover", + delay: 800, + container: this.el, + title: downloadToolTip, + }); + + this.$(".resultItem-ViewCount") + .html( + this.metricStatTemplate({ + metricValue: MetacatUI.appView.numberAbbreviator(viewCount, 1), + metricIcon: "icon-eye-open", + }) + ) + .tooltip({ + placement: "top", + trigger: "hover", + delay: 800, + container: this.el, + title: viewToolTip, + }); + + // Removing Citation metric if the citationCount is 0 + if (citationCount === 0) { + this.$(".resultItem-CitationCount").css("visibility", "hidden"); + } + + // Removing Download metric if the downloadCount is 0 + if (downloadCount === 0) { + this.$(".resultItem-DownloadCount").css("visibility", "hidden"); + } + + // Removing View metric if the viewCount is 0 + if (viewCount === 0) { + this.$(".resultItem-ViewCount").css("visibility", "hidden"); + } + } catch (e) { + console.log("Error displaying metrics: " + e); } - else if( this.metricsModel ) { - // waiting for the fetch() call to succeed. - this.listenTo(this.metricsModel, "sync", this.displayMetrics); + }, + + /** + * Toggles the selected state of the model + */ + toggleSelected: function () { + this.model.toggle(); + }, + + /** + * Navigates the app to the metadata page for a result item that was + * clicked on + * @param {*} e - The click event + */ + routeToMetadata: function (e) { + var id = this.model.get("id"); + + //If the user clicked on a download button or any element with the class + //'stop-route', we don't want to navigate to the metadata + if ( + $(e.target).hasClass("stop-route") || + typeof id === "undefined" || + !id + ) + return; + + MetacatUI.uiRouter.navigate("view/" + encodeURIComponent(id), { + trigger: true, + }); + }, + + /** + * Downloads the data package for a result item that was clicked on + * @param {*} e - The click event + */ + download: function (e) { + if ( + MetacatUI.appUserModel.get("loggedIn") && + !this.model.get("isPublic") + ) { + if (e) { + e.preventDefault(); + var packageId = + $(e.target).attr("data-id") || this.model.get("resourceMap"); + } else var packageId = this.model.get("resourceMap"); + + var fileName = this.model.get("fileName") || this.model.get("title"); + + // Download the entire package if there is one + if (packageId) { + // If there is more than one resource map, download all of them + if (Array.isArray(packageId)) { + for (var i = 0; i < packageId.length; i++) { + var pkgFileName = fileName || "Dataset_" + (i + 1); + + //Take off the file extension part of the file name + if (pkgFileName.lastIndexOf(".") > 0) + pkgFileName = pkgFileName.substring( + 0, + pkgFileName.lastIndexOf(".") + ); + + var packageModel = new Package({ + id: packageId[i], + fileName: pkgFileName + ".zip", + }); + packageModel.downloadWithCredentials(); + } + } else { + // Take off the file extension part of the file name + if (fileName.lastIndexOf(".") > 0) + fileName = fileName.substring(0, fileName.lastIndexOf(".")); + + // Create a model to represent the package + var packageModel = new Package({ + id: packageId, + fileName: fileName + ".zip", + }); + packageModel.downloadWithCredentials(); + } + } + // Otherwise just download this solo object + else { + this.model.downloadWithCredentials(); + } + } else return true; + }, + + /** + * Create ContextObjects in Spans (COinS) for the item. COinS is a method + * to embed bibliographic metadata in the HTML code of web pages. This + * allows bibliographic software to publish machine-readable bibliographic + * items and client reference management software to retrieve + * bibliographic metadata. (from wikipedia) + * @returns {HTMLSpanElement} - The span element containing the COinS data + */ + getOpenURLCOinS: function () { + try { + // Create the OpenURL COinS + var spanTitle = + "ctx_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:dc&rfr_id=info:sid/ocoins.info:generator&rft.type=Dataset"; + + if (this.model.get("title")) + spanTitle += "&rft.title=" + this.model.get("title"); + if (this.model.get("origin")) + spanTitle += "&rft.creator=" + this.model.get("origin"); + if (this.model.get("keywords")) + spanTitle += "&rft.subject=" + this.model.get("keywords"); + if (this.model.get("abstract")) + spanTitle += "&rft.description=" + this.model.get("abstract"); + if (this.model.get("datasource")) + spanTitle += "&rft.publisher=" + this.model.get("datasource"); + if (this.model.get("endDate")) + spanTitle += "&rft.date=" + this.model.get("endDate"); + if (this.model.get("formatID")) + spanTitle += "&rft.format=" + this.model.get("formatID"); + if (this.model.get("id")) + spanTitle += "&rft.identifier=" + this.model.get("id"); + if (this.model.get("url")) + spanTitle += "&rft.source=" + this.model.get("url"); + if (this.model.get("northBoundCoord")) { + spanTitle += + "&rft.coverage=POLYGON((" + + this.model.get("southBoundCoord") + + " " + + this.model.get("westBoundCoord") + + ", " + + this.model.get("northBoundCoord") + + " " + + this.model.get("westBoundCoord") + + ", " + + this.model.get("northBoundCoord") + + " " + + this.model.get("eastBoundCoord") + + ", " + + this.model.get("southBoundCoord") + + " " + + this.model.get("eastBoundCoord") + + "))"; + } + + spanTitle = encodeURI(spanTitle); + + return $(document.createElement("span")) + .attr("title", spanTitle) + .addClass("Z3988"); + } catch (e) { + console.log("Error creating COinS: " + e); + return $(document.createElement("span")); } - } - - if( isCollection ){ - this.$el.addClass("collection"); - } - - //Save the id in the DOM for later use - var id = json.id; - this.$el.attr("data-id", id); - - if(this.model.get("abstract")){ - var abridgedAbstract = (this.model.get("abstract").indexOf(" ", 250) < 0) ? this.model.get("abstract") : this.model.get("abstract").substring(0, this.model.get("abstract").indexOf(" ", 250)) + "..."; - var content = $(document.createElement("div")) - .append($(document.createElement("p")).text(abridgedAbstract)); - - this.$(".popover-this.abstract").popover({ - trigger: "hover", - html: true, - content: content, - title: "Abstract", - placement: "top", - container: this.el - }); - } - else{ - this.$(".popover-this.abstract").addClass("inactive"); - this.$(".icon.abstract").addClass("inactive"); - } - - return this; - }, - - displayMetrics: function() { - - - //If metrics for this object should be hidden, exit the function - if( this.model.hideMetrics() ) { - return; - } - - var datasets = this.metricsModel.get("datasets"); - var downloads = this.metricsModel.get("downloads"); - var views = this.metricsModel.get("views"); - var citations = this.metricsModel.get("citations"); - - // Initializing the metric counts - var viewCount = 0; - var downloadCount = 0; - var citationCount = 0; - - // Get the individual dataset metics only if the response from Metrics Service API - // has non-zero array sizes - if(datasets && datasets.length > 0) { - var index = datasets.indexOf(this.model.get("id")); - viewCount = views[index]; - downloadCount = downloads[index]; - citationCount = citations[index]; - } - - // Generating tool-tip title - // Citations - if(citationCount == 1){ - var citationToolTip = citationCount + " citation"; - } - else { - var citationToolTip = MetacatUI.appView.numberAbbreviator(citationCount,1) + " citations"; - } - - // Downloads - if(downloadCount == 1){ - var downloadToolTip = downloadCount + " download"; - } - else { - var downloadToolTip = MetacatUI.appView.numberAbbreviator(downloadCount,1) + " downloads"; - } - - // Views - if(viewCount == 1){ - var viewToolTip = viewCount + " view"; - } - else { - var viewToolTip = MetacatUI.appView.numberAbbreviator(viewCount,1) + " views"; - } - - // Replacing the metric total count with the spinning icon. - this.$('.resultItem-CitationCount').html(this.metricStatTemplate({metricValue:MetacatUI.appView.numberAbbreviator(citationCount,1), metricIcon:'icon-quote-right'})) - .tooltip({ - placement: "top", - trigger: "hover", - delay: 800, - container: this.el, - title: citationToolTip - }); - - this.$('.resultItem-DownloadCount').html(this.metricStatTemplate({metricValue:MetacatUI.appView.numberAbbreviator(downloadCount,1), metricIcon:'icon-cloud-download'})) - .tooltip({ - placement: "top", - trigger: "hover", - delay: 800, - container: this.el, - title: downloadToolTip - }); - - this.$('.resultItem-ViewCount').html(this.metricStatTemplate({metricValue:MetacatUI.appView.numberAbbreviator(viewCount,1), metricIcon:'icon-eye-open'})) - .tooltip({ - placement: "top", - trigger: "hover", - delay: 800, - container: this.el, - title: viewToolTip - }); - - - // Removing Citation metric if the citationCount is 0 - if (citationCount === 0) { - this.$('.resultItem-CitationCount').css("visibility","hidden"); - } - - // Removing Download metric if the downloadCount is 0 - if (downloadCount === 0) { - this.$('.resultItem-DownloadCount').css("visibility","hidden"); - } - - // Removing View metric if the viewCount is 0 - if (viewCount === 0) { - this.$('.resultItem-ViewCount').css("visibility","hidden"); - } - }, - - // Toggle the `"selected"` state of the model. - toggleSelected: function () { - this.model.toggle(); - }, - - routeToMetadata: function(e){ - var id = this.model.get("id"); - - //If the user clicked on a download button or any element with the class 'stop-route', we don't want to navigate to the metadata - if ($(e.target).hasClass('stop-route') || (typeof id === "undefined") || !id) - return; - - MetacatUI.uiRouter.navigate('view/' + encodeURIComponent(id), {trigger: true}); - }, - - download: function(e){ - if(MetacatUI.appUserModel.get("loggedIn") && !this.model.get("isPublic")){ - if(e){ - e.preventDefault(); - var packageId = $(e.target).attr("data-id") || this.model.get("resourceMap"); - } - else - var packageId = this.model.get("resourceMap"); - - var fileName = this.model.get("fileName") || this.model.get("title"); - - //Download the entire package if there is one - if(packageId){ - - //If there is more than one resource map, download all of them - if(Array.isArray(packageId)){ - for(var i = 0; i 0) - pkgFileName = pkgFileName.substring(0, pkgFileName.lastIndexOf(".")); - - var packageModel = new Package({ - id: packageId[i], - fileName: pkgFileName + ".zip" - }); - packageModel.downloadWithCredentials(); - } - } - else{ - //Take off the file extension part of the file name - if(fileName.lastIndexOf(".") > 0) - fileName = fileName.substring(0, fileName.lastIndexOf(".")); - - //Create a model to represent the package - var packageModel = new Package({ - id: packageId, - fileName: fileName + ".zip" - }); - packageModel.downloadWithCredentials(); - } - } - //Otherwise just download this solo object - else{ - this.model.downloadWithCredentials(); - } - } - else - return true; - }, - - getOpenURLCOinS: function(){ - //Create the OpenURL COinS - var spanTitle = "ctx_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:dc&rfr_id=info:sid/ocoins.info:generator&rft.type=Dataset"; - - if(this.model.get("title")) spanTitle += "&rft.title=" + this.model.get("title"); - if(this.model.get("origin")) spanTitle += "&rft.creator=" + this.model.get("origin"); - if(this.model.get("keywords")) spanTitle += "&rft.subject=" + this.model.get("keywords"); - if(this.model.get("abstract")) spanTitle += "&rft.description=" + this.model.get("abstract"); - if(this.model.get("datasource")) spanTitle += "&rft.publisher=" + this.model.get("datasource"); - if(this.model.get("endDate")) spanTitle += "&rft.date=" + this.model.get("endDate"); - if(this.model.get("formatID")) spanTitle += "&rft.format=" + this.model.get("formatID"); - if(this.model.get("id")) spanTitle += "&rft.identifier=" + this.model.get("id"); - if(this.model.get("url")) spanTitle += "&rft.source=" + this.model.get("url"); - if(this.model.get("northBoundCoord")){ - spanTitle += "&rft.coverage=POLYGON((" + this.model.get("southBoundCoord") + " " + this.model.get("westBoundCoord") + ", " + - this.model.get("northBoundCoord") + " " + this.model.get("westBoundCoord") + ", " + - this.model.get("northBoundCoord") + " " + this.model.get("eastBoundCoord") + ", " + - this.model.get("southBoundCoord") + " " + this.model.get("eastBoundCoord") + "))"; - } - - spanTitle = encodeURI(spanTitle); - - return $(document.createElement("span")).attr("title", spanTitle).addClass("Z3988"); - }, - - loading: function(){ - this.$el.html(this.loadingTemplate); - }, - - // Remove the item, destroy the model from *localStorage* and delete its view. - clear: function () { - this.model.destroy(); - }, - - onClose: function(){ - this.clear(); - } - }); - return SearchResultView; + }, + + /** + * Show the loading view + */ + loading: function () { + this.$el.html(this.loadingTemplate); + }, + + /** + * Remove the item, destroy the model from *localStorage* and delete its + * view. + */ + clear: function () { + this.model.destroy(); + }, + + /** + * Functions to perform when the view is closed + */ + onClose: function () { + this.clear(); + }, + } + ); }); diff --git a/src/js/views/search/SearchResultsPagerView.js b/src/js/views/search/SearchResultsPagerView.js index 4c1026da8..12376bbc0 100644 --- a/src/js/views/search/SearchResultsPagerView.js +++ b/src/js/views/search/SearchResultsPagerView.js @@ -1,182 +1,258 @@ /*global define */ -define(["backbone"], -function(Backbone){ - - "use strict"; - - /** - * @class SearchResultsPagerView - * @name SearchResultsPagerView - * @classcategory Views/Search - * @extends Backbone.View - * @description Renders a simple pager element for a SolrResults collection. - * @constructor - * @since 2.22.0 - */ - return Backbone.View.extend( +define(["backbone"], function (Backbone) { + "use strict"; + + /** + * @class SearchResultsPagerView + * @name SearchResultsPagerView + * @classcategory Views/Search + * @extends Backbone.View + * @description Renders a simple pager element for a SolrResults collection. + * @constructor + * @since 2.22.0 + * TODO: Add screenshot + */ + return Backbone.View.extend( /** @lends SearchResultsPagerView.prototype */ { + /** + * The classes to use for this view's element + * @type {string} + */ + className: + `pager-view search-results-pager-view pagination ` + + `pagination-centered resultspager`, - className: "pager-view search-results-pager-view pagination pagination-centered resultspager", - - tagName: "nav", - - template: `
    -
  • -
  • -
  • -
  • ...
  • -
  • -
`, - - /** - * Constructs and returns a URL string to use for the given page in this pager. It assumes that the URL uses - * a ".../page/X" structure. To provide a custom URL, override this function. - * @param {number|string} page - * @returns {string} - */ - url: function(page){ - if(typeof page !== "number"){ - try{ - page=parseInt(page); - } - catch(e){ - console.error(e); - return ""; - } - finally{ - if(isNaN(page)){ - return ""; - } - } - } + /** + * The HTML tag to use for this view's element + * @type {string} + */ + tagName: "nav", - if( window.location.pathname.includes("page") ){ - return window.location.pathname.replace(/\/page\/\d+/, "/page/" + (page+1)); - } - else{ - if(window.location.pathname.endsWith("/")) - return window.location.pathname + "page/" + (page+1); - else - return window.location.pathname + "/page/" + (page+1); - } - }, - - /** - * Constructs and returns the HTML template string for a single page link in the pager - * @type {function} - * @param {object} data - * @returns {string} - */ - linkTemplate: function(data={ - page: 0, - pageDisplay: "", - className: "" - }){ - let href = `${this.url(data.page)}`; - if(href.length) href= `href="${href}"`; - return `
  • ${data.pageDisplay}
  • ` - }, - - /** - * A SolrResults collection that contains the page data that this Pager displays. - * @type SolrResults - */ - searchResults: null, - - /** - * An object literal of events to listen to on this view - * @type {object} - */ - events: { - "click a" : "handleClick" - }, - - /** - * Renders the Pager View - */ - render: function(){ - this.loading(); - this.el.innerHTML = this.template; - - if(this.searchResults){ - this.renderPages(); - this.listenTo(this.searchResults, "reset", this.renderPages); + /** + * The HTML to display when no search results are found. This will be + * updated by the view. + * @type {string} + */ + template: ` +
      +
    • +
    • +
    • +
    • ...
    • +
    • +
    `, + + /** + * Constructs and returns a URL string to use for the given page in this + * pager. It assumes that the URL uses a ".../page/X" structure. To + * provide a custom URL, override this function. + * @param {number|string} page + * @returns {string} + */ + url: function (page) { + if (typeof page !== "number") { + try { + page = parseInt(page); + } catch (e) { + console.error(e); + return ""; + } finally { + if (isNaN(page)) { + return ""; } - }, - - /** - * Render the page numbers and links. - */ - renderPages: function(){ - //Only show pages if the search results have been retrieved (by checking for the header property which is set during parse()) - if(this.searchResults?.header){ - this.removeLoading(); - - let container = this.el.querySelector("ul"), - lastPage = this.searchResults.getNumPages(), - firstPage = 0, - currentPage = this.searchResults.getCurrentPage(); - - //Empty the pager container - container.innerHTML = ""; - - //Show prev button and the first page number - if(currentPage > 0){ - container.insertAdjacentHTML("afterbegin", this.linkTemplate({ page: currentPage-1, pageDisplay: "<"})); - container.insertAdjacentHTML("beforeend", this.linkTemplate({ page: 0, pageDisplay: 1})); - - //If there are pages between the first page and the current-2, then show an ellipsis - if(currentPage-2 > firstPage){ - container.insertAdjacentHTML("beforeend", this.linkTemplate({ page: "", pageDisplay: "...", className: "inactive"})); - } - } - - //Show the current page plus two on each side - let pages = [currentPage-2, currentPage-1, currentPage, currentPage+1, currentPage+2]; - for(let page of pages){ - if((page > firstPage && page < lastPage) || page==currentPage){ - container.insertAdjacentHTML("beforeend", this.linkTemplate({ page: page, pageDisplay: page+1, className: page==currentPage? "active" : ""})); - } - } - - //Show next button and the last page number - if(currentPage < lastPage){ - //If there are pages between the last page and the current-2, then show an ellipsis - if(currentPage+2 < lastPage){ - container.insertAdjacentHTML("beforeend", this.linkTemplate({ page: "", pageDisplay: "...", className: "inactive" })); - } - - container.insertAdjacentHTML("beforeend", this.linkTemplate({ page: lastPage, pageDisplay: lastPage+1 })); - container.insertAdjacentHTML("beforeend", this.linkTemplate({ page: currentPage+1, pageDisplay: ">" })); - } + } + } + + if (window.location.pathname.includes("page")) { + return window.location.pathname.replace( + /\/page\/\d+/, + "/page/" + (page + 1) + ); + } else { + if (window.location.pathname.endsWith("/")) + return window.location.pathname + "page/" + (page + 1); + else return window.location.pathname + "/page/" + (page + 1); + } + }, + + /** + * Constructs and returns the HTML template string for a single page link + * in the pager + * @type {function} + * @param {object} data + * @returns {string} + */ + linkTemplate: function ( + data = { + page: 0, + pageDisplay: "", + className: "", + } + ) { + // Expand the data object into individual variables + let { page, pageDisplay, className } = data; + let href = `${this.url(data.page)}`; + if (href.length) href = `href="${href}"`; + return ` +
  • + + ${pageDisplay} + +
  • `; + }, + + /** + * A SolrResults collection that contains the page data that this Pager + * displays. + * @type {SolrResults} + */ + searchResults: null, + + /** + * An object literal of events to listen to on this view + * @type {object} + */ + events: { + "click a": "handleClick", + }, + /** + * Renders the Pager View + */ + render: function () { + this.loading(); + this.el.innerHTML = this.template; + + if (this.searchResults) { + this.renderPages(); + this.listenTo(this.searchResults, "reset", this.renderPages); + } + }, + + /** + * Render the page numbers and links. + */ + renderPages: function () { + // Only show pages if the search results have been retrieved (by + // checking for the header property which is set during parse()) + if (this.searchResults?.header) { + this.removeLoading(); + + let container = this.el.querySelector("ul"), + lastPage = this.searchResults.getNumPages(), + firstPage = 0, + currentPage = this.searchResults.getCurrentPage(); + + //Empty the pager container + container.innerHTML = ""; + + //Show prev button and the first page number + if (currentPage > 0) { + container.insertAdjacentHTML( + "afterbegin", + this.linkTemplate({ page: currentPage - 1, pageDisplay: "<" }) + ); + container.insertAdjacentHTML( + "beforeend", + this.linkTemplate({ page: 0, pageDisplay: 1 }) + ); + + //If there are pages between the first page and the current-2, then + //show an ellipsis + if (currentPage - 2 > firstPage) { + container.insertAdjacentHTML( + "beforeend", + this.linkTemplate({ + page: "", + pageDisplay: "...", + className: "inactive", + }) + ); } - }, + } - handleClick: function(evt){ - // Don't hijack the event if the user had Control or Command held down - if (evt.ctrlKey || evt.metaKey) { - return; + //Show the current page plus two on each side + let pages = [ + currentPage - 2, + currentPage - 1, + currentPage, + currentPage + 1, + currentPage + 2, + ]; + for (let page of pages) { + if ((page > firstPage && page < lastPage) || page == currentPage) { + container.insertAdjacentHTML( + "beforeend", + this.linkTemplate({ + page: page, + pageDisplay: page + 1, + className: page == currentPage ? "active" : "", + }) + ); } + } - evt.preventDefault(); - evt.stopPropagation(); - let page = evt.target.getAttribute("data-page"); - if(this.searchResults){ - this.searchResults.toPage(page); - MetacatUI.appModel.set("page", page); - console.log("nav to ", this.url(page)); - MetacatUI.uiRouter.navigate(this.url(page), {trigger: false}); + //Show next button and the last page number + if (currentPage < lastPage) { + //If there are pages between the last page and the current-2, then + //show an ellipsis + if (currentPage + 2 < lastPage) { + container.insertAdjacentHTML( + "beforeend", + this.linkTemplate({ + page: "", + pageDisplay: "...", + className: "inactive", + }) + ); } - }, - loading: function(){ - this.el.classList.add("loading"); - }, + container.insertAdjacentHTML( + "beforeend", + this.linkTemplate({ page: lastPage, pageDisplay: lastPage + 1 }) + ); + container.insertAdjacentHTML( + "beforeend", + this.linkTemplate({ page: currentPage + 1, pageDisplay: ">" }) + ); + } + } + }, - removeLoading: function(){ - this.el.classList.remove("loading"); + /** + * Handles clicks on the pager links + * @param {Event} evt + */ + handleClick: function (evt) { + // Don't hijack the event if the user had Control or Command held down + if (evt.ctrlKey || evt.metaKey) { + return; } - }); -}); \ No newline at end of file + evt.preventDefault(); + evt.stopPropagation(); + let page = evt.target.getAttribute("data-page"); + if (this.searchResults) { + this.searchResults.toPage(page); + MetacatUI.appModel.set("page", page); + console.log("nav to ", this.url(page)); + MetacatUI.uiRouter.navigate(this.url(page), { trigger: false }); + } + }, + + /** + * Shows the loading version of the pager + */ + loading: function () { + this.el.classList.add("loading"); + }, + + /** + * Removes the loading version of the pager + */ + removeLoading: function () { + this.el.classList.remove("loading"); + }, + } + ); +}); diff --git a/src/js/views/search/SearchResultsView.js b/src/js/views/search/SearchResultsView.js index 983c44b26..0bfbbe5dc 100644 --- a/src/js/views/search/SearchResultsView.js +++ b/src/js/views/search/SearchResultsView.js @@ -1,178 +1,192 @@ /*global define */ -define(["backbone", - "collections/SolrResults", - "views/search/SearchResultView" - ], -function(Backbone, SearchResults, SearchResultView){ - - "use strict"; - - /** - * @class SearchResultsView - * @name SearchResultsView - * @classcategory Views/Search - * @extends Backbone.View - * @since 2.22.0 - * @constructor - */ - return Backbone.View.extend( - /** @lends SearchResultsView.prototype */ { - - /** - * The type of View this is - * @type {string} - */ - type: "SearchResults", - - /** - * The HTML tag to use for this view's element - * @type {string} - */ - tagName: "div", - - /** - * The HTML classes to use for this view's element - * @type {string} - */ - className: "search-results-view", - - /** - * The events this view will listen to and the associated function to call. - * @type {Object} - */ - events: { - }, - +define([ + "backbone", + "collections/SolrResults", + "views/search/SearchResultView", +], function (Backbone, SearchResults, SearchResultView) { + "use strict"; + + /** + * @class SearchResultsView + * @name SearchResultsView + * @classcategory Views/Search + * @extends Backbone.View + * @since 2.22.0 + * @constructor + * TODO: Add screenshot and description + */ + return Backbone.View.extend( /** - * The SolrResults collection that fetches and parses the searches. - * @type {SolrResults} - */ - searchResults: null, - - /** - * The HTML to display when no search results are found. - * @since 2.22.0 - * @type {string} - */ - noResultsTemplate: `
    No results found.
    `, - - render: function(){ - - if( !this.searchResults ){ - this.setSearchResults(); - } - - this.loading(); - - this.addResultCollection(); - - this.startListening(); - - }, - - /** - * Sets listeners on the {@link SearchResultsView#searchResults} to change what is displayed in this view. - */ - startListening: function(){ - this.listenTo(this.searchResults, "add", this.addResultModel); - this.listenTo(this.searchResults, "reset", this.addResultCollection); - this.listenTo(this.searchResults, "request", this.loading); - }, - - /** - * Creates and sets the {@link SearchResultsView#searchResults} property. - * @returns {SolrResults} - */ - setSearchResults: function(){ - this.searchResults = new SearchResults(); - return this.searchResults; - }, - - /** - * Renders the given {@link SolrResult} model inside this view. - * @param {SolrResult} searchResult - */ - addResultModel: function(searchResult){ - try{ - let view = this.createSearchResultView(); - view.model = searchResult; - this.addResultView(view); - } - catch(e){ - console.error("Failed to add a search result to the page: ", e); - } - }, - - /** - * Renders all {@link SolrResult}s from the {@link SearchResultsView#searchResults} collection. - */ - addResultCollection: function(){ - if( !this.searchResults ) - return; - else if( this.searchResults?.header?.get("numFound") == 0){ - this.showNoResults(); - return; - } - - this.empty(); - - this.searchResults.models.forEach( result => { - this.addResultModel(result); - }); - }, - - /** - * Adds a Search Result View to the page - * @param {SearchResultView} view - */ - addResultView: function(view) { - this.el.append(view.el); - view.render(); - }, - - /** - * Creates a Search Result View - */ - createSearchResultView: function(){ - return new SearchResultView(); - }, - - /** - * Shows a message when no search results have been found. - */ - showNoResults: function(){ - - this.empty(); - - this.el.replaceChildren(this.noResultsTemplate); - - }, - - empty: function(){ - this.el.innerHTML = ""; - }, - - /** - * Renders a skeleton of this view that communicates to the user that it is loading. - */ - loading: function(){ - - this.empty(); - - let rows = this.searchResults.rows, - i=0; - - while(i < rows){ - let view = this.createSearchResultView(); - this.addResultView(view); - view.loading(); - i++; - } - + * @lends SearchResultsView.prototype + */ { + /** + * The type of View this is + * @type {string} + */ + type: "SearchResults", + + /** + * The HTML tag to use for this view's element + * @type {string} + */ + tagName: "div", + + /** + * The HTML classes to use for this view's element + * @type {string} + */ + className: "search-results-view", + + /** + * The events this view will listen to and the associated function to + * call. + * @type {Object} + */ + events: {}, + + /** + * The SolrResults collection that fetches and parses the searches. + * @type {SolrResults} + */ + searchResults: null, + + /** + * The HTML to display when no search results are found. + * @since 2.22.0 + * @type {string} + */ + noResultsTemplate: `
    No results found.
    `, + + /** + * Render the view. + */ + render: function () { + try { + if (!this.searchResults) { + this.setSearchResults(); + } + + this.loading(); + + this.addResultCollection(); + + this.startListening(); + } catch (e) { + console.log("Failed to render the search results: ", e); + // TODO: Add error handling + } + }, + + /** + * Removes listeners set by the {@link SearchResultsView#startListening} + * method. This is important to prevent zombie listeners from being + * created. + */ + removeListeners: function () { + this.stopListening(this.searchResults, "add"); + this.stopListening(this.searchResults, "reset"); + this.stopListening(this.searchResults, "request"); + }, + + /** + * Sets listeners on the {@link SearchResultsView#searchResults} to change + * what is displayed in this view. + */ + startListening: function () { + this.stopListening(); + this.listenTo(this.searchResults, "add", this.addResultModel); + this.listenTo(this.searchResults, "reset", this.addResultCollection); + this.listenTo(this.searchResults, "request", this.loading); + }, + + /** + * Creates and sets the {@link SearchResultsView#searchResults} property. + * @returns {SolrResults} + */ + setSearchResults: function () { + this.searchResults = new SearchResults(); + return this.searchResults; + }, + + /** + * Renders the given {@link SolrResult} model inside this view. + * @param {SolrResult} searchResult + */ + addResultModel: function (searchResult) { + try { + let view = this.createSearchResultView(); + view.model = searchResult; + this.addResultView(view); + } catch (e) { + console.error("Failed to add a search result to the page: ", e); + } + }, + + /** + * Renders all {@link SolrResult}s from the + * {@link SearchResultsView#searchResults} collection. + */ + addResultCollection: function () { + if (!this.searchResults) return; + else if (this.searchResults?.header?.get("numFound") == 0) { + this.showNoResults(); + return; + } + + this.empty(); + + this.searchResults.models.forEach((result) => { + this.addResultModel(result); + }); + }, + + /** + * Adds a Search Result View to the page + * @param {SearchResultView} view + */ + addResultView: function (view) { + this.el.append(view.el); + view.render(); + }, + + /** + * Creates a Search Result View + */ + createSearchResultView: function () { + return new SearchResultView(); + }, + + /** + * Shows a message when no search results have been found. + */ + showNoResults: function () { + // TODO: this is not working + this.empty(); + + this.el.replaceChildren(this.noResultsTemplate); + }, + + empty: function () { + this.el.innerHTML = ""; + }, + + /** + * Renders a skeleton of this view that communicates to the user that it + * is loading. + */ + loading: function () { + this.empty(); + + let rows = this.searchResults.rows, + i = 0; + + while (i < rows) { + let view = this.createSearchResultView(); + this.addResultView(view); + view.loading(); + i++; + } + }, } - - - - }); - -}); \ No newline at end of file + ); +}); diff --git a/src/js/views/search/SorterView.js b/src/js/views/search/SorterView.js index b9eb6eb15..698dca418 100644 --- a/src/js/views/search/SorterView.js +++ b/src/js/views/search/SorterView.js @@ -1,21 +1,24 @@ /*global define */ define(["backbone"], function (Backbone) { "use strict"; -/** - * @class SorterView - * @classdesc A view that displays a sort controller and sets the sort order on the attached {@link SolrResults} collection. - * @name SorterView - * @extends Backbone.View - * @constructor - * @since 2.22.0 - * @classcategory Views/Search - * */ + /** + * @class SorterView + * @classdesc A view that displays a sort controller and sets the sort order + * on the attached {@link SolrResults} collection. + * @name SorterView + * @extends Backbone.View + * @constructor + * @since 2.22.0 + * @classcategory Views/Search + * TODO: Add screenshot + */ return Backbone.View.extend( /** * @lends SorterView.prototype */ { /** - * A reference to the {@link SolrResults} collection that this sorter displays and controls. + * A reference to the {@link SolrResults} collection that this sorter + * displays and controls. * @type {SolrResults} */ searchResults: null, @@ -23,8 +26,10 @@ define(["backbone"], function (Backbone) { /** * A list of sort order options to display in this view. * @typedef {Object} SearchSortOptions - * @property {string} value The sort value that will be sent directly to the search index in the query string. - * @property {string} label The name of the sort option that will be shown to the user. + * @property {string} value The sort value that will be sent directly to + * the search index in the query string. + * @property {string} label The name of the sort option that will be shown + * to the user. * @since 2.22.0 */ sortOptions: [ @@ -34,9 +39,23 @@ define(["backbone"], function (Backbone) { { value: "authorSurNameSort+asc", label: "Author (a-z)" }, ], + /** + * The HTML tag to use for this view's element + * @type {string} + */ tagName: "div", + + /** + * The HTML classes to use for this view's element + * @type {string} + */ className: "sorter-view", + /** + * The events this view will listen to and the associated function to + * call. + * @type {Object} + */ events: { change: "setSort", }, @@ -59,7 +78,8 @@ define(["backbone"], function (Backbone) { }, /** - * Sets the sort order on the {@link SolrResults} when the sort is changed in the UI. + * Sets the sort order on the {@link SolrResults} when the sort is changed + * in the UI. * @param {Event} e */ setSort: function (e) { From e82bdb13d34764fbe90479d89ef3eedd39951ea1 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 22 Mar 2023 18:56:42 -0400 Subject: [PATCH 22/79] Handle errors and zero results in CatalogSearch Fixes #2111 --- src/js/views/AppView.js | 2 + src/js/views/search/CatalogSearchView.js | 77 ++++++++++--------- src/js/views/search/SearchResultsPagerView.js | 31 +++++++- src/js/views/search/SearchResultsView.js | 45 ++++++++--- src/js/views/search/SorterView.js | 32 ++++++++ 5 files changed, 139 insertions(+), 48 deletions(-) diff --git a/src/js/views/AppView.js b/src/js/views/AppView.js index 28e769ae6..af9cd8860 100644 --- a/src/js/views/AppView.js +++ b/src/js/views/AppView.js @@ -373,6 +373,7 @@ define([ * @property {boolean} [options.remove] If true, the user will be able to remove the alert with a "close" icon. * @property {boolean} [options.includeEmail] If true, the alert will include a link to the {@link AppConfig#emailContact} * @property {string} [options.emailBody] Specify an email body to use in the email link. + * @returns {Element} The alert element */ showAlert: function () { if (arguments.length > 1) { @@ -443,6 +444,7 @@ define([ $(options.container).prepend(alert); } } + return alert; }, /** diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 35174e3ca..ab1a0a5d5 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -135,11 +135,11 @@ define([ searchModel: null, /** - * An array of Filter models, outside of their parent FilterGroup, - * that can be used to filter the search results. These models are passed - * to the {@link FiltersSearchConnector} to be used in the search. This - * property is added to the view by the {@link CatalogSearchView#setupSearch} - * method. + * An array of Filter models, outside of their parent FilterGroup, that + * can be used to filter the search results. These models are passed to + * the {@link FiltersSearchConnector} to be used in the search. This + * property is added to the view by the + * {@link CatalogSearchView#setupSearch} method. * @type {Filter[]} * @since 2.22.0 */ @@ -237,7 +237,7 @@ define([ // Set up the view for styling and layout this.setupView(); - // Set up the search and search result models + // Set up the search and search result models, as well as the map this.setupSearch(); // Render the search components @@ -265,8 +265,8 @@ define([ this.mode = "map"; } - // Use map mode on tablets and browsers only - // TODO: should we set a listener for window resize? + // Use map mode on tablets and browsers only. TODO: should we set a + // listener for window resize? if ($(window).outerWidth() <= 600) { this.mode = "list"; } @@ -284,9 +284,9 @@ define([ */ setupView: function () { try { - document - .querySelector("body") - .classList.add(this.bodyClass, `${this.mode}Mode`); + document.querySelector("body").classList.add(this.bodyClass); + + this.toggleMode(this.mode); // Add LinkedData to the page this.addLinkedData(); @@ -455,23 +455,22 @@ define([ */ titleTemplate: function (start, end, numFound) { try { - let html = ` -
    -
    - - ${MetacatUI.appView.commaSeparateNumber(start)} - to - ${MetacatUI.appView.commaSeparateNumber(end)} - `; - - if (typeof numFound == "number") { - html += ` of - ${MetacatUI.appView.commaSeparateNumber(numFound)} - `; + let content = ""; + const csn = MetacatUI.appView.commaSeparateNumber; + if (numFound < end) end = numFound; + + if (numFound > 0) { + content = `${csn(start)} to ${csn(end)}`; + if (typeof numFound == "number") { + content += ` of ${csn(numFound)}`; + } } - - html += `
    `; - return html; + return ` +
    +
    + ${content} +
    +
    `; } catch (e) { console.log("There was an error creating the title template:" + e); return ""; @@ -485,6 +484,7 @@ define([ */ renderTitle: function () { try { + const searchResults = this.searchResultsView.searchResults; let titleEl = this.el.querySelector(this.titleContainer); if (!titleEl) { @@ -496,9 +496,9 @@ define([ titleEl.innerHTML = ""; let title = this.titleTemplate( - this.searchResultsView.searchResults.getStart() + 1, - this.searchResultsView.searchResults.getEnd() + 1, - this.searchResultsView.searchResults.getNumFound() + searchResults.getStart() + 1, + searchResults.getEnd() + 1, + searchResults.getNumFound() ); titleEl.insertAdjacentHTML("beforeend", title); @@ -549,11 +549,12 @@ define([ createFilterGroups: function (filterGroupsJSON = this.filterGroupsJSON) { try { try { - // Start an array for the FilterGroups and the individual Filter models + // Start an array for the FilterGroups and the individual Filter + // models let filterGroups = []; - // Iterate over each default FilterGroup in the app config and create a - // FilterGroup model + // Iterate over each default FilterGroup in the app config and + // create a FilterGroup model ( filterGroupsJSON || MetacatUI.appModel.get("defaultFilterGroups") ).forEach((filterGroupJSON) => { @@ -661,9 +662,9 @@ define([ $.extend(elJSON, conditionalData); } - // Check if the jsonld already exists from the previous data view If not - // create a new script tag and append otherwise replace the text for the - // script + // Check if the jsonld already exists from the previous data view If + // not create a new script tag and append otherwise replace the text + // for the script if (!document.getElementById("jsonld")) { var el = document.createElement("script"); el.type = "application/ld+json"; @@ -690,8 +691,8 @@ define([ try { let classList = document.querySelector("body").classList; - // If the new mode is not provided, the new mode is the opposite of the - // current mode + // If the new mode is not provided, the new mode is the opposite of + // the current mode newMode = newMode != "map" && newMode != "list" ? null : newMode; newMode = newMode || (this.mode == "map" ? "list" : "map"); diff --git a/src/js/views/search/SearchResultsPagerView.js b/src/js/views/search/SearchResultsPagerView.js index 12376bbc0..aa4fe6ce5 100644 --- a/src/js/views/search/SearchResultsPagerView.js +++ b/src/js/views/search/SearchResultsPagerView.js @@ -126,6 +126,8 @@ define(["backbone"], function (Backbone) { if (this.searchResults) { this.renderPages(); this.listenTo(this.searchResults, "reset", this.renderPages); + // Hide the pager if there is an error with the search results + this.listenTo(this.searchResults, "error", this.hide); } }, @@ -135,7 +137,14 @@ define(["backbone"], function (Backbone) { renderPages: function () { // Only show pages if the search results have been retrieved (by // checking for the header property which is set during parse()) - if (this.searchResults?.header) { + if (!this.searchResults || !this.searchResults.header) return; + if (this.searchResults.getNumPages() < 2) { + this.hide(); + return; + } + + try { + this.show(); this.removeLoading(); let container = this.el.querySelector("ul"), @@ -216,6 +225,9 @@ define(["backbone"], function (Backbone) { this.linkTemplate({ page: currentPage + 1, pageDisplay: ">" }) ); } + } catch (e) { + console.log("There was an error rendering the pager: ", e); + this.hide(); } }, @@ -244,9 +256,26 @@ define(["backbone"], function (Backbone) { * Shows the loading version of the pager */ loading: function () { + this.show(); this.el.classList.add("loading"); }, + /** + * Hides the pager + * @since x.x.x + */ + hide: function () { + this.el.style.visibility = "hidden"; + }, + + /** + * Shows the pager + * @since x.x.x + */ + show: function () { + this.el.style.visibility = "visible"; + }, + /** * Removes the loading version of the pager */ diff --git a/src/js/views/search/SearchResultsView.js b/src/js/views/search/SearchResultsView.js index 0bfbbe5dc..07d604f4e 100644 --- a/src/js/views/search/SearchResultsView.js +++ b/src/js/views/search/SearchResultsView.js @@ -62,13 +62,13 @@ define([ */ render: function () { try { - if (!this.searchResults) { - this.setSearchResults(); - } + if (!this.searchResults) this.setSearchResults(); this.loading(); - this.addResultCollection(); + if (typeof this.searchResults.getNumFound() == "number") { + this.addResultCollection(); + } this.startListening(); } catch (e) { @@ -86,6 +86,7 @@ define([ this.stopListening(this.searchResults, "add"); this.stopListening(this.searchResults, "reset"); this.stopListening(this.searchResults, "request"); + this.listenTo(this.searchResults, "error"); }, /** @@ -93,10 +94,35 @@ define([ * what is displayed in this view. */ startListening: function () { - this.stopListening(); + this.removeListeners(); this.listenTo(this.searchResults, "add", this.addResultModel); this.listenTo(this.searchResults, "reset", this.addResultCollection); this.listenTo(this.searchResults, "request", this.loading); + this.listenTo(this.searchResults, "error", this.showError); + }, + + showError: function (searchResults, response) { + console.log("Failed to fetch search results."); + if (response) console.log(response); + + const thisRepo = MetacatUI.appModel.get("repositoryName") || "DataONE"; + const responseText = encodeURIComponent(response.responseText); + + const alert = MetacatUI.appView.showAlert({ + message: `Oops! It looks like there was a problem retrieving your + search results. Please try your search again and contact support + if the issue persists.

    `, + classes: `alert-warning`, + container: this.el, + replaceContents: true, + delay: false, + remove: false, + includeEmail: true, + emailBody: `I'm having trouble searching ${thisRepo}. + Here is the error message I received: ${responseText}`, + }); + // alert is an HTMLDivElement, add a margin to the left and right: + alert[0].style.margin = "0 1rem"; }, /** @@ -128,7 +154,7 @@ define([ */ addResultCollection: function () { if (!this.searchResults) return; - else if (this.searchResults?.header?.get("numFound") == 0) { + if (this.searchResults.getNumFound() == 0) { this.showNoResults(); return; } @@ -160,12 +186,13 @@ define([ * Shows a message when no search results have been found. */ showNoResults: function () { - // TODO: this is not working this.empty(); - - this.el.replaceChildren(this.noResultsTemplate); + this.el.innerHTML = this.noResultsTemplate; }, + /** + * Removes all child elements from this view. + */ empty: function () { this.el.innerHTML = ""; }, diff --git a/src/js/views/search/SorterView.js b/src/js/views/search/SorterView.js index 698dca418..76da7a0e7 100644 --- a/src/js/views/search/SorterView.js +++ b/src/js/views/search/SorterView.js @@ -64,6 +64,9 @@ define(["backbone"], function (Backbone) { * Renders the view */ render: function () { + this.stopListening(this.searchResults, "error reset"); + this.listenTo(this.searchResults, "error reset", this.hideIfNoResults); + let select = document.createElement("select"); select.setAttribute("id", "sortOrder"); @@ -77,6 +80,19 @@ define(["backbone"], function (Backbone) { this.el.replaceChildren(select); }, + hideIfNoResults: function () { + if ( + !this.searchResults || + !this.searchResults.header || + !this.searchResults.getNumFound() + ) { + this.hide(); + } else { + this.show(); + this.render(); + } + }, + /** * Sets the sort order on the {@link SolrResults} when the sort is changed * in the UI. @@ -85,6 +101,22 @@ define(["backbone"], function (Backbone) { setSort: function (e) { this.searchResults.setSort(e.target.value); }, + + /** + * Hides the view + * @since x.x.x + */ + hide: function () { + this.el.style.visibility = "hidden"; + }, + + /** + * Shows the view + * @since x.x.x + */ + show: function () { + this.el.style.visibility = "visible"; + }, } ); }); From e783d5ce4984a4382b8fdf8babae4637c053899f Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 23 Mar 2023 09:42:51 -0400 Subject: [PATCH 23/79] Standardize formatting in Filters-Search --- src/js/models/connectors/Filters-Search.js | 175 ++++++++++++--------- 1 file changed, 99 insertions(+), 76 deletions(-) diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 69a5f6d54..9e2c352b5 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -1,91 +1,114 @@ /*global define */ -define(['backbone', "collections/Filters", "collections/SolrResults"], - function(Backbone, Filters, SearchResults) { - 'use strict'; +define([ + "backbone", + "collections/Filters", + "collections/SolrResults", +], function (Backbone, Filters, SearchResults) { + "use strict"; /** - * @class FiltersSearchConnector - * @name FiltersSearchConnector - * @classdesc A model that creates listeners between a Filters collection and a SearchResults. It does not assume anything - * about how the search results or filters will be displayed in the UI or why those components need to be connected. It simply - * sends a new search when the filters have been changed. - * @name FiltersSearchConnector - * @extends Backbone.Model - * @constructor - * @classcategory Models/Connectors - */ + * @class FiltersSearchConnector + * @name FiltersSearchConnector + * @classdesc A model that creates listeners between a Filters collection and + * a SearchResults. It does not assume anything about how the search results + * or filters will be displayed in the UI or why those components need to be + * connected. It simply sends a new search when the filters have been changed. + * @name FiltersSearchConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + */ return Backbone.Model.extend( /** @lends FiltersSearchConnector.prototype */ { + /** + * @type {object} + * @property {Filter[]} filtersList An array of Filter models to + * optionally add to the Filters collection + * @property {Filters} filters A Filters collection to use for this search + * @property {SolrResults} searchResults The SolrResults collection that + * the search results will be stored in + */ + defaults: function () { + return { + filtersList: [], + filters: new Filters([], { catalogSearch: true }), + searchResults: new SearchResults(), + }; + }, - /** - * @type {object} - * @property {Filter[]} filtersList An array of Filter models to optionally add to the Filters collection - * @property {Filters} filters A Filters collection to use for this search - * @property {SolrResults} searchResults The SolrResults collection that the search results will be stored in - */ - defaults: function(){ - return{ - filtersList: [], - filters: new Filters([], { catalogSearch: true }), - searchResults: new SearchResults() - } - }, + initialize: function () { + if (this.get("filtersList")?.length) { + this.get("filters").add(this.get("filtersList")); + } + }, - initialize: function(){ - if( this.get("filtersList")?.length ){ - this.get("filters").add(this.get("filtersList")) - } - }, + /** + * Sets listeners on the Filters and SearchResults to trigger a search + * when the search changes + * @since 2.22.0 + */ + startListening: function () { + // Listen to changes in the Filters to trigger a search + this.stopListening( + this.get("filters"), + "add remove update reset change" + ); + this.listenTo( + this.get("filters"), + "add remove update reset change", + this.triggerSearch + ); - /** - * Sets listeners on the Filters and SearchResults to trigger a search when the search changes - * @since 2.22.0 - */ - startListening: function(){ + // Listen to the sort order changing + this.stopListening( + this.get("searchResults"), + "change:sort change:facet" + ); + this.listenTo( + this.get("searchResults"), + "change:sort change:facet", + this.triggerSearch + ); - // Listen to changes in the Filters to trigger a search - this.stopListening(this.get("filters"), "add remove update reset change"); - this.listenTo(this.get("filters"), "add remove update reset change", this.triggerSearch); + // If the logged-in status changes, send a new search + this.listenTo( + MetacatUI.appUserModel, + "change:loggedIn", + this.triggerSearch + ); + }, - //Listen to the sort order changing - this.stopListening(this.get("searchResults"), "change:sort change:facet"); - this.listenTo(this.get("searchResults"), "change:sort change:facet", this.triggerSearch); + /** + * Get Results from the Solr index by combining the Filter query string + * fragments in each Filter instance in the Search collection and querying + * Solr. + * @fires SolrResults#toPage + * @since 2.22.0 + */ + triggerSearch: function () { + let filters = this.get("filters"), + searchResults = this.get("searchResults"); - //If the logged-in status changes, send a new search - this.listenTo(MetacatUI.appUserModel, "change:loggedIn", this.triggerSearch); + // Get the Solr query string from the Search filter collection + let query = filters.getQuery(); - }, + // Set the query on the SolrResults collection + searchResults.setQuery(query); + // If the query hasn't changed since the last query that was sent, don't + // do anything. This function may have been triggered by a change event + // on a filter that doesn't affect the query at all + if (!searchResults.hasChanged()) { + return; + } - /** - * Get Results from the Solr index by combining the Filter query string fragments - * in each Filter instance in the Search collection and querying Solr. - * @fires SolrResults#toPage - * @since 2.22.0 - */ - triggerSearch: function() { - let filters = this.get("filters"), - searchResults = this.get("searchResults"); + // Get the page number + let page = MetacatUI.appModel.get("page") || 0; + searchResults.start = page * searchResults.rows; - // Get the Solr query string from the Search filter collection - let query = filters.getQuery(); - - // Set the query on the SolrResults collection - searchResults.setQuery(query); - - //If the query hasn't changed since the last query that was sent, don't do anything. - //This function may have been triggered by a change event on a filter that doesn't - //affect the query at all - if( !searchResults.hasChanged() ){ - return; - } - - // Get the page number - let page = MetacatUI.appModel.get("page") || 0; - searchResults.start = page * searchResults.rows; - - //Send the query to the server via the SolrResults collection - searchResults.toPage(page); - }, - }); -}) \ No newline at end of file + // Send the query to the server via the SolrResults collection + searchResults.toPage(page); + }, + } + ); +}); From 4f21b5d6699fe6bc937632d5c401e5bc1ed6476a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 23 Mar 2023 09:46:01 -0400 Subject: [PATCH 24/79] Nav to page 1 on new search in CatalogSearchView Fixes #2112 --- src/js/models/connectors/Filters-Search.js | 7 ++++++- src/js/views/search/SearchResultsPagerView.js | 16 ++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 9e2c352b5..710a45ce8 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -48,6 +48,7 @@ define([ * @since 2.22.0 */ startListening: function () { + const view = this; // Listen to changes in the Filters to trigger a search this.stopListening( this.get("filters"), @@ -56,7 +57,11 @@ define([ this.listenTo( this.get("filters"), "add remove update reset change", - this.triggerSearch + function () { + // Start at the first page when the filters change + MetacatUI.appModel.set("page", 0); + view.triggerSearch(); + } ); // Listen to the sort order changing diff --git a/src/js/views/search/SearchResultsPagerView.js b/src/js/views/search/SearchResultsPagerView.js index aa4fe6ce5..3decd86d5 100644 --- a/src/js/views/search/SearchResultsPagerView.js +++ b/src/js/views/search/SearchResultsPagerView.js @@ -34,13 +34,13 @@ define(["backbone"], function (Backbone) { * @type {string} */ template: ` -
      -
    • -
    • -
    • -
    • ...
    • -
    • -
    `, +
      +
    • +
    • +
    • +
    • ...
    • +
    • +
    `, /** * Constructs and returns a URL string to use for the given page in this @@ -125,6 +125,7 @@ define(["backbone"], function (Backbone) { if (this.searchResults) { this.renderPages(); + this.stopListening(this.searchResults, "reset error"); this.listenTo(this.searchResults, "reset", this.renderPages); // Hide the pager if there is an error with the search results this.listenTo(this.searchResults, "error", this.hide); @@ -247,7 +248,6 @@ define(["backbone"], function (Backbone) { if (this.searchResults) { this.searchResults.toPage(page); MetacatUI.appModel.set("page", page); - console.log("nav to ", this.url(page)); MetacatUI.uiRouter.navigate(this.url(page), { trigger: false }); } }, From 4c40a1db26127b9b145ef7d723ff2a04c43bd633 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 24 Mar 2023 15:12:59 -0400 Subject: [PATCH 25/79] Handle page url consistently in CatalogSearchView Relates to #2113 --- src/js/views/search/CatalogSearchView.js | 7 ++++++- src/js/views/search/SearchResultsView.js | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index ab1a0a5d5..774a7fd72 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -242,6 +242,12 @@ define([ // Render the search components this.renderComponents(); + + // When everything is ready, run the initial search and then start + // listening for changes. Wait for components to render first because + // when filters are added, they trigger a search unnecessarily. + this.connector.triggerSearch(); + this.connector.startListening(); }, /** @@ -526,7 +532,6 @@ define([ filtersList: allFilters, }); this.connector = connector; - connector.startListening(); this.createSearchResults(); diff --git a/src/js/views/search/SearchResultsView.js b/src/js/views/search/SearchResultsView.js index 07d604f4e..2efade21a 100644 --- a/src/js/views/search/SearchResultsView.js +++ b/src/js/views/search/SearchResultsView.js @@ -101,6 +101,12 @@ define([ this.listenTo(this.searchResults, "error", this.showError); }, + /** + * When there is an error fetching the search results, show an alert + * message to the user. + * @param {SolrResults} searchResults - The collection of search results + * @param {Object} response - The response from the server + */ showError: function (searchResults, response) { console.log("Failed to fetch search results."); if (response) console.log(response); From 6ee4dcf817b5782edf60b4f1b4aa404c80e12c3a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 24 Mar 2023 16:16:43 -0400 Subject: [PATCH 26/79] Handle query url consistently in CatalogSearchView Fixes #2113 --- src/js/routers/router.js | 52 +++++++++++------------- src/js/themes/arctic/routers/router.js | 45 ++++++++------------ src/js/views/filters/FilterGroupsView.js | 27 ++++++++++-- src/js/views/search/CatalogSearchView.js | 12 ++++++ 4 files changed, 77 insertions(+), 59 deletions(-) diff --git a/src/js/routers/router.js b/src/js/routers/router.js index 69bce995e..2f2076416 100644 --- a/src/js/routers/router.js +++ b/src/js/routers/router.js @@ -297,40 +297,36 @@ function ($, _, Backbone) { renderData: function (mode, query, page) { this.routeHistory.push("data"); + // Check for a page URL parameter + if(!page) MetacatUI.appModel.set("page", 0); + else MetacatUI.appModel.set('page', page - 1); + + // Check if we are using the new CatalogSearchView + if(!MetacatUI.appModel.get("useDeprecatedDataCatalogView")){ + require(["views/search/CatalogSearchView"], function(CatalogSearchView){ + MetacatUI.appView.catalogSearchView = new CatalogSearchView({ + initialQuery: query, + }); + MetacatUI.appView.showView(MetacatUI.appView.catalogSearchView); + }); + return; + } - ///Check for a page URL parameter - if((typeof page === "undefined") || !page) - MetacatUI.appModel.set("page", 0); - else if(page == 0) - MetacatUI.appModel.set('page', 0); - else - MetacatUI.appModel.set('page', page-1); - - //Check if we are using the new CatalogSearchView - if(!MetacatUI.appModel.get("useDeprecatedDataCatalogView")){ - require(["views/search/CatalogSearchView"], function(CatalogSearchView){ - MetacatUI.appView.catalogSearchView = new CatalogSearchView(); - MetacatUI.appView.showView(MetacatUI.appView.catalogSearchView); - }); - return; - } - - //Check for a query URL parameter - if((typeof query !== "undefined") && query){ + // Check for a query URL parameter + if ((typeof query !== "undefined") && query) { MetacatUI.appSearchModel.set('additionalCriteria', [query]); } - require(['views/DataCatalogView'], function(DataCatalogView){ - if(!MetacatUI.appView.dataCatalogView) - MetacatUI.appView.dataCatalogView = new DataCatalogView(); + // Check for a search mode URL parameter + if((typeof mode !== "undefined") && mode) + MetacatUI.appView.dataCatalogView.mode = mode; - //Check for a search mode URL parameter - if((typeof mode !== "undefined") && mode) - MetacatUI.appView.dataCatalogView.mode = mode; - - MetacatUI.appView.showView(MetacatUI.appView.dataCatalogView); - }); + require(['views/DataCatalogView'], function(DataCatalogView){ + if(!MetacatUI.appView.dataCatalogView) + MetacatUI.appView.dataCatalogView = new DataCatalogView(); + MetacatUI.appView.showView(MetacatUI.appView.dataCatalogView); + }); }, /** diff --git a/src/js/themes/arctic/routers/router.js b/src/js/themes/arctic/routers/router.js index 7af708d96..c889c3999 100644 --- a/src/js/themes/arctic/routers/router.js +++ b/src/js/themes/arctic/routers/router.js @@ -111,47 +111,36 @@ function ($, _, Backbone) { renderData: function (mode, query, page) { this.routeHistory.push("data"); + // Check for a page URL parameter + if(!page) MetacatUI.appModel.set("page", 0); + else MetacatUI.appModel.set('page', page - 1); - ///Check for a page URL parameter - if((typeof page === "undefined") || !page) - MetacatUI.appModel.set("page", 0); - else if(page == 0) - MetacatUI.appModel.set('page', 0); - else - MetacatUI.appModel.set('page', page - 1); - - //Check if we are using the new CatalogSearchView + // Check if we are using the new CatalogSearchView if(!MetacatUI.appModel.get("useDeprecatedDataCatalogView")){ require(["views/search/CatalogSearchView"], function(CatalogSearchView){ - MetacatUI.appView.catalogSearchView = new CatalogSearchView(); - MetacatUI.appView.showView(MetacatUI.appView.catalogSearchView); + MetacatUI.appView.catalogSearchView = new CatalogSearchView({ + initialQuery: query, + }); + MetacatUI.appView.showView(MetacatUI.appView.catalogSearchView); }); return; } - //Check for a query URL parameter - if((typeof query !== "undefined") && query){; + // Check for a query URL parameter + if ((typeof query !== "undefined") && query) { MetacatUI.appSearchModel.set('additionalCriteria', [query]); } - if(!MetacatUI.appView.dataCatalogView){ - require(['views/DataCatalogView'], function(DataCatalogView){ - MetacatUI.appView.dataCatalogView = new DataCatalogView(); - - //Check for a search mode URL parameter - if((typeof mode !== "undefined") && mode) - MetacatUI.appView.dataCatalogView.mode = mode; + // Check for a search mode URL parameter + if((typeof mode !== "undefined") && mode) + MetacatUI.appView.dataCatalogView.mode = mode; - MetacatUI.appView.showView(MetacatUI.appView.dataCatalogView); - }); - } - else{ - //Check for a search mode URL parameter - if((typeof mode !== "undefined") && mode) - MetacatUI.appView.dataCatalogView.mode = mode; + require(['views/DataCatalogView'], function(DataCatalogView){ + if(!MetacatUI.appView.dataCatalogView) + MetacatUI.appView.dataCatalogView = new DataCatalogView(); MetacatUI.appView.showView(MetacatUI.appView.dataCatalogView); - } + }); }, renderMyData: function(page){ diff --git a/src/js/views/filters/FilterGroupsView.js b/src/js/views/filters/FilterGroupsView.js index b69dfe90e..e61a19e51 100644 --- a/src/js/views/filters/FilterGroupsView.js +++ b/src/js/views/filters/FilterGroupsView.js @@ -70,6 +70,14 @@ define(['jquery', 'underscore', 'backbone', */ edit: false, + /** + * The initial query to use when the view is first rendered. This is a text value + * that will be set on the general `text` Solr field. + * @type {string} + * @since x.x.x + */ + initialQuery: undefined, + /** * @inheritdoc */ @@ -107,6 +115,10 @@ define(['jquery', 'underscore', 'backbone', this.edit = true } + if (options.initialQuery) { + this.initialQuery = options.initialQuery; + } + }, /** @@ -272,8 +284,9 @@ define(['jquery', 'underscore', 'backbone', //Render the applied filters this.renderAppliedFiltersSection(); - //Render an "All" filter - this.renderAllFilter(); + // Render an "All" filter. If the view was initialized with an initial + // query, set it on this filter. + this.renderAllFilter(this.initialQuery); } if(this.edit){ @@ -366,7 +379,12 @@ define(['jquery', 'underscore', 'backbone', }, - renderAllFilter: function(){ + /** + * Renders an "All" filter that will search the general `text` Solr field + * @param {string} searchFor - The initial value of the "All" filter. This + * will get set on the filter model and trigger a change event. Optional. + */ + renderAllFilter: function (searchFor="") { //Create an "All" filter that will search the general `text` Solr field var filter = new Filter({ @@ -387,6 +405,9 @@ define(['jquery', 'underscore', 'backbone', filterView.render(); this.$(".filters-header").prepend(filterView.el); + if (searchFor && searchFor.length) { + filter.set('values', [searchFor]); + } }, postRender: function(){ diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 774a7fd72..69a99789a 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -228,6 +228,17 @@ define([ /** * Initializes the view + * @param {Object} options + * @param {string} options.initialQuery - The initial text query to run + * when the view is rendered. + * @since x.x.x + */ + initialize: function (options) { + this.initialQuery = options?.initialQuery; + }, + + /** + * Renders the view * @since 2.22.0 */ render: function () { @@ -355,6 +366,7 @@ define([ filters: this.connector?.get("filters"), vertical: true, parentView: this, + initialQuery: this.initialQuery, }); // Add the FilterGroupsView element to this view From f8bc799382715db5296136caf8fc974e8919819a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 24 Mar 2023 16:44:54 -0400 Subject: [PATCH 27/79] Ensure CatalogSearch doesn't go beyond last page Fixes #971, related to #2113 --- src/js/views/search/SearchResultsPagerView.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/js/views/search/SearchResultsPagerView.js b/src/js/views/search/SearchResultsPagerView.js index 3decd86d5..d3524b529 100644 --- a/src/js/views/search/SearchResultsPagerView.js +++ b/src/js/views/search/SearchResultsPagerView.js @@ -139,6 +139,16 @@ define(["backbone"], function (Backbone) { // Only show pages if the search results have been retrieved (by // checking for the header property which is set during parse()) if (!this.searchResults || !this.searchResults.header) return; + + // Ensure that we don't navigate to a page that doesn't exist + const numPages = this.searchResults.getNumPages(); + const currentPage = MetacatUI.appModel.get("page"); + if (currentPage > numPages) { + MetacatUI.appModel.set("page", numPages); + this.searchResults.toPage(numPages); + return; + } + if (this.searchResults.getNumPages() < 2) { this.hide(); return; From 5be539ddeda1b61698283db34ea5a76468282664 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 27 Mar 2023 14:40:06 -0400 Subject: [PATCH 28/79] Standardize formatting in Geohash related models Relates to #2069 --- src/js/models/connectors/Geohash-Search.js | 107 ++- src/js/models/filters/SpatialFilter.js | 396 +++++------ src/js/models/maps/assets/CesiumGeohash.js | 731 ++++++++++----------- 3 files changed, 622 insertions(+), 612 deletions(-) diff --git a/src/js/models/connectors/Geohash-Search.js b/src/js/models/connectors/Geohash-Search.js index f26e999d8..d2874decf 100644 --- a/src/js/models/connectors/Geohash-Search.js +++ b/src/js/models/connectors/Geohash-Search.js @@ -1,60 +1,59 @@ /*global define */ -define(['backbone', "models/maps/assets/CesiumGeohash", "collections/SolrResults", "models/Search"], - function(Backbone, CesiumGeohash, SearchResults, Search) { - 'use strict'; +define([ + "backbone", + "models/maps/assets/CesiumGeohash", + "collections/SolrResults", + "models/Search", +], function (Backbone, CesiumGeohash, SearchResults, Search) { + "use strict"; /** - * @class GeohashSearchConnector - * @classdesc A model that creates listeners between the CesiumGeohash MapAsset model and the Search model. - * @name GeohashSearchConnector - * @extends Backbone.Model - * @constructor - * @classcategory Models/Connectors - */ + * @class GeohashSearchConnector + * @classdesc A model that creates listeners between the CesiumGeohash MapAsset model and the Search model. + * @name GeohashSearchConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + */ return Backbone.Model.extend( /** @lends GeohashSearchConnector.prototype */ { - - /** - * @type {object} - * @property {SolrResults} searchResults - * @property {CesiumGeohash} cesiumGeohash - */ - defaults: function(){ - return { - searchResults: null, - cesiumGeohash: null - } - }, - - /** - * Sets listeners on the CesiumGeohash map asset and the SearchResults. It will get the geohash facet data - * from the SolrResults and set it on the CesiumGeohash so it can be used by a map view. It also updates the - * geohash level in the SolrResults so that it can be used by the next query. - * @since 2.22.0 - */ - startListening: function () { - - const geohashLayer = this.get("cesiumGeohash") - const searchResults = this.get("searchResults") - - this.listenTo(searchResults, "reset", function(){ - - const level = geohashLayer.get("level") || 1; - const facetCounts = searchResults.facetCounts["geohash_" + level] - const totalFound = searchResults.getNumFound() - - // Set the new geohash facet counts on the CesiumGeohash MapAsset - geohashLayer.set("counts", facetCounts); - geohashLayer.set("totalCount", totalFound); - - }); - - this.listenTo(geohashLayer, "change:geohashLevel", function () { - const level = geohashLayer.get("level") || 1; - searchResults.setFacet(["geohash_" + level]); - }); - } - - }); - + /** + * @type {object} + * @property {SolrResults} searchResults + * @property {CesiumGeohash} cesiumGeohash + */ + defaults: function () { + return { + searchResults: null, + cesiumGeohash: null, + }; + }, + + /** + * Sets listeners on the CesiumGeohash map asset and the SearchResults. It will get the geohash facet data + * from the SolrResults and set it on the CesiumGeohash so it can be used by a map view. It also updates the + * geohash level in the SolrResults so that it can be used by the next query. + * @since 2.22.0 + */ + startListening: function () { + const geohashLayer = this.get("cesiumGeohash"); + const searchResults = this.get("searchResults"); + + this.listenTo(searchResults, "reset", function () { + const level = geohashLayer.get("level") || 1; + const facetCounts = searchResults.facetCounts["geohash_" + level]; + const totalFound = searchResults.getNumFound(); + + // Set the new geohash facet counts on the CesiumGeohash MapAsset + geohashLayer.set("counts", facetCounts); + geohashLayer.set("totalCount", totalFound); + }); + + this.listenTo(geohashLayer, "change:geohashLevel", function () { + const level = geohashLayer.get("level") || 1; + searchResults.setFacet(["geohash_" + level]); + }); + }, + } + ); }); diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 8db1e32c6..9b52b7f25 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -1,190 +1,212 @@ -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", - fieldsOperator: "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 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") || ""; - } - }, - - /** - * 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; +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", + fieldsOperator: "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"; } - 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); - } + }); + // 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 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") || ""; + } + }, + + /** + * 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); }); - return SpatialFilter; + //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; }); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 964e3b599..7ba637f85 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -1,406 +1,395 @@ -'use strict'; +"use strict"; -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'cesium', - 'nGeohash', - 'models/maps/assets/CesiumVectorData', - ], - function ( - $, - _, - Backbone, - Cesium, - nGeohash, - CesiumVectorData - ) { - /** - * @classdesc A Geohash Model represents a geohash layer in a map. - * @classcategory Models/Maps/Assets - * @class CesiumGeohash - * @name CesiumGeohash - * @extends CesiumVectorData - * @since 2.18.0 - * @constructor - */ - return CesiumVectorData.extend( - /** @lends Geohash.prototype */ { +define([ + "jquery", + "underscore", + "backbone", + "cesium", + "nGeohash", + "models/maps/assets/CesiumVectorData", +], function ($, _, Backbone, Cesium, nGeohash, CesiumVectorData) { + /** + * @classdesc A Geohash Model represents a geohash layer in a map. + * @classcategory Models/Maps/Assets + * @class CesiumGeohash + * @name CesiumGeohash + * @extends CesiumVectorData + * @since 2.18.0 + * @constructor + */ + return CesiumVectorData.extend( + /** @lends Geohash.prototype */ { + /** + * The name of this type of model + * @type {string} + */ + type: "CesiumGeohash", - /** - * The name of this type of model - * @type {string} - */ - type: 'CesiumGeohash', + /** + * Default attributes for Geohash models + * @name CesiumGeohash#defaults + * @type {Object} + * @extends CesiumVectorData#defaults + * @property {'CesiumGeohash'} type The format of the data. Must be + * 'CesiumGeohash'. + * @property {boolean} isGeohashLayer A flag to indicate that this is a + * Geohash layer, since we change the type to CesiumVectorData. Used by + * the Catalog Search View to find this layer so it can be connected to + * search results. + * @property {object} precisionAltMap Map of precision integer to + * minimum altitude (m) + * @property {Number} maxNumGeohashes The maximum number of geohashes + * allowed. Set to null to remove the limit. If the given bounds + + * altitude/level result in more geohashes than the max limit, then the + * level will be reduced by one until the number of geohashes is under + * the limit. This improves rendering performance, especially when the + * map is focused on either pole, or is tilted in a "street view" like + * perspective. + * @property {Number} altitude The current distance from the surface of + * the earth in meters + * @property {Number} level The geohash level, an integer between 0 and + * 9. + * @property {object} bounds The current bounding box (south, west, + * north, east) within which to render geohashes (in longitude/latitude + * coordinates). + * @property {string[]} counts An array of geohash strings followed by + * their associated count. e.g. ["a", 123, "f", 8] + * @property {Number} totalCount The total number of results that were + * just fetched + * @property {Number} geohashes + */ - /** - * Default attributes for Geohash models - * @name CesiumGeohash#defaults - * @type {Object} - * @extends CesiumVectorData#defaults - * @property {'CesiumGeohash'} type The format of the data. Must be - * 'CesiumGeohash'. - * @property {boolean} isGeohashLayer A flag to indicate that this is a - * Geohash layer, since we change the type to CesiumVectorData. Used by - * the Catalog Search View to find this layer so it can be connected to - * search results. - * @property {object} precisionAltMap Map of precision integer to - * minimum altitude (m) - * @property {Number} maxNumGeohashes The maximum number of geohashes - * allowed. Set to null to remove the limit. If the given bounds + - * altitude/level result in more geohashes than the max limit, then the - * level will be reduced by one until the number of geohashes is under - * the limit. This improves rendering performance, especially when the - * map is focused on either pole, or is tilted in a "street view" like - * perspective. - * @property {Number} altitude The current distance from the surface of - * the earth in meters - * @property {Number} level The geohash level, an integer between 0 and - * 9. - * @property {object} bounds The current bounding box (south, west, - * north, east) within which to render geohashes (in longitude/latitude - * coordinates). - * @property {string[]} counts An array of geohash strings followed by - * their associated count. e.g. ["a", 123, "f", 8] - * @property {Number} totalCount The total number of results that were - * just fetched - * @property {Number} geohashes - */ + defaults: function () { + return Object.assign(CesiumVectorData.prototype.defaults(), { + type: "GeoJsonDataSource", + label: "Geohashes", + isGeohashLayer: true, + precisionAltMap: { + 1: 6800000, + 2: 2400000, + 3: 550000, + 4: 120000, + 5: 7000, + 6: 0, + }, + maxNumGeohashes: 1000, + altitude: null, + level: 1, + bounds: { + north: null, + east: null, + south: null, + west: null, + }, + level: 1, + counts: [], + totalCount: 0, + geohashes: [], + }); + }, - defaults: function () { - return Object.assign( - CesiumVectorData.prototype.defaults(), - { - type: 'GeoJsonDataSource', - label: 'Geohashes', - isGeohashLayer: true, - precisionAltMap: { - 1: 6800000, - 2: 2400000, - 3: 550000, - 4: 120000, - 5: 7000, - 6: 0 - }, - maxNumGeohashes: 1000, - altitude: null, - level: 1, - bounds: { - north: null, - east: null, - south: null, - west: null - }, - level: 1, - counts: [], - totalCount: 0, - geohashes: [] - } - ) - }, + /** + * Executed when a new CesiumGeohash model is created. + * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of + * the attributes, which will be set on the model. + */ + initialize: function (assetConfig) { + try { + this.setGeohashListeners(); + this.set("type", "GeoJsonDataSource"); + CesiumVectorData.prototype.initialize.call(this, assetConfig); + } catch (error) { + console.log( + "There was an error initializing a CesiumVectorData model" + + ". Error details: " + + error + ); + } + }, - /** - * Executed when a new CesiumGeohash model is created. - * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of - * the attributes, which will be set on the model. - */ - initialize: function (assetConfig) { - try { - this.setGeohashListeners() - this.set('type', 'GeoJsonDataSource') - CesiumVectorData.prototype.initialize.call(this, assetConfig); - } - catch (error) { - console.log( - 'There was an error initializing a CesiumVectorData model' + - '. Error details: ' + error - ); - } - }, + /** + * Connect this layer to the map to get updates on the current view + * extent (bounds) and altitude. Update the Geohashes when the altitude + * or bounds in the model change. + */ + setGeohashListeners: function () { + try { + const model = this; - /** - * Connect this layer to the map to get updates on the current view - * extent (bounds) and altitude. Update the Geohashes when the altitude - * or bounds in the model change. - */ - setGeohashListeners: function () { - try { - const model = this + // Update the geohashes when the bounds or altitude change - // Update the geohashes when the bounds or altitude change + // TODO: Determine best way to set listeners, without re-creating + // the cesium model twice when both bounds and altitude change + // simultaneously - // TODO: Determine best way to set listeners, without re-creating - // the cesium model twice when both bounds and altitude change - // simultaneously - - // model.stopListening(model, - // 'change:level change:bounds change:altitude change:geohashes') - // model.listenTo(model, 'change:altitude', model.setGeohashLevel) - // model.listenTo(model, 'change:bounds change:level', model.setGeohashes) - // model.listenTo(model, 'change:geohashes', function () { - // model.createCesiumModel(true) - // }) + // model.stopListening(model, + // 'change:level change:bounds change:altitude change:geohashes') + // model.listenTo(model, 'change:altitude', model.setGeohashLevel) + // model.listenTo(model, 'change:bounds change:level', model.setGeohashes) + // model.listenTo(model, 'change:geohashes', function () { + // model.createCesiumModel(true) + // }) - // Connect this layer to the map to get current bounds and altitude - function setMapListeners() { - const mapModel = model.get('mapModel') - if (!mapModel) { return } - model.listenTo(mapModel, 'change:currentViewExtent', - function (map, newExtent) { - const altitude = newExtent.height - delete newExtent.height - model.set('bounds', newExtent) - model.set('altitude', altitude) - model.setGeohashLevel() - model.setGeohashes() - model.createCesiumModel(true) - } - ) + // Connect this layer to the map to get current bounds and altitude + function setMapListeners() { + const mapModel = model.get("mapModel"); + if (!mapModel) { + return; } - setMapListeners.call(model) - model.stopListening(model, 'change:mapModel', setMapListeners) - model.listenTo(model, 'change:mapModel', setMapListeners) - } - catch (error) { - console.log( - 'There was an error setting listeners in a CesiumGeohash' + - '. Error details: ', error - ); - } - }, - - /** - * Given the geohashes set on the model, return as geoJSON - * @returns {object} GeoJSON representing the geohashes with counts - */ - toGeoJSON: function () { - try { - // The base GeoJSON format - const geojson = { - "type": "FeatureCollection", - "features": [] - } - const geohashes = this.get('geohashes') - if (!geohashes) { - return geojson - } - const features = [] - // Format for geohashes: - // { geohashID: [minlat, minlon, maxlat, maxlon] }. - for (const [id, bb] of Object.entries(geohashes)) { - const minlat = bb[0] <= -90 ? -89.99999 : bb[0] - const minlon = bb[1] - const maxlat = bb[2] - const maxlon = bb[3] - const feature = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [minlon, minlat], - [minlon, maxlat], - [maxlon, maxlat], - [maxlon, minlat], - [minlon, minlat] - ] - ] - }, - "properties": { - // "count": 0, // TODO - add counts - "geohash": id - } + model.listenTo( + mapModel, + "change:currentViewExtent", + function (map, newExtent) { + const altitude = newExtent.height; + delete newExtent.height; + model.set("bounds", newExtent); + model.set("altitude", altitude); + model.setGeohashLevel(); + model.setGeohashes(); + model.createCesiumModel(true); } - features.push(feature) - } - geojson['features'] = features - return geojson - } - catch (error) { - console.log( - 'There was an error converting geohashes to GeoJSON ' + - 'in a CesiumGeohash model. Error details: ', error ); } - }, + setMapListeners.call(model); + model.stopListening(model, "change:mapModel", setMapListeners); + model.listenTo(model, "change:mapModel", setMapListeners); + } catch (error) { + console.log( + "There was an error setting listeners in a CesiumGeohash" + + ". Error details: ", + error + ); + } + }, - /** - * 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 - * {@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. - */ - createCesiumModel: function (recreate = false) { - try { - const model = this; - // Set the GeoJSON representing geohashes on the model - const cesiumOptions = model.get('cesiumOptions') - cesiumOptions['data'] = model.toGeoJSON() - // TODO: outlines don't work when features are clamped to ground - // cesiumOptions['clampToGround'] = true - cesiumOptions['height'] = 0 - model.set('cesiumOptions', cesiumOptions) - // Create the model like a regular GeoJSON data source - CesiumVectorData.prototype.createCesiumModel.call(this, recreate) - } - catch (error) { - console.log( - 'There was an error creating a CesiumGeohash model' + - '. Error details: ', error - ); + /** + * Given the geohashes set on the model, return as geoJSON + * @returns {object} GeoJSON representing the geohashes with counts + */ + toGeoJSON: function () { + try { + // The base GeoJSON format + const geojson = { + type: "FeatureCollection", + features: [], + }; + const geohashes = this.get("geohashes"); + if (!geohashes) { + return geojson; } - }, - - /** - * Reset the geohash level set on the model, given the altitude that is - * currently set on the model. - */ - setGeohashLevel: function () { - try { - const precisionAltMap = this.get('precisionAltMap') - const altitude = this.get('altitude') - const precision = Object.keys(precisionAltMap) - .find(key => altitude >= precisionAltMap[key]); - this.set('level', precision); - } - catch (error) { - console.log( - 'There was an error getting the geohash level from altitude in ' + - 'a Geohash mode. Setting to level 1 by default. ' + - 'Error details: ' + error - ); - this.set('level', 1); + const features = []; + // Format for geohashes: + // { geohashID: [minlat, minlon, maxlat, maxlon] }. + for (const [id, bb] of Object.entries(geohashes)) { + const minlat = bb[0] <= -90 ? -89.99999 : bb[0]; + const minlon = bb[1]; + const maxlat = bb[2]; + const maxlon = bb[3]; + const feature = { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [minlon, minlat], + [minlon, maxlat], + [maxlon, maxlat], + [maxlon, minlat], + [minlon, minlat], + ], + ], + }, + properties: { + // "count": 0, // TODO - add counts + geohash: id, + }, + }; + features.push(feature); } - }, - - /** - * Update the geohash property with geohashes for the current - * altitude/precision and bounding box. - */ - setGeohashes: function () { - try { + geojson["features"] = features; + return geojson; + } catch (error) { + console.log( + "There was an error converting geohashes to GeoJSON " + + "in a CesiumGeohash model. Error details: ", + error + ); + } + }, - const bounds = this.get('bounds') - const precision = this.get('level') - const limit = this.get('maxNumGeohashes') + /** + * 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 + * {@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. + */ + createCesiumModel: function (recreate = false) { + try { + const model = this; + // Set the GeoJSON representing geohashes on the model + const cesiumOptions = model.get("cesiumOptions"); + cesiumOptions["data"] = model.toGeoJSON(); + // TODO: outlines don't work when features are clamped to ground + // cesiumOptions['clampToGround'] = true + cesiumOptions["height"] = 0; + model.set("cesiumOptions", cesiumOptions); + // Create the model like a regular GeoJSON data source + CesiumVectorData.prototype.createCesiumModel.call(this, recreate); + } catch (error) { + console.log( + "There was an error creating a CesiumGeohash model" + + ". Error details: ", + error + ); + } + }, - const all_bounds = [] - let geohashIDs = [] - const geohashes = [] + /** + * Reset the geohash level set on the model, given the altitude that is + * currently set on the model. + */ + setGeohashLevel: function () { + try { + const precisionAltMap = this.get("precisionAltMap"); + const altitude = this.get("altitude"); + const precision = Object.keys(precisionAltMap).find( + (key) => altitude >= precisionAltMap[key] + ); + this.set("level", precision); + } catch (error) { + console.log( + "There was an error getting the geohash level from altitude in " + + "a Geohash mode. Setting to level 1 by default. " + + "Error details: " + + error + ); + this.set("level", 1); + } + }, - // Get all the geohash tiles contained in the current bounds. - if (bounds.east < bounds.west) { - // If the bounding box crosses the prime meridian, then we need to - // search for geohashes on both sides. Otherwise nGeohash returns - // 0 geohashes. - all_bounds.push({ - north: bounds.north, - south: bounds.south, - east: 180, - west: bounds.west - }) - all_bounds.push({ - north: bounds.north, - south: bounds.south, - east: bounds.east, - west: -180 - }) - } else { - all_bounds.push(bounds) - } - all_bounds.forEach(function (bb) { - geohashIDs = geohashIDs.concat(nGeohash.bboxes( - bb.south, bb.west, bb.north, bb.east, precision - )) - }) + /** + * Update the geohash property with geohashes for the current + * altitude/precision and bounding box. + */ + setGeohashes: function () { + try { + const bounds = this.get("bounds"); + const precision = this.get("level"); + const limit = this.get("maxNumGeohashes"); - // When the map is centered on the poles or is zoomed in and tilted, - // the bounds + level result in too many geohashes. Reduce the - // number of geohashes to the model's limit by reducing the - // precision. - if (limit && geohashIDs.length > limit && precision > 1) { - this.set('level', (precision - 1)) - this.setGeohashes(limit=limit) - return - } + const all_bounds = []; + let geohashIDs = []; + const geohashes = []; - // Get the bounds for each of the geohashes - geohashIDs.forEach(function (id) { - geohashes[id] = nGeohash.decode_bbox(id) - }) - this.set('geohashes', geohashes) + // Get all the geohash tiles contained in the current bounds. + if (bounds.east < bounds.west) { + // If the bounding box crosses the prime meridian, then we need to + // search for geohashes on both sides. Otherwise nGeohash returns + // 0 geohashes. + all_bounds.push({ + north: bounds.north, + south: bounds.south, + east: 180, + west: bounds.west, + }); + all_bounds.push({ + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: -180, + }); + } else { + all_bounds.push(bounds); } - catch (error) { - console.log( - 'There was an error getting geohashes in a Geohash model' + - '. Error details: ' + error + all_bounds.forEach(function (bb) { + geohashIDs = geohashIDs.concat( + nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, precision) ); - } - }, + }); - // /** - // * Parses the given input into a JSON object to be set on the model. - // * - // * @param {TODO} input - The raw response object - // * @return {TODO} - The JSON object of all the Geohash attributes - // */ - // parse: function (input) { + // When the map is centered on the poles or is zoomed in and tilted, + // the bounds + level result in too many geohashes. Reduce the + // number of geohashes to the model's limit by reducing the + // precision. + if (limit && geohashIDs.length > limit && precision > 1) { + this.set("level", precision - 1); + this.setGeohashes((limit = limit)); + return; + } - // try { + // Get the bounds for each of the geohashes + geohashIDs.forEach(function (id) { + geohashes[id] = nGeohash.decode_bbox(id); + }); + this.set("geohashes", geohashes); + } catch (error) { + console.log( + "There was an error getting geohashes in a Geohash model" + + ". Error details: " + + error + ); + } + }, - // var modelJSON = {}; + // /** + // * Parses the given input into a JSON object to be set on the model. + // * + // * @param {TODO} input - The raw response object + // * @return {TODO} - The JSON object of all the Geohash attributes + // */ + // parse: function (input) { - // return modelJSON + // try { - // } - // catch (error) {console.log('There was an error parsing a Geohash model' + '. - // Error details: ' + error - // ); - // } + // var modelJSON = {}; - // }, + // return modelJSON - // /** - // * Overrides the default Backbone.Model.validate.function() to check if this if - // * the values set on this model are valid. - // * - // * @param {Object} [attrs] - A literal object of model attributes to validate. - // * @param {Object} [options] - A literal object of options for this validation - // * process - // * - // * @return {Object} - Returns a literal object with the invalid attributes and - // * their corresponding error message, if there are any. If there are no errors, - // * returns nothing. - // */ - // validate: function (attrs, options) {try { + // } + // catch (error) {console.log('There was an error parsing a Geohash model' + '. + // Error details: ' + error + // ); + // } - // } - // catch (error) {console.log('There was an error validating a Geohash model' + - // '. Error details: ' + error - // ); - // } - // }, + // }, - // /** - // * Creates a string using the values set on this model's attributes. - // * @return {string} The Geohash string - // */ - // serialize: function () {try {var serializedGeohash = ''; + // /** + // * Overrides the default Backbone.Model.validate.function() to check if this if + // * the values set on this model are valid. + // * + // * @param {Object} [attrs] - A literal object of model attributes to validate. + // * @param {Object} [options] - A literal object of options for this validation + // * process + // * + // * @return {Object} - Returns a literal object with the invalid attributes and + // * their corresponding error message, if there are any. If there are no errors, + // * returns nothing. + // */ + // validate: function (attrs, options) {try { - // return serializedGeohash; - // } - // catch (error) {console.log('There was an error serializing a Geohash model' + - // '. Error details: ' + error - // ); - // } - // }, + // } + // catch (error) {console.log('There was an error validating a Geohash model' + + // '. Error details: ' + error + // ); + // } + // }, - }); + // /** + // * Creates a string using the values set on this model's attributes. + // * @return {string} The Geohash string + // */ + // serialize: function () {try {var serializedGeohash = ''; - } -); + // return serializedGeohash; + // } + // catch (error) {console.log('There was an error serializing a Geohash model' + + // '. Error details: ' + error + // ); + // } + // }, + } + ); +}); From 8e35e70c581a8aa39582bd3fe88b72e142fe675d Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 28 Mar 2023 19:38:57 -0400 Subject: [PATCH 29/79] Work on CatalogSearch connectors [WIP] - fix SpatialFilter - Add Filters-Map connector - Identify TODOs Relates to #2069 --- src/js/collections/Filters.js | 5 + src/js/models/connectors/Filters-Map.js | 194 +++++++++++ src/js/models/connectors/Filters-Search.js | 5 +- src/js/models/filters/Filter.js | 4 +- src/js/models/filters/SpatialFilter.js | 378 ++++++++++++++------- src/js/models/maps/assets/CesiumGeohash.js | 23 +- src/js/views/search/CatalogSearchView.js | 43 ++- 7 files changed, 505 insertions(+), 147 deletions(-) create mode 100644 src/js/models/connectors/Filters-Map.js diff --git a/src/js/collections/Filters.js b/src/js/collections/Filters.js index 9d31a3f00..ee5b089f1 100644 --- a/src/js/collections/Filters.js +++ b/src/js/collections/Filters.js @@ -2,11 +2,13 @@ define([ "jquery", "underscore", "backbone", "models/filters/Filter", "models/filters/BooleanFilter", "models/filters/ChoiceFilter", "models/filters/DateFilter", "models/filters/NumericFilter", "models/filters/ToggleFilter", + "models/filters/SpatialFilter" ], function ( $, _, Backbone, Filter, BooleanFilter, ChoiceFilter, DateFilter, NumericFilter, ToggleFilter, + SpatialFilter ) { "use strict"; @@ -114,6 +116,9 @@ define([ case "togglefilter": return new ToggleFilter(attrs, options); + + case "spatialfilter": + return new SpatialFilter(attrs, options); default: return new Filter(attrs, options); diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js new file mode 100644 index 000000000..9ec6cb7be --- /dev/null +++ b/src/js/models/connectors/Filters-Map.js @@ -0,0 +1,194 @@ +/*global define */ +define([ + "backbone", + "collections/Filters", + "models/filters/SpatialFilter", + "models/maps/Map", +], function (Backbone, Filters, SpatialFilter, Map) { + "use strict"; + + /** + * @class FiltersMapConnector + * @name FiltersMapConnector + * @classdesc A model that creates listeners between a Map model and a + * collection of Filters. The Map will update any spatial filters in the + * collection with map extent and zoom level changes. This connector does not + * assume anything about how the map or filters will be displayed in the UI or + * why those components need to be connected. + * @name FiltersMapConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + * @since x.x.x + */ + return Backbone.Model.extend( + /** @lends FiltersMapConnector.prototype */ { + /** + * @type {object} + * @property {Filter[]} filtersList An array of Filter models to + * optionally add to the Filters collection + * @property {Filters} filters A Filters collection to connect to the Map + * @property {SpatialFilter[]} spatialFilters An array of SpatialFilter + * models present in the Filters collection. + * @property {Map} map The Map model that will update the spatial filters + * @property {boolean} isListening Whether the connector is currently + * listening to the Map model for changes. Set automatically when the + * connector is started or stopped. + * @since x.x.x + */ + defaults: function () { + return { + filtersList: [], + filters: new Filters([], { catalogSearch: true }), + spatialFilters: [], + map: new Map(), + isListening: false, + }; + }, + + /** + * Set up the model connections. + * @param {object} attr - The attributes passed to the model, must include + * a Filters collection and a Map model. + * @param {object} options - The options passed to the model. + * @param {boolean} [options.addSpatialFilter=true] - Whether to add a + * SpatialFilter to the Filters collection if none are found. The + * connector won't work without a SpatialFilter, but it will listen for + * updates to the Filters collection and connect to any SpatialFilters + * that are added. + */ + initialize: function (attr, options) { + try { + this.addFiltersList(); + const add = options?.addSpatialFilter ?? true; + this.findAndSetSpatialFilters(add); + } catch (e) { + console.log("Error initializing Filters-Map connector: ", e); + } + }, + + /** + * Adds the filter models from filtersList to the Filters collection if + * filtersList is not empty. + */ + addFiltersList: function () { + if (this.get("filtersList")?.length) { + this.get("filters").add(this.get("filtersList")); + } + }, + + /** + * Finds and sets the spatial filters within the Filters collection. Stops + * any existing listeners, adds a new listener for collection updates, and + * adds a spatial filter if needed. + * @param {boolean} [add=false] - Whether to add a SpatialFilter if none + * are found in the collection. + */ + findAndSetSpatialFilters: function (add = false) { + const wasListening = this.get("isListening"); + this.stopListeners(); + this.setSpatialFilters(); + this.listenOnceToFiltersUpdates(); + this.addSpatialFilterIfNeeded(add); + if (wasListening) { + this.startListening(); + } + }, + + /** + * Sets the SpatialFilter models found within the Filters collection to + * the 'spatialFilters' attribute. + */ + setSpatialFilters: function () { + const spatialFilters = this.get("filters").where({ + filterType: "SpatialFilter", + }); + this.set("spatialFilters", spatialFilters); + }, + + /** + * Adds a listener to the Filters collection for updates, to re-run the + * findAndSetSpatialFilters function. + */ + listenOnceToFiltersUpdates: function () { + this.listenToOnce( + this.get("filters"), + "add remove", + this.findAndSetSpatialFilters + ); + }, + + /** + * Adds a new SpatialFilter to the Filters collection if no spatial + * filters are found and 'add' is true. This will trigger a collection + * update, which will re-run the findAndSetSpatialFilters function. + * @param {boolean} add - Whether to add a SpatialFilter if none are found + * in the collection. + */ + addSpatialFilterIfNeeded: function (add) { + const spatialFilters = this.get("spatialFilters"); + if (!spatialFilters?.length && add) { + this.get("filters").add(new SpatialFilter()); + // 🐛🐛🐛 TODO: When a new SpatialFilter is added, the SolrResults are + // not hearing changes. Do we need to add a listener somewhere to the + // filters collection for updates? + } + }, + + /** + * Stops all Filter-Map listeners, including listeners on the Filters + * collection and the Map model. + */ + stopListeners: function () { + try { + this.stopListening(this.get("filters"), "add remove"); + this.stopListening(this.get("map"), "change:currentViewExtent"); + this.set("isListening", false); + } catch (e) { + console.log("Error stopping Filter-Map listeners: ", e); + } + }, + + /** + * Starts listening to the Map model for changes in the + * 'currentViewExtent' attribute, and calls the updateSpatialFilters + * function when changes are detected. This method needs to be called for + * the connector to work. + */ + startListening: function () { + try { + this.stopListeners(); + this.listenTo( + this.get("map"), + "change:currentViewExtent", + this.updateSpatialFilters + ); + this.set("isListening", true); + } catch (e) { + console.log("Error starting Filter-Map listeners: ", e); + } + }, + + /** + * Updates the spatial filters with the current map extent and zoom level. + */ + updateSpatialFilters: function () { + try { + const map = this.get("map"); + const extent = map.get("currentViewExtent"); + const spatialFilters = this.get("spatialFilters"); + + if (!spatialFilters?.length) { + return; + } + + spatialFilters.forEach((spFilter) => { + spFilter.set(extent); + }); + } 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 710a45ce8..7fab0bf1b 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -40,6 +40,7 @@ define([ if (this.get("filtersList")?.length) { this.get("filters").add(this.get("filtersList")); } + // TODO: Set a listeners for changes to filters? }, /** @@ -48,7 +49,7 @@ define([ * @since 2.22.0 */ startListening: function () { - const view = this; + const model = this; // Listen to changes in the Filters to trigger a search this.stopListening( this.get("filters"), @@ -60,7 +61,7 @@ define([ function () { // Start at the first page when the filters change MetacatUI.appModel.set("page", 0); - view.triggerSearch(); + model.triggerSearch(); } ); diff --git a/src/js/models/filters/Filter.js b/src/js/models/filters/Filter.js index 81e747a95..980961863 100644 --- a/src/js/models/filters/Filter.js +++ b/src/js/models/filters/Filter.js @@ -570,9 +570,9 @@ define(['jquery', 'underscore', 'backbone'], try { var fields = this.get("fields"), values = this.get("values"), - noFields = !fields || fields.length == 0; + noFields = !fields || fields.length == 0, fieldsEmpty = _.every(fields, function(item) { return item == "" }), - noValues = !values || values.length == 0; + noValues = !values || values.length == 0, valuesEmpty = _.every(values, function(item) { return item == "" }); var noMinNoMax = _.every( diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 9b52b7f25..b463f948b 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -1,9 +1,10 @@ -define(["underscore", "jquery", "backbone", "models/filters/Filter"], function ( - _, - $, - Backbone, - Filter -) { +define([ + "underscore", + "jquery", + "backbone", + "nGeohash", + "models/filters/Filter", +], function (_, $, Backbone, nGeohash, 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 @@ -29,22 +30,35 @@ define(["underscore", "jquery", "backbone", "models/filters/Filter"], function ( * @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 + * @property {number} geohashLevel The default precision level of the geohash-based search + * // TODO update the above */ defaults: function () { return _.extend(Filter.prototype.defaults(), { geohashes: [], + filterType: "SpatialFilter", east: null, west: null, north: null, south: null, - geohashLevel: null, - groupedGeohashes: {}, + height: null, + level: null, + maxGeohashes: 1000, + // groupedGeohashes: {}, + fields: ["geohash_1"], label: "Limit search to the map area", icon: "globe", operator: "OR", fieldsOperator: "OR", matchSubstring: false, + levelHeightMap: { + 1: 6800000, + 2: 2400000, + 3: 550000, + 4: 120000, + 5: 7000, + 6: 0, + }, }); }, @@ -52,53 +66,185 @@ define(["underscore", "jquery", "backbone", "models/filters/Filter"], function ( * Initialize the model, calling super */ initialize: function (attributes, options) { - this.on("change:geohashes", this.groupGeohashes); + Filter.prototype.initialize.call(this, attributes, options); + this.listenTo( + this, + "change:height change:north change:south change:east", + this.updateGeohashes + ); }, /** - * Builds a query string that represents this spatial filter - * @return queryFragment - the query string representing the geohash constraints + * Update the level, fields, geohashes, and values on the model, according + * to the current height, north, south and east attributes. */ - 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 += ")"; + updateGeohashes: function () { + try { + const height = this.get("height"); + const limit = this.get("maxGeohashes"); + const bounds = { + north: this.get("north"), + south: this.get("south"), + east: this.get("east"), + west: this.get("west"), + }; + let level = this.getGeohashLevel(height); + let geohashIDs = this.getGeohashIDs(bounds, level); + if (limit && geohashIDs.length > limit && level > 1) { + while (geohashIDs.length > limit && level > 1) { + level--; + geohashIDs = this.getGeohashIDs(bounds, level); + } } + this.set("level", level); + this.set("fields", ["geohash_" + level]); + this.set("geohashes", geohashIDs); + this.set("values", geohashIDs); + } catch (e) { + console.log("Failed to update geohashes" + e); + } + }, + + /** + * Get the geohash level to use for a given height. + * + * @param {number} [height] - Altitude to use to calculate the geohash + * level/precision. + */ + getGeohashLevel: function (height) { + try { + const levelHeightMap = this.get("levelHeightMap"); + return Object.keys(levelHeightMap).find( + (key) => height >= levelHeightMap[key] + ); + } catch (e) { + console.log("Failed to get geohash level, returning 1" + e); + return 1; } - return queryFragment; }, + /** + * Retrieves the geohash IDs for the provided bounding boxes and level. + * + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @param {number} level - Geohash level. + * @returns {string[]} Array of geohash IDs. + */ + getGeohashIDs: function (bounds, level) { + let geohashIDs = []; + bounds = this.splitBoundingBox(bounds); + bounds.forEach(function (bb) { + geohashIDs = geohashIDs.concat( + nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, level) + ); + }); + return geohashIDs; + }, + + /** + * Splits the 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 + */ + splitBoundingBox: function (bounds) { + const { north, south, east, west } = bounds; + + if (east < west) { + return [ + { north, south, east: 180, west }, + { north, south, east, west: -180 }, + ]; + } else { + return [{ north, south, east, west }]; + } + }, + + // TODO: Use the `groupGeohashes` function to consolidate geohashes into + // groups and shorten the query. We can add each group as a sub-filter + // within a filters group. Then the get Query function is fairly simple, + // we might not have to override it at all. (to check). Question: Will + // SolrResults give results for only the geohashes in the group, or will + // it give results for all highest level geohashes? This will impact what + // is shown on the cesium map, because we need counts for each geohash, + // not each group. + + // /** + // * Sets geohashes for the model, considering the maximum geohash limit. + // * If the limit is exceeded, it reduces the level and calls the function recursively. + // */ + // setGeohashes: function () { + // try { + // const level = this.get("level"); + // const limit = this.get("maxGeohashes"); + // let bounds = {}[("north", "south", "east", "west")].forEach((key) => { + // bounds[key] = this.get(key); + // }); + // // bounds = this.splitBoundingBox(...bounds); + // const geohashIDs = this.getGeohashIDs(bounds, level); + // if (limit && geohashIDs.length > limit && level > 1) { + // this.set("level", level - 1); + // this.setGeohashes(); + // return; + // } + // this.set("geohashes", geohashIDs); + // } catch (error) { + // console.log( + // "There was an error getting geohashes in a Geohash model" + + // ". Error details: " + + // error + // ); + // } + // }, + + /** + * 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 ( + // !geohashes || + // !geohashes.length || + // !groups || + // !Object.keys(groups).length + // ) { + // return queryFragment; + // } + + // // 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 */ @@ -135,77 +281,77 @@ define(["underscore", "jquery", "backbone", "models/filters/Filter"], function ( } }, - /** - * 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; - } - ); + // /** + // * 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 - ); + // // 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); - this.set("fields", fields); - this.set("values", values); - }, + // // 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 - /** - * @inheritdoc - */ - resetValue: function () { - this.set("fields", this.defaults().fields); - this.set("values", this.defaults().values); - }, + // //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; diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 7ba637f85..44c8d41b8 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -81,7 +81,6 @@ define([ south: null, west: null, }, - level: 1, counts: [], totalCount: 0, geohashes: [], @@ -136,19 +135,15 @@ define([ if (!mapModel) { return; } - model.listenTo( - mapModel, - "change:currentViewExtent", - function (map, newExtent) { - const altitude = newExtent.height; - delete newExtent.height; - model.set("bounds", newExtent); - model.set("altitude", altitude); - model.setGeohashLevel(); - model.setGeohashes(); - model.createCesiumModel(true); - } - ); + // model.listenTo( + // mapModel, + // "change:currentViewExtent", + // function (map, newExtent) { + // const newAltitude = newExtent.height; + // delete newExtent.height; + // model.updateData(newAltitude, newExtent); + // } + // ); } setMapListeners.call(model); model.stopListening(model, "change:mapModel", setMapListeners); diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 69a99789a..61cff9f8a 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -2,9 +2,11 @@ define([ "jquery", "backbone", + "collections/Filters", "models/filters/FilterGroup", "models/connectors/Filters-Search", "models/connectors/Geohash-Search", + "models/connectors/Filters-Map", "models/maps/Map", "views/search/SearchResultsView", "views/filters/FilterGroupsView", @@ -15,9 +17,11 @@ define([ ], function ( $, Backbone, + Filters, FilterGroup, FiltersSearchConnector, GeohashSearchConnector, + FiltersMapConnector, Map, SearchResultsView, FilterGroupsView, @@ -538,6 +542,7 @@ define([ this.filterGroups.forEach((group) => { allFilters = allFilters.concat(group.get("filters")?.models); }); + this.allFilters = allFilters; // Connect the filters to the search and search results let connector = new FiltersSearchConnector({ @@ -600,23 +605,35 @@ define([ ); const map = new Map(mapOptions); - const geohashLayer = map - .get("layers") - .findWhere({ isGeohashLayer: true }); + // TODO: Make a CatalogSearchModel of a SearchFiltersMap connector + // that coordiantes all of the sub-connectors, (SolrResults <-> + // Filters, SolrResults <-> Map, Filters <-> Map) + + // const geohashLayer = map + // .get("layers") + // .findWhere({ isGeohashLayer: true }); + + // if (!geohashLayer) { + // this.listenTo(map, "change:layers", (map, layers) => { + // const geohashLayer = layers.findWhere({ isGeohashLayer: true }); + // if (geohashLayer) this.createMap(); + // }); + // return; + // } // Connect the CesiumGeohash to the SolrResults - const connector = new GeohashSearchConnector({ - cesiumGeohash: geohashLayer, - searchResults: this.searchResultsView.searchResults, + // const connector = new GeohashSearchConnector({ + // cesiumGeohash: geohashLayer, + // searchResults: this.searchResultsView.searchResults, + // }); + // connector.startListening(); + // this.geohashSearchConnector = connector; + + const connector = new FiltersMapConnector({ + map: map, + filters: new Filters(this.allFilters), }); connector.startListening(); - this.geohashSearchConnector = connector; - - // Set the geohash level for the search - const searchFacet = this.searchResultsView.searchResults.facet; - const newLevel = "geohash_" + geohashLayer.get("level"); - if (Array.isArray(searchFacet)) searchFacet.push(newLevel); - else searchFacet = newLevel; // Create the Map model and view this.mapView = new MapView({ model: map }); From d458ad115bf65b2bd984661f98a103ee26042839 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 29 Mar 2023 13:44:55 -0400 Subject: [PATCH 30/79] Ensure SolrResults hear when spatial filter added Also add new methods to Filters-Search connector Relates to #2069 --- src/js/models/connectors/Filters-Search.js | 81 +++++++++++++++++----- src/js/views/search/CatalogSearchView.js | 6 +- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 7fab0bf1b..4eed9a309 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -22,25 +22,57 @@ define([ /** @lends FiltersSearchConnector.prototype */ { /** * @type {object} - * @property {Filter[]} filtersList An array of Filter models to - * optionally add to the Filters collection * @property {Filters} filters A Filters collection to use for this search * @property {SolrResults} searchResults The SolrResults collection that * the search results will be stored in + * @property {boolean} isListening Whether or not the model has listeners + * set between the Filters and SearchResults. Set this with the + * startListening and stopListeners methods. */ defaults: function () { return { - filtersList: [], filters: new Filters([], { catalogSearch: true }), searchResults: new SearchResults(), + isListening: false, }; }, - initialize: function () { - if (this.get("filtersList")?.length) { - this.get("filters").add(this.get("filtersList")); + /** + * Swap out the Filters and SearchResults models with new ones. Do not + * set the models directly, as this will not remove the listeners from + * the old models. + * (TODO: Create custom set methods for the Filters and SearchResults) + * @param {SolrResults|Filters[]} models - A model or array of models to + * update in this connector. + */ + updateModels(models) { + if (!models) return; + models = Array.isArray(models) ? models : [models]; + + const wasListening = this.get("isListening"); + this.stopListeners(); + + const attrClassMap = { + filters: Filters, + searchResults: SearchResults, + }; + + models.forEach((model) => { + try { + for (const [attr, ModelClass] of Object.entries(attrClassMap)) { + if (model instanceof ModelClass) { + this.set(attr, model); + break; // If a match is found, no need to check other entries in attrClassMap + } + } + } catch (e) { + console.log("Error updating model", model, e); + } + }); + + if (wasListening) { + this.startListening(); } - // TODO: Set a listeners for changes to filters? }, /** @@ -49,12 +81,10 @@ define([ * @since 2.22.0 */ startListening: function () { + this.stopListeners(); const model = this; // Listen to changes in the Filters to trigger a search - this.stopListening( - this.get("filters"), - "add remove update reset change" - ); + this.listenTo( this.get("filters"), "add remove update reset change", @@ -65,11 +95,6 @@ define([ } ); - // Listen to the sort order changing - this.stopListening( - this.get("searchResults"), - "change:sort change:facet" - ); this.listenTo( this.get("searchResults"), "change:sort change:facet", @@ -82,6 +107,26 @@ define([ "change:loggedIn", this.triggerSearch ); + this.set("isListening", true); + }, + + /** + * Stops listening to changes in the Filters and SearchResults + * @since x.x.x + */ + stopListeners: function () { + const model = this; + this.stopListening(MetacatUI.appUserModel, "change:loggedIn"); + this.stopListening( + this.get("filters"), + "add remove update reset change" + ); + // Listen to the sort order changing + this.stopListening( + this.get("searchResults"), + "change:sort change:facet" + ); + this.set("isListening", false); }, /** @@ -92,8 +137,8 @@ define([ * @since 2.22.0 */ triggerSearch: function () { - let filters = this.get("filters"), - searchResults = this.get("searchResults"); + const filters = this.get("filters"); + const searchResults = this.get("searchResults"); // Get the Solr query string from the Search filter collection let query = filters.getQuery(); diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 61cff9f8a..109bdf6c2 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -542,11 +542,11 @@ define([ this.filterGroups.forEach((group) => { allFilters = allFilters.concat(group.get("filters")?.models); }); - this.allFilters = allFilters; + this.allFilters = new Filters(allFilters, { catalogSearch: true }); // Connect the filters to the search and search results let connector = new FiltersSearchConnector({ - filtersList: allFilters, + filters: this.allFilters, }); this.connector = connector; @@ -631,7 +631,7 @@ define([ const connector = new FiltersMapConnector({ map: map, - filters: new Filters(this.allFilters), + filters: this.allFilters, }); connector.startListening(); From aa28250dae0ad6433f7ee62c1185645766aea7e3 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 29 Mar 2023 14:24:46 -0400 Subject: [PATCH 31/79] Remove logic from Geohash model (now in Sp. Filter) Make the Geohash layer simply draw geohashes when counts are set on the model. Relates to #2069 --- src/js/models/maps/assets/CesiumGeohash.js | 266 ++++----------------- 1 file changed, 44 insertions(+), 222 deletions(-) diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 44c8d41b8..52ac960e0 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -20,7 +20,8 @@ define([ return CesiumVectorData.extend( /** @lends Geohash.prototype */ { /** - * The name of this type of model + * The name of this type of model. This will be updated to + * 'CesiumVectorData' upon initialization. * @type {string} */ type: "CesiumGeohash", @@ -36,27 +37,12 @@ define([ * Geohash layer, since we change the type to CesiumVectorData. Used by * the Catalog Search View to find this layer so it can be connected to * search results. - * @property {object} precisionAltMap Map of precision integer to - * minimum altitude (m) - * @property {Number} maxNumGeohashes The maximum number of geohashes - * allowed. Set to null to remove the limit. If the given bounds + - * altitude/level result in more geohashes than the max limit, then the - * level will be reduced by one until the number of geohashes is under - * the limit. This improves rendering performance, especially when the - * map is focused on either pole, or is tilted in a "street view" like - * perspective. - * @property {Number} altitude The current distance from the surface of - * the earth in meters - * @property {Number} level The geohash level, an integer between 0 and - * 9. - * @property {object} bounds The current bounding box (south, west, - * north, east) within which to render geohashes (in longitude/latitude - * coordinates). * @property {string[]} counts An array of geohash strings followed by * their associated count. e.g. ["a", 123, "f", 8] * @property {Number} totalCount The total number of results that were * just fetched - * @property {Number} geohashes + * @property {string[]} geohashIDs An array of geohash strings + * @property {} geohashes */ defaults: function () { @@ -64,23 +50,6 @@ define([ type: "GeoJsonDataSource", label: "Geohashes", isGeohashLayer: true, - precisionAltMap: { - 1: 6800000, - 2: 2400000, - 3: 550000, - 4: 120000, - 5: 7000, - 6: 0, - }, - maxNumGeohashes: 1000, - altitude: null, - level: 1, - bounds: { - north: null, - east: null, - south: null, - west: null, - }, counts: [], totalCount: 0, geohashes: [], @@ -94,8 +63,8 @@ define([ */ initialize: function (assetConfig) { try { - this.setGeohashListeners(); this.set("type", "GeoJsonDataSource"); + this.startListening(); CesiumVectorData.prototype.initialize.call(this, assetConfig); } catch (error) { console.log( @@ -107,53 +76,50 @@ define([ }, /** - * Connect this layer to the map to get updates on the current view - * extent (bounds) and altitude. Update the Geohashes when the altitude - * or bounds in the model change. + * Stop the model from listening to itself for changes in the counts or + * geohashes. */ - setGeohashListeners: function () { - try { - const model = this; - - // Update the geohashes when the bounds or altitude change - - // TODO: Determine best way to set listeners, without re-creating - // the cesium model twice when both bounds and altitude change - // simultaneously + stopListeners: function () { + this.stopListening(this, "change:counts"); + this.stopListening(this, "change:geohashes"); + }, - // model.stopListening(model, - // 'change:level change:bounds change:altitude change:geohashes') - // model.listenTo(model, 'change:altitude', model.setGeohashLevel) - // model.listenTo(model, 'change:bounds change:level', model.setGeohashes) - // model.listenTo(model, 'change:geohashes', function () { - // model.createCesiumModel(true) - // }) + /** + * Update and re-render the geohashes when the counts change. + */ + startListening: function () { + try { + this.stopListeners(); + this.listenTo(this, "change:counts", this.updateGeohashes); + this.listenTo(this, "change:geohashes", function () { + this.createCesiumModel(true); + }); + } catch (error) { + console.log("Failed to set listeners in CesiumGeohash", error); + } + }, - // Connect this layer to the map to get current bounds and altitude - function setMapListeners() { - const mapModel = model.get("mapModel"); - if (!mapModel) { - return; - } - // model.listenTo( - // mapModel, - // "change:currentViewExtent", - // function (map, newExtent) { - // const newAltitude = newExtent.height; - // delete newExtent.height; - // model.updateData(newAltitude, newExtent); - // } - // ); + /** + * Get the counts currently set on this model and create the geohash array + * [{ counts, id, bounds}]. Set this array on the model, which will + * trigger the cesiumModel to re-render. + */ + updateGeohashes: function () { + try { + // Counts are formatted as [geohash, count, geohash, count, ...] + const counts = this.get("counts"); + const geohashes = []; + for (let i = 0; i < counts.length; i += 2) { + const id = counts[i]; + geohashes.append({ + id: id, + count: counts[i + 1], + bounds: nGeohash.decode_bbox(id), + }); } - setMapListeners.call(model); - model.stopListening(model, "change:mapModel", setMapListeners); - model.listenTo(model, "change:mapModel", setMapListeners); + this.set("geohashes", geohashes); } catch (error) { - console.log( - "There was an error setting listeners in a CesiumGeohash" + - ". Error details: ", - error - ); + console.log("Failed to update geohashes in CesiumGeohash", error); } }, @@ -241,150 +207,6 @@ define([ ); } }, - - /** - * Reset the geohash level set on the model, given the altitude that is - * currently set on the model. - */ - setGeohashLevel: function () { - try { - const precisionAltMap = this.get("precisionAltMap"); - const altitude = this.get("altitude"); - const precision = Object.keys(precisionAltMap).find( - (key) => altitude >= precisionAltMap[key] - ); - this.set("level", precision); - } catch (error) { - console.log( - "There was an error getting the geohash level from altitude in " + - "a Geohash mode. Setting to level 1 by default. " + - "Error details: " + - error - ); - this.set("level", 1); - } - }, - - /** - * Update the geohash property with geohashes for the current - * altitude/precision and bounding box. - */ - setGeohashes: function () { - try { - const bounds = this.get("bounds"); - const precision = this.get("level"); - const limit = this.get("maxNumGeohashes"); - - const all_bounds = []; - let geohashIDs = []; - const geohashes = []; - - // Get all the geohash tiles contained in the current bounds. - if (bounds.east < bounds.west) { - // If the bounding box crosses the prime meridian, then we need to - // search for geohashes on both sides. Otherwise nGeohash returns - // 0 geohashes. - all_bounds.push({ - north: bounds.north, - south: bounds.south, - east: 180, - west: bounds.west, - }); - all_bounds.push({ - north: bounds.north, - south: bounds.south, - east: bounds.east, - west: -180, - }); - } else { - all_bounds.push(bounds); - } - all_bounds.forEach(function (bb) { - geohashIDs = geohashIDs.concat( - nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, precision) - ); - }); - - // When the map is centered on the poles or is zoomed in and tilted, - // the bounds + level result in too many geohashes. Reduce the - // number of geohashes to the model's limit by reducing the - // precision. - if (limit && geohashIDs.length > limit && precision > 1) { - this.set("level", precision - 1); - this.setGeohashes((limit = limit)); - return; - } - - // Get the bounds for each of the geohashes - geohashIDs.forEach(function (id) { - geohashes[id] = nGeohash.decode_bbox(id); - }); - this.set("geohashes", geohashes); - } catch (error) { - console.log( - "There was an error getting geohashes in a Geohash model" + - ". Error details: " + - error - ); - } - }, - - // /** - // * Parses the given input into a JSON object to be set on the model. - // * - // * @param {TODO} input - The raw response object - // * @return {TODO} - The JSON object of all the Geohash attributes - // */ - // parse: function (input) { - - // try { - - // var modelJSON = {}; - - // return modelJSON - - // } - // catch (error) {console.log('There was an error parsing a Geohash model' + '. - // Error details: ' + error - // ); - // } - - // }, - - // /** - // * Overrides the default Backbone.Model.validate.function() to check if this if - // * the values set on this model are valid. - // * - // * @param {Object} [attrs] - A literal object of model attributes to validate. - // * @param {Object} [options] - A literal object of options for this validation - // * process - // * - // * @return {Object} - Returns a literal object with the invalid attributes and - // * their corresponding error message, if there are any. If there are no errors, - // * returns nothing. - // */ - // validate: function (attrs, options) {try { - - // } - // catch (error) {console.log('There was an error validating a Geohash model' + - // '. Error details: ' + error - // ); - // } - // }, - - // /** - // * Creates a string using the values set on this model's attributes. - // * @return {string} The Geohash string - // */ - // serialize: function () {try {var serializedGeohash = ''; - - // return serializedGeohash; - // } - // catch (error) {console.log('There was an error serializing a Geohash model' + - // '. Error details: ' + error - // ); - // } - // }, } ); }); From bf6cc2d8897fc84fbace62e2e59e1a2551967508 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 29 Mar 2023 16:42:05 -0400 Subject: [PATCH 32/79] Standardize formatting in Filters collection Relates to #2069 --- src/js/collections/Filters.js | 1075 ++++++++++++++++++--------------- 1 file changed, 572 insertions(+), 503 deletions(-) diff --git a/src/js/collections/Filters.js b/src/js/collections/Filters.js index ee5b089f1..633e3c905 100644 --- a/src/js/collections/Filters.js +++ b/src/js/collections/Filters.js @@ -1,538 +1,606 @@ define([ - "jquery", "underscore", "backbone", - "models/filters/Filter", "models/filters/BooleanFilter", "models/filters/ChoiceFilter", - "models/filters/DateFilter", "models/filters/NumericFilter", "models/filters/ToggleFilter", - "models/filters/SpatialFilter" -], - function ( - $, _, Backbone, - Filter, BooleanFilter, ChoiceFilter, - DateFilter, NumericFilter, ToggleFilter, - SpatialFilter - ) { - "use strict"; - - /** - * @class Filters - * @classdesc A collection of Filter models that represents a full search - * @classcategory Collections - * @name Filters - * @extends Backbone.Collection - * @constructor - */ - var Filters = Backbone.Collection.extend( - /** @lends Filters.prototype */{ - - /** - * If the search results must always match one of the ids in the id filters, - * then the id filters will be added to the query with an AND operator. - * @type {boolean} - */ - mustMatchIds: false, - - /** - * Function executed whenever a new Filters collection is created. - * @param {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} models - - * Array of filter or filter group models to add to this creation - * @param {Object} [options] - - * @property {boolean} isUIFilterType - Set to true to indicate that these filters - * or filterGroups are part of a UIFilterGroup (aka custom Portal search filter). - * Otherwise, it's assumed that this model is in a Collection model definition. - * @property {XMLElement} objectDOM - A FilterGroupType or UIFilterGroupType XML - * element from a portal or collection document. If provided, the XML will be - * parsed and the Filters models extracted - * @property {boolean} catalogSearch - If set to true, a catalog search phrase - * will be appended to the search query that limits the results to un-obsoleted - * metadata. - */ - initialize: function (models, options) { - try { - if (typeof options === "undefined") { - var options = {}; - } - if (options && options.objectDOM) { - // Models are automatically added to the collection by the parse function. - var isUIFilterType = options.isUIFilterType == true ? true : false - this.parse(options.objectDOM, isUIFilterType); - } - if (options.catalogSearch) { - this.catalogSearchQuery = this.createCatalogSearchQuery(); - } - } catch (error) { - console.log("Error initializing a Filters collection. Error details: " + error); + "jquery", + "underscore", + "backbone", + "models/filters/Filter", + "models/filters/BooleanFilter", + "models/filters/ChoiceFilter", + "models/filters/DateFilter", + "models/filters/NumericFilter", + "models/filters/ToggleFilter", + "models/filters/SpatialFilter", +], function ( + $, + _, + Backbone, + Filter, + BooleanFilter, + ChoiceFilter, + DateFilter, + NumericFilter, + ToggleFilter, + SpatialFilter +) { + "use strict"; + + /** + * @class Filters + * @classdesc A collection of Filter models that represents a full search + * @classcategory Collections + * @name Filters + * @extends Backbone.Collection + * @constructor + */ + var Filters = Backbone.Collection.extend( + /** @lends Filters.prototype */ { + /** + * If the search results must always match one of the ids in the id + * filters, then the id filters will be added to the query with an AND + * operator. + * @type {boolean} + */ + mustMatchIds: false, + + /** + * Function executed whenever a new Filters collection is created. + * @param + * {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} + * models - Array of filter or filter group models to add to this creation + * @param {Object} [options] - + * @property {boolean} isUIFilterType - Set to true to indicate that these + * filters or filterGroups are part of a UIFilterGroup (aka custom Portal + * search filter). Otherwise, it's assumed that this model is in a + * Collection model definition. + * @property {XMLElement} objectDOM - A FilterGroupType or + * UIFilterGroupType XML element from a portal or collection document. If + * provided, the XML will be parsed and the Filters models extracted + * @property {boolean} catalogSearch - If set to true, a catalog search + * phrase will be appended to the search query that limits the results to + * un-obsoleted metadata. + */ + initialize: function (models, options) { + try { + if (typeof options === "undefined") { + var options = {}; } - }, - - /** - * Creates the type of Filter Model based on the given filter type. This - * function is typically not called directly. It is used by Backbone.js when adding - * a new model to the collection. - * @param {object} attrs - A literal object that contains the attributes to pass to the model - * @property {string} attrs.filterType - The type of Filter to create - * @property {XMLElement} attrs.objectDOM - The Filter XML - * @param {object} options - A literal object of additional options to pass to the model - * @returns {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup} - */ - model: function (attrs, options) { - - // Get the model type - var type = "" - // If no filterType was specified, but an objectDOM exists (from parsing a - // Collection or Portal document), get the filter type from the objectDOM - // node name - if (!attrs.filterType && attrs.objectDOM) { - type = attrs.objectDOM.nodeName; - } else if (attrs.filterType) { - type = attrs.filterType; - } else if (attrs.nodeName){ - type = attrs.nodeName + if (options && options.objectDOM) { + // Models are automatically added to the collection by the parse + // function. + var isUIFilterType = options.isUIFilterType == true ? true : false; + this.parse(options.objectDOM, isUIFilterType); } - // Ignoring the case of the type allows using either the - // filter type (e.g. BooleanFilter) or the nodeName value - // (e.g. "booleanFilter") - type = type.toLowerCase(); - - switch (type) { - case "booleanfilter": - return new BooleanFilter(attrs, options); - - case "datefilter": - return new DateFilter(attrs, options); - - case "numericfilter": - return new NumericFilter(attrs, options); - - case "filtergroup": - // We must initialize a Filter Group using the inline require syntax to - // avoid the problem of circular dependencies. Filters requires Filter - // Groups, and Filter Groups require Filters. For more info, see - // https://requirejs.org/docs/api.html#circular - var FilterGroup = require('models/filters/FilterGroup'); - var newFilterGroup = new FilterGroup(attrs, options) - return newFilterGroup; - - case "choicefilter": - return new ChoiceFilter(attrs, options); - - case "togglefilter": - return new ToggleFilter(attrs, options); - - case "spatialfilter": - return new SpatialFilter(attrs, options); - - default: - return new Filter(attrs, options); + if (options.catalogSearch) { + this.catalogSearchQuery = this.createCatalogSearchQuery(); } + } catch (error) { + console.log( + "Error initializing a Filters collection. Error details: " + error + ); + } + }, + + /** + * Creates the type of Filter Model based on the given filter type. This + * function is typically not called directly. It is used by Backbone.js + * when adding a new model to the collection. + * @param {object} attrs - A literal object that contains the attributes + * to pass to the model + * @property {string} attrs.filterType - The type of Filter to create + * @property {XMLElement} attrs.objectDOM - The Filter XML + * @param {object} options - A literal object of additional options to + * pass to the model + * @returns + * {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup} + */ + model: function (attrs, options) { + // Get the model type + var type = ""; + // If no filterType was specified, but an objectDOM exists (from parsing + // a Collection or Portal document), get the filter type from the + // objectDOM node name + if (!attrs.filterType && attrs.objectDOM) { + type = attrs.objectDOM.nodeName; + } else if (attrs.filterType) { + type = attrs.filterType; + } else if (attrs.nodeName) { + type = attrs.nodeName; + } + // Ignoring the case of the type allows using either the filter type + // (e.g. BooleanFilter) or the nodeName value (e.g. "booleanFilter") + type = type.toLowerCase(); - }, - - /** - * Parses a or element from a collection or portal - * document and sets the resulting models on this collection. - * - * @param {XMLElement} objectDOM - A FilterGroupType or UIFilterGroupType XML - * element from a portal or collection document - * @param {boolean} isUIFilterType - Set to true to indicate that these filters - * or filterGroups are part of a UIFilterGroup (aka custom Portal search filter). - * Otherwise, it's assumed that the filters are part of a Collection model - * definition. - * @return {JSON} The result of the parsed XML, in JSON. - */ - parse: function (objectDOM, isUIFilterType) { + switch (type) { + case "booleanfilter": + return new BooleanFilter(attrs, options); + + case "datefilter": + return new DateFilter(attrs, options); + + case "numericfilter": + return new NumericFilter(attrs, options); + + case "filtergroup": + // We must initialize a Filter Group using the inline require syntax + // to avoid the problem of circular dependencies. Filters requires + // Filter Groups, and Filter Groups require Filters. For more info, + // see https://requirejs.org/docs/api.html#circular + var FilterGroup = require("models/filters/FilterGroup"); + var newFilterGroup = new FilterGroup(attrs, options); + return newFilterGroup; - var filters = this; + case "choicefilter": + return new ChoiceFilter(attrs, options); - $(objectDOM).children().each(function (i, filterNode) { + case "togglefilter": + return new ToggleFilter(attrs, options); + + case "spatialfilter": + return new SpatialFilter(attrs, options); + + default: + return new Filter(attrs, options); + } + }, + + /** + * Parses a or element from a collection or + * portal document and sets the resulting models on this collection. + * + * @param {XMLElement} objectDOM - A FilterGroupType or UIFilterGroupType + * XML element from a portal or collection document + * @param {boolean} isUIFilterType - Set to true to indicate that these + * filters or filterGroups are part of a UIFilterGroup (aka custom Portal + * search filter). Otherwise, it's assumed that the filters are part of a + * Collection model definition. + * @return {JSON} The result of the parsed XML, in JSON. + */ + parse: function (objectDOM, isUIFilterType) { + var filters = this; + + $(objectDOM) + .children() + .each(function (i, filterNode) { filters.add({ objectDOM: filterNode, - isUIFilterType: isUIFilterType == true ? true : false - }) + isUIFilterType: isUIFilterType == true ? true : false, + }); }); - return filters.toJSON(); - }, - - /** - * Builds the query string to send to the query engine. Iterates over each filter - * in the collection and adds to the query string. - * - * @param {string} [operator=AND] The operator to use to combine multiple filters in this filter group. Must be AND or OR. - * @return {string} The query string to send to Solr - */ - getQuery: function (operator = "AND") { - - // The complete query string that eventually gets returned - var completeQuery = "" - - // Ensure that the operator is AND or OR so that the query string will be valid. - // Default to AND. - if (typeof operator !== "string") { - var operator = "AND"; - } - operator = operator.toUpperCase(); - if(!["AND", "OR"].includes(operator)){ - operator = "AND" - } + return filters.toJSON(); + }, + + /** + * Builds the query string to send to the query engine. Iterates over each + * filter in the collection and adds to the query string. + * + * @param {string} [operator=AND] The operator to use to combine multiple + * filters in this filter group. Must be AND or OR. + * @return {string} The query string to send to Solr + */ + getQuery: function (operator = "AND") { + // The complete query string that eventually gets returned + var completeQuery = ""; + + // Ensure that the operator is AND or OR so that the query string will + // be valid. Default to AND. + if (typeof operator !== "string") { + var operator = "AND"; + } + operator = operator.toUpperCase(); + if (!["AND", "OR"].includes(operator)) { + operator = "AND"; + } - // Adds URI encoded spaces to either side of a string - var padString = function(string){ return "%20" + string + "%20" } - - // Get the list of filters that use id fields since these are used differently. - var idFilters = this.getIdFilters(); - // Get the remaining filters that don't contain any ID fields - var mainFilters = this.getNonIdFilters(); - - // Create the grouped query for the id filters - var idFilterQuery = this.getGroupQuery(idFilters, "OR"); - // Make a query for all of the filters that do not contain ID fields - var mainQuery = this.getGroupQuery(mainFilters, operator); - - // First add the query string built from the non-ID filters - completeQuery += mainQuery; - - // Then add the Data Catalog filters if Filters was initialized with the - // catalogSearch = true option. Filters that are used in the data catalog are - // treated specially - if(this.catalogSearchQuery && this.catalogSearchQuery.length){ - // If there are other filters besides the catalog filters, AND the catalog - // filters to the end of the query for the other filters, regardless of which - // operator this function uses to combine other filters. - if (completeQuery && completeQuery.trim().length) { - completeQuery += padString("AND"); - } - completeQuery += this.catalogSearchQuery - } - - // Finally, add the ID filters to the very end of the query. This is done so - // that the query string is constructed with these filters "OR"ed into the - // query. For example, a query might be to look for datasets by a certain - // scientist OR with the given id. If those filters were ANDed together, the - // search would essentially ignore the creator filter and only return the - // dataset with the matching id. - if(idFilterQuery && idFilterQuery.trim().length){ - if (completeQuery && completeQuery.trim().length) { - // If the search results must always match one of the ids in the id filters, - // then add the id filters to the query with the AND operator. This flag - // is set on this Collection. Otherwise, use the OR operator - var idOperator = this.mustMatchIds ? padString("AND") : padString("OR"); - completeQuery = "(" + completeQuery + ")" + idOperator + idFilterQuery; - } else { - // If the query is ONLY made of id filters, then the id filter query is the - // complete query - completeQuery += idFilterQuery - } + // Adds URI encoded spaces to either side of a string + var padString = function (string) { + return "%20" + string + "%20"; + }; + + // Get the list of filters that use id fields since these are used + // differently. + var idFilters = this.getIdFilters(); + // Get the remaining filters that don't contain any ID fields + var mainFilters = this.getNonIdFilters(); + + // Create the grouped query for the id filters + var idFilterQuery = this.getGroupQuery(idFilters, "OR"); + // Make a query for all of the filters that do not contain ID fields + var mainQuery = this.getGroupQuery(mainFilters, operator); + + // First add the query string built from the non-ID filters + completeQuery += mainQuery; + + // Then add the Data Catalog filters if Filters was initialized with the + // catalogSearch = true option. Filters that are used in the data + // catalog are treated specially + if (this.catalogSearchQuery && this.catalogSearchQuery.length) { + // If there are other filters besides the catalog filters, AND the + // catalog filters to the end of the query for the other filters, + // regardless of which operator this function uses to combine other + // filters. + if (completeQuery && completeQuery.trim().length) { + completeQuery += padString("AND"); } + completeQuery += this.catalogSearchQuery; + } - // Return the completed query - return completeQuery; - - }, - - /** - * Searches the Filter models in this collection and returns any that have at - * least one field that matches any of the ID query fields, such as by id, seriesId, or the isPartOf relationship. - * @returns {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} - * Returns an array of filter models that include at least one ID field - */ - getIdFilters: function(){ - try { - return this.filter(function (filterModel) { - if(typeof filterModel.isIdFilter == "undefined"){ - return false - } - return filterModel.isIdFilter() - }); - } catch (error) { - console.log("Error trying to find ID Filters, error details: " + error); - } - }, - - /** - * Searches the Filter models in this collection and returns all have no fields - * matching any of the ID query fields. - * @returns {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} - * Returns an array of filter models that do not include any ID fields - */ - getNonIdFilters: function(){ - try { - return this.difference(this.getIdFilters()); - } catch (error) { - console.log("Error trying to find non-ID Filters, error details: " + error); + // Finally, add the ID filters to the very end of the query. This is + // done so that the query string is constructed with these filters + // "OR"ed into the query. For example, a query might be to look for + // datasets by a certain scientist OR with the given id. If those + // filters were ANDed together, the search would essentially ignore the + // creator filter and only return the dataset with the matching id. + if (idFilterQuery && idFilterQuery.trim().length) { + if (completeQuery && completeQuery.trim().length) { + // If the search results must always match one of the ids in the id + // filters, then add the id filters to the query with the AND + // operator. This flag is set on this Collection. Otherwise, use the + // OR operator + var idOperator = this.mustMatchIds + ? padString("AND") + : padString("OR"); + completeQuery = + "(" + completeQuery + ")" + idOperator + idFilterQuery; + } else { + // If the query is ONLY made of id filters, then the id filter query + // is the complete query + completeQuery += idFilterQuery; } - }, - - /** - * Get a query string for a group of Filters. - * The Filters will be ANDed together, unless a different operator is given. - * @param {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} filterModels - The Filters to turn into a query string - * @param {string} [operator="AND"] - The operator to use between filter models - * @return {string} The query string - */ - getGroupQuery: function (filterModels, operator="AND") { + } - try { - if(!filterModels || !filterModels.length || !this.getNonEmptyFilters(filterModels)){ - return "" + // Return the completed query + return completeQuery; + }, + + /** + * Searches the Filter models in this collection and returns any that have + * at least one field that matches any of the ID query fields, such as by + * id, seriesId, or the isPartOf relationship. + * @returns + * {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} + * Returns an array of filter models that include at least one ID field + */ + getIdFilters: function () { + try { + return this.filter(function (filterModel) { + if (typeof filterModel.isIdFilter == "undefined") { + return false; } - //Start an array to contain the query fragments - var groupQueryFragments = []; - - //For each Filter in this group, get the query string - _.each(filterModels, function (filterModel) { - // Get the Solr query string from this model. Pass on the group operator so - // that we can detect whether this filter query needs a positive clause in - // case it has exclude set to true. + return filterModel.isIdFilter(); + }); + } catch (error) { + console.log( + "Error trying to find ID Filters, error details: " + error + ); + } + }, + + /** + * Searches the Filter models in this collection and returns all have no + * fields matching any of the ID query fields. + * @returns + * {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} + * Returns an array of filter models that do not include any ID fields + */ + getNonIdFilters: function () { + try { + return this.difference(this.getIdFilters()); + } catch (error) { + console.log( + "Error trying to find non-ID Filters, error details: " + error + ); + } + }, + + /** + * Get a query string for a group of Filters. The Filters will be ANDed + * together, unless a different operator is given. + * @param + * {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} + * filterModels - The Filters to turn into a query string + * @param {string} [operator="AND"] - The operator to use between filter + * models + * @return {string} The query string + */ + getGroupQuery: function (filterModels, operator = "AND") { + try { + if ( + !filterModels || + !filterModels.length || + !this.getNonEmptyFilters(filterModels) + ) { + return ""; + } + //Start an array to contain the query fragments + var groupQueryFragments = []; + + //For each Filter in this group, get the query string + _.each( + filterModels, + function (filterModel) { + // Get the Solr query string from this model. Pass on the group + // operator so that we can detect whether this filter query needs + // a positive clause in case it has exclude set to true. var filterQuery = filterModel.getQuery(operator); //Add the filter query string to the overall array if (filterQuery && filterQuery.length > 0) { groupQueryFragments.push(filterQuery); } - }, this); + }, + this + ); - //Join this group's query fragments with an OR operator - if (groupQueryFragments.length) { - var queryString = groupQueryFragments.join("%20" + operator + "%20"); - if(groupQueryFragments.length > 1){ - queryString = "(" + queryString + ")" - } - return queryString - } - //Otherwise, return an empty string - else { - return ""; + //Join this group's query fragments with an OR operator + if (groupQueryFragments.length) { + var queryString = groupQueryFragments.join( + "%20" + operator + "%20" + ); + if (groupQueryFragments.length > 1) { + queryString = "(" + queryString + ")"; } - } catch (error) { - console.log("Error creating a group query, returning a blank string. " + - " Error details: " + error); - return "" + return queryString; } - - }, - - /** - * Given a Solr field name, determines if that field is set as a filter option - */ - filterIsAvailable: function (field) { - - var matchingFilter = this.find(function (filterModel) { - return _.contains(filterModel.fields, field); - }); - - if (matchingFilter) { - return true; - } else { - return false; + //Otherwise, return an empty string + else { + return ""; } - }, - - /* - * Returns an array of filter models in this collection that have a value set - * - * @return {Array} - an array of filter models in this collection that have a value set - */ - getCurrentFilters: function () { - var currentFilters = new Array(); - - this.each(function (filterModel) { - //If the filter model has values set differently than the default AND it is - // not an invisible filter, then add it to the current filters array - if (!filterModel.get("isInvisible") && - ((Array.isArray(filterModel.get("values")) && filterModel.get("values").length && - _.difference(filterModel.get("values"), filterModel.defaults().values).length) || - (!Array.isArray(filterModel.get("values")) && filterModel.get("values") !== filterModel.defaults().values)) - ) { - currentFilters.push(filterModel); - } - }); - - return currentFilters; - }, - - /* - * Clear the values of all geohash-related models in the collection - */ - resetGeohash: function () { - //Find all the filters in this collection that are related to geohashes - this.each(function (filterModel) { - if (!filterModel.get("isInvisible") && - (filterModel.type == "SpatialFilter" || - _.intersection(filterModel.fields, ["geohashes", "geohashLevel", "geohashGroups"]).length)) { - filterModel.resetValue(); - } - }); - }, - - /** - * Create a partial query string that's required for catalog searches - * @returns {string} - Returns the query string fragment for a catalog search - */ - createCatalogSearchQuery: function(){ - var catalogFilters = new Filters([ - { - fields: ["obsoletedBy"], - values: ["*"], - exclude: true - }, - { - fields: ["formatType"], - values: ["METADATA"], - matchSubstring: false - }]); - var query = catalogFilters.getGroupQuery(catalogFilters.models, "AND"); - return query - }, - - /** - * Creates and adds a Filter to this collection that filters datasets - * to only those that the logged-in user has permission to change permission of. - */ - addOwnershipFilter: function () { - - if (MetacatUI.appUserModel.get("loggedIn")) { - //Filter datasets by their ownership - this.add({ - fields: ["rightsHolder", "changePermission"], - values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"), - operator: "OR", - fieldsOperator: "OR", - matchSubstring: false, - exclude: false - }); + } catch (error) { + console.log( + "Error creating a group query, returning a blank string. " + + " Error details: " + + error + ); + return ""; + } + }, + + /** + * Given a Solr field name, determines if that field is set as a filter + * option + */ + filterIsAvailable: function (field) { + var matchingFilter = this.find(function (filterModel) { + return _.contains(filterModel.fields, field); + }); + + if (matchingFilter) { + return true; + } else { + return false; + } + }, + + /* + * Returns an array of filter models in this collection that have a value + * set + * + * @return {Array} - an array of filter models in this collection that + * have a value set + */ + getCurrentFilters: function () { + var currentFilters = new Array(); + + this.each(function (filterModel) { + //If the filter model has values set differently than the default AND + // it is not an invisible filter, then add it to the current filters + // array + if ( + !filterModel.get("isInvisible") && + ((Array.isArray(filterModel.get("values")) && + filterModel.get("values").length && + _.difference( + filterModel.get("values"), + filterModel.defaults().values + ).length) || + (!Array.isArray(filterModel.get("values")) && + filterModel.get("values") !== filterModel.defaults().values)) + ) { + currentFilters.push(filterModel); } - - }, - - /** - * Creates and adds a Filter to this collection that filters datasets - * to only those that the logged-in user has permission to write to. - */ - addWritePermissionFilter: function () { - - if (MetacatUI.appUserModel.get("loggedIn")) { - //Filter datasets by their ownership - this.add({ - fields: ["rightsHolder", "writePermission", "changePermission"], - values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"), - operator: "OR", - fieldsOperator: "OR", - matchSubstring: false, - exclude: false - }); + }); + + return currentFilters; + }, + + /* + * Clear the values of all geohash-related models in the collection + */ + resetGeohash: function () { + //Find all the filters in this collection that are related to geohashes + this.each(function (filterModel) { + if ( + !filterModel.get("isInvisible") && + (filterModel.type == "SpatialFilter" || + _.intersection(filterModel.fields, [ + "geohashes", + "geohashLevel", + "geohashGroups", + ]).length) + ) { + filterModel.resetValue(); } - - }, - - /** - * Removes Filter models from this collection if they match the given field - * @param {string} field - The field whose matching filters that should be removed from this collection - */ - removeFiltersByField: function (field) { - - var toRemove = []; - - this.each(function (filter) { - if (filter.get && filter.get("fields").includes(field)) { - toRemove.push(filter); - } + }); + }, + + /** + * Create a partial query string that's required for catalog searches + * @returns {string} - Returns the query string fragment for a catalog + * search + */ + createCatalogSearchQuery: function () { + var catalogFilters = new Filters([ + { + fields: ["obsoletedBy"], + values: ["*"], + exclude: true, + }, + { + fields: ["formatType"], + values: ["METADATA"], + matchSubstring: false, + }, + ]); + var query = catalogFilters.getGroupQuery(catalogFilters.models, "AND"); + return query; + }, + + /** + * Creates and adds a Filter to this collection that filters datasets to + * only those that the logged-in user has permission to change permission + * of. + */ + addOwnershipFilter: function () { + if (MetacatUI.appUserModel.get("loggedIn")) { + //Filter datasets by their ownership + this.add({ + fields: ["rightsHolder", "changePermission"], + values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"), + operator: "OR", + fieldsOperator: "OR", + matchSubstring: false, + exclude: false, }); - - this.remove(toRemove); - - }, - - /** - * Remove filters from the collection that are - * lacking fields, values, and in the case of a numeric filter, - * a min and max value. - * @param {boolean} [recursive=false] - Set to true to also remove empty filters - * from within any and all nested filterGroups. - */ - removeEmptyFilters: function(recursive = false){ - - try { - var toRemove = this.difference(this.getNonEmptyFilters()); - this.remove(toRemove); - - if (recursive){ - var nestedGroups = this.filter(function (filterModel) { - return filterModel.type == "FilterGroup" }); - - if(nestedGroups){ - nestedGroups.forEach(function(filterGroupModel){ - filterGroupModel.get("filters").removeEmptyFilters(true) - }); - } - } - - } catch (e) { - console.log("Failed to remove empty Filter models from the Filters collection, error message: " + e); + } + }, + + /** + * Creates and adds a Filter to this collection that filters datasets to + * only those that the logged-in user has permission to write to. + */ + addWritePermissionFilter: function () { + if (MetacatUI.appUserModel.get("loggedIn")) { + //Filter datasets by their ownership + this.add({ + fields: ["rightsHolder", "writePermission", "changePermission"], + values: MetacatUI.appUserModel.get("allIdentitiesAndGroups"), + operator: "OR", + fieldsOperator: "OR", + matchSubstring: false, + exclude: false, + }); + } + }, + + /** + * Removes Filter models from this collection if they match the given + * field + * @param {string} field - The field whose matching filters that should be + * removed from this collection + */ + removeFiltersByField: function (field) { + var toRemove = []; + + this.each(function (filter) { + if (filter.get && filter.get("fields").includes(field)) { + toRemove.push(filter); } + }); + + this.remove(toRemove); + }, + + /** + * Remove filters from the collection that are lacking fields, values, and + * in the case of a numeric filter, a min and max value. + * @param {boolean} [recursive=false] - Set to true to also remove empty + * filters from within any and all nested filterGroups. + */ + removeEmptyFilters: function (recursive = false) { + try { + var toRemove = this.difference(this.getNonEmptyFilters()); + this.remove(toRemove); - }, - - - /** - * getNonEmptyFilters - Returns the array of filters that are not empty - * @return {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} - * returns an array of Filter or FilterGroup models that are not empty - */ - getNonEmptyFilters: function(){ - try { - return this.filter(function(filterModel){ - return !filterModel.isEmpty(); + if (recursive) { + var nestedGroups = this.filter(function (filterModel) { + return filterModel.type == "FilterGroup"; }); - } catch (e) { - console.log("Failed to remove empty Filter models from the Filters collection, error message: " + e); - } - }, - - /** - * Remove a Filter from the Filters collection silently, and - * replace it with a new model. - * - * @param {Filter} model The model to replace - * @param {object} newAttrs Attributes for the replacement model. Use the filterType attribute to replace with a different type of Filter. - * @return {Filter} Returns the replacement Filter model, which is already part of the Filters collection. - */ - replaceModel: function (model, newAttrs) { - try { - var index = this.indexOf(model), - oldModelId = model.cid; - - this.remove(oldModelId, { silent: true }); - - var newModel = this.add( - newAttrs, - { at: index } - ); - return newModel; - } catch (e) { - console.log("Failed to replace a Filter model in a Filters collection, " + e); - } - }, - - /** - * visibleIndexOf - Get the index of a given model, excluding any - * filters that are marked as invisible. - * - * @param {Filter|BooleanFilter|NumericFilter|DateFilter} model The filter model for which to get the visible index - * @return {number} An integer representing the filter model's position in the list of visible filters. - */ - visibleIndexOf: function(model){ - try { - // Don't count invisible filters in the index we display to the user - var visibleFilters = this.filter(function(filterModel){ - var isInvisible = filterModel.get("isInvisible"); - return typeof isInvisible == "undefined" || isInvisible === false - }); - return _.indexOf(visibleFilters, model); - } catch (e) { - console.log("Failed to get the index of a Filter within the collection of visible Filters, error message: " + e); + if (nestedGroups) { + nestedGroups.forEach(function (filterGroupModel) { + filterGroupModel.get("filters").removeEmptyFilters(true); + }); + } } - }, + } catch (e) { + console.log( + "Failed to remove empty Filter models from the Filters collection, error message: " + + e + ); + } + }, + + /** + * getNonEmptyFilters - Returns the array of filters that are not empty + * @return + * {Filter|BooleanFilter|ChoiceFilter|DateFilter|NumericFilter|ToggleFilter|FilterGroup[]} + * returns an array of Filter or FilterGroup models that are not empty + */ + getNonEmptyFilters: function () { + try { + return this.filter(function (filterModel) { + return !filterModel.isEmpty(); + }); + } catch (e) { + console.log( + "Failed to remove empty Filter models from the Filters collection, error message: " + + e + ); + } + }, + + /** + * Remove a Filter from the Filters collection silently, and replace it + * with a new model. + * + * @param {Filter} model The model to replace + * @param {object} newAttrs Attributes for the replacement model. Use the + * filterType attribute to replace with a different type of Filter. + * @return {Filter} Returns the replacement Filter model, which + * is already part of the Filters collection. + */ + replaceModel: function (model, newAttrs) { + try { + var index = this.indexOf(model), + oldModelId = model.cid; + + this.remove(oldModelId, { silent: true }); + + var newModel = this.add(newAttrs, { at: index }); + + return newModel; + } catch (e) { + console.log( + "Failed to replace a Filter model in a Filters collection, " + e + ); + } + }, + + /** + * visibleIndexOf - Get the index of a given model, excluding any filters + * that are marked as invisible. + * + * @param {Filter|BooleanFilter|NumericFilter|DateFilter} model The + * filter model for which to get the visible index + * @return {number} An integer representing the filter model's position in + * the list of visible filters. + */ + visibleIndexOf: function (model) { + try { + // Don't count invisible filters in the index we display to the user + var visibleFilters = this.filter(function (filterModel) { + var isInvisible = filterModel.get("isInvisible"); + return typeof isInvisible == "undefined" || isInvisible === false; + }); + return _.indexOf(visibleFilters, model); + } catch (e) { + console.log( + "Failed to get the index of a Filter within the collection of visible Filters, error message: " + + e + ); + } + }, - /* + /* hasGeohashFilter: function() { var currentFilters = this.getCurrentFilters(); @@ -548,6 +616,7 @@ define([ } } */ - }); - return Filters; - }); + } + ); + return Filters; +}); From a6bc5463c5dd16b81609b20c0a6b01ceed90bbff Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 29 Mar 2023 16:48:50 -0400 Subject: [PATCH 33/79] Standardize formatting in Map & MapAssets Relates to #2069 --- src/js/collections/maps/MapAssets.js | 361 +++++++++--------- src/js/models/maps/Map.js | 545 +++++++++++++-------------- 2 files changed, 455 insertions(+), 451 deletions(-) diff --git a/src/js/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index 85a2431c0..bfc312f69 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -1,191 +1,198 @@ -'use strict'; +"use strict"; -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'models/maps/assets/MapAsset', - 'models/maps/assets/Cesium3DTileset', - 'models/maps/assets/CesiumVectorData', - 'models/maps/assets/CesiumImagery', - 'models/maps/assets/CesiumTerrain', - 'models/maps/assets/CesiumGeohash' - ], - function ( - $, - _, - Backbone, - MapAsset, - Cesium3DTileset, - CesiumVectorData, - CesiumImagery, - CesiumTerrain, - CesiumGeohash - ) { +define([ + "jquery", + "underscore", + "backbone", + "models/maps/assets/MapAsset", + "models/maps/assets/Cesium3DTileset", + "models/maps/assets/CesiumVectorData", + "models/maps/assets/CesiumImagery", + "models/maps/assets/CesiumTerrain", + "models/maps/assets/CesiumGeohash", +], function ( + $, + _, + Backbone, + MapAsset, + Cesium3DTileset, + CesiumVectorData, + CesiumImagery, + CesiumTerrain, + CesiumGeohash +) { + /** + * @class MapAssets + * @classdesc A MapAssets collection is a group of MapAsset models - models + * that provide the information required to render geo-spatial data on a map, + * including imagery (raster), vector, and terrain data. + * @class MapAssets + * @classcategory Collections/Maps + * @extends Backbone.Collection + * @since 2.18.0 + * @constructor + */ + var MapAssets = Backbone.Collection.extend( + /** @lends MapAssets.prototype */ { + /** + * Creates the type of Map Asset based on the given type. This function is + * typically not called directly. It is used by Backbone.js when adding a + * new model to the collection. + * @param {MapConfig#MapAssetConfig} assetConfig - An object that + * configured the source the asset data, as well as metadata and display + * properties of the asset. + * @returns + * {(Cesium3DTileset|CesiumImagery|CesiumTerrain|CesiumVectorData)} + * Returns a MapAsset model + */ + model: function (assetConfig) { + try { + // Supported types: Matches each 'type' attribute to the appropriate + // MapAsset model. See also CesiumWidgetView.mapAssetRenderFunctions + var mapAssetTypes = [ + { + types: ["Cesium3DTileset"], + model: Cesium3DTileset, + }, + { + types: ["GeoJsonDataSource"], + model: CesiumVectorData, + }, + { + types: [ + "BingMapsImageryProvider", + "IonImageryProvider", + "WebMapTileServiceImageryProvider", + "TileMapServiceImageryProvider", + "WebMapServiceImageryProvider", + "NaturalEarthII", + "USGSImageryTopo", + ], + model: CesiumImagery, + }, + { + types: ["CesiumTerrainProvider"], + model: CesiumTerrain, + }, + { + types: ["CesiumGeohash"], + model: CesiumGeohash, + }, + ]; - /** - * @class MapAssets - * @classdesc A MapAssets collection is a group of MapAsset models - models that - * provide the information required to render geo-spatial data on a map, including - * imagery (raster), vector, and terrain data. - * @class MapAssets - * @classcategory Collections/Maps - * @extends Backbone.Collection - * @since 2.18.0 - * @constructor - */ - var MapAssets = Backbone.Collection.extend( - /** @lends MapAssets.prototype */ { - - /** - * Creates the type of Map Asset based on the given type. This function is - * typically not called directly. It is used by Backbone.js when adding a new - * model to the collection. - * @param {MapConfig#MapAssetConfig} assetConfig - An object that configured the - * source the asset data, as well as metadata and display properties of the asset. - * @returns {(Cesium3DTileset|CesiumImagery|CesiumTerrain|CesiumVectorData)} - * Returns a MapAsset model - */ - model: function (assetConfig) { - try { - - // Supported types: Matches each 'type' attribute to the appropriate MapAsset - // model. See also CesiumWidgetView.mapAssetRenderFunctions - var mapAssetTypes = [ - { - types: ['Cesium3DTileset'], - model: Cesium3DTileset - }, - { - types: ['GeoJsonDataSource'], - model: CesiumVectorData - }, - { - types: ['BingMapsImageryProvider', 'IonImageryProvider', 'WebMapTileServiceImageryProvider', 'TileMapServiceImageryProvider', 'WebMapServiceImageryProvider', 'NaturalEarthII', 'USGSImageryTopo'], - model: CesiumImagery - }, - { - types: ['CesiumTerrainProvider'], - model: CesiumTerrain - }, - { - types: ['CesiumGeohash'], - model: CesiumGeohash - } - ]; - - var type = assetConfig.type - var modelOption = _.find(mapAssetTypes, function (option) { - return option.types.includes(type) - }) - - // Don't add an unsupported type to the collection - if (modelOption) { - return new modelOption.model(assetConfig) - } else { - // Return a generic MapAsset as a default - return new MapAsset(assetConfig) - } + var type = assetConfig.type; + var modelOption = _.find(mapAssetTypes, function (option) { + return option.types.includes(type); + }); + // Don't add an unsupported type to the collection + if (modelOption) { + return new modelOption.model(assetConfig); + } else { + // Return a generic MapAsset as a default + return new MapAsset(assetConfig); } - catch (error) { - console.log( - 'Failed to add a new model to a MapAssets collection' + - '. Error details: ' + error - ); - } - }, - - /** - * Executed when a new MapAssets collection is created. - */ - initialize: function () { - try { + } catch (error) { + console.log( + "Failed to add a new model to a MapAssets collection" + + ". Error details: " + + error + ); + } + }, - // Only allow one Map Asset in the collection to be selected at a time. When a - // Map Asset model's 'selected' attribute is changed to true, change all of the - // other models' selected attributes to false. - this.stopListening(this, 'change:selected'); - this.listenTo(this, 'change:selected', function (changedAsset, newValue) { + /** + * Executed when a new MapAssets collection is created. + */ + initialize: function () { + try { + // Only allow one Map Asset in the collection to be selected at a + // time. When a Map Asset model's 'selected' attribute is changed to + // true, change all of the other models' selected attributes to false. + this.stopListening(this, "change:selected"); + this.listenTo( + this, + "change:selected", + function (changedAsset, newValue) { if (newValue === true) { var otherModels = this.reject(function (assetModel) { - return assetModel === changedAsset - }) + return assetModel === changedAsset; + }); otherModels.forEach(function (otherModel) { - otherModel.set('selected', false) - }) + otherModel.set("selected", false); + }); } - }) - } - catch (error) { - console.log( - 'There was an error initializing a MapAssets collection' + - '. Error details: ' + error - ); - } - }, - - /** - * Set the parent map model on each of the MapAsset models in this collection. - * This must be the Map model that contains this asset collection. - * @param {MapModel} mapModel The map model to set on each of the MapAsset models - */ - setMapModel: function (mapModel) { - try { - this.each(function (mapAssetModel) { - mapAssetModel.set('mapModel', mapModel) - }) - } - catch (error) { - console.log( - 'Failed to set the map model on a MapAssets collection' + - '. Error details: ' + error - ); - } - }, - - /** - * Get a list of MapAsset models from this collection that are of a - * given type. - * @param {'Cesium3DTileset'|'CesiumVectorData'|'CesiumImagery'|'CesiumTerrain'} assetType - - * The general type of asset to filter the collection by. - * @returns {MapAsset[]} - Returns an array of MapAsset models that are - * instances of the given asset type. - * @since 2.22.0 - */ - getAll: function (assetType) { - try { - // map strings to the models they represent - var assetTypeMap = { - 'Cesium3DTileset': Cesium3DTileset, - 'CesiumVectorData': CesiumVectorData, - 'CesiumImagery': CesiumImagery, - 'CesiumTerrain': CesiumTerrain - } - if (assetType) { - return this.filter(function (assetModel) { - return assetModel instanceof assetTypeMap[assetType] - }) - } else { - return this.models } - } - catch (error) { - console.log( - 'Failed to get all of the MapAssets in a MapAssets collection' + - '. Error details: ' + error + - '\n\n' + - 'Returning all models in the asset collection.' - ); - return this.models - } + ); + } catch (error) { + console.log( + "There was an error initializing a MapAssets collection" + + ". Error details: " + + error + ); } + }, - } - ); + /** + * Set the parent map model on each of the MapAsset models in this + * collection. This must be the Map model that contains this asset + * collection. + * @param {MapModel} mapModel The map model to set on each of the MapAsset + * models + */ + setMapModel: function (mapModel) { + try { + this.each(function (mapAssetModel) { + mapAssetModel.set("mapModel", mapModel); + }); + } catch (error) { + console.log( + "Failed to set the map model on a MapAssets collection" + + ". Error details: " + + error + ); + } + }, - return MapAssets; + /** + * Get a list of MapAsset models from this collection that are of a given + * type. + * @param + * {'Cesium3DTileset'|'CesiumVectorData'|'CesiumImagery'|'CesiumTerrain'} + * assetType - The general type of asset to filter the collection by. + * @returns {MapAsset[]} - Returns an array of MapAsset models that are + * instances of the given asset type. + * @since 2.22.0 + */ + getAll: function (assetType) { + try { + // map strings to the models they represent + var assetTypeMap = { + Cesium3DTileset: Cesium3DTileset, + CesiumVectorData: CesiumVectorData, + CesiumImagery: CesiumImagery, + CesiumTerrain: CesiumTerrain, + }; + if (assetType) { + return this.filter(function (assetModel) { + return assetModel instanceof assetTypeMap[assetType]; + }); + } else { + return this.models; + } + } catch (error) { + console.log( + "Failed to get all of the MapAssets in a MapAssets collection" + + ". Error details: " + + error + + "\n\n" + + "Returning all models in the asset collection." + ); + return this.models; + } + }, + } + ); - } -); \ No newline at end of file + return MapAssets; +}); diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 10ce089ed..3d5415816 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -1,290 +1,287 @@ -'use strict'; +"use strict"; -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'collections/maps/Features', - 'models/maps/Feature', - 'collections/maps/MapAssets', - ], - function ( - $, - _, - Backbone, - Features, - Feature, - MapAssets, - ) { - /** - * @class MapModel - * @classdesc The Map Model contains all of the settings and options for a required to - * render a map view. - * @classcategory Models/Maps - * @name MapModel - * @since 2.18.0 - * @extends Backbone.Model - */ - var MapModel = Backbone.Model.extend( - /** @lends MapModel.prototype */ { +define([ + "jquery", + "underscore", + "backbone", + "collections/maps/Features", + "models/maps/Feature", + "collections/maps/MapAssets", +], function ($, _, Backbone, Features, Feature, MapAssets) { + /** + * @class MapModel + * @classdesc The Map Model contains all of the settings and options for a + * required to render a map view. + * @classcategory Models/Maps + * @name MapModel + * @since 2.18.0 + * @extends Backbone.Model + */ + var MapModel = Backbone.Model.extend( + /** @lends MapModel.prototype */ { + /** + * Configuration options for a {@link MapModel} that control the + * appearance of the map, the data/imagery displayed, and which UI + * components are rendered. A MapConfig object can be used when + * initializing a Map model, e.g. `new Map(myMapConfig)` + * @namespace {Object} MapConfig + * @property {MapConfig#CameraPosition} [homePosition] - A set of + * coordinates that give the (3D) starting point of the Viewer. This + * position is also where the "home" button in the Cesium widget will + * navigate to when clicked. + * @property {MapConfig#MapAssetConfig[]} [layers] - A collection of + * imagery, tiles, vector data, etc. to display on the map. Layers wil be + * displayed in the order they appear. The array of the layer + * MapAssetConfigs are passed to a {@link MapAssets} collection. + * @property {MapConfig#MapAssetConfig[]} [terrains] - Configuration for + * one or more digital elevation models (DEM) for the surface of the + * earth. Note: Though multiple terrains are supported, currently only the + * first terrain is used in the CesiumWidgetView and there is not yet a UI + * for switching terrains in the map. The array of the terrain + * MapAssetConfigs are passed to a {@link MapAssets} collection. + * @property {Boolean} [showToolbar=true] - Whether or not to show the + * side bar with layer list, etc. If true, the {@link MapView} will render + * a {@link ToolbarView}. + * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is + * open when the map is initialized. Set to false by default, so that the + * toolbar is hidden by default. + * @property {Boolean} [showScaleBar=true] - Whether or not to show a + * scale bar. If true, the {@link MapView} will render a + * {@link ScaleBarView}. + * @property {Boolean} [showFeatureInfo=true] - Whether or not to allow + * users to click on map features to show more information about them. If + * true, the {@link MapView} will render a {@link FeatureInfoView} and + * will initialize "picking" in the {@link CesiumWidgetView}. + * + * @example + * { + * "homePosition": { + * "latitude": 74.23, + * "longitude": -105.7 + * }, + * "layers": [ + * { + * "label": "My 3D Tile layer", + * "type": "Cesium3DTileset", + * "description": "This is an example 3D tileset. This description will be visible in the LayerDetailsView. It will be the default color, since to colorPalette is specified.", + * "cesiumOptions": { + * "ionAssetId": "555" + * }, + * } + * ], + * "terrains": [ + * { + * "label": "Arctic DEM", + * "type": "CesiumTerrainProvider", + * "cesiumOptions": { + * "ionAssetId": "3956", + * "requestVertexNormals": true + * } + * } + * ], + * "showToolbar": true, + * "showScaleBar": false, + * "showFeatureInfo": false + * } + */ - /** - * Configuration options for a {@link MapModel} that control the appearance of the - * map, the data/imagery displayed, and which UI components are rendered. A - * MapConfig object can be used when initializing a Map model, e.g. `new - * Map(myMapConfig)` - * @namespace {Object} MapConfig - * @property {MapConfig#CameraPosition} [homePosition] - A set of coordinates that - * give the (3D) starting point of the Viewer. This position is also where the - * "home" button in the Cesium widget will navigate to when clicked. - * @property {MapConfig#MapAssetConfig[]} [layers] - A collection of imagery, - * tiles, vector data, etc. to display on the map. Layers wil be displayed in the - * order they appear. The array of the layer MapAssetConfigs are passed to a - * {@link MapAssets} collection. - * @property {MapConfig#MapAssetConfig[]} [terrains] - Configuration for one or more digital - * elevation models (DEM) for the surface of the earth. Note: Though multiple - * terrains are supported, currently only the first terrain is used in the - * CesiumWidgetView and there is not yet a UI for switching terrains in the map. - * The array of the terrain MapAssetConfigs are passed to a {@link MapAssets} - * collection. - * @property {Boolean} [showToolbar=true] - Whether or not to show the side bar - * with layer list, etc. If true, the {@link MapView} will render a - * {@link ToolbarView}. - * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar - * is open when the map is initialized. Set to false by default, so - * that the toolbar is hidden by default. - * @property {Boolean} [showScaleBar=true] - Whether or not to show a scale bar. - * If true, the {@link MapView} will render a {@link ScaleBarView}. - * @property {Boolean} [showFeatureInfo=true] - Whether or not to allow users to - * click on map features to show more information about them. If true, the - * {@link MapView} will render a {@link FeatureInfoView} and will initialize - * "picking" in the {@link CesiumWidgetView}. - * - * @example - * { - * "homePosition": { - * "latitude": 74.23, - * "longitude": -105.7 - * }, - * "layers": [ - * { - * "label": "My 3D Tile layer", - * "type": "Cesium3DTileset", - * "description": "This is an example 3D tileset. This description will be visible in the LayerDetailsView. It will be the default color, since to colorPalette is specified.", - * "cesiumOptions": { - * "ionAssetId": "555" - * }, - * } - * ], - * "terrains": [ - * { - * "label": "Arctic DEM", - * "type": "CesiumTerrainProvider", - * "cesiumOptions": { - * "ionAssetId": "3956", - * "requestVertexNormals": true - * } - * } - * ], - * "showToolbar": true, - * "showScaleBar": false, - * "showFeatureInfo": false - * } - */ + /** + * Coordinates that describe a camera position for Cesium. Requires at + * least a longitude and latitude. + * @typedef {Object} MapConfig#CameraPosition + * @property {number} longitude - Longitude of the central home point + * @property {number} latitude - Latitude of the central home point + * @property {number} [height] - Height above sea level (meters) + * @property {number} [heading] - The rotation about the negative z axis + * (degrees) + * @property {number} [pitch] - The rotation about the negative y axis + * (degrees) + * @property {number} [roll] - The rotation about the positive x axis + * (degrees) + * + * @example + * { + * longitude: -119.8489, + * latitude: 34.4140 + * } + * + * @example + * { + * longitude: -65, + * latitude: 56, + * height: 10000000, + * heading: 1, + * pitch: -90, + * roll: 0 + * } + */ - /** - * Coordinates that describe a camera position for Cesium. Requires at least a - * longitude and latitude. - * @typedef {Object} MapConfig#CameraPosition - * @property {number} longitude - Longitude of the central home point - * @property {number} latitude - Latitude of the central home point - * @property {number} [height] - Height above sea level (meters) - * @property {number} [heading] - The rotation about the negative z axis - * (degrees) - * @property {number} [pitch] - The rotation about the negative y axis (degrees) - * @property {number} [roll] - The rotation about the positive x axis (degrees) - * - * @example - * { - * longitude: -119.8489, - * latitude: 34.4140 - * } - * - * @example - * { - * longitude: -65, - * latitude: 56, - * height: 10000000, - * heading: 1, - * pitch: -90, - * roll: 0 - * } - */ - - /** - * Overrides the default Backbone.Model.defaults() function to specify default - * attributes for the Map - * @name MapModel#defaults - * @type {Object} - * @property {MapConfig#CameraPosition} [homePosition={longitude: -65, latitude: 56, height: 10000000, heading: 1, pitch: -90, roll: 0}] - * A set of coordinates that give the - * (3D) starting point of the Viewer. This position is also where the "home" - * button in the Cesium viewer will navigate to when clicked. - * @property {MapAssets} [terrains = new MapAssets()] - The terrain options to - * show in the map. - * @property {MapAssets} [layers = new MapAssets()] - The imagery and vector data - * to render in the map. - * @property {Features} [selectedFeatures = new Features()] - Particular features - * from one or more layers that are highlighted/selected on the map. The - * 'selectedFeatures' attribute is updated by the map widget (cesium) with a - * Feature model when a user selects a geographical feature on the map (e.g. by - * clicking) - * @property {Boolean} [showToolbar=true] - Whether or not to show the side bar - * with layer list, etc. True by default. - * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar - * is open when the map is initialized. Set to false by default, so - * that the toolbar is hidden by default. - * @property {Boolean} [showScaleBar=true] - Whether or not to show a scale bar. - * @property {Boolean} [showFeatureInfo=true] - Whether or not to allow users to - * click on map features to show more information about them. - * @property {Object} [currentPosition={ longitude: null, latitude: null, height: null}] - * An object updated by the map widget to show the longitude, latitude, and - * height (elevation) at the position of the mouse on the map. Note: The - * CesiumWidgetView does not yet update the height property. - * @property {Object} [currentScale={ meters: null, pixels: null }] An object - * updated by the map widget that gives two equivalent measurements based on the - * map's current position and zoom level: The number of pixels on the screen that - * equal the number of meters on the map/globe. - * @property {Object} [currentViewExtent={ north: null, east: null, south: null, west: null }] - * An object updated by the map widget that gives the extent of the current - * visible area as a bounding box in longitude/latitude coordinates, as well - * as the height/altitude in meters. - */ - defaults: function () { - return { - homePosition: { - longitude: -65, - latitude: 56, - height: 10000000, - heading: 1, - pitch: -90, - roll: 0 - }, - layers: new MapAssets([{ - type: 'NaturalEarthII', - label: 'Base layer' - }]), - terrains: new MapAssets(), - selectedFeatures: new Features(), - showToolbar: true, - toolbarOpen: false, - showScaleBar: true, - showFeatureInfo: true, - currentPosition: { - longitude: null, - latitude: null, - height: null - }, - currentScale: { - meters: null, - pixels: null + /** + * Overrides the default Backbone.Model.defaults() function to specify + * default attributes for the Map + * @name MapModel#defaults + * @type {Object} + * @property {MapConfig#CameraPosition} [homePosition={longitude: -65, + * latitude: 56, height: 10000000, heading: 1, pitch: -90, roll: 0}] A set + * of coordinates that give the (3D) starting point of the Viewer. This + * position is also where the "home" button in the Cesium viewer will + * navigate to when clicked. + * @property {MapAssets} [terrains = new MapAssets()] - The terrain + * options to show in the map. + * @property {MapAssets} [layers = new MapAssets()] - The imagery and + * vector data to render in the map. + * @property {Features} [selectedFeatures = new Features()] - Particular + * features from one or more layers that are highlighted/selected on the + * map. The 'selectedFeatures' attribute is updated by the map widget + * (cesium) with a Feature model when a user selects a geographical + * feature on the map (e.g. by clicking) + * @property {Boolean} [showToolbar=true] - Whether or not to show the + * side bar with layer list, etc. True by default. + * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is + * open when the map is initialized. Set to false by default, so that the + * toolbar is hidden by default. + * @property {Boolean} [showScaleBar=true] - Whether or not to show a + * scale bar. + * @property {Boolean} [showFeatureInfo=true] - Whether or not to allow + * users to click on map features to show more information about them. + * @property {Object} [currentPosition={ longitude: null, latitude: null, + * height: null}] An object updated by the map widget to show the + * longitude, latitude, and height (elevation) at the position of the + * mouse on the map. Note: The CesiumWidgetView does not yet update the + * height property. + * @property {Object} [currentScale={ meters: null, pixels: null }] An + * object updated by the map widget that gives two equivalent measurements + * based on the map's current position and zoom level: The number of + * pixels on the screen that equal the number of meters on the map/globe. + * @property {Object} [currentViewExtent={ north: null, east: null, south: + * null, west: null }] An object updated by the map widget that gives the + * extent of the current visible area as a bounding box in + * longitude/latitude coordinates, as well as the height/altitude in + * meters. + */ + defaults: function () { + return { + homePosition: { + longitude: -65, + latitude: 56, + height: 10000000, + heading: 1, + pitch: -90, + roll: 0, + }, + layers: new MapAssets([ + { + type: "NaturalEarthII", + label: "Base layer", }, - currentViewExtent: { - north: null, - east: null, - south: null, - west: null, - height: null - } - }; - }, - - /** - * Run when a new Map is created. - * @param {MapConfig} config - An object specifying configuration options for the - * map. If any config option is not specified, the default will be used instead - * (see {@link MapModel#defaults}). - */ - initialize: function (config) { - try { - if (config) { - - function isNonEmptyArray(a) { - return a && a.length && Array.isArray(a) - } - - if (isNonEmptyArray(config.layers)) { - this.set('layers', new MapAssets(config.layers)) - this.get('layers').setMapModel(this) - } - if (isNonEmptyArray(config.terrains)) { - this.set('terrains', new MapAssets(config.terrains)) - } + ]), + terrains: new MapAssets(), + selectedFeatures: new Features(), + showToolbar: true, + toolbarOpen: false, + showScaleBar: true, + showFeatureInfo: true, + currentPosition: { + longitude: null, + latitude: null, + height: null, + }, + currentScale: { + meters: null, + pixels: null, + }, + currentViewExtent: { + north: null, + east: null, + south: null, + west: null, + height: null, + }, + }; + }, + /** + * Run when a new Map is created. + * @param {MapConfig} config - An object specifying configuration options + * for the map. If any config option is not specified, the default will be + * used instead (see {@link MapModel#defaults}). + */ + initialize: function (config) { + try { + if (config) { + function isNonEmptyArray(a) { + return a && a.length && Array.isArray(a); } - } - catch (error) { - console.log( - 'There was an error initializing a Map model' + - '. Error details: ' + error - ); - } - }, - /** - * Set or unset the selected Features on the map model. A selected feature is a - * polygon, line, point, or other element of vector data that is in focus on the - * map (e.g. because a user clicked it to show more details.) - * @param {(Feature|Object[])} features - An array of Feature models or objects with - * attributes to set on new Feature models. If no features argument is passed to - * this function, then any currently selected feature will be removed (as long as - * replace is set to true) - * @param {Boolean} [replace=true] - If true, any currently selected features will - * be replaced with the newly selected features. If false, then the newly selected - * features will be added to any that are currently selected. - */ - selectFeatures(features, replace = true) { - try { - - const model = this; - const defaults = new Feature().defaults() - - if (!model.get('selectedFeatures')) { - model.set('selectedFeatures', new Features()) + if (isNonEmptyArray(config.layers)) { + this.set("layers", new MapAssets(config.layers)); + this.get("layers").setMapModel(this); } - - // If no feature is passed to this function (and replace is true), then empty - // the Features collection - if (!features || !Array.isArray(features)) { - features = [] + if (isNonEmptyArray(config.terrains)) { + this.set("terrains", new MapAssets(config.terrains)); } + } + } catch (error) { + console.log( + "There was an error initializing a Map model" + + ". Error details: " + + error + ); + } + }, - // If feature is a Feature model, get the attributes to update the model. - features.forEach(function (feature, i) { - if (feature instanceof Feature) { - feature = feature.attributes - } - features[i] = _.extend(_.clone(defaults), feature) - }) - - // Update the Feature model with the new selected feature information. - const options = { - remove: replace - } - model.get('selectedFeatures').set(features, options) + /** + * Set or unset the selected Features on the map model. A selected feature + * is a polygon, line, point, or other element of vector data that is in + * focus on the map (e.g. because a user clicked it to show more details.) + * @param {(Feature|Object[])} features - An array of Feature models or + * objects with attributes to set on new Feature models. If no features + * argument is passed to this function, then any currently selected + * feature will be removed (as long as replace is set to true) + * @param {Boolean} [replace=true] - If true, any currently selected + * features will be replaced with the newly selected features. If false, + * then the newly selected features will be added to any that are + * currently selected. + */ + selectFeatures(features, replace = true) { + try { + const model = this; + const defaults = new Feature().defaults(); + if (!model.get("selectedFeatures")) { + model.set("selectedFeatures", new Features()); } - catch (error) { - console.log( - 'Failed to select a Feature in a Map model' + - '. Error details: ' + error - ); + + // If no feature is passed to this function (and replace is true), + // then empty the Features collection + if (!features || !Array.isArray(features)) { + features = []; } - }, + // If feature is a Feature model, get the attributes to update the + // model. + features.forEach(function (feature, i) { + if (feature instanceof Feature) { + feature = feature.attributes; + } + features[i] = _.extend(_.clone(defaults), feature); + }); - }); + // Update the Feature model with the new selected feature information. + const options = { + remove: replace, + }; + model.get("selectedFeatures").set(features, options); + } catch (error) { + console.log( + "Failed to select a Feature in a Map model" + + ". Error details: " + + error + ); + } + }, + } + ); - return MapModel; - }); + return MapModel; +}); From fa7579efd2ead1ee6e1521497ff4831b2bf2685f Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 29 Mar 2023 17:14:10 -0400 Subject: [PATCH 34/79] Add facet counts to Geohash Layer from Search Create a Map-Search connector that finds/adds a Geohash layer and keeps it up-to-date with SolrResults. Also, in the Filters-Search connector, set facets on the SolrResults when the Spatial filter changes in the Filters collection. Relates to #2069 --- src/js/collections/Filters.js | 58 +++++--- src/js/collections/maps/MapAssets.js | 29 +++- src/js/models/connectors/Filters-Map.js | 3 - src/js/models/connectors/Filters-Search.js | 15 +- src/js/models/connectors/Map-Search.js | 164 +++++++++++++++++++++ src/js/models/maps/Map.js | 11 ++ src/js/models/maps/assets/CesiumGeohash.js | 21 +-- src/js/views/search/CatalogSearchView.js | 10 +- 8 files changed, 272 insertions(+), 39 deletions(-) create mode 100644 src/js/models/connectors/Map-Search.js diff --git a/src/js/collections/Filters.js b/src/js/collections/Filters.js index 633e3c905..c40a89086 100644 --- a/src/js/collections/Filters.js +++ b/src/js/collections/Filters.js @@ -402,6 +402,47 @@ define([ return currentFilters; }, + /** + * Get all filters in this collection that are of a given filter type. + * @param {string} type - The filter type to get, e.g. "BooleanFilter". If + * not set, all filters will be returned. + * @returns {Filter[]} An array of filter models + */ + getAllOfType: function (type) { + if (!type) { + return this.models; + } + return this.filter(function (filterModel) { + return filterModel.get("filterType") == type; + }); + }, + + /** + * Returns the geohash levels that are set on any SpatialFilter models in + * this collection. If no SpatialFilter models are found, or no geohash + * levels are set, an empty array is returned. + * @returns {string[]} An array of geohash levels in the format + * ["geohash_1", "geohash_2", ...] + */ + getGeohashLevels: function () { + const spFilters = this.getAllOfType("SpatialFilter"); + if (!spFilters.length) { + return []; + } + return _.uniq( + _.flatten( + _.map(spFilters, function (spFilter) { + const fields = spFilter.get("fields"); + if (fields && fields.length) { + return _.filter(fields, function (field) { + return field.indexOf("geohash") > -1; + }); + } + }) + ) + ); + }, + /* * Clear the values of all geohash-related models in the collection */ @@ -599,23 +640,6 @@ define([ ); } }, - - /* - hasGeohashFilter: function() { - - var currentFilters = this.getCurrentFilters(); - var geohashFilter = _.find(currentFilters, function(filterModel){ - return (_.intersection(filterModel.get("fields"), - ["geohashes", "geohash"]).length > 0); - }); - - if(geohashFilter) { - return true; - } else { - return false; - } - } - */ } ); return Filters; diff --git a/src/js/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index bfc312f69..8dd1871de 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -172,6 +172,7 @@ define([ CesiumVectorData: CesiumVectorData, CesiumImagery: CesiumImagery, CesiumTerrain: CesiumTerrain, + CesiumGeohash: CesiumGeohash, }; if (assetType) { return this.filter(function (assetModel) { @@ -180,15 +181,33 @@ define([ } else { return this.models; } + } catch (e) { + console.log( + "Failed to get all of the MapAssets in a MapAssets collection." + + " Returning all models in the asset collection." + + e + ); + return this.models; + } + }, + + /** + * Add a new Geohash layer to the collection. + * @param {MapConfig#MapAssetConfig} assetConfig - Configuration object + * for the Geohash layer (optional). + */ + addGeohashLayer: function (assetConfig) { + try { + const config = Object.assign({}, assetConfig, { + type: "CesiumGeohash", + }); + return this.add(config); } catch (error) { console.log( - "Failed to get all of the MapAssets in a MapAssets collection" + + "Failed to add a geohash layer to a MapAssets collection" + ". Error details: " + - error + - "\n\n" + - "Returning all models in the asset collection." + error ); - return this.models; } }, } diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index 9ec6cb7be..d63467f20 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -129,9 +129,6 @@ define([ const spatialFilters = this.get("spatialFilters"); if (!spatialFilters?.length && add) { this.get("filters").add(new SpatialFilter()); - // 🐛🐛🐛 TODO: When a new SpatialFilter is added, the SolrResults are - // not hearing changes. Do we need to add a listener somewhere to the - // filters collection for updates? } }, diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 4eed9a309..d0be7a443 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -83,20 +83,29 @@ define([ startListening: function () { this.stopListeners(); const model = this; + const filters = this.get("filters"); + const searchResults = this.get("searchResults"); // Listen to changes in the Filters to trigger a search this.listenTo( - this.get("filters"), + filters, "add remove update reset change", function () { // Start at the first page when the filters change MetacatUI.appModel.set("page", 0); - model.triggerSearch(); + // If there is a spatial filter, update the facets in the SolrResults + // The setFacet method will trigger a search. + const facets = filters.getGeohashLevels(); + if (facets && facets.length) { + searchResults.setFacet(facets); + } else { + searchResults.setFacet(null); + } } ); this.listenTo( - this.get("searchResults"), + searchResults, "change:sort change:facet", this.triggerSearch ); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js new file mode 100644 index 000000000..d79894f4d --- /dev/null +++ b/src/js/models/connectors/Map-Search.js @@ -0,0 +1,164 @@ +/*global define */ +define([ + "backbone", + "models/maps/Map", + "collections/SolrResults", + "models/maps/assets/CesiumGeohash", +], function (Backbone, Map, SearchResults, Geohash) { + "use strict"; + + /** + * @class MapSearchConnector + * @classdesc A model that updates the counts on a Geohash layer in a Map + * model when the search results from a search model are reset. + * @name MapSearchConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + */ + return Backbone.Model.extend( + /** @lends MapSearchConnector.prototype */ { + /** + * @type {object} + * @property {SolrResults} searchResults + * @property {Map} map + */ + defaults: function () { + return { + searchResults: null, + map: null, + }; + }, + + /** + * @inheritdoc + */ + initialize: function () { + this.findAndSetGeohashLayer(); + }, + + /** + * Find the first Geohash layer in the Map model's layers collection. + * @returns {CesiumGeohash} The first Geohash layer in the Map model's + * layers collection or null if there is no Layers collection set on this + * model or no Geohash layer in the collection. + */ + findGeohash: function () { + const layers = this.get("layers"); + if (!layers) return null; + let geohashes = layers.getAll("CesiumGeohash"); + if (!geohashes || !geohashes.length) { + return null; + } else { + return geohashes[0] || null; + } + }, + + /** + * Find the Layers collection from the Map model. + * @returns {Layers} The Layers collection from the Map model or null if + * there is no Map model set on this model. + */ + findLayers: function () { + const model = this; + const map = this.get("map"); + if (!map) return null; + return map.get("layers"); + }, + + /** + * Create a new Layers collection and set it on the Map model. + * @returns {Layers} The new Layers collection. + */ + createLayers: function () { + const map = this.get("map"); + if (!map) return null; + return map.resetLayers(); + }, + + /** + * Create a new Geohash layer and add it to the Layers collection. + * @returns {CesiumGeohash} The new Geohash layer or null if there is no + * Layers collection set on this model. + * @fires Layers#add + */ + createGeohash() { + const layers = this.get("layers"); + if (!layers) return null; + return layers.addGeohashLayer(); + }, + + /** + * Find the Geohash layer in the Map model's layers collection and + * optionally create one if it doesn't exist. This will also create and + * set a map and a layers collection from that map if they don't exist. + * @param {boolean} [add=false] - If true, create a new Geohash layer if + * one doesn't exist. + * @returns {CesiumGeohash} The Geohash layer in the Map model's layers + * collection or null if there is no Layers collection set on this model + * and `add` is false. + * @fires Layers#add + */ + findAndSetGeohashLayer: function (add = false) { + const wasListening = this.get("isListening"); + this.stopListeners(); + let map = this.get("map") || this.set("map", new Map()).get("map"); + const layers = this.findLayers() || this.createLayers(); + this.set("layers", layers); + let geohash = this.findGeohash() || (add ? this.createGeohash() : null); + this.set("geohashLayer", geohash); + if (wasListening) { + this.startListening(); + } + return geohash; + }, + + /** + * Connect the Map to the Search. When a new search is performed, the + * Search will set the new facet counts on the GeoHash layer in the Map. + */ + startListening: function () { + this.stopListeners(); + const searchResults = this.get("searchResults"); + this.listenTo(searchResults, "reset", this.updateGeohashCounts); + this.set("isListening", true); + }, + + /** + * Disconnect the Map from the Search. Stops listening to the Search + * results collection. + */ + stopListeners: function () { + const searchResults = this.get("searchResults"); + this.stopListening(searchResults, "reset"); + this.set("isListening", false); + }, + + /** + * Update the Geohash layer in the Map model with the new facet counts + * from the Search results. + * @fires CesiumGeohash#change:counts + * @fires CesiumGeohash#change:totalCount + */ + updateGeohashCounts: function () { + const geohashLayer = this.get("geohashLayer"); + const searchResults = this.get("searchResults"); + const facetCounts = searchResults.facetCounts; + + // Get every facet that begins with "geohash_" + const geohashFacets = Object.keys(facetCounts).filter((key) => + key.startsWith("geohash_") + ); + + // Flatten counts from geohashFacets + const allCounts = geohashFacets.flatMap((key) => facetCounts[key]); + + const totalFound = searchResults.getNumFound(); + + // Set the new geohash facet counts on the Map MapAsset + geohashLayer.set("counts", allCounts); + geohashLayer.set("totalCount", totalFound); + }, + } + ); +}); diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 3d5415816..2f4a921cb 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -280,6 +280,17 @@ define([ ); } }, + + /** + * Reset the layers to the default layers. This will set a new MapAssets + * collection on the layer attribute. + * @returns {MapAssets} The new layers collection. + */ + resetLayers: function () { + const newLayers = this.defaults()?.layers || new MapAssets(); + this.set("layers", newLayers); + return newLayers; + }, } ); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 52ac960e0..d5d594127 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -33,10 +33,6 @@ define([ * @extends CesiumVectorData#defaults * @property {'CesiumGeohash'} type The format of the data. Must be * 'CesiumGeohash'. - * @property {boolean} isGeohashLayer A flag to indicate that this is a - * Geohash layer, since we change the type to CesiumVectorData. Used by - * the Catalog Search View to find this layer so it can be connected to - * search results. * @property {string[]} counts An array of geohash strings followed by * their associated count. e.g. ["a", 123, "f", 8] * @property {Number} totalCount The total number of results that were @@ -49,8 +45,9 @@ define([ return Object.assign(CesiumVectorData.prototype.defaults(), { type: "GeoJsonDataSource", label: "Geohashes", - isGeohashLayer: true, counts: [], + // TODO: split this into geohashIDs and counts. Maybe make totalCount + // optional, so we can calculate from counts if needed. totalCount: 0, geohashes: [], }); @@ -111,13 +108,14 @@ define([ const geohashes = []; for (let i = 0; i < counts.length; i += 2) { const id = counts[i]; - geohashes.append({ + geohashes.push({ id: id, count: counts[i + 1], bounds: nGeohash.decode_bbox(id), }); } this.set("geohashes", geohashes); + this.createCesiumModel(true); } catch (error) { console.log("Failed to update geohashes in CesiumGeohash", error); } @@ -140,8 +138,11 @@ define([ } const features = []; // Format for geohashes: - // { geohashID: [minlat, minlon, maxlat, maxlon] }. - for (const [id, bb] of Object.entries(geohashes)) { + // [{ counts, id, bounds}] + geohashes.forEach((geohash) => { + const bb = geohash.bounds; + const id = geohash.id; + const count = geohash.count; const minlat = bb[0] <= -90 ? -89.99999 : bb[0]; const minlon = bb[1]; const maxlat = bb[2]; @@ -161,12 +162,12 @@ define([ ], }, properties: { - // "count": 0, // TODO - add counts + "count": count, geohash: id, }, }; features.push(feature); - } + }) geojson["features"] = features; return geojson; } catch (error) { diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 109bdf6c2..0a2847210 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -14,6 +14,7 @@ define([ "views/search/SearchResultsPagerView", "views/search/SorterView", "text!templates/search/catalogSearch.html", + "models/connectors/Map-Search" ], function ( $, Backbone, @@ -28,7 +29,8 @@ define([ MapView, PagerView, SorterView, - Template + Template, + MapSearchConnector ) { "use strict"; @@ -635,6 +637,12 @@ define([ }); connector.startListening(); + const otherConnector = new MapSearchConnector({ + map: map, + searchResults: this.searchResultsView.searchResults, + }); + otherConnector.startListening(); + // Create the Map model and view this.mapView = new MapView({ model: map }); } catch (e) { From 6808fcf54fd77117e9139d32c8831ac0d83e6a37 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 29 Mar 2023 20:11:28 -0400 Subject: [PATCH 35/79] Create Map-Search-Filters connector Move connector/model logic from CatalogSearchView to Map-Search-Filters Relates to #2069 --- src/js/models/connectors/Filters-Map.js | 26 ++- src/js/models/connectors/Filters-Search.js | 24 +- src/js/models/connectors/Geohash-Search.js | 59 ----- .../models/connectors/Map-Search-Filters.js | 211 ++++++++++++++++++ src/js/models/connectors/Map-Search.js | 18 +- src/js/views/search/CatalogSearchView.js | 208 +++-------------- 6 files changed, 273 insertions(+), 273 deletions(-) delete mode 100644 src/js/models/connectors/Geohash-Search.js create mode 100644 src/js/models/connectors/Map-Search-Filters.js diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index d63467f20..e70a29bba 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -31,7 +31,7 @@ define([ * @property {SpatialFilter[]} spatialFilters An array of SpatialFilter * models present in the Filters collection. * @property {Map} map The Map model that will update the spatial filters - * @property {boolean} isListening Whether the connector is currently + * @property {boolean} isConnected Whether the connector is currently * listening to the Map model for changes. Set automatically when the * connector is started or stopped. * @since x.x.x @@ -42,7 +42,7 @@ define([ filters: new Filters([], { catalogSearch: true }), spatialFilters: [], map: new Map(), - isListening: false, + isConnected: false, }; }, @@ -85,13 +85,13 @@ define([ * are found in the collection. */ findAndSetSpatialFilters: function (add = false) { - const wasListening = this.get("isListening"); - this.stopListeners(); + const wasConnected = this.get("isConnected"); + this.disconnect(); this.setSpatialFilters(); this.listenOnceToFiltersUpdates(); this.addSpatialFilterIfNeeded(add); - if (wasListening) { - this.startListening(); + if (wasConnected) { + this.connect(); } }, @@ -128,7 +128,9 @@ define([ addSpatialFilterIfNeeded: function (add) { const spatialFilters = this.get("spatialFilters"); if (!spatialFilters?.length && add) { - this.get("filters").add(new SpatialFilter()); + this.get("filters").add(new SpatialFilter({ + isInvisible: true, + })); } }, @@ -136,11 +138,11 @@ define([ * Stops all Filter-Map listeners, including listeners on the Filters * collection and the Map model. */ - stopListeners: function () { + disconnect: function () { try { this.stopListening(this.get("filters"), "add remove"); this.stopListening(this.get("map"), "change:currentViewExtent"); - this.set("isListening", false); + this.set("isConnected", false); } catch (e) { console.log("Error stopping Filter-Map listeners: ", e); } @@ -152,15 +154,15 @@ define([ * function when changes are detected. This method needs to be called for * the connector to work. */ - startListening: function () { + connect: function () { try { - this.stopListeners(); + this.disconnect(); this.listenTo( this.get("map"), "change:currentViewExtent", this.updateSpatialFilters ); - this.set("isListening", true); + this.set("isConnected", true); } catch (e) { console.log("Error starting Filter-Map listeners: ", e); } diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index d0be7a443..ad37a4a9f 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -25,15 +25,15 @@ define([ * @property {Filters} filters A Filters collection to use for this search * @property {SolrResults} searchResults The SolrResults collection that * the search results will be stored in - * @property {boolean} isListening Whether or not the model has listeners + * @property {boolean} isConnected Whether or not the model has listeners * set between the Filters and SearchResults. Set this with the - * startListening and stopListeners methods. + * connect and disconnect methods. */ defaults: function () { return { filters: new Filters([], { catalogSearch: true }), searchResults: new SearchResults(), - isListening: false, + isConnected: false, }; }, @@ -49,8 +49,8 @@ define([ if (!models) return; models = Array.isArray(models) ? models : [models]; - const wasListening = this.get("isListening"); - this.stopListeners(); + const wasConnected = this.get("isConnected"); + this.disconnect(); const attrClassMap = { filters: Filters, @@ -70,8 +70,8 @@ define([ } }); - if (wasListening) { - this.startListening(); + if (wasConnected) { + this.connect(); } }, @@ -80,8 +80,8 @@ define([ * when the search changes * @since 2.22.0 */ - startListening: function () { - this.stopListeners(); + connect: function () { + this.disconnect(); const model = this; const filters = this.get("filters"); const searchResults = this.get("searchResults"); @@ -116,14 +116,14 @@ define([ "change:loggedIn", this.triggerSearch ); - this.set("isListening", true); + this.set("isConnected", true); }, /** * Stops listening to changes in the Filters and SearchResults * @since x.x.x */ - stopListeners: function () { + disconnect: function () { const model = this; this.stopListening(MetacatUI.appUserModel, "change:loggedIn"); this.stopListening( @@ -135,7 +135,7 @@ define([ this.get("searchResults"), "change:sort change:facet" ); - this.set("isListening", false); + this.set("isConnected", false); }, /** diff --git a/src/js/models/connectors/Geohash-Search.js b/src/js/models/connectors/Geohash-Search.js deleted file mode 100644 index d2874decf..000000000 --- a/src/js/models/connectors/Geohash-Search.js +++ /dev/null @@ -1,59 +0,0 @@ -/*global define */ -define([ - "backbone", - "models/maps/assets/CesiumGeohash", - "collections/SolrResults", - "models/Search", -], function (Backbone, CesiumGeohash, SearchResults, Search) { - "use strict"; - - /** - * @class GeohashSearchConnector - * @classdesc A model that creates listeners between the CesiumGeohash MapAsset model and the Search model. - * @name GeohashSearchConnector - * @extends Backbone.Model - * @constructor - * @classcategory Models/Connectors - */ - return Backbone.Model.extend( - /** @lends GeohashSearchConnector.prototype */ { - /** - * @type {object} - * @property {SolrResults} searchResults - * @property {CesiumGeohash} cesiumGeohash - */ - defaults: function () { - return { - searchResults: null, - cesiumGeohash: null, - }; - }, - - /** - * Sets listeners on the CesiumGeohash map asset and the SearchResults. It will get the geohash facet data - * from the SolrResults and set it on the CesiumGeohash so it can be used by a map view. It also updates the - * geohash level in the SolrResults so that it can be used by the next query. - * @since 2.22.0 - */ - startListening: function () { - const geohashLayer = this.get("cesiumGeohash"); - const searchResults = this.get("searchResults"); - - this.listenTo(searchResults, "reset", function () { - const level = geohashLayer.get("level") || 1; - const facetCounts = searchResults.facetCounts["geohash_" + level]; - const totalFound = searchResults.getNumFound(); - - // Set the new geohash facet counts on the CesiumGeohash MapAsset - geohashLayer.set("counts", facetCounts); - geohashLayer.set("totalCount", totalFound); - }); - - this.listenTo(geohashLayer, "change:geohashLevel", function () { - const level = geohashLayer.get("level") || 1; - searchResults.setFacet(["geohash_" + level]); - }); - }, - } - ); -}); diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js new file mode 100644 index 000000000..3fd1fa7a7 --- /dev/null +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -0,0 +1,211 @@ +/*global define */ +define([ + "backbone", + "models/maps/Map", + "collections/SolrResults", + "collections/Filters", + "models/connectors/Map-Search", + "models/connectors/Filters-Search", + "models/connectors/Filters-Map", + "models/filters/FilterGroup", +], function ( + Backbone, + Map, + SearchResults, + Filters, + MapSearchConnector, + FiltersSearchConnector, + FiltersMapConnector, + FilterGroup +) { + "use strict"; + + /** + * @class MapSearchFiltersConnector + * @classdesc A model that handles connecting the Map model, the SolrResults + * model, and the Filters model, e.g. for a CatalogSearchView. + * @name MapSearchFiltersConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + * @since x.x.x + */ + return Backbone.Model.extend( + /** @lends MapSearchFiltersConnector.prototype */ { + /** + * The default values for this model. + * @type {object} + * @property {Map} map + * @property {SolrResults} searchResults + * @property {Filters} filters + */ + defaults: function () { + return { + map: new Map(), + searchResults: new SearchResults(), + filterGroups: [], + filters: null, + }; + }, + + /** + * @inheritdoc + */ + initialize: function () { + // TODO: allow setting these with args here + const filterGroupsJSON = MetacatUI.appModel.get("defaultFilterGroups"); + const mapOptions = MetacatUI.appModel.get("catalogSearchMapOptions"); + this.setMap(mapOptions); + this.setSearchResults(); + this.setFilters(filterGroupsJSON); + this.setConnectors(); + // TODO: Listen to change:map, change:filters, etc. and update the + // connectors + }, + + /** + * Set the Map model for this connector. + * @param {Map | Object } map - The Map model to use for this connector or + * a JSON object with options to create a new Map model. + */ + setMap: function (map) { + let mapModel = map instanceof Map ? map : new Map(map || {}); + this.set("map", mapModel); + }, + + /** + * Set the SearchResults model for this connector. + * @param {SolrResults | Object } searchResults - The SolrResults model to + * use for this connector or a JSON object with options to create a new + * SolrResults model. + */ + setSearchResults: function (searchResults) { + const resultsModel = + searchResults instanceof SearchResults + ? searchResults + : new SearchResults(searchResults || {}); + this.set("searchResults", resultsModel); + }, + + /** + * Set the Filters model for this connector. + * @param {Array} filters - An array of FilterGroup models or JSON objects + * with options to create new FilterGroup models. If a single FilterGroup + * is passed, it will be wrapped in an array. + * @param {boolean} [catalogSearch=true] - If true, the Filters model will + * be created with the catalogSearch option set to true. + * @see Filters + * @see FilterGroup + */ + setFilters: function (filtersArray, catalogSearch = true) { + const filterGroups = []; + const filters = new Filters(null, { catalogSearch: catalogSearch }); + // TODO: catalogSearch should be an option set in initialize + + filtersArray = Array.isArray(filtersArray) + ? filtersArray + : [filtersArray]; + + filtersArray.forEach((filterGroup) => { + // filterGroupJSON.isUIFilterType = true; // TODO - do we need this? + const filterGroupModel = + filterGroup instanceof FilterGroup + ? filterGroup + : new FilterGroup(filterGroup || {}); + filterGroups.push(filterGroupModel); + filters.add(filterGroupModel.get("filters").models); + }); + + this.set("filterGroups", filterGroups); + this.set("filters", filters); + }, + + /** + * Set all the connectors required to connect the Map, SearchResults, and + * Filters. This does not connect them (see connect()). + */ + setConnectors: function () { + const map = this.get("map"); + const searchResults = this.get("searchResults"); + const filters = this.get("filters"); + + this.set( + "mapSearchConnector", + new MapSearchConnector({ map, searchResults }) + ); + this.set( + "filtersSearchConnector", + new FiltersSearchConnector({ filters, searchResults }) + ); + this.set( + "filtersMapConnector", + new FiltersMapConnector({ filters, map }) + ); + }, + + /** + * Get all the connectors associated with this connector. + * @returns {Backbone.Model[]} An array of connector models. + */ + getConnectors: function () { + return [ + this.get("mapSearchConnector"), + this.get("filtersSearchConnector"), + this.get("filtersMapConnector"), + ]; + }, + + /** + * Get all the connectors associated with the Map. + * @returns {Backbone.Model[]} An array of connector models. + */ + getMapConnectors: function () { + return [ + this.get("mapSearchConnector"), + this.get("filtersMapConnector"), + ]; + }, + + /** + * Set all necessary listeners between the Map, SearchResults, and Filters + * so that they work together. + */ + connect: function () { + this.getConnectors().forEach((connector) => connector.connect()); + }, + + /** + * Disconnect all listeners between the Map, SearchResults, and Filters. + */ + disconnect: function () { + this.getConnectors().forEach((connector) => connector.disconnect()); + }, + + /** + * Disconnect all listeners associated with the Map. This disconnects + * both the search and filters from the map. + */ + disconnectMap: function () { + this.getMapConnectors().forEach((connector) => connector.disconnect()); + }, + + /** + * Connect all listeners associated with the Map. This connects both the + * search and filters to the map. + */ + connectMap: function () { + this.getMapConnectors().forEach((connector) => connector.connect()); + }, + + /** + * Check if all connectors are connected. + * @returns {boolean} True if all connectors are connected, false if any + * are disconnected. + */ + isConnected: function () { + const connectors = this.getConnectors(); + return connectors.every((connector) => connector.get("isConnected")); + }, + } + ); +}); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index d79894f4d..441771730 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -100,15 +100,15 @@ define([ * @fires Layers#add */ findAndSetGeohashLayer: function (add = false) { - const wasListening = this.get("isListening"); - this.stopListeners(); + const wasConnected = this.get("isConnected"); + this.disconnect(); let map = this.get("map") || this.set("map", new Map()).get("map"); const layers = this.findLayers() || this.createLayers(); this.set("layers", layers); let geohash = this.findGeohash() || (add ? this.createGeohash() : null); this.set("geohashLayer", geohash); - if (wasListening) { - this.startListening(); + if (wasConnected) { + this.connect(); } return geohash; }, @@ -117,21 +117,21 @@ define([ * Connect the Map to the Search. When a new search is performed, the * Search will set the new facet counts on the GeoHash layer in the Map. */ - startListening: function () { - this.stopListeners(); + connect: function () { + this.disconnect(); const searchResults = this.get("searchResults"); this.listenTo(searchResults, "reset", this.updateGeohashCounts); - this.set("isListening", true); + this.set("isConnected", true); }, /** * Disconnect the Map from the Search. Stops listening to the Search * results collection. */ - stopListeners: function () { + disconnect: function () { const searchResults = this.get("searchResults"); this.stopListening(searchResults, "reset"); - this.set("isListening", false); + this.set("isConnected", false); }, /** diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 0a2847210..e3c32d4c1 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -2,35 +2,23 @@ define([ "jquery", "backbone", - "collections/Filters", - "models/filters/FilterGroup", - "models/connectors/Filters-Search", - "models/connectors/Geohash-Search", - "models/connectors/Filters-Map", - "models/maps/Map", "views/search/SearchResultsView", "views/filters/FilterGroupsView", "views/maps/MapView", "views/search/SearchResultsPagerView", "views/search/SorterView", "text!templates/search/catalogSearch.html", - "models/connectors/Map-Search" + "models/connectors/Map-Search-Filters", ], function ( $, Backbone, - Filters, - FilterGroup, - FiltersSearchConnector, - GeohashSearchConnector, - FiltersMapConnector, - Map, SearchResultsView, FilterGroupsView, MapView, PagerView, SorterView, Template, - MapSearchConnector + MapSearchFiltersConnector ) { "use strict"; @@ -133,45 +121,6 @@ define([ */ sorterView: null, - /** - * The model that retrieves the search results. - * @type {SearchModel} - * @since 2.22.0 - */ - searchModel: null, - - /** - * An array of Filter models, outside of their parent FilterGroup, that - * can be used to filter the search results. These models are passed to - * the {@link FiltersSearchConnector} to be used in the search. This - * property is added to the view by the - * {@link CatalogSearchView#setupSearch} method. - * @type {Filter[]} - * @since 2.22.0 - */ - allFilters: null, - - /** - * An array of FilterGroup models created by the - * {@link CatalogSearchView#createFilterGroups} method, using the - * {@link CatalogSearchView#filterGroupsJSON} property. These FilterGroups - * will be displayed in this view and used for searching. This property is - * added to the view by the {@link CatalogSearchView#createFilterGroups} - * method. - * @type {FilterGroup[]} - * @since 2.22.0 - */ - filterGroups: null, - - /** - * An array of literal objects to transform into FilterGroup models. These - * FilterGroups will be displayed in this view and used for searching. If - * not provided, the {@link AppConfig#defaultFilterGroups} will be used. - * @type {FilterGroup#defaults[]} - * @since 2.22.0 - */ - filterGroupsJSON: null, - /** * The CSS class to add to the body of the CatalogSearch. * @type {string} @@ -241,6 +190,18 @@ define([ */ initialize: function (options) { this.initialQuery = options?.initialQuery; + // TODO: allow for initial models/filters to be passed in, as well as + // options like, whether or not to create a SpatialFilter or Geohash + // layer if not present, etc. + + // const mapOptions = Object.assign( + // {}, + // MetacatUI.appModel.get("catalogSearchMapOptions") || {} + // ); + // Create the Map model and view + + this.model = new MapSearchFiltersConnector(); + this.model.connect(); }, /** @@ -254,17 +215,8 @@ define([ // Set up the view for styling and layout this.setupView(); - // Set up the search and search result models, as well as the map - this.setupSearch(); - // Render the search components this.renderComponents(); - - // When everything is ready, run the initial search and then start - // listening for changes. Wait for components to render first because - // when filters are added, they trigger a search unnecessarily. - this.connector.triggerSearch(); - this.connector.startListening(); }, /** @@ -331,6 +283,10 @@ define([ */ renderComponents: function () { try { + this.createSearchResults(); + + this.createMap(); + this.renderFilters(); // Render the list of search results @@ -339,7 +295,7 @@ define([ // Render the Title this.renderTitle(); this.listenTo( - this.searchResultsView.searchResults, + this.model.get("searchResults"), "reset", this.renderTitle ); @@ -368,8 +324,8 @@ define([ try { // Render FilterGroups this.filterGroupsView = new FilterGroupsView({ - filterGroups: this.filterGroups, - filters: this.connector?.get("filters"), + filterGroups: this.model.get("filterGroups"), + filters: this.model.get("filters"), vertical: true, parentView: this, initialQuery: this.initialQuery, @@ -393,11 +349,8 @@ define([ createSearchResults: function () { try { this.searchResultsView = new SearchResultsView(); - - if (this.connector) { - this.searchResultsView.searchResults = - this.connector.get("searchResults"); - } + this.searchResultsView.searchResults = + this.model.get("searchResults"); } catch (e) { console.log("There was an error creating the SearchResultsView:" + e); } @@ -432,7 +385,7 @@ define([ this.pagerView = new PagerView(); // Give the PagerView the SearchResults to listen to and update - this.pagerView.searchResults = this.searchResultsView.searchResults; + this.pagerView.searchResults = this.model.get("searchResults"); // Add the pager view to the page this.el @@ -455,7 +408,7 @@ define([ this.sorterView = new SorterView(); // Give the SorterView the SearchResults to listen to and update - this.sorterView.searchResults = this.searchResultsView.searchResults; + this.sorterView.searchResults = this.model.get("searchResults"); // Add the sorter view to the page this.el @@ -508,7 +461,7 @@ define([ */ renderTitle: function () { try { - const searchResults = this.searchResultsView.searchResults; + const searchResults = this.model.get("searchResults"); let titleEl = this.el.querySelector(this.titleContainer); if (!titleEl) { @@ -531,120 +484,13 @@ define([ } }, - /** - * Creates the Filter models and SolrResults that will be used for - * searches - * @since 2.22.0 - */ - setupSearch: function () { - try { - // Get an array of all Filter models - let allFilters = []; - this.filterGroups = this.createFilterGroups(); - this.filterGroups.forEach((group) => { - allFilters = allFilters.concat(group.get("filters")?.models); - }); - this.allFilters = new Filters(allFilters, { catalogSearch: true }); - - // Connect the filters to the search and search results - let connector = new FiltersSearchConnector({ - filters: this.allFilters, - }); - this.connector = connector; - - this.createSearchResults(); - - this.createMap(); - } catch (e) { - console.log("There was an error setting up the search:" + e); - } - }, - - /** - * Creates UI Filter Groups. UI Filter Groups are custom, interactive - * search filter elements, grouped together in one panel, section, tab, - * etc. - * @param {FilterGroup#defaults[]} filterGroupsJSON An array of literal - * objects to transform into FilterGroup models. These FilterGroups will - * be displayed in this view and used for searching. If not provided, the - * {@link AppConfig#defaultFilterGroups} will be used. - * @since 2.22.0 - */ - createFilterGroups: function (filterGroupsJSON = this.filterGroupsJSON) { - try { - try { - // Start an array for the FilterGroups and the individual Filter - // models - let filterGroups = []; - - // Iterate over each default FilterGroup in the app config and - // create a FilterGroup model - ( - filterGroupsJSON || MetacatUI.appModel.get("defaultFilterGroups") - ).forEach((filterGroupJSON) => { - // Create the FilterGroup model Add to the array - filterGroups.push(new FilterGroup(filterGroupJSON)); - }); - - return filterGroups; - } catch (e) { - console.error("Couldn't create Filter Groups in search. ", e); - } - } catch (e) { - console.error("Couldn't create Filter Groups in search. ", e); - } - }, - /** * Create the models and views associated with the map and map search * @since 2.22.0 */ createMap: function () { try { - const mapOptions = Object.assign( - {}, - MetacatUI.appModel.get("catalogSearchMapOptions") || {} - ); - const map = new Map(mapOptions); - - // TODO: Make a CatalogSearchModel of a SearchFiltersMap connector - // that coordiantes all of the sub-connectors, (SolrResults <-> - // Filters, SolrResults <-> Map, Filters <-> Map) - - // const geohashLayer = map - // .get("layers") - // .findWhere({ isGeohashLayer: true }); - - // if (!geohashLayer) { - // this.listenTo(map, "change:layers", (map, layers) => { - // const geohashLayer = layers.findWhere({ isGeohashLayer: true }); - // if (geohashLayer) this.createMap(); - // }); - // return; - // } - - // Connect the CesiumGeohash to the SolrResults - // const connector = new GeohashSearchConnector({ - // cesiumGeohash: geohashLayer, - // searchResults: this.searchResultsView.searchResults, - // }); - // connector.startListening(); - // this.geohashSearchConnector = connector; - - const connector = new FiltersMapConnector({ - map: map, - filters: this.allFilters, - }); - connector.startListening(); - - const otherConnector = new MapSearchConnector({ - map: map, - searchResults: this.searchResultsView.searchResults, - }); - otherConnector.startListening(); - - // Create the Map model and view - this.mapView = new MapView({ model: map }); + this.mapView = new MapView({ model: this.model.get("map") }); } catch (e) { console.error("Couldn't create map in search. ", e); this.toggleMode("list"); From 289879658be130b788e6676217f32966579e6faa Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 30 Mar 2023 11:17:24 -0400 Subject: [PATCH 36/79] Add init options to MapSearchFilters connector - Create a Geohash layer and Spatial Filter by default, but allow changing options - Allow passing in Map, SolrResults, and/or Filters models/collections, default to appModel config - Other minor Cesium map fixes Relates to #2069 --- src/js/models/AppModel.js | 13 ++-- .../models/connectors/Map-Search-Filters.js | 72 +++++++++++++++---- src/js/models/connectors/Map-Search.js | 34 +++++++-- src/js/models/maps/Map.js | 2 +- src/js/models/maps/assets/CesiumGeohash.js | 1 + src/js/views/maps/LegendView.js | 4 ++ 6 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index a228ad2f8..00df659cf 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -96,11 +96,14 @@ define(['jquery', 'underscore', 'backbone'], dataCatalogMap: "google", /** - * The default options for the Cesium map used in the {@link CatalogSearchView} for searching the data - * catalog. Add custom layers, a default home position (for example, zoom into your area of research), - * and enable/disable map widgets. See {@link MapConfig} for the full suite of options. Keep the `CesiumGeohash` - * layer here in order to show the search results in the map as geohash boxes. Use any satellite imagery - * layer of your choice, such as a self-hosted imagery layer or hosted on Cesium Ion. + * The default options for the Cesium map used in the + * {@link CatalogSearchView} for searching the data catalog. Add custom + * layers, a default home position (for example, zoom into your area of + * research), and enable/disable map widgets. See {@link MapConfig} for + * the full suite of options. Use any satellite imagery layer of your + * choice, such as a self-hosted imagery layer or hosted on Cesium Ion. If + * no layer of type `CesiumGeohash` is included, a geohash layer will be + * added automatically in order to show the search results on the map. * @type {MapConfig} * @since 2.22.0 */ diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index 3fd1fa7a7..9b6757ea7 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -49,18 +49,51 @@ define([ }, /** - * @inheritdoc + * Initialize the model. + * @param {Object} options - The options for this model. + * @param {Map | Object} [options.map] - The Map model to use for this + * connector or a JSON object with options to create a new Map model. If + * not provided, the default from the appModel will be used. See + * {@link AppModel#catalogSearchMapOptions}. + * @param {SolrResults | Object} [options.searchResults] - The SolrResults + * model to use for this connector or a JSON object with options to create + * a new SolrResults model. If not provided, a new SolrResults model will + * be created. + * @param {FilterGroup[] | FilterGroup} [options.filterGroups] - An array + * of FilterGroup models or JSON objects with options to create new + * FilterGroup models. If a single FilterGroup is passed, it will be + * wrapped in an array. If not provided, the default from the appModel + * will be used. See {@link AppModel#defaultFilterGroups}. + * @param {boolean} [addGeohashLayer=true] - If set to true, a Geohash + * layer will be added to the Map model if one is not already present. If + * set to false, no Geohash layer will be added. A geohash layer is + * required for the Search-Map connector to work. + * @param {boolean} [addSpatialFilter=true] - If set to true, a spatial + * filter will be added to the Filters model if one is not already + * present. If set to false, no spatial filter will be added. A spatial + * filter is required for the Filters-Map connector to work. + * @param {boolean} [options.catalogSearch=false] - If set to true, a + * catalog search phrase in the Filters will be appended to the search + * query that limits the results to un-obsoleted metadata. See + * {@link Filters#createCatalogSearchQuery}.If set to true, a catalog + * search phrase will be appended to the search query that limits the + * results to un-obsoleted metadata. */ - initialize: function () { + initialize: function (attrs, options = {}) { // TODO: allow setting these with args here - const filterGroupsJSON = MetacatUI.appModel.get("defaultFilterGroups"); - const mapOptions = MetacatUI.appModel.get("catalogSearchMapOptions"); - this.setMap(mapOptions); - this.setSearchResults(); - this.setFilters(filterGroupsJSON); - this.setConnectors(); - // TODO: Listen to change:map, change:filters, etc. and update the - // connectors + if (!options) options = {}; + const app = MetacatUI.appModel; + const map = options.map || app.get("catalogSearchMapOptions"); + const searchResults = options.searchResults || null; + const filterGroups = + options.filterGroups || app.get("defaultFilterGroups"); + const catalogSearch = options.catalogSearch !== true; + const addGeohashLayer = options.addGeohashLayer !== false; + const addSpatialFilter = options.addGeohashLayer !== false; + this.setMap(map); + this.setSearchResults(searchResults); + this.setFilters(filterGroups, catalogSearch); + this.setConnectors(addGeohashLayer, addSpatialFilter); }, /** @@ -69,7 +102,7 @@ define([ * a JSON object with options to create a new Map model. */ setMap: function (map) { - let mapModel = map instanceof Map ? map : new Map(map || {}); + const mapModel = map instanceof Map ? map : new Map(map || null); this.set("map", mapModel); }, @@ -123,15 +156,26 @@ define([ /** * Set all the connectors required to connect the Map, SearchResults, and * Filters. This does not connect them (see connect()). + * @param {boolean} [addGeohashLayer=true] - If set to true, a Geohash + * layer will be added to the Map model if one is not already present. If + * set to false, no Geohash layer will be added. A geohash layer is + * required for the Search-Map connector to work. + * @param {boolean} [addSpatialFilter=true] - If set to true, a spatial + * filter will be added to the Filters model if one is not already + * present. If set to false, no spatial filter will be added. A spatial + * filter is required for the Filters-Map connector to work. */ - setConnectors: function () { + setConnectors: function ( + addGeohashLayer = true, + addSpatialFilter = true + ) { const map = this.get("map"); const searchResults = this.get("searchResults"); const filters = this.get("filters"); this.set( "mapSearchConnector", - new MapSearchConnector({ map, searchResults }) + new MapSearchConnector({ map, searchResults }, { addGeohashLayer }) ); this.set( "filtersSearchConnector", @@ -139,7 +183,7 @@ define([ ); this.set( "filtersMapConnector", - new FiltersMapConnector({ filters, map }) + new FiltersMapConnector({ filters, map }, { addSpatialFilter }) ); }, diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 441771730..59583d71e 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -31,10 +31,24 @@ define([ }, /** - * @inheritdoc + * Initialize the model. + * @param {Object} attrs - The attributes for this model. + * @param {SolrResults | Object} [attributes.searchResults] - The + * SolrResults model to use for this connector or a JSON object with + * options to create a new SolrResults model. If not provided, a new + * SolrResults model will be created. + * @param {Map | Object} [attributes.map] - The Map model to use for this + * connector or a JSON object with options to create a new Map model. If + * not provided, a new Map model will be created. + * @param {Object} [options] - The options for this model. + * @param {boolean} [addGeohashLayer=true] - If true, a Geohash layer will + * be added to the Map model if there is not already a Geohash layer in + * the Map model's Layers collection. If false, no Geohash layer will be + * added. A geohash layer is required for this connector to work. */ - initialize: function () { - this.findAndSetGeohashLayer(); + initialize: function (attrs, options) { + const add = options?.addGeohashLayer ?? true; + this.findAndSetGeohashLayer(add); }, /** @@ -92,17 +106,16 @@ define([ * Find the Geohash layer in the Map model's layers collection and * optionally create one if it doesn't exist. This will also create and * set a map and a layers collection from that map if they don't exist. - * @param {boolean} [add=false] - If true, create a new Geohash layer if + * @param {boolean} [add=true] - If true, create a new Geohash layer if * one doesn't exist. * @returns {CesiumGeohash} The Geohash layer in the Map model's layers * collection or null if there is no Layers collection set on this model * and `add` is false. * @fires Layers#add */ - findAndSetGeohashLayer: function (add = false) { + findAndSetGeohashLayer: function (add = true) { const wasConnected = this.get("isConnected"); this.disconnect(); - let map = this.get("map") || this.set("map", new Map()).get("map"); const layers = this.findLayers() || this.createLayers(); this.set("layers", layers); let geohash = this.findGeohash() || (add ? this.createGeohash() : null); @@ -110,6 +123,12 @@ define([ if (wasConnected) { this.connect(); } + // If there is still no Geohash layer, then we should wait for one to + // be added to the Layers collection, then try to find it again. + if (!geohash) { + this.listenToOnce(layers, "add", this.findAndSetGeohashLayer); + return + } return geohash; }, @@ -143,6 +162,9 @@ define([ updateGeohashCounts: function () { const geohashLayer = this.get("geohashLayer"); const searchResults = this.get("searchResults"); + + if(!geohashLayer || !searchResults) return; + const facetCounts = searchResults.facetCounts; // Get every facet that begins with "geohash_" diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 2f4a921cb..2796dca0b 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -208,7 +208,7 @@ define([ */ initialize: function (config) { try { - if (config) { + if (config && config instanceof Object) { function isNonEmptyArray(a) { return a && a.length && Array.isArray(a); } diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index d5d594127..7c2a1c443 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -50,6 +50,7 @@ define([ // optional, so we can calculate from counts if needed. totalCount: 0, geohashes: [], + opacity: 0.5, }); }, diff --git a/src/js/views/maps/LegendView.js b/src/js/views/maps/LegendView.js index ab8f32b15..80b60f32b 100644 --- a/src/js/views/maps/LegendView.js +++ b/src/js/views/maps/LegendView.js @@ -139,6 +139,10 @@ define( try { + if (!this.model) { + return; + } + // Save a reference to this view var view = this; From d7e25579862a79d8c27cec0b9809723109557e0f Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 30 Mar 2023 12:35:20 -0400 Subject: [PATCH 37/79] Add init options to CatalogSearchView Relates to #2069 --- .../models/connectors/Map-Search-Filters.js | 7 ++- src/js/views/search/CatalogSearchView.js | 47 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index 9b6757ea7..fe0f3d42e 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -50,6 +50,7 @@ define([ /** * Initialize the model. + * @param {Object} attrs - The attributes for this model. * @param {Object} options - The options for this model. * @param {Map | Object} [options.map] - The Map model to use for this * connector or a JSON object with options to create a new Map model. If @@ -64,11 +65,11 @@ define([ * FilterGroup models. If a single FilterGroup is passed, it will be * wrapped in an array. If not provided, the default from the appModel * will be used. See {@link AppModel#defaultFilterGroups}. - * @param {boolean} [addGeohashLayer=true] - If set to true, a Geohash + * @param {boolean} [options.addGeohashLayer=true] - If set to true, a Geohash * layer will be added to the Map model if one is not already present. If * set to false, no Geohash layer will be added. A geohash layer is * required for the Search-Map connector to work. - * @param {boolean} [addSpatialFilter=true] - If set to true, a spatial + * @param {boolean} [options.addSpatialFilter=true] - If set to true, a spatial * filter will be added to the Filters model if one is not already * present. If set to false, no spatial filter will be added. A spatial * filter is required for the Filters-Map connector to work. @@ -80,7 +81,6 @@ define([ * results to un-obsoleted metadata. */ initialize: function (attrs, options = {}) { - // TODO: allow setting these with args here if (!options) options = {}; const app = MetacatUI.appModel; const map = options.map || app.get("catalogSearchMapOptions"); @@ -133,7 +133,6 @@ define([ setFilters: function (filtersArray, catalogSearch = true) { const filterGroups = []; const filters = new Filters(null, { catalogSearch: catalogSearch }); - // TODO: catalogSearch should be an option set in initialize filtersArray = Array.isArray(filtersArray) ? filtersArray diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index e3c32d4c1..57f88edd9 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -182,26 +182,41 @@ define([ }, /** - * Initializes the view - * @param {Object} options - * @param {string} options.initialQuery - The initial text query to run + * Initialize the view. In addition to the options described below, any + * option that is available in the + * {@link MapSearchFiltersConnector#initialize} method can be passed to + * this view, such as Map, SolrResult, and FilterGroup models, and whether + * to create a geohash layer or spatial filter if they are not present. + * @param {Object} options - The options for this view. + * @param {string} [options.initialQuery] - The initial text query to run * when the view is rendered. + * @param {MapSearchFiltersConnector} [options.model] - A + * MapSearchFiltersConnector model to use for this view. If not provided, + * a new one will be created. If one is provided, then other options that + * would be passed to the MapSearchFiltersConnector model will be ignored + * (such as map, searchResults, filterGroups, catalogSearch, etc.) * @since x.x.x */ initialize: function (options) { - this.initialQuery = options?.initialQuery; - // TODO: allow for initial models/filters to be passed in, as well as - // options like, whether or not to create a SpatialFilter or Geohash - // layer if not present, etc. - - // const mapOptions = Object.assign( - // {}, - // MetacatUI.appModel.get("catalogSearchMapOptions") || {} - // ); - // Create the Map model and view - - this.model = new MapSearchFiltersConnector(); - this.model.connect(); + if (!options) options = {}; + + this.initialQuery = options.initialQuery || null; + + let model = options.model; + if (!model) { + const app = MetacatUI.appModel; + model = new MapSearchFiltersConnector({ + map: options.map || app.get("catalogSearchMapOptions"), + searchResults: options.searchResults || null, + filterGroups: + options.filterGroups || app.get("defaultFilterGroups"), + catalogSearch: options.catalogSearch !== false, + addGeohashLayer: options.addGeohashLayer !== false, + addSpatialFilter: options.addSpatialFilter !== false, + }); + } + model.connect(); + this.model = model; }, /** From 14bbfd68b7bdfca0e19deb58d3566a936b3f0140 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 30 Mar 2023 20:42:22 -0400 Subject: [PATCH 38/79] Small refactor to toggle map spatial filter [WIP] - Add Geohash model & collection - use in both the SpatialFilter and CesiumGeohash - Move logic from SpatialFilter and CesiumGeohash to Geohash model/collection - Ensure that mapModel is always added as a property of new layers (e.g. new geohash layers) - Fix a small SolrResults bug with facetting Relates to #2069 --- src/js/collections/SolrResults.js | 2 + src/js/collections/maps/Geohashes.js | 163 +++++++++++++++ src/js/collections/maps/MapAssets.js | 30 +-- src/js/models/connectors/Filters-Map.js | 35 ++-- src/js/models/connectors/Filters-Search.js | 1 - .../models/connectors/Map-Search-Filters.js | 7 + src/js/models/connectors/Map-Search.js | 9 +- src/js/models/filters/SpatialFilter.js | 133 +++++-------- src/js/models/maps/Geohash.js | 107 ++++++++++ src/js/models/maps/Map.js | 13 ++ src/js/models/maps/assets/CesiumGeohash.js | 188 +++++++++--------- src/js/templates/search/catalogSearch.html | 6 + src/js/views/search/CatalogSearchView.js | 40 ++++ 13 files changed, 520 insertions(+), 214 deletions(-) create mode 100644 src/js/collections/maps/Geohashes.js create mode 100644 src/js/models/maps/Geohash.js diff --git a/src/js/collections/SolrResults.js b/src/js/collections/SolrResults.js index ee7e7ac74..5af0f4afa 100644 --- a/src/js/collections/SolrResults.js +++ b/src/js/collections/SolrResults.js @@ -121,6 +121,8 @@ define(['jquery', 'underscore', 'backbone', 'models/SolrHeader', 'models/SolrRes //Get the facet counts and store them in this model if( solr.facet_counts ){ this.facetCounts = solr.facet_counts.facet_fields; + } else { + this.facetCounts = "nothing"; } //Cache this set of results diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js new file mode 100644 index 000000000..2f7fc6605 --- /dev/null +++ b/src/js/collections/maps/Geohashes.js @@ -0,0 +1,163 @@ +"use strict"; + +define([ + "jquery", + "underscore", + "backbone", + "nGeohash", + "models/maps/Geohash", +], function ($, _, Backbone, nGeohash, Geohash) { + /** + * @classdesc A Geohashes Collection represents a collection of Geohash models. + * @classcategory Collections/Geohashes + * @class Geohashes + * @name Geohashes + * @extends Backbone.Collection + * @since x.x.x + * @constructor + */ + var Geohashes = Backbone.Collection.extend( + /** @lends Geohashes.prototype */ { + /** + * The name of this type of collection + * @type {string} + */ + type: "Geohashes", + + /** + * The model class for this collection + * @type {Geohash} + */ + model: Geohash, + + getLevelHeightMap: function () { + return { + 1: 6800000, + 2: 2400000, + 3: 550000, + 4: 120000, + 5: 7000, + 6: 0, + }; + }, + + /** + * Get the geohash level to use for a given height. + * + * @param {number} [height] - Altitude to use to calculate the geohash + * level/precision. + */ + heightToLevel: function (height) { + try { + const levelHeightMap = this.getLevelHeightMap(); + return Object.keys(levelHeightMap).find( + (key) => height >= levelHeightMap[key] + ); + } catch (e) { + console.log("Failed to get geohash level, returning 1" + e); + return 1; + } + }, + + /** + * Retrieves the geohash IDs for the provided bounding boxes and level. + * + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @param {number} level - Geohash level. + * @returns {string[]} Array of geohash IDs. + */ + getGeohashIDs: function (bounds, level) { + let geohashIDs = []; + bounds = this.splitBoundingBox(bounds); + bounds.forEach(function (bb) { + geohashIDs = geohashIDs.concat( + nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, level) + ); + }); + return geohashIDs; + }, + + /** + * Splits the 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 + */ + splitBoundingBox: function (bounds) { + if (!bounds) return []; + const { north, south, east, west } = bounds; + + if (east < west) { + return [ + { north, south, east: 180, west }, + { north, south, east, west: -180 }, + ]; + } else { + return [{ north, south, east, west }]; + } + }, + + /** + * Add geohashes to the collection based on a bounding box and height. + * @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 {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); + }, + + /** + * Add geohashes to the collection based on an array of geohash IDs. + * @param {string[]} geohashIDs - Array of geohash IDs. + * @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 }))); + }, + + /** + * Get the unique geohash levels for all geohashes in the collection. + */ + getLevels: function () { + return _.uniq(this.pluck("level")); + }, + + /** + * Return the geohashes as a GeoJSON FeatureCollection, where each + * geohash is represented as a GeoJSON Polygon (rectangle). + * @returns {Object} GeoJSON FeatureCollection. + */ + toGeoJSON: function () { + return { + type: "FeatureCollection", + features: this.map(function (geohash) { + return geohash.toGeoJSON(); + }), + }; + }, + } + ); + + 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/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index 8dd1871de..a16ec04a6 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -192,22 +192,24 @@ define([ }, /** - * Add a new Geohash layer to the collection. - * @param {MapConfig#MapAssetConfig} assetConfig - Configuration object - * for the Geohash layer (optional). + * Add a new MapAsset model to this collection. This is useful if adding + * the collection from a Map model, since this method will attach the Map + * model to the MapAsset model. + * @param {MapConfig#MapAssetConfig | MapAsset } asset - The configuration + * object for the MapAsset model, or the MapAsset model itself. + * @param {MapModel} [mapModel] - The Map model that contains this + * collection. This is optional, but if provided, it will be attached to + * the MapAsset model. + * @returns {MapAsset} - Returns the MapAsset model that was added to the + * collection. */ - addGeohashLayer: function (assetConfig) { + addAsset: function (asset, mapModel) { try { - const config = Object.assign({}, assetConfig, { - type: "CesiumGeohash", - }); - return this.add(config); - } catch (error) { - console.log( - "Failed to add a geohash layer to a MapAssets collection" + - ". Error details: " + - error - ); + const newModel = this.add(asset); + if (mapModel) newModel.set("mapModel", mapModel); + return newModel; + } catch (e) { + console.log("Failed to add a layer to a MapAssets collection", e); } }, } diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index e70a29bba..adb1f6a4b 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -2,9 +2,8 @@ define([ "backbone", "collections/Filters", - "models/filters/SpatialFilter", "models/maps/Map", -], function (Backbone, Filters, SpatialFilter, Map) { +], function (Backbone, Filters, Map) { "use strict"; /** @@ -38,7 +37,6 @@ define([ */ defaults: function () { return { - filtersList: [], filters: new Filters([], { catalogSearch: true }), spatialFilters: [], map: new Map(), @@ -59,7 +57,6 @@ define([ */ initialize: function (attr, options) { try { - this.addFiltersList(); const add = options?.addSpatialFilter ?? true; this.findAndSetSpatialFilters(add); } catch (e) { @@ -67,16 +64,6 @@ define([ } }, - /** - * Adds the filter models from filtersList to the Filters collection if - * filtersList is not empty. - */ - addFiltersList: function () { - if (this.get("filtersList")?.length) { - this.get("filters").add(this.get("filtersList")); - } - }, - /** * Finds and sets the spatial filters within the Filters collection. Stops * any existing listeners, adds a new listener for collection updates, and @@ -128,10 +115,26 @@ define([ addSpatialFilterIfNeeded: function (add) { const spatialFilters = this.get("spatialFilters"); if (!spatialFilters?.length && add) { - this.get("filters").add(new SpatialFilter({ + this.get("filters").add({ + filterType: "SpatialFilter", isInvisible: true, - })); + }); + } + }, + + /** + * Removes all SpatialFilter models from the Filters collection and + * destroys them. + */ + removeSpatialFilter: function () { + const spatialFilters = this.get("spatialFilters"); + if (spatialFilters?.length) { + spatialFilters.forEach((filter) => { + filter.collection.remove(filter); + filter.destroy(); + }); } + this.set("spatialFilters", []); }, /** diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index ad37a4a9f..76820d4a7 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -82,7 +82,6 @@ define([ */ connect: function () { this.disconnect(); - const model = this; const filters = this.get("filters"); const searchResults = this.get("searchResults"); // Listen to changes in the Filters to trigger a search diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index fe0f3d42e..bdaa84c40 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -249,6 +249,13 @@ define([ const connectors = this.getConnectors(); return connectors.every((connector) => connector.get("isConnected")); }, + + /** + * Remove the spatial filter from the Filters model. + */ + removeSpatialFilter: function () { + this.get("filtersMapConnector").removeSpatialFilter(); + } } ); }); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 59583d71e..828d5231a 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -74,7 +74,6 @@ define([ * there is no Map model set on this model. */ findLayers: function () { - const model = this; const map = this.get("map"); if (!map) return null; return map.get("layers"); @@ -97,9 +96,8 @@ define([ * @fires Layers#add */ createGeohash() { - const layers = this.get("layers"); - if (!layers) return null; - return layers.addGeohashLayer(); + const map = this.get("map"); + return map.addLayer({ type: "CesiumGeohash" }); }, /** @@ -140,6 +138,9 @@ define([ this.disconnect(); const searchResults = this.get("searchResults"); this.listenTo(searchResults, "reset", this.updateGeohashCounts); + // TODO: ‼️ The map needs to send the height/geohash level to the search. + // and set the facet (so that the results include counts for each + // geohash at the current level). ‼️ this.set("isConnected", true); }, diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index b463f948b..03fc94813 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -2,9 +2,10 @@ define([ "underscore", "jquery", "backbone", - "nGeohash", "models/filters/Filter", -], function (_, $, Backbone, nGeohash, Filter) { + "models/maps/Geohash", + "collections/maps/Geohashes", +], function (_, $, Backbone, Filter, Geohash, 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 @@ -23,6 +24,7 @@ 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. @@ -42,23 +44,12 @@ define([ north: null, south: null, height: null, - level: null, - maxGeohashes: 1000, - // groupedGeohashes: {}, fields: ["geohash_1"], label: "Limit search to the map area", icon: "globe", operator: "OR", fieldsOperator: "OR", matchSubstring: false, - levelHeightMap: { - 1: 6800000, - 2: 2400000, - 3: 550000, - 4: 120000, - 5: 7000, - 6: 0, - }, }); }, @@ -67,101 +58,67 @@ define([ */ initialize: function (attributes, options) { Filter.prototype.initialize.call(this, attributes, options); + this.setUpGeohashCollection(); + this.update(); + this.setListeners(); + }, + + setUpGeohashCollection: function () { + this.set("geohashCollection", new Geohashes()); + }, + + setListeners: function () { this.listenTo( this, - "change:height change:north change:south change:east", - this.updateGeohashes + "change:height change:north change:south change:east change:west", + this.update ); }, - /** - * Update the level, fields, geohashes, and values on the model, according - * to the current height, north, south and east attributes. - */ - updateGeohashes: function () { - try { - const height = this.get("height"); - const limit = this.get("maxGeohashes"); - const bounds = { + update: function () { + this.updateGeohashCollection(); + this.updateFilter(); + }, + + 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"), - }; - let level = this.getGeohashLevel(height); - let geohashIDs = this.getGeohashIDs(bounds, level); - if (limit && geohashIDs.length > limit && level > 1) { - while (geohashIDs.length > limit && level > 1) { - level--; - geohashIDs = this.getGeohashIDs(bounds, level); - } - } - this.set("level", level); - this.set("fields", ["geohash_" + level]); - this.set("geohashes", geohashIDs); - this.set("values", geohashIDs); - } catch (e) { - console.log("Failed to update geohashes" + e); - } + }), + (height = this.get("height")), + (overwrite = true) + ); }, /** - * Get the geohash level to use for a given height. - * - * @param {number} [height] - Altitude to use to calculate the geohash - * level/precision. + * Update the level, fields, geohashes, and values on the model, according + * to the current height, north, south and east attributes. */ - getGeohashLevel: function (height) { + updateFilter: function () { try { - const levelHeightMap = this.get("levelHeightMap"); - return Object.keys(levelHeightMap).find( - (key) => height >= levelHeightMap[key] - ); + const levels = this.getGeohashLevels().forEach((lvl) => { + return "geohash_" + lvl; + }); + const IDs = this.getGeohashIDs(); + this.set("fields", levels); + this.set("values", IDs); } catch (e) { - console.log("Failed to get geohash level, returning 1" + e); - return 1; + console.log("Failed to update geohashes" + e); } }, - /** - * Retrieves the geohash IDs for the provided bounding boxes and level. - * - * @param {Object} bounds - Bounding box with north, south, east, and west - * properties. - * @param {number} level - Geohash level. - * @returns {string[]} Array of geohash IDs. - */ - getGeohashIDs: function (bounds, level) { - let geohashIDs = []; - bounds = this.splitBoundingBox(bounds); - bounds.forEach(function (bb) { - geohashIDs = geohashIDs.concat( - nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, level) - ); - }); - return geohashIDs; + getGeohashLevels: function () { + const gCollection = this.get("geohashCollection"); + return gCollection.getLevels(); }, - /** - * Splits the 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 - */ - splitBoundingBox: function (bounds) { - const { north, south, east, west } = bounds; - - if (east < west) { - return [ - { north, south, east: 180, west }, - { north, south, east, west: -180 }, - ]; - } else { - return [{ north, south, east, west }]; - } + getGeohashIDs: function () { + const gCollection = this.get("geohashCollection"); + return gCollection.getGeohashIDs(); }, // TODO: Use the `groupGeohashes` function to consolidate geohashes into diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js new file mode 100644 index 000000000..86c6cb588 --- /dev/null +++ b/src/js/models/maps/Geohash.js @@ -0,0 +1,107 @@ +"use strict"; + +define(["jquery", "underscore", "backbone", "nGeohash"], function ( + $, + _, + Backbone, + nGeohash +) { + /** + * @classdesc A Geohash Model represents a single geohash. + * @classcategory Models/Geohashes + * @class Geohash + * @name Geohash + * @extends Backbone.Model + * @since x.x.x + * @constructor + */ + var Geohash = Backbone.Model.extend( + /** @lends Geohash.prototype */ { + /** + * The name of this type of model + * @type {string} + */ + type: "Geohash", + + /** + * Default attributes for Geohash models + * @name Geohash#defaults + * @type {Object} + * @property {string} geohash The geohash value/ID. + * @property {Object} [properties] An object containing arbitrary + * properties associated with the geohash. (e.g. count values from + * SolrResults) + */ + defaults: function () { + return { + geohash: "", + properties: {}, + }; + }, + + /** + * 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. + */ + isEmpty: function () { + const geohash = this.get("geohash"); + return !geohash || geohash.length === 0; + }, + + /** + * Get the bounds of the geohash "tile". + * @returns {Array} An array containing the bounds of the geohash. + */ + getBounds: function () { + if (this.isEmpty()) return null; + return nGeohash.decode_bbox(this.get("geohash")); + }, + + /** + * Get the center point of the geohash. + * @returns {Array} An array containing the center point of the geohash. + * The first element is the longitude, the second is the latitude. + */ + getPoint: function () { + if (this.isEmpty()) return null; + return nGeohash.decode(this.get("geohash")); + }, + + /** + * Get the level of the geohash. + * @returns {number} The level of the geohash. + */ + getLevel: function () { + if (this.isEmpty()) return null; + return this.get("geohash").length; + }, + + /** + * Get the geohash as a GeoJSON Feature. + * @returns {Object} A GeoJSON Feature representing the geohash. + */ + toGeoJSON: function () { + const bounds = this.getBounds(); + if (!bounds) return null; + return { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [bounds[0], bounds[1]], + [bounds[2], bounds[1]], + [bounds[2], bounds[3]], + [bounds[0], bounds[3]], + [bounds[0], bounds[1]], + ], + ], + }, + properties: this.get("properties"), + }; + } + } + ); + + return Geohash; +}); diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 2796dca0b..976f1c5fd 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -291,6 +291,19 @@ define([ this.set("layers", newLayers); return newLayers; }, + + /** + * Add a layer to the map. This is the best way to add a layer to the map + * because it will ensure that this map model is set on the layer model. + * @param {Object | MapAsset} layer - A map asset model or object with + * attributes to set on a new map asset model. + * @returns {MapAsset} The new layer model. + * @since x.x.x + */ + addLayer: function (layer) { + const layers = this.get("layers") || this.resetLayers(); + return layers.addAsset(layer, this); + }, } ); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 7c2a1c443..63c14ee84 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -5,9 +5,11 @@ define([ "underscore", "backbone", "cesium", - "nGeohash", "models/maps/assets/CesiumVectorData", -], function ($, _, Backbone, Cesium, nGeohash, CesiumVectorData) { + "models/maps/Geohash", + "collections/maps/Geohashes" + +], function ($, _, Backbone, Cesium, CesiumVectorData, Geohash, Geohashes) { /** * @classdesc A Geohash Model represents a geohash layer in a map. * @classcategory Models/Maps/Assets @@ -45,11 +47,7 @@ define([ return Object.assign(CesiumVectorData.prototype.defaults(), { type: "GeoJsonDataSource", label: "Geohashes", - counts: [], - // TODO: split this into geohashIDs and counts. Maybe make totalCount - // optional, so we can calculate from counts if needed. - totalCount: 0, - geohashes: [], + geohashes: new Geohashes(), opacity: 0.5, }); }, @@ -73,13 +71,17 @@ define([ } }, + limitToMapExtent: function () { + // TODO + }, + /** * Stop the model from listening to itself for changes in the counts or * geohashes. */ stopListeners: function () { - this.stopListening(this, "change:counts"); this.stopListening(this, "change:geohashes"); + this.stopListening(this.get("geohashes"), "change"); }, /** @@ -88,8 +90,12 @@ define([ startListening: function () { try { this.stopListeners(); - this.listenTo(this, "change:counts", this.updateGeohashes); this.listenTo(this, "change:geohashes", function () { + this.stopListeners(); + this.startListening(); + this.createCesiumModel(true); + }); + this.listenTo(this.get("geohashes"), "change", function () { this.createCesiumModel(true); }); } catch (error) { @@ -97,88 +103,88 @@ define([ } }, - /** - * Get the counts currently set on this model and create the geohash array - * [{ counts, id, bounds}]. Set this array on the model, which will - * trigger the cesiumModel to re-render. - */ - updateGeohashes: function () { - try { - // Counts are formatted as [geohash, count, geohash, count, ...] - const counts = this.get("counts"); - const geohashes = []; - for (let i = 0; i < counts.length; i += 2) { - const id = counts[i]; - geohashes.push({ - id: id, - count: counts[i + 1], - bounds: nGeohash.decode_bbox(id), - }); - } - this.set("geohashes", geohashes); - this.createCesiumModel(true); - } catch (error) { - console.log("Failed to update geohashes in CesiumGeohash", error); - } - }, + // /** + // * Get the counts currently set on this model and create the geohash array + // * [{ counts, id, bounds}]. Set this array on the model, which will + // * trigger the cesiumModel to re-render. + // */ + // updateGeohashes: function () { + // try { + // // Counts are formatted as [geohash, count, geohash, count, ...] + // // const counts = this.get("counts"); + // const geohashes = []; + // for (let i = 0; i < counts.length; i += 2) { + // const id = counts[i]; + // geohashes.push({ + // id: id, + // count: counts[i + 1], + // bounds: nGeohash.decode_bbox(id), + // }); + // } + // this.set("geohashes", geohashes); + // this.createCesiumModel(true); + // } catch (error) { + // console.log("Failed to update geohashes in CesiumGeohash", error); + // } + // }, - /** - * Given the geohashes set on the model, return as geoJSON - * @returns {object} GeoJSON representing the geohashes with counts - */ - toGeoJSON: function () { - try { - // The base GeoJSON format - const geojson = { - type: "FeatureCollection", - features: [], - }; - const geohashes = this.get("geohashes"); - if (!geohashes) { - return geojson; - } - const features = []; - // Format for geohashes: - // [{ counts, id, bounds}] - geohashes.forEach((geohash) => { - const bb = geohash.bounds; - const id = geohash.id; - const count = geohash.count; - const minlat = bb[0] <= -90 ? -89.99999 : bb[0]; - const minlon = bb[1]; - const maxlat = bb[2]; - const maxlon = bb[3]; - const feature = { - type: "Feature", - geometry: { - type: "Polygon", - coordinates: [ - [ - [minlon, minlat], - [minlon, maxlat], - [maxlon, maxlat], - [maxlon, minlat], - [minlon, minlat], - ], - ], - }, - properties: { - "count": count, - geohash: id, - }, - }; - features.push(feature); - }) - geojson["features"] = features; - return geojson; - } catch (error) { - console.log( - "There was an error converting geohashes to GeoJSON " + - "in a CesiumGeohash model. Error details: ", - error - ); - } - }, + // /** + // * Given the geohashes set on the model, return as geoJSON + // * @returns {object} GeoJSON representing the geohashes with counts + // */ + // toGeoJSON: function () { + // try { + // // The base GeoJSON format + // const geojson = { + // type: "FeatureCollection", + // features: [], + // }; + // const geohashes = this.get("geohashes"); + // if (!geohashes) { + // return geojson; + // } + // const features = []; + // // Format for geohashes: + // // [{ counts, id, bounds}] + // geohashes.forEach((geohash) => { + // const bb = geohash.bounds; + // const id = geohash.id; + // const count = geohash.count; + // const minlat = bb[0] <= -90 ? -89.99999 : bb[0]; + // const minlon = bb[1]; + // const maxlat = bb[2]; + // const maxlon = bb[3]; + // const feature = { + // type: "Feature", + // geometry: { + // type: "Polygon", + // coordinates: [ + // [ + // [minlon, minlat], + // [minlon, maxlat], + // [maxlon, maxlat], + // [maxlon, minlat], + // [minlon, minlat], + // ], + // ], + // }, + // properties: { + // "count": count, + // geohash: id, + // }, + // }; + // features.push(feature); + // }) + // geojson["features"] = features; + // return geojson; + // } catch (error) { + // console.log( + // "There was an error converting geohashes to GeoJSON " + + // "in a CesiumGeohash model. Error details: ", + // error + // ); + // } + // }, /** * Creates a Cesium.DataSource model and sets it to this model's @@ -194,7 +200,7 @@ define([ const model = this; // Set the GeoJSON representing geohashes on the model const cesiumOptions = model.get("cesiumOptions"); - cesiumOptions["data"] = model.toGeoJSON(); + cesiumOptions["data"] = this.get("geohashes")?.toGeoJSON(); // TODO: outlines don't work when features are clamped to ground // cesiumOptions['clampToGround'] = true cesiumOptions["height"] = 0; diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html index 7394583a6..13e399dcc 100644 --- a/src/js/templates/search/catalogSearch.html +++ b/src/js/templates/search/catalogSearch.html @@ -18,6 +18,12 @@ Hide Map + +
    diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 57f88edd9..7b477842a 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -81,6 +81,15 @@ define([ */ mode: "map", + /** + * Whether to limit the search to the extent of the map. If true, the + * search will update when the user pans or zooms the map. + * @type {boolean} + * @since x.x.x + * @default true + */ + limitSearchToMapArea: true, + /** * The View that displays the search results. The render method will be * attach the search results view to the @@ -179,6 +188,7 @@ define([ */ events: { "click .map-toggle-container": "toggleMode", + "click .toggle-map-filter": "toggleMapFilter", }, /** @@ -613,6 +623,36 @@ define([ } }, + /** + * Toggles the map filter on and off + * @param {boolean} newSetting - Optionally provide the desired new mode + * to switch to. true = limit search to map area, false = do not limit + * search to map area. If not provided, the opposite of the current mode + * will be used. + */ + toggleMapFilter: function (newSetting) { + // Make sure the new setting is a boolean + newSetting = + typeof newSetting != "boolean" + ? !this.limitSearchToMapArea // the opposite of the current mode + : newSetting; // the provided new mode if it is a boolean + + if (newSetting) { + // If true, then the filter should be ON + // this.model.connectMap(); + // TODO + } else { + // If false, then the filter should be OFF + // this.model.disconnectMap(); + this.model.removeSpatialFilter(); + // TODO: We still need to set the facet (current geohash level) on + // the SolrResults model before we send the query. This is how we + // get the resulting counts to display on the map. + } + + this.limitSearchToMapArea = newSetting; + }, + /** * Tasks to perform when the view is closed * @since 2.22.0 From 84a6183b9ac8ec60107f2906155f11e6793f0e60 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 3 Apr 2023 19:33:45 -0400 Subject: [PATCH 39/79] Make more efficient spatial queries [WIP] - "merge" geohashes into one where possible for purposes of querying to reduce the length of the query - Add lots of other functionality to Geohash model and Geohashes collection required for getting spatial filters to work and for rendering geohashes in Cesium Relates to #2069 --- src/js/collections/Filters.js | 6 +- src/js/collections/maps/Geohashes.js | 92 ++++++++++- src/js/models/connectors/Filters-Search.js | 9 +- src/js/models/connectors/Map-Search.js | 98 ++++++++--- src/js/models/filters/SpatialFilter.js | 182 ++++----------------- src/js/models/maps/Geohash.js | 109 +++++++++++- src/js/models/maps/assets/CesiumGeohash.js | 127 ++++---------- 7 files changed, 338 insertions(+), 285 deletions(-) diff --git a/src/js/collections/Filters.js b/src/js/collections/Filters.js index c40a89086..90a070cd2 100644 --- a/src/js/collections/Filters.js +++ b/src/js/collections/Filters.js @@ -344,11 +344,9 @@ define([ else { return ""; } - } catch (error) { + } catch (e) { console.log( - "Error creating a group query, returning a blank string. " + - " Error details: " + - error + "Error creating a group query, returning a blank string. ", e ); return ""; } diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 2f7fc6605..3fde69b19 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -30,6 +30,21 @@ define([ */ model: Geohash, + /** + * Add a comparator to sort the geohashes by length. + * @param {Geohash} model - Geohash model to compare. + * @returns {number} Length of the geohash. + */ + comparator: function (model) { + return model.get("geohash")?.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. + */ getLevelHeightMap: function () { return { 1: 6800000, @@ -127,13 +142,86 @@ define([ this.add(geohashIDs.map((id) => ({ geohash: id }))); }, + /** + * Get a subset of geohashes from this collection that are within the + * provided bounding box. + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @returns {Geohashes} Subset of geohashes. + */ + getSubsetByBounds: function (bounds) { + const levels = this.getLevels(); + const hashes = []; + levels.forEach((level) => { + hashes = hashes.concat(this.getGeohashIDs(bounds, level)); + }); + const geohashes = this.filter((geohash) => { + return hashes.includes(geohash.get("geohash")); + }); + 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. + */ + includes: function (geohash) { + const allHashes = this.getGeohashIDs(); + const geohashID = + geohash instanceof Geohash ? geohash.get("geohash") : geohash; + return allHashes.includes(geohashID); + }, + + /** + * 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. + */ + canMerge: function (geohashes, target) { + const children = target.getChildGeohashes(); + return children.every((child) => geohashes.includes(child)); + }, + + /** + * 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. + */ + getMerged: function () { + // We will merge recursively, so we need to make a copy of the + // collection. + const geohashes = this.clone(); + 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; + } + } + } + return geohashes; + }, + /** * Get the unique geohash levels for all geohashes in the collection. */ getLevels: function () { - return _.uniq(this.pluck("level")); + return Array.from(new Set(this.map((geohash) => geohash.get("level")))); }, - /** * Return the geohashes as a GeoJSON FeatureCollection, where each * geohash is represented as a GeoJSON Polygon (rectangle). diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 76820d4a7..2e7f20c4a 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -92,14 +92,7 @@ define([ function () { // Start at the first page when the filters change MetacatUI.appModel.set("page", 0); - // If there is a spatial filter, update the facets in the SolrResults - // The setFacet method will trigger a search. - const facets = filters.getGeohashLevels(); - if (facets && facets.length) { - searchResults.setFacet(facets); - } else { - searchResults.setFacet(null); - } + this.triggerSearch(); } ); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 828d5231a..5ff803701 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -97,7 +97,7 @@ define([ */ createGeohash() { const map = this.get("map"); - return map.addLayer({ type: "CesiumGeohash" }); + return map.addAsset({ type: "CesiumGeohash" }); }, /** @@ -125,7 +125,7 @@ define([ // be added to the Layers collection, then try to find it again. if (!geohash) { this.listenToOnce(layers, "add", this.findAndSetGeohashLayer); - return + return; } return geohash; }, @@ -137,10 +137,9 @@ define([ connect: function () { this.disconnect(); const searchResults = this.get("searchResults"); + const map = this.get("map"); this.listenTo(searchResults, "reset", this.updateGeohashCounts); - // TODO: ‼️ The map needs to send the height/geohash level to the search. - // and set the facet (so that the results include counts for each - // geohash at the current level). ‼️ + this.listenTo(map, "change:currentViewExtent", this.updateFacet); this.set("isConnected", true); }, @@ -149,38 +148,91 @@ define([ * results collection. */ disconnect: function () { + const map = this.get("map"); const searchResults = this.get("searchResults"); this.stopListening(searchResults, "reset"); + this.stopListening(map, "change:currentViewExtent"); this.set("isConnected", false); }, /** - * Update the Geohash layer in the Map model with the new facet counts - * from the Search results. - * @fires CesiumGeohash#change:counts - * @fires CesiumGeohash#change:totalCount + * Given the counts results in the format returned by the SolrResults + * model, return an array of objects with a geohash and a count property, + * formatted for the CesiumGeohash layer. + * @param {Array} counts - The facet counts from the SolrResults model. + * Given as an array of alternating keys and values. + * @returns {Array} An array of objects with a geohash and a count + * property. */ - updateGeohashCounts: function () { - const geohashLayer = this.get("geohashLayer"); - const searchResults = this.get("searchResults"); - - if(!geohashLayer || !searchResults) return; - - const facetCounts = searchResults.facetCounts; + facetCountsToGeohashAttrs: function (counts) { + if (!counts) return []; + const props = []; + for (let i = 0; i < counts.length; i += 2) { + props.push({ + geohash: counts[i], + properties: { + count: counts[i + 1], + }, + }); + } + return props; + }, - // Get every facet that begins with "geohash_" + /** + * Look in the Search results for the facet counts for the Geohash layer. + * @returns {Array} An array of objects with a geohash and a count + * property or null if there are no Search results or no facet counts. + */ + getGeohashCounts: function () { + const searchResults = this.get("searchResults"); + const facetCounts = searchResults?.facetCounts; + if (!facetCounts) return null; const geohashFacets = Object.keys(facetCounts).filter((key) => key.startsWith("geohash_") ); + return geohashFacets.flatMap((key) => facetCounts[key]); + }, - // Flatten counts from geohashFacets - const allCounts = geohashFacets.flatMap((key) => facetCounts[key]); + /** + * Get the total number of results from the Search results. + * @returns {number} The total number of results or null if there are no + * Search results. + * TODO: This is not currently used, but it could be used to set a + * totalCount property on the Geohash layer, and scale the colors based + * on this max. + */ + getTotalNumberOfResults: function () { + return this.get("searchResults")?.getNumFound(); + }, - const totalFound = searchResults.getNumFound(); + /** + * Update the Geohash layer in the Map model with the new facet counts + * from the Search results. + * @fires CesiumGeohash#change:counts + * @fires CesiumGeohash#change:totalCount + */ + updateGeohashCounts: function () { + const geohashLayer = this.get("geohashLayer"); + const counts = this.getGeohashCounts(); + const modelAttrs = this.facetCountsToGeohashAttrs(counts); + // const totalCount = this.getTotalNumberOfResults(); // TODO + geohashLayer.resetGeohashes(modelAttrs); + }, - // Set the new geohash facet counts on the Map MapAsset - geohashLayer.set("counts", allCounts); - geohashLayer.set("totalCount", totalFound); + /** + * Update the facet on the Search results to match the current Geohash + * level. + * @fires SolrResults#change:facet + */ + 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]}`); + } else { + searchResults.setFacet(null); + } }, } ); diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 03fc94813..94115a650 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -3,9 +3,9 @@ define([ "jquery", "backbone", "models/filters/Filter", - "models/maps/Geohash", + "collections/Filters", "collections/maps/Geohashes", -], function (_, $, Backbone, Filter, Geohash, Geohashes) { +], function (_, $, Backbone, Filter, Filters, 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 @@ -107,7 +107,7 @@ define([ this.set("fields", levels); this.set("values", IDs); } catch (e) { - console.log("Failed to update geohashes" + e); + console.log("Failed to update geohashes", e); } }, @@ -121,86 +121,30 @@ define([ return gCollection.getGeohashIDs(); }, - // TODO: Use the `groupGeohashes` function to consolidate geohashes into - // groups and shorten the query. We can add each group as a sub-filter - // within a filters group. Then the get Query function is fairly simple, - // we might not have to override it at all. (to check). Question: Will - // SolrResults give results for only the geohashes in the group, or will - // it give results for all highest level geohashes? This will impact what - // is shown on the cesium map, because we need counts for each geohash, - // not each group. - - // /** - // * Sets geohashes for the model, considering the maximum geohash limit. - // * If the limit is exceeded, it reduces the level and calls the function recursively. - // */ - // setGeohashes: function () { - // try { - // const level = this.get("level"); - // const limit = this.get("maxGeohashes"); - // let bounds = {}[("north", "south", "east", "west")].forEach((key) => { - // bounds[key] = this.get(key); - // }); - // // bounds = this.splitBoundingBox(...bounds); - // const geohashIDs = this.getGeohashIDs(bounds, level); - // if (limit && geohashIDs.length > limit && level > 1) { - // this.set("level", level - 1); - // this.setGeohashes(); - // return; - // } - // this.set("geohashes", geohashIDs); - // } catch (error) { - // console.log( - // "There was an error getting geohashes in a Geohash model" + - // ". Error details: " + - // error - // ); - // } - // }, - /** * Builds a query string that represents this spatial filter - * @return queryFragment - the query string representing the geohash constraints + * @return {string} The query fragment */ - // 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 ( - // !geohashes || - // !geohashes.length || - // !groups || - // !Object.keys(groups).length - // ) { - // return queryFragment; - // } - - // // 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; - // }, + 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], + }); + filters.add(filter); + }); + return filters.getQuery(); + }, /** * @inheritdoc @@ -238,77 +182,13 @@ define([ } }, - // /** - // * 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); - // }, + /** + * @inheritdoc + */ + resetValue: function () { + this.set("fields", this.defaults().fields); + this.set("values", this.defaults().values); + }, } ); return SpatialFilter; diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 86c6cb588..613f35cfc 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -34,11 +34,26 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( */ defaults: function () { return { - geohash: "", + 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. properties: {}, }; }, + /** + * Overwrite the get method to calculate bounds, point, level, and + * arbitrary properties on the fly. + * @param {string} attr The attribute to get the value of. + * @returns {*} The value of the attribute. + */ + get: function (attr) { + if (attr === "bounds") return this.getBounds(); + if (attr === "point") return this.getPoint(); + if (attr === "level") return this.getLevel(); + if (attr === "geojson") return this.toGeoJSON(); + 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. @@ -48,9 +63,57 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( return !geohash || geohash.length === 0; }, + /** + * Checks if the geohash has a property with the given key. + * @param {string} key The key to check for. + * @returns {boolean} True if the geohash has a property with the given + * key, false otherwise. + */ + isProperty: function (key) { + // Must use prototype.get to avoid infinite loop + const properties = Backbone.Model.prototype.get.call(this, key); + return properties?.hasOwnProperty(key); + }, + + /** + * Get a property from the geohash. + * @param {string} key The key of the property to get. + * @returns {*} The value of the property. + */ + getProperty: function (key) { + if (!key) return null; + if (!this.isProperty(key)) { + return null; + } + return this.get("properties")[key]; + }, + + /** + * Set a property on the geohash. + * @param {string} key The key of the property to set. + * @param {*} value The value of the property to set. + */ + addProperty: function (key, value) { + if (!key) return; + const properties = this.get("properties"); + properties[key] = value; + this.set("properties", properties); + }, + + /** + * Remove a property from the geohash. + * @param {string} key The key of the property to remove. + */ + removeProperty: function (key) { + if (!key) return; + const properties = this.get("properties"); + delete properties[key]; + this.set("properties", properties); + }, + /** * Get the bounds of the geohash "tile". - * @returns {Array} An array containing the bounds of the geohash. + * @returns {Array|null} An array containing the bounds of the geohash. */ getBounds: function () { if (this.isEmpty()) return null; @@ -59,8 +122,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( /** * Get the center point of the geohash. - * @returns {Array} An array containing the center point of the geohash. - * The first element is the longitude, the second is the latitude. + * @returns {Object|null} Returns object with latitude and longitude keys. */ getPoint: function () { if (this.isEmpty()) return null; @@ -69,13 +131,48 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( /** * Get the level of the geohash. - * @returns {number} The level of the geohash. + * @returns {number|null} The level of the geohash. */ getLevel: function () { if (this.isEmpty()) return null; return this.get("geohash").length; }, + /** + * Get the 32 child geohashes of the geohash. + * @param {boolean} [keepProperties=false] If true, the child geohashes + * will have the same properties as the parent geohash. + * @returns {Geohash[]} An array of Geohash models. + */ + getChildGeohashes: function (keepProperties = false) { + if (this.isEmpty()) return null; + const geohashes = []; + const geohash = this.get("geohash"); + for (let i = 0; i < 32; i++) { + geohashes.push(new Geohash({ + geohash: geohash + i.toString(32), + properties: keepProperties ? this.get("properties") : {} + })); + } + return geohashes; + }, + + /** + * Get the parent geohash of the geohash. + * @param {boolean} [keepProperties=false] If true, the parent geohash + * will have the same properties as this child geohash. + * @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") : {} + }); + }, + /** * Get the geohash as a GeoJSON Feature. * @returns {Object} A GeoJSON Feature representing the geohash. @@ -99,7 +196,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( }, properties: this.get("properties"), }; - } + }, } ); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 63c14ee84..64d391483 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -35,12 +35,7 @@ define([ * @extends CesiumVectorData#defaults * @property {'CesiumGeohash'} type The format of the data. Must be * 'CesiumGeohash'. - * @property {string[]} counts An array of geohash strings followed by - * their associated count. e.g. ["a", 123, "f", 8] - * @property {Number} totalCount The total number of results that were - * just fetched - * @property {string[]} geohashIDs An array of geohash strings - * @property {} geohashes + * // TODO */ defaults: function () { @@ -71,8 +66,12 @@ define([ } }, - limitToMapExtent: function () { - // TODO + getLevels: function () { + return this.get("geohashes").getLevels(); + }, + + resetGeohashes: function (geohashes) { + this.get("geohashes").reset(geohashes); }, /** @@ -98,93 +97,30 @@ define([ this.listenTo(this.get("geohashes"), "change", 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); } }, - // /** - // * Get the counts currently set on this model and create the geohash array - // * [{ counts, id, bounds}]. Set this array on the model, which will - // * trigger the cesiumModel to re-render. - // */ - // updateGeohashes: function () { - // try { - // // Counts are formatted as [geohash, count, geohash, count, ...] - // // const counts = this.get("counts"); - // const geohashes = []; - // for (let i = 0; i < counts.length; i += 2) { - // const id = counts[i]; - // geohashes.push({ - // id: id, - // count: counts[i + 1], - // bounds: nGeohash.decode_bbox(id), - // }); - // } - // this.set("geohashes", geohashes); - // this.createCesiumModel(true); - // } catch (error) { - // console.log("Failed to update geohashes in CesiumGeohash", error); - // } - // }, - - // /** - // * Given the geohashes set on the model, return as geoJSON - // * @returns {object} GeoJSON representing the geohashes with counts - // */ - // toGeoJSON: function () { - // try { - // // The base GeoJSON format - // const geojson = { - // type: "FeatureCollection", - // features: [], - // }; - // const geohashes = this.get("geohashes"); - // if (!geohashes) { - // return geojson; - // } - // const features = []; - // // Format for geohashes: - // // [{ counts, id, bounds}] - // geohashes.forEach((geohash) => { - // const bb = geohash.bounds; - // const id = geohash.id; - // const count = geohash.count; - // const minlat = bb[0] <= -90 ? -89.99999 : bb[0]; - // const minlon = bb[1]; - // const maxlat = bb[2]; - // const maxlon = bb[3]; - // const feature = { - // type: "Feature", - // geometry: { - // type: "Polygon", - // coordinates: [ - // [ - // [minlon, minlat], - // [minlon, maxlat], - // [maxlon, maxlat], - // [maxlon, minlat], - // [minlon, minlat], - // ], - // ], - // }, - // properties: { - // "count": count, - // geohash: id, - // }, - // }; - // features.push(feature); - // }) - // geojson["features"] = features; - // return geojson; - // } catch (error) { - // console.log( - // "There was an error converting geohashes to GeoJSON " + - // "in a CesiumGeohash model. Error details: ", - // error - // ); - // } - // }, + /** + * 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. + * @returns {Object} The GeoJSON representation of the geohashes. + */ + getGeoJson: function (limitToExtent = true) { + if (!limitToExtent) { + return this.get("geohashes")?.toGeoJSON(); + } + const extent = this.get("mapModel").get("currentExtent"); + // copy it and delete the height attr + const bounds = Object.assign({}, extent); + delete bounds.height; + return this.get("geohashes")?.getSubsetByBounds(bounds)?.toGeoJSON() + }, /** * Creates a Cesium.DataSource model and sets it to this model's @@ -198,9 +134,18 @@ define([ createCesiumModel: function (recreate = false) { try { const model = this; + // If there is no map model, wait for it to be set so that we can + // limit the geohashes to the current extent. Otherwise, too many + // geohashes will be rendered. + if (!model.get("mapModel")) { + model.listenToOnce(model, "change:mapModel", function () { + model.createCesiumModel(recreate); + }); + return; + } // Set the GeoJSON representing geohashes on the model const cesiumOptions = model.get("cesiumOptions"); - cesiumOptions["data"] = this.get("geohashes")?.toGeoJSON(); + cesiumOptions["data"] = this.getGeoJson(); // TODO: outlines don't work when features are clamped to ground // cesiumOptions['clampToGround'] = true cesiumOptions["height"] = 0; From 261025dbcf0f818ad78713337f7efa5a8a88bd07 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 5 Apr 2023 18:58:28 -0400 Subject: [PATCH 40/79] 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); +// } +// } From 6d55bf57f45968be706181968094b535b48462ed Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 6 Apr 2023 14:52:15 -0400 Subject: [PATCH 41/79] Fix MapFiltersSearch connector & geohash rendering Relates to #1720 --- src/js/collections/maps/Geohashes.js | 2 +- src/js/models/connectors/Filters-Map.js | 50 +++++++++++----- src/js/models/connectors/Filters-Search.js | 48 ++++++--------- .../models/connectors/Map-Search-Filters.js | 30 ++++++---- src/js/models/connectors/Map-Search.js | 60 +++++++++++++++++-- src/js/models/filters/SpatialFilter.js | 22 +++---- src/js/models/maps/Geohash.js | 6 ++ src/js/models/maps/Map.js | 7 ++- src/js/models/maps/assets/CesiumGeohash.js | 23 +++---- src/js/views/maps/CesiumWidgetView.js | 17 ++++++ src/js/views/search/CatalogSearchView.js | 10 +--- src/js/views/search/SearchResultsView.js | 2 +- 12 files changed, 181 insertions(+), 96 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index a83a55ef9..97310a199 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -205,7 +205,7 @@ define([ */ getSubsetByBounds: function (bounds) { const precisions = this.getPrecisions(); - const hashes = []; + let hashes = []; precisions.forEach((precision) => { hashes = hashes.concat( this.getHashStringsByExtent(bounds, precision) diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index 3ffcf0002..bfde139c9 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -1,9 +1,9 @@ /*global define */ -define([ - "backbone", - "collections/Filters", - "models/maps/Map", -], function (Backbone, Filters, Map) { +define(["backbone", "collections/Filters", "models/maps/Map"], function ( + Backbone, + Filters, + Map +) { "use strict"; /** @@ -137,14 +137,33 @@ define([ this.set("spatialFilters", []); }, + /** + * Reset the spatial filter values to their defaults. This will remove + * any spatial constraints from the search. + */ + resetSpatialFilter: function () { + const spatialFilters = this.get("spatialFilters"); + if (spatialFilters?.length) { + spatialFilters.forEach((filter) => { + filter.resetValue(); + }); + } + }, + /** * Stops all Filter-Map listeners, including listeners on the Filters * collection and the Map model. + * @param {boolean} [resetSpatialFilter=false] - Whether to reset the + * spatial filter values to their defaults. This will remove any spatial + * constraints from the search. */ - disconnect: function () { + disconnect: function (resetSpatialFilter = false) { try { + if (resetSpatialFilter) { + this.resetSpatialFilter(); + } this.stopListening(this.get("filters"), "add remove"); - this.stopListening(this.get("map"), "change:currentViewExtent"); + this.stopListening(this.get("map"), "moveEnd moveStart"); this.set("isConnected", false); } catch (e) { console.log("Error stopping Filter-Map listeners: ", e); @@ -160,11 +179,15 @@ define([ connect: function () { try { this.disconnect(); - this.listenTo( - this.get("map"), - "change:currentViewExtent", - this.updateSpatialFilters - ); + const map = this.get("map"); + // Constrain the spatial filter to the current map extent right away + this.updateSpatialFilters(); + // Trigger a 'changing' event on the filters collection to + // indicate that the spatial filter is being updated + this.listenTo(map, "moveStart", function () { + this.get("filters").trigger("changing"); + }); + this.listenTo(map, "moveEnd", this.updateSpatialFilters); this.set("isConnected", true); } catch (e) { console.log("Error starting Filter-Map listeners: ", e); @@ -183,10 +206,9 @@ define([ if (!spatialFilters?.length) { return; } - spatialFilters.forEach((spFilter) => { spFilter.set(extent, { silent: true }); - spFilter.trigger("change:height"); + spFilter.trigger("change:height"); // Only trigger one change event }); } 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 2b5498ddc..098c3bd9f 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -82,28 +82,19 @@ define([ */ connect: function () { this.disconnect(); - // const filters = this.get("filters"); - const searchResults = this.get("searchResults"); - // Listen to changes in the Filters to trigger a search + const filters = this.get("filters"); + const search = this.get("searchResults"); - 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(); - }); + // Start results at first page and recreate query when the filters change + this.listenTo(filters, "update", this.triggerSearch, true); - this.listenTo(this.get("filters"), "change update", function () { - // Start at the first page when the filters change - MetacatUI.appModel.set("page", 0); - this.triggerSearch(); + // "changing" event triggers when the query is about to change, but + // before it has been sent. Useful for showing a loading indicator. + this.listenTo(filters, "changing", function () { + search.trigger("changing"); }); - this.listenTo( - searchResults, - "change:sort change:facet", - this.triggerSearch - ); + this.listenTo(search, "change:sort change:facet", this.triggerSearch); // If the logged-in status changes, send a new search this.listenTo( @@ -111,6 +102,7 @@ define([ "change:loggedIn", this.triggerSearch ); + this.set("isConnected", true); }, @@ -119,17 +111,11 @@ define([ * @since x.x.x */ disconnect: function () { - const model = this; + const filters = this.get("filters"); + const searchResults = this.get("searchResults"); this.stopListening(MetacatUI.appUserModel, "change:loggedIn"); - this.stopListening( - this.get("filters"), - "add remove update reset change" - ); - // Listen to the sort order changing - this.stopListening( - this.get("searchResults"), - "change:sort change:facet" - ); + this.stopListening(filters, "update changing"); + this.stopListening(searchResults, "change:sort change:facet"); this.set("isConnected", false); }, @@ -137,10 +123,14 @@ define([ * Get Results from the Solr index by combining the Filter query string * fragments in each Filter instance in the Search collection and querying * Solr. + * @param {boolean} resetPage - Whether or not to reset the page number + * to 0. Defaults to false. * @fires SolrResults#toPage * @since 2.22.0 */ - triggerSearch: function () { + triggerSearch: function (resetPage = false) { + if (resetPage) MetacatUI.appModel.set("page", 0); + const filters = this.get("filters"); const searchResults = this.get("searchResults"); diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index bdaa84c40..2fe18d66b 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -219,25 +219,35 @@ define([ /** * Disconnect all listeners between the Map, SearchResults, and Filters. + * @param {boolean} [resetSpatialFilter=false] - If true, the spatial + * filter will be reset to the default value, which will effectively + * remove any spatial constraints from the search. */ - disconnect: function () { - this.getConnectors().forEach((connector) => connector.disconnect()); + disconnect: function (resetSpatialFilter = false) { + this.get("filtersMapConnector").disconnect(resetSpatialFilter); + this.get("filtersSearchConnector").disconnect(); + this.get("mapSearchConnector").disconnect(); }, /** - * Disconnect all listeners associated with the Map. This disconnects - * both the search and filters from the map. + * Disconnect the filters from the map. This stops the map from updating + * any spatial filters in the filters collection with the extent of the + * map view. + * @param {boolean} [resetSpatialFilter=false] - If true, the spatial + * filter will be reset to the default value, which will effectively + * remove any spatial constraints from the search. */ - disconnectMap: function () { - this.getMapConnectors().forEach((connector) => connector.disconnect()); + disconnectFiltersMap: function (resetSpatialFilter = false) { + this.get("filtersMapConnector").disconnect(resetSpatialFilter); }, /** - * Connect all listeners associated with the Map. This connects both the - * search and filters to the map. + * Connect or re-connect the filters to the map. This will enable the map + * to start updating any spatial filters in the filters collection with + * the extent of the map view. */ - connectMap: function () { - this.getMapConnectors().forEach((connector) => connector.connect()); + connectFiltersMap: function () { + this.get("filtersMapConnector").connect(); }, /** diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 7be72ec58..cf740b18d 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: function() { + createGeohash: function () { const map = this.get("map"); return map.addAsset({ type: "CesiumGeohash" }); }, @@ -133,16 +133,66 @@ define([ /** * Connect the Map to the Search. When a new search is performed, the * Search will set the new facet counts on the GeoHash layer in the Map. + * When the view extent on the map has changed, the geohash facet on the + * search will be updated to reflect the new height/altitude of the view. + * This will trigger a new search, which will update the counts on the + * GeoHash layer in the Map. When connected, the Geohash layer will also + * be hidden during search requests, and while the user is panning/zooming + * the map. */ connect: function () { this.disconnect(); + const searchResults = this.get("searchResults"); const map = this.get("map"); - this.listenTo(searchResults, "reset", this.updateGeohashCounts); - this.listenTo(map, "change:currentViewExtent", this.updateFacet); + + // Pass the facet counts to the GeoHash layer when the search results + // are returned. + this.listenTo(searchResults, "update reset", function () { + this.updateGeohashCounts(); + this.showGeoHashLayer(); + }); + + // When the user is panning/zooming in the map, hide the GeoHash layer + // to indicate that the map is not up to date with the search results, + // which are about to be updated. + this.listenTo(map, "moveStart", this.hideGeoHashLayer); + + // When the user is done panning/zooming in the map, show the GeoHash + // layer again and update the search results (thereby updating the + // facet counts on the GeoHash layer) + this.listenTo(map, "moveEnd", function () { + this.showGeoHashLayer(); + this.updateFacet(); + searchResults.trigger("reset"); + }); + + // When a new search is being performed, hide the GeoHash layer to + // indicate that the map is not up to date with the search results, + // which are about to be updated. + this.listenTo(searchResults, "request", function () { + this.hideGeoHashLayer(); + }); + this.set("isConnected", true); }, + /** + * Make the geoHashLayer invisible. + * @fires CesiumGeohash#change:visible + */ + hideGeoHashLayer: function () { + this.get("geohashLayer")?.set("visible", false); + }, + + /** + * Make the geoHashLayer visible. + * @fires CesiumGeohash#change:visible + */ + showGeoHashLayer: function () { + this.get("geohashLayer")?.set("visible", true); + }, + /** * Disconnect the Map from the Search. Stops listening to the Search * results collection. @@ -151,7 +201,7 @@ define([ const map = this.get("map"); const searchResults = this.get("searchResults"); this.stopListening(searchResults, "reset"); - this.stopListening(map, "change:currentViewExtent"); + this.stopListening(map, "moveStart moveEnd"); this.set("isConnected", false); }, @@ -169,7 +219,7 @@ define([ const props = []; for (let i = 0; i < counts.length; i += 2) { props.push({ - geohash: counts[i], + hashString: counts[i], properties: { count: counts[i + 1], }, diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index a9972b207..0a273f28c 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -4,7 +4,8 @@ define([ "backbone", "models/filters/Filter", "collections/maps/Geohashes", -], function (_, $, Backbone, Filter, Geohashes) { + "collections/Filters", +], function (_, $, Backbone, Filter, Geohashes, Filters) { /** * @classdesc A SpatialFilter represents a spatial constraint on the query to * be executed. @@ -74,26 +75,29 @@ define([ * 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 + * */ - validateCoordinates: function () { + validateCoordinates: function (silent=true) { if (!this.hasCoordinates()) return; if (this.get("east") > 180) { - this.set("east", 180); + this.set("east", 180, { silent: silent }); } if (this.get("west") < -180) { - this.set("west", -180); + this.set("west", -180, { silent: silent }); } if (this.get("north") > 90) { - this.set("north", 90); + this.set("north", 90, { silent: silent }); } if (this.get("south") < -90) { - this.set("south", -90); + this.set("south", -90), { silent: silent }; } if (this.get("east") < this.get("west")) { - this.set("east", this.get("west")); + this.set("east", this.get("west", { silent: silent })); } if (this.get("north") < this.get("south")) { - this.set("north", this.get("south")); + this.set("north", this.get("south", { silent: silent })); } }, @@ -273,13 +277,11 @@ define([ }, /** - * // TODO: Do we need this? * @inheritdoc */ resetValue: function () { const df = this.defaults(); this.set({ - fields: df.fields, values: df.values, east: df.east, west: df.west, diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 52d607ecf..d1ff12544 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -205,6 +205,12 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( const properties = this.get("properties"); properties["hashString"] = this.get("hashString"); if (!bounds) return null; + + // TODO: Where should this be done? + // Set min latitude to -89.99999 for Geohashes, Cesium throws an error when the latitude is -90 + // Compare to https://github.com/NCEAS/metacatui/commit/af7a432c5cb296a2e36a5ceb13eef51f55c33e30 + if (bounds[1] === -90) bounds[1] = -89.99999; + return { type: "Feature", geometry: { diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 976f1c5fd..ea620bb6f 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -293,14 +293,15 @@ define([ }, /** - * Add a layer to the map. This is the best way to add a layer to the map - * because it will ensure that this map model is set on the layer model. + * Add a layer or other asset to the map. This is the best way to add a + * layer to the map because it will ensure that this map model is set on + * the layer model. * @param {Object | MapAsset} layer - A map asset model or object with * attributes to set on a new map asset model. * @returns {MapAsset} The new layer model. * @since x.x.x */ - addLayer: function (layer) { + addAsset: function (layer) { const layers = this.get("layers") || this.resetLayers(); return layers.addAsset(layer, this); }, diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 4c76de13b..5bb81e197 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -97,8 +97,7 @@ define([ * geohashes. */ stopListeners: function () { - this.stopListening(this, "change:geohashes"); - this.stopListening(this.get("geohashes"), "change"); + this.stopListening(this.get("geohashes"), "add remove update reset"); }, /** @@ -107,17 +106,9 @@ define([ startListening: function () { try { this.stopListeners(); - this.listenTo(this, "change:geohashes", function () { - this.stopListeners(); - this.startListening(); - this.createCesiumModel(true); - }); - this.listenTo(this.get("geohashes"), "change", function () { - this.createCesiumModel(true); - }); this.listenTo( - this.get("mapModel"), - "change:currentExtent", + this.get("geohashes"), + "add remove update reset", function () { this.createCesiumModel(true); } @@ -133,13 +124,15 @@ define([ * 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 = false) { + return this.get("geohashes")?.toGeoJSON(); + + // TODO fix limitToExtent if (!limitToExtent) { return this.get("geohashes")?.toGeoJSON(); } const extent = this.get("mapModel").get("currentExtent"); - // copy it and delete the height attr - const bounds = Object.assign({}, extent); + let bounds = Object.assign({}, extent); delete bounds.height; return this.get("geohashes")?.getSubsetByBounds(bounds)?.toGeoJSON(); }, diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index bf49d6efe..0f57e15f8 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -35,6 +35,12 @@ define( * @screenshot views/maps/CesiumWidgetView.png * @since 2.18.0 * @constructs + * @fires CesiumWidgetView#moved + * @fires CesiumWidgetView#moveEnd + * @fires CesiumWidgetView#moveStart + * @fires Map#moved + * @fires Map#moveEnd + * @fires Map#moveStart */ var CesiumWidgetView = Backbone.View.extend( /** @lends CesiumWidgetView.prototype */{ @@ -228,6 +234,8 @@ define( // Set listeners for when the Cesium camera changes a significant amount. view.camera.changed.addEventListener(function () { + view.trigger('moved') + view.model.trigger('moved') // Update the bounding box for the visible area in the Map model view.updateViewExtent() // If the scale bar is showing, update the pixel to meter scale on the map @@ -237,6 +245,15 @@ define( } }) + view.camera.moveEnd.addEventListener(function () { + view.trigger('moveEnd') + view.model.trigger('moveEnd') + }) + view.camera.moveStart.addEventListener(function () { + view.trigger('moveStart') + view.model.trigger('moveStart') + }) + // Sets listeners for when the mouse moves, depending on the value of the map // model's showScaleBar and showFeatureInfo attributes view.setMouseMoveListeners() diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 7b477842a..0855f2db1 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -639,17 +639,11 @@ define([ if (newSetting) { // If true, then the filter should be ON - // this.model.connectMap(); - // TODO + this.model.connectFiltersMap(); } else { // If false, then the filter should be OFF - // this.model.disconnectMap(); - this.model.removeSpatialFilter(); - // TODO: We still need to set the facet (current geohash level) on - // the SolrResults model before we send the query. This is how we - // get the resulting counts to display on the map. + this.model.disconnectFiltersMap(true); } - this.limitSearchToMapArea = newSetting; }, diff --git a/src/js/views/search/SearchResultsView.js b/src/js/views/search/SearchResultsView.js index 2efade21a..7946e0e4d 100644 --- a/src/js/views/search/SearchResultsView.js +++ b/src/js/views/search/SearchResultsView.js @@ -97,7 +97,7 @@ define([ this.removeListeners(); this.listenTo(this.searchResults, "add", this.addResultModel); this.listenTo(this.searchResults, "reset", this.addResultCollection); - this.listenTo(this.searchResults, "request", this.loading); + this.listenTo(this.searchResults, "changing request", this.loading); this.listenTo(this.searchResults, "error", this.showError); }, From 6bc3a13fe8ebe07bc28541e16f0b581f7167d659 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 6 Apr 2023 17:58:05 -0400 Subject: [PATCH 42/79] Fix geohash geoJSON & getting hashes for filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix coordiante order in creating Geohash feature - Fix bug where SpatialFilter failed when view extent crossed the antimeridian - The geohashes are finally drawn correctly on the map and the correct hashstrings for the view area are identified for the search! 🎉 Relates to #1720 --- src/js/collections/maps/Geohashes.js | 4 ++-- src/js/models/filters/SpatialFilter.js | 9 ++------- src/js/models/maps/Geohash.js | 23 +++++++++++----------- src/js/models/maps/assets/CesiumGeohash.js | 10 ++++------ 4 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 97310a199..28fb9295e 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -211,10 +211,10 @@ define([ this.getHashStringsByExtent(bounds, precision) ); }); - const geohashes = this.filter((geohash) => { + const subsetModels = this.filter((geohash) => { return hashes.includes(geohash.get("hashString")); }); - return new Geohashes(geohashes); + return new Geohashes(subsetModels); }, /** diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 0a273f28c..3353ee3aa 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -79,7 +79,8 @@ define([ * the case where the coordinates are adjusted * */ - validateCoordinates: function (silent=true) { + validateCoordinates: function (silent = true) { + if (!this.hasCoordinates()) return; if (this.get("east") > 180) { this.set("east", 180, { silent: silent }); @@ -93,12 +94,6 @@ define([ if (this.get("south") < -90) { this.set("south", -90), { silent: silent }; } - if (this.get("east") < this.get("west")) { - this.set("east", this.get("west", { silent: silent })); - } - if (this.get("north") < this.get("south")) { - this.set("north", this.get("south", { silent: silent })); - } }, /** diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index d1ff12544..03de865c4 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -201,27 +201,26 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( * @returns {Object} A GeoJSON Feature representing the geohash. */ toGeoJSON: function () { + if (this.isEmpty()) return null; const bounds = this.getBounds(); + if (!bounds) return null; + let [south, west, north, east] = bounds; + if (!south && !west && !north && !east) return null; const properties = this.get("properties"); properties["hashString"] = this.get("hashString"); - if (!bounds) return null; - - // TODO: Where should this be done? - // Set min latitude to -89.99999 for Geohashes, Cesium throws an error when the latitude is -90 - // Compare to https://github.com/NCEAS/metacatui/commit/af7a432c5cb296a2e36a5ceb13eef51f55c33e30 - if (bounds[1] === -90) bounds[1] = -89.99999; - + // Set min latitude to -89.99999 for Geohashes. This is for Cesium. + if (south === -90) south = -89.99999; return { type: "Feature", geometry: { type: "Polygon", coordinates: [ [ - [bounds[0], bounds[1]], - [bounds[2], bounds[1]], - [bounds[2], bounds[3]], - [bounds[0], bounds[3]], - [bounds[0], bounds[1]], + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], ], ], }, diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 5bb81e197..42174b407 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -124,17 +124,15 @@ define([ * GeoJSON for all geohashes, not just those in the current extent. * @returns {Object} The GeoJSON representation of the geohashes. */ - getGeoJSON: function (limitToExtent = false) { - return this.get("geohashes")?.toGeoJSON(); - - // TODO fix limitToExtent + getGeoJSON: function (limitToExtent = true) { if (!limitToExtent) { return this.get("geohashes")?.toGeoJSON(); } - const extent = this.get("mapModel").get("currentExtent"); + const extent = this.get("mapModel").get("currentViewExtent"); let bounds = Object.assign({}, extent); delete bounds.height; - return this.get("geohashes")?.getSubsetByBounds(bounds)?.toGeoJSON(); + const subset = this.get("geohashes")?.getSubsetByBounds(bounds); + return subset?.toGeoJSON(); }, /** From f02d1a2fe37671e5dce2552230df312357830f61 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 7 Apr 2023 17:42:23 -0400 Subject: [PATCH 43/79] Style/connect catalog<->map buttons; move CSS - Move the catalogSearchView into it's own CSS file to keep it more manageable [WIP] - Add and style the show/hide map button & filter by extent toggle. Connect them to their actions. Relates to: #2069, #2065 --- src/css/catalog-search-view.css | 198 +++++++++++++++++++++ src/css/metacatui-common.css | 81 --------- src/js/templates/search/catalogSearch.html | 27 ++- src/js/views/search/CatalogSearchView.js | 47 ++++- 4 files changed, 252 insertions(+), 101 deletions(-) create mode 100644 src/css/catalog-search-view.css diff --git a/src/css/catalog-search-view.css b/src/css/catalog-search-view.css new file mode 100644 index 000000000..76fa45363 --- /dev/null +++ b/src/css/catalog-search-view.css @@ -0,0 +1,198 @@ +/****************************************** +** CatalogSearchView *** +******************************************/ + +/* +TODO: + - transfer over any other styles specific to this component + - make sure there are not conflicts with the old data catalog view + - see if there are any theme-specific overrides for this search view that we can eliminate. + - switch what we can to CSS variables + */ + +.catalog-search-view { + height: 100%; +} + +.catalog-search-inner { + height: 100%; + display: grid; + justify-content: stretch; + align-items: stretch; + grid-template-columns: auto 1fr 1fr; + grid-template-rows: 100%; +} + +.catalog-search-view .filter-groups-container { + width: 215px; + padding: var(--pad); + padding-bottom: 3rem; + overflow: scroll; +} + +.catalog-search-body.mapMode { + height: 100vh; + width: 100vw; + padding-bottom: 0px; + display: grid; + align-items: stretch; + justify-content: stretch; + overflow: hidden; +} + +.catalog-search-body.mapMode .search-results-view .result-row:last-child { + margin-bottom: 100px; +} + +.search-results-container { + overflow-y: scroll; + height: 100%; +} + +.search-results-panel-container { + display: grid; + grid-auto-columns: 1fr; + grid-template-columns: max-content 1fr; + grid-template-rows: min-content min-content min-content 1fr; + gap: 0px 0px; + grid-template-areas: + "map-toggle-container map-toggle-container" + "title-container title-container" + "pager-container sorter-container" + "search-results-container search-results-container"; +} + +.search-results-container { + grid-area: search-results-container; +} + +.pager-container { + grid-area: pager-container; +} + +.sorter-container { + grid-area: sorter-container; + justify-self: end; + padding-right: var(--pad); +} + +.title-container { + grid-area: title-container; +} + +.map-toggle-container { + grid-area: map-toggle-container; +} + +.catalog-search-body.mapMode .search-results-panel-container .map-toggle-container { + display: none; +} + +.catalog-search-body.listMode .catalog-search-inner { + grid-template-columns: auto 1fr 0; +} + +.catalog-search-view .cesium-widget-view { + width: inherit; + margin-left: 0; +} + +.search-results-view .result-row { + padding: var(--pad); +} + +.catalog-search-view .no-search-results { + padding: var(--pad); + text-align: center; +} + + + +.map-panel-container { + position: relative; +} + +/* When map is hidden... */ +.listMode .map-panel-container { + position: unset; +} + +.listMode .catalog-search-inner { + position: relative; +} + +.catalog-search-body.listMode .map-panel-container { + display: block; +} + +.listMode .map-container { + display: none; +} + + + +/* map controls */ + +.map-controls { + position: absolute; + top: 1rem; + left: 1rem; + z-index: 1; + display: grid; + grid-template-columns: auto auto; + gap: 1rem; + align-items: center; +} +.listMode .map-controls { + right: 1.1rem; + left: auto; + top: 0.3rem; + gap: 0; +} + +.show-hide-map-button { + background-color: #19B36A; + color: white; + padding: 0.3rem 0.5rem; + cursor: pointer; + box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15), 0 1px 14px -6px rgba(0, 0, 0, 0.28); + border-radius: 0.5rem; + letter-spacing: 0.02em; +} + +.show-hide-map-button:hover { + background-color: #1E9E5A; + color: white; + /* make a smaller darker box shadow than in the non-hover state */ + box-shadow: 0 1px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 5px -2px rgba(0, 0, 0, 0.3); +} + +/* Spatial Filter Toggle */ +.spatial-filter{ + display: grid; + grid-template-columns: auto auto; + background: var(--map-col-bkg, black); + color: var(--map-col-text, white); + border-radius: 0.5rem; + opacity: 0.8; + top: 0.5rem; + padding: 0.3rem 0.5rem; + box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15), 0 1px 14px -6px rgba(0, 0, 0, 0.28); +} +.listMode .spatial-filter { + display: none; +} +.spatial-filter-label{ + margin: 0; +} +/* rule is specific to overwrite bootstrap */ +input[type=checkbox].spatial-filter-checkbox{ + margin: 0; + margin-right: 0.5rem; + /* make it look nicer */ + transform: scale(1.2); + /* put it to the left of the label */ + order: -1; + margin-right: 0.5rem; + +} \ No newline at end of file diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 3f233dda1..f5dcdb110 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -545,87 +545,6 @@ text-shadow: none; .data-tag-icon.crimson g{ fill: #800000; } -/****************************************** -** CatalogSearchView *** -******************************************/ - -.catalog-search-view { - height: 100%; -} -.catalog-search-inner{ - height: 100%; - display: grid; - justify-content: stretch; - align-items: stretch; - grid-template-columns: auto 1fr 1fr; - grid-template-rows: 100%; -} -.catalog-search-view .filter-groups-container { - width: 215px; - padding: var(--pad); - padding-bottom: 3rem; - overflow: scroll; -} - -.catalog-search-body.mapMode{ - height: 100vh; - width: 100vw; - padding-bottom: 0px; - display: grid; - align-items: stretch; - justify-content: stretch; - overflow: hidden; -} - -.catalog-search-body.mapMode .search-results-view .result-row:last-child{ - margin-bottom: 100px; -} - -.search-results-container { - overflow-y: scroll; - height: 100%; -} -.search-results-panel-container{ - display: grid; - grid-auto-columns: 1fr; - grid-template-columns: max-content 1fr; - grid-template-rows: min-content min-content min-content 1fr; - gap: 0px 0px; - grid-template-areas: - "map-toggle-container map-toggle-container" - "title-container title-container" - "pager-container sorter-container" - "search-results-container search-results-container"; -} -.search-results-container { grid-area: search-results-container; } -.pager-container { grid-area: pager-container; } -.sorter-container { - grid-area: sorter-container; - justify-self: end; - padding-right: var(--pad); -} -.title-container { grid-area: title-container; } -.map-toggle-container { grid-area: map-toggle-container; } -.catalog-search-body.mapMode .search-results-panel-container .map-toggle-container{ - display: none; -} -.catalog-search-body.listMode .map-panel-container{ - display: none; -} -.catalog-search-body.listMode .catalog-search-inner{ - grid-template-columns: auto 1fr 0; -} -.catalog-search-view .cesium-widget-view { - width: inherit; - margin-left: 0; -} -.search-results-view .result-row{ - padding: var(--pad); -} -.catalog-search-view .no-search-results{ - padding: var(--pad); - text-align: center; -} /****************************************** ** Results and Result Rows *** diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html index 13e399dcc..ac061e3c4 100644 --- a/src/js/templates/search/catalogSearch.html +++ b/src/js/templates/search/catalogSearch.html @@ -1,29 +1,26 @@
    -
    - +
    + Hide Map - + - - +
    + + +
    diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 0855f2db1..2aaf328e3 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -9,6 +9,7 @@ define([ "views/search/SorterView", "text!templates/search/catalogSearch.html", "models/connectors/Map-Search-Filters", + "text!" + MetacatUI.root + "/css/catalog-search-view.css" ], function ( $, Backbone, @@ -18,7 +19,8 @@ define([ PagerView, SorterView, Template, - MapSearchFiltersConnector + MapSearchFiltersConnector, + CatalogSearchViewCSS ) { "use strict"; @@ -180,15 +182,26 @@ define([ */ titleContainer: ".title-container", + /** + * The query selector for button that is used to either show or hide the + * map. + * @type {string} + * @since 2.22.0 + */ + showHideMapButton: ".show-hide-map-button", + /** * The events this view will listen to and the associated function to * call. * @type {Object} * @since 2.22.0 */ - events: { - "click .map-toggle-container": "toggleMode", - "click .toggle-map-filter": "toggleMapFilter", + events: function () { + const e = { + "click .spatial-filter": "toggleMapFilter", + } + e[`click ${this.showHideMapButton}`] = "toggleMode"; + return e; }, /** @@ -208,6 +221,10 @@ define([ * @since x.x.x */ initialize: function (options) { + + this.cssID = "catalogSearchView"; + MetacatUI.appModel.addCSS(CatalogSearchViewCSS, this.cssID); + if (!options) options = {}; this.initialQuery = options.initialQuery || null; @@ -602,7 +619,7 @@ define([ */ toggleMode: function (newMode) { try { - let classList = document.querySelector("body").classList; + const classList = document.querySelector("body").classList; // If the new mode is not provided, the new mode is the opposite of // the current mode @@ -618,11 +635,30 @@ define([ classList.remove("listMode"); classList.add("mapMode"); } + this.updateShowHideMapButton(); } catch (e) { console.error("Couldn't toggle search mode. ", e); } }, + /** + * Change the content of the map toggle button to indicate whether + * clicking it will show or hide the map. + */ + updateShowHideMapButton: function () { + try { + const mapToggle = this.el.querySelector(this.showHideMapButton); + if(!mapToggle) return; + if (this.mode == "map") { + mapToggle.innerHTML = 'Hide Map ' + } else { + mapToggle.innerHTML = ' Show Map ' + } + } catch (e) { + console.log("Couldn't update map toggle. ", e); + } + }, + /** * Toggles the map filter on and off * @param {boolean} newSetting - Optionally provide the desired new mode @@ -653,6 +689,7 @@ define([ */ onClose: function () { try { + MetacatUI.appModel.removeCSS(this.cssID); document .querySelector("body") .classList.remove(this.bodyClass, `${this.mode}Mode`); From fd48825fa570bcf651e4ce4b30e66df676291371 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 10 Apr 2023 11:38:30 -0400 Subject: [PATCH 44/79] Fix bug in filters-search connector Relates to #1720 --- src/js/models/connectors/Filters-Search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 098c3bd9f..7732e61ba 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -86,7 +86,7 @@ define([ const search = this.get("searchResults"); // Start results at first page and recreate query when the filters change - this.listenTo(filters, "update", this.triggerSearch, true); + this.listenTo(filters, "update change", this.triggerSearch, true); // "changing" event triggers when the query is about to change, but // before it has been sent. Useful for showing a loading indicator. From f9b0aaeef2fda112061461943dadf4526389f90a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 12 Apr 2023 15:31:41 -0400 Subject: [PATCH 45/79] Improvements to Geohash appearance - Enable adding outlines to GeoJSON - Enable setting colors with alpha values in Cesium layer color palettes - Enable setting the min and max for a color palette dynamically - Use all of these features to set reasonable defaults for the Cesium Geohash layer Issues #2123, #2124, #2125, #1720 --- src/js/collections/maps/AssetColors.js | 29 + src/js/collections/maps/Geohashes.js | 10 + src/js/models/AppModel.js | 4 - src/js/models/maps/AssetColor.js | 111 +--- src/js/models/maps/AssetColorPalette.js | 573 +++++++++--------- src/js/models/maps/Geohash.js | 19 +- src/js/models/maps/assets/CesiumGeohash.js | 70 ++- src/js/models/maps/assets/CesiumVectorData.js | 46 +- 8 files changed, 461 insertions(+), 401 deletions(-) diff --git a/src/js/collections/maps/AssetColors.js b/src/js/collections/maps/AssetColors.js index 9f7665ff9..ddf59ca3d 100644 --- a/src/js/collections/maps/AssetColors.js +++ b/src/js/collections/maps/AssetColors.js @@ -34,6 +34,22 @@ define( */ model: AssetColor, + /** + * Add custom sort functionality such that values are sorted + * numerically, but keep the special value key words "min" and "max" at + * the beginning or end of the collection, respectively. + */ + comparator: function (color) { + let value = color.get('value'); + if (value === 'min') { + return -Infinity; + } else if (value === 'max') { + return Infinity; + } else { + return value + } + }, + /** * Finds the last color model in the collection. If there are no colors in the * collection, returns the default color set in a new Asset Color model. @@ -45,7 +61,20 @@ define( defaultColor = new AssetColor(); } return defaultColor + }, + + /** + * For any attribute that exists in the models in this collection, return an + * array of the values for that attribute. + * @param {string} attr - The attribute to get the values for. + * @return {Array} + */ + getAttr: function(attr) { + return this.map(function (model) { + return model.get(attr); + }); } + } ); diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 28fb9295e..6bbc6a285 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -144,6 +144,16 @@ define([ } }, + /** + * Get an array of all the values for a given property in the geohash + * models in this collection. + * @param {string} attr The key of the property in the properties object + * in each geohash model. + */ + getAttr(attr) { + return this.models.map((geohash) => geohash.get(attr)); + }, + /** * Splits a given bounding box if it crosses the prime meridian. Returns * an array of bounding boxes. diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index 00df659cf..ccae8f8bc 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -110,10 +110,6 @@ define(['jquery', 'underscore', 'backbone'], catalogSearchMapOptions: { showToolbar: false, layers: [ - { - "type": "CesiumGeohash", - "opacity": 0.7, - }, { "label": "Satellite imagery", "icon": "urn:uuid:4177c2e1-3037-4964-bf00-5f13182308d9", diff --git a/src/js/models/maps/AssetColor.js b/src/js/models/maps/AssetColor.js index 676c6b2eb..5e205d491 100644 --- a/src/js/models/maps/AssetColor.js +++ b/src/js/models/maps/AssetColor.js @@ -76,6 +76,8 @@ define( * red in this color. * @property {number} [green=1] A number between 0 and 1 indicating the intensity of * red in this color. + * @property {number} [alpha=1] A number between 0 and 1 indicating the opacity of + * this color. */ /** @@ -97,7 +99,8 @@ define( color: { red: 1, blue: 1, - green: 1 + green: 1, + alpha: 1 } } }, @@ -112,15 +115,23 @@ define( // If the color is a hex code instead of an object with RGB values, then // convert it. if (colorConfig && colorConfig.color && typeof colorConfig.color === 'string') { - // Assume the string is an hex color code and convert it to RGB - var rgb = this.hexToRGB(colorConfig.color) - if (rgb) { - this.set('color', rgb) - } else { - // Otherwise, the color is invalid, set it to the default - this.set('color', this.defaults().color) - } + // Assume the string is an hex color code and convert it to RGBA, + // otherwise use the default color + this.set('color', + this.hexToRGBA(colorConfig.color) || + this.defaults().color + ) } + // Set missing RGB values to 0, and alpha to 1 + let color = this.get('color'); + color.red = color.red || 0; + color.green = color.green || 0; + color.blue = color.blue || 0; + if (!color.alpha && color.alpha !== 0) { + color.alpha = 1; + } + this.set('color', color) + } catch (error) { console.log( @@ -130,86 +141,22 @@ define( } }, + /** - * Converts hex color values to RGB values between 0 and 1 - * - * @param {string} hex a color in hexadecimal format - * @return {Color} a color in RGB format - */ - hexToRGB: function (hex) { - var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + * Converts an 6 to 8 digit hex color value to RGBA values between 0 and 1 + * @param {string} hex - A hex color code, e.g. '#44A96A' or '#44A96A88' + * @return {Color} - The RGBA values of the color + */ + hexToRGBA: function (hex) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex); return result ? { red: parseInt(result[1], 16) / 255, green: parseInt(result[2], 16) / 255, - blue: parseInt(result[3], 16) / 255 + blue: parseInt(result[3], 16) / 255, + alpha: parseInt(result[4], 16) / 255 } : null; }, - // /** - // * Parses the given input into a JSON object to be set on the model. - // * - // * @param {TODO} input - The raw response object - // * @return {TODO} - The JSON object of all the AssetColor attributes - // */ - // parse: function (input) { - - // try { - // // var modelJSON = {}; - - // // return modelJSON - - // } - // catch (error) { - // console.log( - // 'There was an error parsing a AssetColor model' + - // '. Error details: ' + error - // ); - // } - - // }, - - // /** - // * Overrides the default Backbone.Model.validate.function() to check if this if - // * the values set on this model are valid. - // * - // * @param {Object} [attrs] - A literal object of model attributes to validate. - // * @param {Object} [options] - A literal object of options for this validation - // * process - // * - // * @return {Object} - Returns a literal object with the invalid attributes and - // * their corresponding error message, if there are any. If there are no errors, - // * returns nothing. - // */ - // validate: function (attrs, options) { - // try { - - // } - // catch (error) { - // console.log( - // 'There was an error validating a AssetColor model' + - // '. Error details: ' + error - // ); - // } - // }, - - // /** - // * Creates a string using the values set on this model's attributes. - // * @return {string} The AssetColor string - // */ - // serialize: function () { - // try { - // var serializedAssetColor = ""; - - // return serializedAssetColor; - // } - // catch (error) { - // console.log( - // 'There was an error serializing a AssetColor model' + - // '. Error details: ' + error - // ); - // } - // }, - }); return AssetColor; diff --git a/src/js/models/maps/AssetColorPalette.js b/src/js/models/maps/AssetColorPalette.js index bdf379731..a47198009 100644 --- a/src/js/models/maps/AssetColorPalette.js +++ b/src/js/models/maps/AssetColorPalette.js @@ -1,317 +1,290 @@ -'use strict'; +"use strict"; -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'collections/maps/AssetColors' - ], - function ( - $, - _, - Backbone, - AssetColors - ) { - /** - * @classdesc An AssetColorPalette Model represents a color scale that is mapped to - * some attribute of a Map Asset. For vector assets, like 3D tilesets, this palette is - * used to conditionally color features on a map. For any type of asset, it can be - * used to generate a legend. - * @classcategory Models/Maps - * @class AssetColorPalette - * @name AssetColorPalette - * @extends Backbone.Model - * @since 2.18.0 - * @constructor - */ - var AssetColorPalette = Backbone.Model.extend( - /** @lends AssetColorPalette.prototype */ { +define([ + "jquery", + "underscore", + "backbone", + "collections/maps/AssetColors", +], function ($, _, Backbone, AssetColors) { + /** + * @classdesc An AssetColorPalette Model represents a color scale that is + * mapped to some attribute of a Map Asset. For vector assets, like 3D + * tilesets, this palette is used to conditionally color features on a map. + * For any type of asset, it can be used to generate a legend. + * @classcategory Models/Maps + * @class AssetColorPalette + * @name AssetColorPalette + * @extends Backbone.Model + * @since 2.18.0 + * @constructor + */ + var AssetColorPalette = Backbone.Model.extend( + /** @lends AssetColorPalette.prototype */ { + /** + * The name of this type of model + * @type {string} + */ + type: "AssetColorPalette", - /** - * The name of this type of model - * @type {string} - */ - type: 'AssetColorPalette', + /** + * Default attributes for AssetColorPalette models + * @name AssetColorPalette#defaults + * @type {Object} + * @property {('categorical'|'continuous'|'classified')} + * [paletteType='categorical'] Set to 'categorical', 'continuous', or + * 'classified'. NOTE: Currently only categorical and continuous palettes + * are supported. + * - Categorical: the color conditions will be interpreted such that one + * color represents a single value (e.g. a discrete palette). + * - Continuous: each color in the colors attribute will represent a point + * in a gradient. The point in the gradient will be associated with the + * number set with the color, and numbers in between points will be set + * to an interpolated color. + * - Classified: the numbers set in the colors attribute will be + * interpreted as maximums. Continuous properties will be forced into + * discrete bins. + * @property {string} property The name (ID) of the property in the asset + * layer's attribute table to color the vector data by (or for imagery + * data that does not have an attribute table, just the name of the + * attribute that these colors map to). + * @property {string} [label = null] A user-friendly name to display + * instead of the actual property name. + * @property {AssetColors} [colors = new AssetColors()] The colors to use + * in the color palette, along with the conditions associated with each + * color (i.e. the properties of the feature that must be true to use the + * given color.) . The last color in the collection will always be treated + * as the default color - any feature that doesn't match the other colors + * will be colored with this color. + * @property {number} [minVal = null] The minimum value of the property to + * use in the color palette when the special value 'min' is used for the + * value of a color. + * @property {number} [maxVal = null] The maximum value of the property to + * use in the color palette when the special value 'max' is used for the + * value of a color. + */ + defaults: function () { + return { + paletteType: "categorical", + property: null, + label: null, + colors: new AssetColors(), + minVal: null, + maxVal: null, + }; + }, - /** - * Default attributes for AssetColorPalette models - * @name AssetColorPalette#defaults - * @type {Object} - * @property {('categorical'|'continuous'|'classified')} - * [paletteType='categorical'] Set to 'categorical', 'continuous', or - * 'classified'. NOTE: Currently only categorical and continuous palettes are - * supported. - * - Categorical: the color conditions will be interpreted such that one color - * represents a single value (e.g. a discrete palette). - * - Continuous: each color in the colors attribute will represent a point in a - * gradient. The point in the gradient will be associated with the number set - * with the color, and numbers in between points will be set to an interpolated - * color. - * - Classified: the numbers set in the colors attribute will be interpreted as - * maximums. Continuous properties will be forced into discrete bins. - * @property {string} property The name (ID) of the property in the asset layer's - * attribute table to color the vector data by (or for imagery data that does not - * have an attribute table, just the name of the attribute that these colors map - * to). - * @property {string} [label = null] A user-friendly name to display instead of - * the actual property name. - * @property {AssetColors} [colors = new AssetColors()] The colors to use in the - * color palette, along with the conditions associated with each color (i.e. the - * properties of the feature that must be true to use the given color.) . The last - * color in the collection will always be treated as the default color - any - * feature that doesn't match the other colors will be colored with this color. - */ - defaults: function () { - return { - paletteType: 'categorical', - property: null, - label: null, - colors: new AssetColors() - } - }, - - /** - * The ColorPaletteConfig specifies a color scale that is mapped to some attribute - * of a {@link MapAsset}. For vector assets, like 3D tilesets, this palette is - * used to conditionally color features on a map. For any type of asset, including - * imagery, it can be used to generate a legend. The ColorPaletteConfig is passed - * to a {@link AssetColorPalette} model. - * @typedef {Object} ColorPaletteConfig - * @name MapConfig#ColorPaletteConfig - * @property {('categorical'|'continuous'|'classified')} - * [paletteType='categorical'] NOTE: Currently only categorical and continuous - * palettes are supported. - * - Categorical: the color conditions will be interpreted such that one color - * represents a single value (e.g. a discrete palette). - * - Continuous: each color in the colors attribute will represent a point in a - * gradient. The point in the gradient will be associated with the number set - * with the color, and numbers in between points will be set to an interpolated - * color. - * - Classified: the numbers set in the colors attribute will be interpreted as - * maximums. Continuous properties will be forced into discrete bins. - * @property {string} property The name (ID) of the property in the asset layer's - * attribute table to color the vector data by (or for imagery data that does not - * have an attribute table, just the name of the attribute that these colors - * represent). - * @property {string} [label] A user-friendly name to display instead of the - * actual property name. - * @property {MapConfig#ColorConfig[]} colors The colors to use in the color - * palette, along with the conditions associated with each color (i.e. the - * properties of the feature that must be true to use the given color). The array - * of ColorConfig objects are passed to a {@link AssetColors} collection, which in - * turn passes each ColorConfig to a {@link AssetColor} model. - * - * @example - * { - * paletteType: 'categorical', - * property: 'landUse', - * label: 'Land Use in 2016', - * colors: [ - * { value: "agriculture", color: "#FF5733" }, - * { value: "park", color: "#33FF80" } - * ] - * } - */ + /** + * The ColorPaletteConfig specifies a color scale that is mapped to some + * attribute of a {@link MapAsset}. For vector assets, like 3D tilesets, + * this palette is used to conditionally color features on a map. For any + * type of asset, including imagery, it can be used to generate a legend. + * The ColorPaletteConfig is passed to a {@link AssetColorPalette} model. + * @typedef {Object} ColorPaletteConfig + * @name MapConfig#ColorPaletteConfig + * @property {('categorical'|'continuous'|'classified')} + * [paletteType='categorical'] NOTE: Currently only categorical and + * continuous palettes are supported. + * - Categorical: the color conditions will be interpreted such that one + * color represents a single value (e.g. a discrete palette). + * - Continuous: each color in the colors attribute will represent a point + * in a gradient. The point in the gradient will be associated with the + * number set with the color, and numbers in between points will be set + * to an interpolated color. + * - Classified: the numbers set in the colors attribute will be + * interpreted as maximums. Continuous properties will be forced into + * discrete bins. + * @property {string} property The name (ID) of the property in the asset + * layer's attribute table to color the vector data by (or for imagery + * data that does not have an attribute table, just the name of the + * attribute that these colors represent). + * @property {string} [label] A user-friendly name to display instead of + * the actual property name. + * @property {MapConfig#ColorConfig[]} colors The colors to use in the + * color palette, along with the conditions associated with each color + * (i.e. the properties of the feature that must be true to use the given + * color). The array of ColorConfig objects are passed to a + * {@link AssetColors} collection, which in turn passes each ColorConfig + * to a {@link AssetColor} model. + * + * @example + * { + * paletteType: 'categorical', + * property: 'landUse', + * label: 'Land Use in 2016', + * colors: [ + * { value: "agriculture", color: "#FF5733" }, + * { value: "park", color: "#33FF80" } + * ] + * } + */ - /** - * Executed when a new AssetColorPalette model is created. - * @param {MapConfig#ColorPaletteConfig} [paletteConfig] The initial values of the - * attributes, which will be set on the model. - */ - initialize: function (paletteConfig) { - try { - if (paletteConfig && paletteConfig.colors) { - this.set('colors', new AssetColors(paletteConfig.colors)) - } - // If a continuous palette has only 1 colour, then treat it as categorical - if (this.get('paletteType') === 'continuous' && this.get('colors').length === 1) { - this.set('paletteType', 'categorical') - } + /** + * Executed when a new AssetColorPalette model is created. + * @param {MapConfig#ColorPaletteConfig} [paletteConfig] The initial + * values of the attributes, which will be set on the model. + */ + initialize: function (paletteConfig) { + try { + if (paletteConfig && paletteConfig.colors) { + this.set("colors", new AssetColors(paletteConfig.colors)); } - catch (error) { - console.log( - 'There was an error initializing a AssetColorPalette model' + - '. Error details: ' + error - ); + // If a continuous palette has only 1 colour, then treat it as + // categorical + if ( + this.get("paletteType") === "continuous" && + this.get("colors").length === 1 + ) { + this.set("paletteType", "categorical"); } - }, - - /** - * Given properties of a feature, returns the color associated with that feature. - * @param {Object} properties The properties of the feature to get the color for. - * (See the 'properties' attribute of {@link Feature#defaults}.) - * @returns {AssetColor#Color} The color associated with the given set of - * properties. - */ - getColor: function (properties) { - - const colorPalette = this; - - // As a backup, use the default color - const defaultColor = this.getDefaultColor(); - let color = defaultColor - - // The name of the property to conditionally color the features by - const prop = colorPalette.get('property'); - // The value for the this property in the given properties - const propValue = properties[prop]; - // Each palette type has different ways of getting the color - const type = colorPalette.get('paletteType'); - // The collection of colors + conditions - const colors = colorPalette.get('colors'); - - if (!colors || colors.length === 0) { - // Skip the other if statements, use default color. - } else if (colors.length === 1) { - // If there's just 1 color, then return that color. - color = colors.at(0).get('color'); - } else if (type === 'categorical') { - // For a categorical color palette, the value of the feature property just - // needs to match one of the values in the list of color conditions. - // If it matches, then return the color associated with that value. - const colorMatch = colors.findWhere({ value: propValue }); - if (colorMatch) { - color = colorMatch.get('color'); - } - } else if (type === 'classified') { - // TODO: test - - // For a classified color palette, the value of the feature property needs to - // be greater than or equal to the value of the color condition. Use a - // sorted array. - - // const sortedColors = colors.toArray().sort(function (a, b) { - // return a.get('value') - b.get('value') - // }) - // let i = 0; - // while (i < sortedColors.length && propValue >= sortedColors[i].get('value')) { - // i++; - // } - // color = sortedColors[i].get('color'); - } else if (type === 'continuous') { - // For a continuous color palette, the value of the feature property must - // either match one of the values in the color palette, or be interpolated - // between the values in the palette. - const sortedColors = colors.toArray().sort(function (a, b) { - return a.get('value') - b.get('value') - }) - let i = 0; - while (i < sortedColors.length && propValue >= sortedColors[i].get('value')) { - i++; - } - if (i === 0) { - color = sortedColors[0].get('color'); - } - else if (i === sortedColors.length) { - color = sortedColors[i - 1].get('color'); - } - else { - const percent = (propValue - sortedColors[i - 1].get('value')) / - (sortedColors[i].get('value') - sortedColors[i - 1].get('value')); - color = colorPalette.interpolate(sortedColors[i - 1].get('color'), sortedColors[i].get('color'), percent) - } - } - return color - }, - - /** - * Given two colors, returns a color that is a linear interpolation between the - * two colors. - * @param {AssetColor#Color} color1 The first color. - * @param {AssetColor#Color} color2 The second color. - * @param {number} fraction The percentage of the way between the two colors, 0-1. - * @returns {AssetColor#Color} The interpolated color. - */ - interpolate: function (color1, color2, fraction) { - const red = color1.red + fraction * (color2.red - color1.red) - const green = color1.green + fraction * (color2.green - color1.green) - const blue = color1.blue + fraction * (color2.blue - color1.blue) - return { - red: red, - green: green, - blue: blue - } - }, - - /** - * Gets the default color for the color palette, returns it as an object of RGB - * intestines between 0 and 1. - * @returns {AssetColor#Color} The default color for the palette. - */ - getDefaultColor: function () { - return this.get('colors').getDefaultColor().get('color'); - }, - - // /** - // * Parses the given input into a JSON object to be set on the model. - // * - // * @param {TODO} input - The raw response object - // * @return {TODO} - The JSON object of all the AssetColorPalette attributes - // */ - // parse: function (input) { - - // try { + } catch (error) { + console.log( + "There was an error initializing a AssetColorPalette model" + + ". Error details: " + + error + ); + } + }, - // var modelJSON = {}; + /** + * Given properties of a feature, returns the color associated with that + * feature. + * @param {Object} properties The properties of the feature to get the + * color for. (See the 'properties' attribute of + * {@link Feature#defaults}.) + * @returns {AssetColor#Color} The color associated with the given set of + * properties. + */ + getColor: function (properties) { + const colorPalette = this; - // return modelJSON + // As a backup, use the default color + let color = this.getDefaultColor(); - // } - // catch (error) { - // console.log( - // 'There was an error parsing a AssetColorPalette model' + - // '. Error details: ' + error - // ); - // } + // The name of the property to conditionally color the features by + const prop = colorPalette.get("property"); + // The value for the this property in the given properties + const propValue = properties[prop]; + // Each palette type has different ways of getting the color + const type = colorPalette.get("paletteType"); + // The collection of colors + conditions + let colors = colorPalette.get("colors"); - // }, + if (!colors || colors.length === 0) { + // Do nothing + } else if (colors.length === 1) { + color = colors.at(0).get("color"); + } else if (type === "categorical") { + color = this.getCategoricalColor(propValue); + } else if (type === "classified") { + color = this.getClassifiedColor(propValue); + } else if (type === "continuous") { + color = this.getContinuousColor(propValue); + } + return color; + }, - // /** - // * Overrides the default Backbone.Model.validate.function() to check if this if - // * the values set on this model are valid. - // * - // * @param {Object} [attrs] - A literal object of model attributes to validate. - // * @param {Object} [options] - A literal object of options for this validation - // * process - // * - // * @return {Object} - Returns a literal object with the invalid attributes and - // * their corresponding error message, if there are any. If there are no errors, - // * returns nothing. - // */ - // validate: function (attrs, options) { - // try { + /** + * Get the color for a categorical color palette for a given value. + * @param {Number|string} value The value to get the color for. + * @returns {AssetColor#Color} The color associated with the given value. + */ + getCategoricalColor: function (value) { + // For a categorical color palette, the value of the feature property + // just needs to match one of the values in the list of color + // conditions. If it matches, then return the color associated with that + // value. + const colorMatch = colors.findWhere({ value: propValue }); + if (colorMatch) { + color = colorMatch.get("color"); + } + return color; + }, - // } - // catch (error) { - // console.log( - // 'There was an error validating a AssetColorPalette model' + - // '. Error details: ' + error - // ); - // } - // }, + /** + * Get the color for a continuous color palette for a given value. + * @param {Number|string} value The value to get the color for. + */ + getContinuousColor: function (value) { + const collection = this.get("colors"); + collection.sort(); + const values = collection.getAttr("value"); + const colors = collection.getAttr("color"); + if (values.indexOf("min") > -1) { + values[values.indexOf("min")] = this.get("minVal"); + } + if (values.indexOf("max") > -1) { + values[values.indexOf("max")] = this.get("maxVal"); + } + const numColors = colors.length; + let i = 0; + while (i < numColors && value >= values[i]) { + i++; + } + if (i === 0) { + return colors[i]; + } else if (i === numColors) { + return colors[i - 1]; + } else { + const col1 = colors[i - 1]; + const val1 = values[i - 1]; + const col2 = colors[i]; + const val2 = values[i]; + const percent = (value - val1) / (val2 - val1); + return this.interpolate(col1, col2, percent); + } + }, - // /** - // * Creates a string using the values set on this model's attributes. - // * @return {string} The AssetColorPalette string - // */ - // serialize: function () { - // try { - // var serializedAssetColorPalette = ''; + /** + * Get the color for a classified color palette for a given value. + * @param {Number|string} value The value to get the color for. + * @returns {AssetColor#Color} The color for the given value. + */ + getClassifiedColor: function (value) { + // TODO: test TODO: allow "min" and "max" keywords For a classified + // color palette, the value of the feature property needs to be greater + // than or equal to the value of the color condition. Use a sorted + // array. const sortedColors = colors.toArray().sort(function (a, b) { + // return a.get('value') - b.get('value') + // }) + // let i = 0; while (i < sortedColors.length && propValue >= + // sortedColors[i].get('value')) { i++; + // } + // color = sortedColors[i].get('color'); + }, - // return serializedAssetColorPalette; - // } - // catch (error) { - // console.log( - // 'There was an error serializing a AssetColorPalette model' + - // '. Error details: ' + error - // ); - // } - // }, + /** + * Given two colors, returns a color that is a linear interpolation + * between the two colors. + * @param {AssetColor#Color} color1 The first color. + * @param {AssetColor#Color} color2 The second color. + * @param {number} fraction The percentage of the way between the two + * colors, 0-1. + * @returns {AssetColor#Color} The interpolated color. + */ + interpolate: function (color1, color2, fraction) { + const red = color1.red + fraction * (color2.red - color1.red); + const green = color1.green + fraction * (color2.green - color1.green); + const blue = color1.blue + fraction * (color2.blue - color1.blue); + const alpha = color1.alpha + fraction * (color2.alpha - color1.alpha); + return { + red: red, + green: green, + blue: blue, + alpha: alpha, + }; + }, - }); + /** + * Gets the default color for the color palette, returns it as an object + * of RGB intestines between 0 and 1. + * @returns {AssetColor#Color} The default color for the palette. + */ + getDefaultColor: function () { + return this.get("colors").getDefaultColor().get("color"); + }, - return AssetColorPalette; + } + ); - } -); + return AssetColorPalette; +}); diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 03de865c4..efd02b8f5 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -75,7 +75,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( */ isProperty: function (key) { // Must use prototype.get to avoid infinite loop - const properties = Backbone.Model.prototype.get.call(this, key); + const properties = Backbone.Model.prototype.get.call(this, "properties"); return properties?.hasOwnProperty(key); }, @@ -201,6 +201,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( * @returns {Object} A GeoJSON Feature representing the geohash. */ toGeoJSON: function () { + // return this.toGeoJSONPoint(); if (this.isEmpty()) return null; const bounds = this.getBounds(); if (!bounds) return null; @@ -227,6 +228,22 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( properties: properties, }; }, + + toGeoJSONPoint: function () { + if (this.isEmpty()) return null; + const point = this.getPoint(); + if (!point) return null; + const properties = this.get("properties"); + properties["hashString"] = this.get("hashString"); + return { + type: "Feature", + geometry: { + type: "Point", + coordinates: [point.longitude, point.latitude], + }, + properties: properties, + }; + } } ); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 42174b407..11c5c3ec9 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -6,9 +6,21 @@ define([ "backbone", "cesium", "models/maps/assets/CesiumVectorData", + "models/maps/AssetColorPalette", + "models/maps/AssetColor", "models/maps/Geohash", "collections/maps/Geohashes", -], function ($, _, Backbone, Cesium, CesiumVectorData, Geohash, Geohashes) { +], function ( + $, + _, + Backbone, + Cesium, + CesiumVectorData, + AssetColorPalette, + AssetColor, + Geohash, + Geohashes +) { /** * @classdesc A Geohash Model represents a geohash layer in a map. * @classcategory Models/Maps/Assets @@ -43,10 +55,57 @@ define([ type: "GeoJsonDataSource", label: "Geohashes", geohashes: new Geohashes(), - opacity: 0.5, + opacity: 0.8, + colorPalette: new AssetColorPalette({ + paletteType: "continuous", + property: "count", + colors: [ + { + value: 0, + color: "#FFFFFF00" + }, + { + value: 1, + color: "#1BFAC44C" + }, + { + value: "max", + color: "#1BFA8FFF" + }, + ], + }), + outlineColor: new AssetColor({ + color: "#DFFAFAED", + }) }); }, + /** + * Get the property that we want the geohashes to display, e.g. count. + * @returns {string} The property of interest. + */ + getPropertyOfInterest: function () { + return this.get("colorPalette")?.get("property"); + }, + + /** + * For the property of interest (e.g. count) Get the min and max values + * from the geohashes collection and update the color palette. These + */ + updateColorRangeValues: function () { + const colorPalette = this.get("colorPalette"); + const geohashes = this.get("geohashes"); + if (!geohashes || !colorPalette) { + return; + } + const vals = geohashes.getAttr(this.getPropertyOfInterest()); + if (!vals || vals.length === 0) { + return; + } + colorPalette.set("minVal", Math.min(...vals)); + colorPalette.set("maxVal", Math.max(...vals)); + }, + /** * Executed when a new CesiumGeohash model is created. * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of @@ -93,15 +152,15 @@ define([ }, /** - * Stop the model from listening to itself for changes in the counts or - * geohashes. + * Stop the model from listening to itself for changes. */ stopListeners: function () { this.stopListening(this.get("geohashes"), "add remove update reset"); }, /** - * Update and re-render the geohashes when the counts change. + * Update and re-render the geohashes when the collection of geohashes + * changes. */ startListening: function () { try { @@ -110,6 +169,7 @@ define([ this.get("geohashes"), "add remove update reset", function () { + this.updateColorRangeValues(); this.createCesiumModel(true); } ); diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index a3adf2351..ae7b2e3b3 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -77,6 +77,10 @@ define( * @property {CesiumVectorData#cesiumOptions} cesiumOptions options are passed to * the function that creates the Cesium model. The properties of options are * specific to each type of asset. + * @property {outlineColor} [outlineColor=null] The color of the outline of the + * features. If null, the outline will not be shown. If a string, it should be a + * valid CSS color string. If an object, it should be an AssetColor object, or + * a set of RGBA values. */ defaults: function () { return Object.assign( @@ -87,7 +91,8 @@ define( cesiumModel: null, cesiumOptions: {}, colorPalette: new AssetColorPalette(), - icon: '' + icon: '', + outlineColor: null, } ); }, @@ -111,6 +116,10 @@ define( // rendered. Used to know when it's safe to calculate a bounding sphere. this.set('displayReady', false) + if (assetConfig.outlineColor && !assetConfig.outlineColor instanceof AssetColor) { + this.set('outlineColor', new AssetColor(assetConfig.outlineColor)) + } + this.createCesiumModel(); } catch (error) { @@ -215,10 +224,10 @@ define( */ setListeners: function () { try { - this.stopListening(this, 'change:visible change:opacity change:color') - this.listenTo( - this, 'change:visible change:opacity change:color', this.updateAppearance - ) + const appearEvents = + 'change:visible change:opacity change:color change:outlineColor'; + this.stopListening(this, appearEvents) + this.listenTo(this, appearEvents, this.updateAppearance) this.stopListening(this.get('filters'), 'update') this.listenTo(this.get('filters'), 'update', this.updateFeatureVisibility) } @@ -331,12 +340,31 @@ define( outlineColor = Cesium.Color.WHITE lineWidth = 7 markerSize = 34 + } else { + outlineColor = model.get("outlineColor")?.get("color"); + if(outlineColor) { + outline = true; + console.log(outlineColor); + outlineColor = new Cesium.Color( + outlineColor.red, outlineColor.green, outlineColor.blue, outlineColor.alpha + ); + } } - const rgb = model.getColor(properties) - const color = new Cesium.Color( - rgb.red, rgb.green, rgb.blue, featureOpacity - ) + const rgba = model.getColor(properties) + const alpha = rgba.alpha * featureOpacity + + // If alpha is 0 then the feature is hidden, don't bother setting up + // colors. + let color = null + if (alpha === 0) { + entity.show = false + } else { + entity.show = true + color = new Cesium.Color( + rgba.red, rgba.green, rgba.blue, alpha + ) + } if (entity.polygon) { entity.polygon.material = color From f46a5013a32b52071e27502c87d7d919a27d1988 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 13 Apr 2023 15:57:20 -0400 Subject: [PATCH 46/79] Support CZML; Add labels to Geohashes (WIP) Issues #2066, #1790 --- src/js/collections/maps/Geohashes.js | 23 +++ src/js/collections/maps/MapAssets.js | 2 +- src/js/models/maps/Geohash.js | 183 +++++++++++++++--- src/js/models/maps/assets/CesiumGeohash.js | 106 ++++++---- src/js/models/maps/assets/CesiumVectorData.js | 25 +-- src/js/views/maps/CesiumWidgetView.js | 2 +- 6 files changed, 261 insertions(+), 80 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 6bbc6a285..6aab7c192 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -319,6 +319,29 @@ define([ }), }; }, + + /** + * Return the geohashes as a CZML document, where each geohash is + * represented as a CZML Polygon (rectangle) and a CZML Label. + * @param {string} [label] - The key for the property that should be + * displayed with a label for each geohash, e.g. "count" + * @returns {Array} CZML document. + */ + toCZML: function (label) { + const czmlHeader = [ + { + id: "document", + version: "1.0", + name: "Geohashes" + }, + ]; + + const czmlData = this.models.flatMap(function (geohash) { + return geohash.toCZML(label); + }); + + return czmlHeader.concat(czmlData); + }, } ); diff --git a/src/js/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index a16ec04a6..17c82f747 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -55,7 +55,7 @@ define([ model: Cesium3DTileset, }, { - types: ["GeoJsonDataSource"], + types: ["GeoJsonDataSource", "CzmlDataSource"], model: CesiumVectorData, }, { diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index efd02b8f5..5b723e721 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -52,6 +52,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( if (attr === "point") return this.getPoint(); if (attr === "precision") return this.getPrecision(); if (attr === "geojson") return this.toGeoJSON(); + if (attr === "czml") return this.toCZML(); if (attr === "groupID") return this.getGroupID(); if (this.isProperty(attr)) return this.getProperty(attr); return Backbone.Model.prototype.get.call(this, attr); @@ -75,7 +76,10 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( */ isProperty: function (key) { // Must use prototype.get to avoid infinite loop - const properties = Backbone.Model.prototype.get.call(this, "properties"); + const properties = Backbone.Model.prototype.get.call( + this, + "properties" + ); return properties?.hasOwnProperty(key); }, @@ -197,44 +201,69 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( }, /** - * Get the geohash as a GeoJSON Feature. - * @returns {Object} A GeoJSON Feature representing the geohash. + * Get the data from this model that is needed to create geometries for various + * formats of geospacial data, like GeoJSON and CZML. + * @param {string} geometry The type of geometry to get. Can be "rectangle", + * "point", or "both". + * @returns {Object|null} An object with the keys "rectangle", "point", and + * "properties". */ - toGeoJSON: function () { - // return this.toGeoJSONPoint(); + getGeoData: function (geometry="both") { if (this.isEmpty()) return null; - const bounds = this.getBounds(); - if (!bounds) return null; - let [south, west, north, east] = bounds; - if (!south && !west && !north && !east) return null; + + const geoData = {}; + const properties = this.get("properties"); properties["hashString"] = this.get("hashString"); - // Set min latitude to -89.99999 for Geohashes. This is for Cesium. - if (south === -90) south = -89.99999; + geoData["properties"] = properties; + + if (geometry === "rectangle" || geometry === "both") { + const bounds = this.getBounds(); + if (bounds) { + let [south, west, north, east] = bounds; + // Set min latitude to -89.99999 for Geohashes. This is for Cesium. + if (south && west && north && east) { + if (south === -90) south = -89.99999; + } + geoData["rectangle"] = [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ]; + } + } + if (geometry === "point" || geometry === "both") { + geoData["point"] = this.getPoint(); + } + + return geoData; + }, + + /** + * Get the geohash as a GeoJSON Feature. + * @returns {Object} A GeoJSON Feature representing the geohash. + */ + toGeoJSON: function () { + const geoData = this.getGeoData("rectangle"); + if (!geoData) return null; + const { rectangle, properties } = geoData; + if (!rectangle) return null; return { type: "Feature", geometry: { type: "Polygon", - coordinates: [ - [ - [west, south], - [east, south], - [east, north], - [west, north], - [west, south], - ], - ], + coordinates: [rectangle], }, properties: properties, }; }, toGeoJSONPoint: function () { - if (this.isEmpty()) return null; - const point = this.getPoint(); - if (!point) return null; - const properties = this.get("properties"); - properties["hashString"] = this.get("hashString"); + const geoData = this.getGeoData("point"); + if (!geoData) return null; + const { point, properties } = geoData; return { type: "Feature", geometry: { @@ -243,7 +272,109 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( }, properties: properties, }; - } + }, + + /** + * Get the geohash as a CZML Feature. + * @param {*} label The key for the property to display as a label. + * @returns {Object} A CZML Feature representing the geohash, including + * a polygon of the geohash area and a label with the value of the + * property specified by the label parameter. + */ + toCZML: function (label) { + const geoData = this.getGeoData("both"); + if (!geoData) return null; + const { rectangle, point, properties } = geoData; + const id = properties["hashString"]; + const features = [this.createCZMLPolygon(id, properties, rectangle)]; + if (label) { + const text = properties[label]; + const czmlLabel = this.createCZMLLabel(id, text, point); + features.push(czmlLabel); + } + // TODO: determine if label and rectangle can share id & position + return features; + }, + + /** + * Create a CZML polygon object. + * @param {string} id The ID of the CZML object. + * @param {Object} properties The properties of the CZML object. + * @param {Array} coordinates The coordinates of the polygon + * @returns {Object} A CZML polygon object. + */ + createCZMLPolygon: function (id, properties, coordinates) { + const ecefCoordinates = coordinates.map((coord) => + this.geodeticToECEF(coord) + ); + return { + id: id, + polygon: { + positions: { + cartesian: ecefCoordinates.flat(), + }, + height: 0, + }, + properties: properties, + }; + }, + + /** + * Create a CZML label object. + * @param {string} id The ID of the CZML object. + * @param {string} text The text of the label. + * @param {Array} position The position of the label. + * @returns {Object} A CZML label object. + */ + createCZMLLabel: function (id, text, position) { + const ecefPosition = this.geodeticToECEF([ + position.longitude, position.latitude, + ]); + + return { + id: id + "label", + position: { + cartesian: ecefPosition, + }, + label: { + // ensure text is a string and not undefined or null + text: text ? text.toString() : "", + show: true, + // fillColor: { + // rgba: [255, 255, 255, 255], + // }, + // font: "50pt Lucida Console", + }, + }; + }, + + /** + * Convert geodetic coordinates to Earth-Centered, Earth-Fixed (ECEF) + * coordinates. + * @param {Object} coord The geodetic coordinates, an array of longitude + * and latitude. + * @returns {Array} The ECEF coordinates. + */ + geodeticToECEF: function (coord) { + const a = 6378137; // WGS-84 semi-major axis (meters) + const f = 1 / 298.257223563; // WGS-84 flattening + const e2 = 2 * f - f * f; // Square of eccentricity + + const lon = coord[0] * (Math.PI / 180); // Convert longitude to radians + const lat = coord[1] * (Math.PI / 180); // Convert latitude to radians + const alt = 10000; + const sinLon = Math.sin(lon); + const cosLon = Math.cos(lon); + const sinLat = Math.sin(lat); + const cosLat = Math.cos(lat); + + const N = a / Math.sqrt(1 - e2 * sinLat * sinLat); // Prime vertical radius of curvature + const x = (N + alt) * cosLat * cosLon; + const y = (N + alt) * cosLat * sinLon; + const z = (N * (1 - e2) + alt) * sinLat; + + return [x, y, z]; + }, } ); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 11c5c3ec9..42c60a434 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -49,10 +49,14 @@ define([ * @property {Geohashes} geohashes The collection of geohashes to display * on the map. * @property {number} opacity The opacity of the layer. + * @property {AssetColorPalette} colorPalette The color palette for the + * layer. + * @property {AssetColor} outlineColor The outline color for the layer. + * @property {boolean} showLabels Whether to show labels for the layer. */ defaults: function () { return Object.assign(CesiumVectorData.prototype.defaults(), { - type: "GeoJsonDataSource", + type: "CZMLDataSource", label: "Geohashes", geohashes: new Geohashes(), opacity: 0.8, @@ -62,24 +66,48 @@ define([ colors: [ { value: 0, - color: "#FFFFFF00" + color: "#FFFFFF00", }, { value: 1, - color: "#1BFAC44C" + color: "#1BFAC44C", }, { value: "max", - color: "#1BFA8FFF" + color: "#1BFA8FFF", }, ], }), outlineColor: new AssetColor({ color: "#DFFAFAED", - }) + }), + showLabels: true, }); }, + /** + * Executed when a new CesiumGeohash model is created. + * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of + * the attributes, which will be set on the model. + */ + initialize: function (assetConfig) { + try { + if (this.get("showLabels")) { + this.set("type", "CzmlDataSource"); + } else { + this.set("type", "GeoJsonDataSource"); + } + this.startListening(); + CesiumVectorData.prototype.initialize.call(this, assetConfig); + } catch (error) { + console.log( + "There was an error initializing a CesiumVectorData model" + + ". Error details: " + + error + ); + } + }, + /** * Get the property that we want the geohashes to display, e.g. count. * @returns {string} The property of interest. @@ -90,7 +118,7 @@ define([ /** * For the property of interest (e.g. count) Get the min and max values - * from the geohashes collection and update the color palette. These + * from the geohashes collection and update the color palette. These */ updateColorRangeValues: function () { const colorPalette = this.get("colorPalette"); @@ -106,25 +134,6 @@ define([ colorPalette.set("maxVal", Math.max(...vals)); }, - /** - * Executed when a new CesiumGeohash model is created. - * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of - * the attributes, which will be set on the model. - */ - initialize: function (assetConfig) { - try { - this.set("type", "GeoJsonDataSource"); - this.startListening(); - CesiumVectorData.prototype.initialize.call(this, assetConfig); - } catch (error) { - console.log( - "There was an error initializing a CesiumVectorData model" + - ". Error details: " + - error - ); - } - }, - /** * 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 @@ -178,6 +187,17 @@ define([ } }, + /** + * Get the geohashes that are currently in the map's extent. + * @returns {Geohashes} The geohashes in the current extent. + */ + getGeohashesForExtent: function () { + const extent = this.get("mapModel")?.get("currentViewExtent"); + const bounds = Object.assign({}, extent); + delete bounds.height; + return this.get("geohashes")?.getSubsetByBounds(bounds); + }, + /** * Returns the GeoJSON representation of the geohashes. * @param {Boolean} [limitToExtent = true] - Set to false to return the @@ -188,21 +208,27 @@ define([ if (!limitToExtent) { return this.get("geohashes")?.toGeoJSON(); } - const extent = this.get("mapModel").get("currentViewExtent"); - let bounds = Object.assign({}, extent); - delete bounds.height; - const subset = this.get("geohashes")?.getSubsetByBounds(bounds); - return subset?.toGeoJSON(); + return this.getGeohashesForExtent()?.toGeoJSON(); + }, + + /** + * Returns the CZML representation of the geohashes. + * @param {Boolean} [limitToExtent = true] - Set to false to return the + * CZML for all geohashes, not just those in the current extent. + * @returns {Object} The CZML representation of the geohashes. + */ + getCZML: function (limitToExtent = true) { + if (!limitToExtent) { + return this.get("geohashes")?.toCZML(); + } + const label = this.getPropertyOfInterest(); + return this.getGeohashesForExtent()?.toCZML(label); }, /** - * 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 - * {@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. + * Create the Cesium model for the geohashes. + * @param {Boolean} [recreate = false] - Set to true to recreate the + * Cesium model. */ createCesiumModel: function (recreate = false) { try { @@ -218,9 +244,9 @@ define([ } // Set the GeoJSON representing geohashes on the model const cesiumOptions = model.get("cesiumOptions"); - cesiumOptions["data"] = this.getGeoJSON(); - // TODO: outlines don't work when features are clamped to ground - // cesiumOptions['clampToGround'] = true + const type = model.get("type"); + const data = type === "geojson" ? this.getGeoJSON() : this.getCZML(); + cesiumOptions["data"] = data; cesiumOptions["height"] = 0; model.set("cesiumOptions", cesiumOptions); // Create the model like a regular GeoJSON data source diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index ae7b2e3b3..14c4dda95 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -22,14 +22,14 @@ define( VectorFilters ) { /** - * @classdesc A CesiumVectorData Model is a vector layer (excluding Cesium3DTilesets) - * that can be used in Cesium maps. This model corresponds to "DataSource" models in - * Cesium. For example, this could represent vectors rendered from a Cesium - * GeoJSONDataSource. - * {@link https://cesium.com/learn/cesiumjs/ref-doc/GeoJsonDataSource.html}. Note: - * GeoJsonDataSource is the only supported DataSource so far, eventually this model - * could be used to support Cesium's CzmlDataSource and KmlDataSource (and perhaps a - * Cesium CustomDataSource). + * @classdesc A CesiumVectorData Model is a vector layer (excluding + * Cesium3DTilesets) that can be used in Cesium maps. This model corresponds + * to "DataSource" models in Cesium. For example, this could represent + * vectors rendered from a Cesium GeoJSONDataSource. + * {@link https://cesium.com/learn/cesiumjs/ref-doc/GeoJsonDataSource.html}. + * Note: The GeoJsonDataSource and CzmlDataSource are the only supported + * DataSources so far, but eventually this model could be used to support + * the KmlDataSource (and perhaps a Cesium CustomDataSource). * @classcategory Models/Maps/Assets * @class CesiumVectorData * @name CesiumVectorData @@ -64,7 +64,7 @@ define( * @extends MapAsset#defaults * @type {Object} * @property {'GeoJsonDataSource'} type The format of the data. Must be - * 'GeoJsonDataSource'. (The only Cesium DataSource supported so far.) + * 'GeoJsonDataSource' or 'CzmlDataSource'. * @property {VectorFilters} [filters=new VectorFilters()] A set of conditions * used to show or hide specific features of this vector data. * @property {AssetColorPalette} [colorPalette=new AssetColorPalette()] The color @@ -149,7 +149,6 @@ define( const label = model.get('label') || '' const dataSourceFunction = Cesium[type] - // If the cesium model already exists, don't create it again unless specified let dataSource = model.get('cesiumModel') if (dataSource) { @@ -168,7 +167,7 @@ define( if (!cesiumOptions || !cesiumOptions.data) { model.set('status', 'error'); - model.set('statusDetails', 'Vector data source is missing: A URL or GeoJSON/TopoJson object is required') + model.set('statusDetails', 'Vector data source is missing: A URL or data object is required') return } @@ -344,7 +343,6 @@ define( outlineColor = model.get("outlineColor")?.get("color"); if(outlineColor) { outline = true; - console.log(outlineColor); outlineColor = new Cesium.Color( outlineColor.red, outlineColor.green, outlineColor.blue, outlineColor.alpha ); @@ -392,6 +390,9 @@ define( entity.polyline.material = color entity.polyline.width = lineWidth } + if (entity.label) { + // TODO... + } } } diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 0f57e15f8..8db94ef4c 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -88,7 +88,7 @@ define( renderFunction: 'add3DTileset' }, { - types: ['GeoJsonDataSource'], + types: ['GeoJsonDataSource', 'CzmlDataSource'], renderFunction: 'addVectorData' }, { From 9190bfd7e0192cf3dd94f62210269237d908e8dc Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 17 Apr 2023 12:06:02 -0400 Subject: [PATCH 47/79] Render geohash count labels Issues #2066, #1720 --- src/js/models/maps/Geohash.js | 76 ++++++++++----------------- src/js/views/maps/CesiumWidgetView.js | 9 +++- 2 files changed, 35 insertions(+), 50 deletions(-) diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 5b723e721..c47e88d68 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -208,7 +208,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( * @returns {Object|null} An object with the keys "rectangle", "point", and * "properties". */ - getGeoData: function (geometry="both") { + getGeoData: function (geometry = "both") { if (this.isEmpty()) return null; const geoData = {}; @@ -286,28 +286,15 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( if (!geoData) return null; const { rectangle, point, properties } = geoData; const id = properties["hashString"]; - const features = [this.createCZMLPolygon(id, properties, rectangle)]; - if (label) { - const text = properties[label]; - const czmlLabel = this.createCZMLLabel(id, text, point); - features.push(czmlLabel); - } - // TODO: determine if label and rectangle can share id & position - return features; - }, - /** - * Create a CZML polygon object. - * @param {string} id The ID of the CZML object. - * @param {Object} properties The properties of the CZML object. - * @param {Array} coordinates The coordinates of the polygon - * @returns {Object} A CZML polygon object. - */ - createCZMLPolygon: function (id, properties, coordinates) { - const ecefCoordinates = coordinates.map((coord) => + const ecefCoordinates = rectangle.map((coord) => this.geodeticToECEF(coord) ); - return { + const ecefPosition = this.geodeticToECEF([ + point.longitude, + point.latitude, + ]); + const feature = { id: id, polygon: { positions: { @@ -317,35 +304,28 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( }, properties: properties, }; - }, - - /** - * Create a CZML label object. - * @param {string} id The ID of the CZML object. - * @param {string} text The text of the label. - * @param {Array} position The position of the label. - * @returns {Object} A CZML label object. - */ - createCZMLLabel: function (id, text, position) { - const ecefPosition = this.geodeticToECEF([ - position.longitude, position.latitude, - ]); - - return { - id: id + "label", - position: { - cartesian: ecefPosition, - }, - label: { - // ensure text is a string and not undefined or null - text: text ? text.toString() : "", + if (label) { + (feature["label"] = { + text: properties[label].toString(), show: true, - // fillColor: { - // rgba: [255, 255, 255, 255], - // }, - // font: "50pt Lucida Console", - }, - }; + fillColor: { + rgba: [255, 255, 255, 255], + }, + outlineColor: { + rgba: [0, 0, 0, 255], + }, + outlineWidth: 1, + style: "FILL_AND_OUTLINE", + font: "14pt Helvetica", + horizontalOrigin: "CENTER", + verticalOrigin: "CENTER", + heightReference: "CLAMP_TO_GROUND", + disableDepthTestDistance: 10000000, + + }), + (feature["position"] = { cartesian: ecefPosition }); + } + return [feature]; }, /** diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 8db94ef4c..2db008bdc 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -176,6 +176,7 @@ define( // We will add a base imagery layer after initialization imageryProvider: false, terrain: false, + useBrowserRecommendedResolution: false, // Use explicit rendering to make the widget must faster. // See https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance requestRenderMode: true, @@ -224,7 +225,7 @@ define( }) // Go to the home position, if one is set. - view.flyHome() + view.flyHome(0) // If users are allowed to click on features for more details, initialize // picking behavior on the map. @@ -663,8 +664,9 @@ define( /** * Navigate to the homePosition that's set on the Map. + * @param {number} duration The duration of the flight in seconds. */ - flyHome: function () { + flyHome: function (duration) { try { var position = this.model.get('homePosition') @@ -693,6 +695,9 @@ define( roll: Cesium.Math.toRadians(position.roll) } } + if (Cesium.defined(duration)) { + target.duration = duration + } this.flyTo(target); } From af71df49217d98537a7fd78cf187ab6e70747918 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 17 Apr 2023 18:38:40 -0400 Subject: [PATCH 48/79] Add zoom to geohash on click behaviour - Move logic from Cesium Widget View to the Map model, Map Asset models, and Feature model - Add config option to Map model that changes the action performed when a user clicks an entity (zoom or showDetails) Resolves #2067 --- src/js/collections/maps/Features.js | 21 ++- src/js/collections/maps/MapAssets.js | 38 ++++- src/js/models/AppModel.js | 3 +- src/js/models/maps/Map.js | 78 +++++++---- src/js/models/maps/assets/Cesium3DTileset.js | 57 +++++--- src/js/models/maps/assets/CesiumVectorData.js | 75 +++++++--- src/js/models/maps/assets/MapAsset.js | 62 +++++++- src/js/views/maps/CesiumWidgetView.js | 132 ++++-------------- src/js/views/maps/MapView.js | 5 +- 9 files changed, 292 insertions(+), 179 deletions(-) diff --git a/src/js/collections/maps/Features.js b/src/js/collections/maps/Features.js index ad87f4cd1..26ebd860f 100644 --- a/src/js/collections/maps/Features.js +++ b/src/js/collections/maps/Features.js @@ -91,13 +91,28 @@ define( /** * Checks if a given feature object is an attribute in one of the Feature models * in this collection. - * @param {*} featureObject + * @param {Feature|Cesium.Cesium3DTilesetFeature|Cesium.Entity} featureObject * @returns {boolean} Returns true if the given feature object is in this * collection, false otherwise. */ containsFeature: function (featureObject) { - const match = this.findWhere({ featureObject: featureObject }) - return match ? true : false + if (!featureObject) return false; + featureObject = featureObject instanceof Feature ? featureObject.get('featureObject') : featureObject; + return this.findWhere({ featureObject: featureObject }) ? true : false; + }, + + /** + * Checks if a given array of feature objects are attributes in one of the + * Feature models in this collection. + * @param {Array} featureObjects An array of feature objects to check if they are + * in this collection. + * @returns {boolean} Returns true if all of the given feature objects are in this + * collection, false otherwise. + */ + containsFeatures: function (featureObjects) { + if (!featureObjects || !featureObjects.length) return false; + return featureObjects.every( + (featureObject) => this.containsFeature(featureObject)); }, } diff --git a/src/js/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index 17c82f747..ac503ac27 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -92,12 +92,8 @@ define([ // Return a generic MapAsset as a default return new MapAsset(assetConfig); } - } catch (error) { - console.log( - "Failed to add a new model to a MapAssets collection" + - ". Error details: " + - error - ); + } catch (e) { + console.log("Failed to add a new model to a MapAssets collection", e); } }, @@ -212,6 +208,36 @@ define([ console.log("Failed to add a layer to a MapAssets collection", e); } }, + + /** + * Find the map asset model that contains a feature selected directly from + * the map view widget. + * @param {Cesium.Entity|Cesium.Cesium3DTilesetFeature} feature - The + * feature selected from the map view widget. + * @returns {MapAsset} - Returns the MapAsset model that contains the + * feature. + * @since x.x.x + */ + findAssetWithFeature: function (feature) { + return this.models.find((model) => { + return model.containsFeature(feature); + }); + }, + + /** + * Given features selected from the map view widget, get the attributes + * required to create new Feature models. + * @param {Cesium.Entity|Cesium.Cesium3DTilesetFeature[]} features - The + * feature selected from the map view widget. + * @returns {Object[]} - Returns an array of attributes that can be used + * to create new Feature models. + */ + getFeatureAttributes: function (features) { + return features.map((feature) => { + const asset = this.findAssetWithFeature(feature); + return asset?.getFeatureAttributes(feature); + }); + }, } ); diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index ccae8f8bc..964ff8ec8 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -122,7 +122,8 @@ define(['jquery', 'underscore', 'backbone'], "ionAssetId": "2" } } - ] + ], + clickFeatureAction: "zoom" }, /** diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index ea620bb6f..3393409ed 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -49,7 +49,7 @@ define([ * scale bar. If true, the {@link MapView} will render a * {@link ScaleBarView}. * @property {Boolean} [showFeatureInfo=true] - Whether or not to allow - * users to click on map features to show more information about them. If + * users to click on map features to show more information about them. If * true, the {@link MapView} will render a {@link FeatureInfoView} and * will initialize "picking" in the {@link CesiumWidgetView}. * @@ -158,6 +158,10 @@ define([ * extent of the current visible area as a bounding box in * longitude/latitude coordinates, as well as the height/altitude in * meters. + * @property {String} [clickFeatureAction="showDetails"] - The default + * action to take when a user clicks on a feature on the map. The + * available options are "showDetails" (show the feature details in the + * sidebar) or "zoom" (zoom to the feature's location). */ defaults: function () { return { @@ -197,6 +201,7 @@ define([ west: null, height: null, }, + clickFeatureAction: "showDetails", }; }, @@ -231,9 +236,9 @@ define([ }, /** - * Set or unset the selected Features on the map model. A selected feature - * is a polygon, line, point, or other element of vector data that is in - * focus on the map (e.g. because a user clicked it to show more details.) + * Set or unset the selected Features. A selected feature is a polygon, + * line, point, or other element of vector data that is in focus on the + * map (e.g. because a user clicked it to show more details.) * @param {(Feature|Object[])} features - An array of Feature models or * objects with attributes to set on new Feature models. If no features * argument is passed to this function, then any currently selected @@ -243,44 +248,63 @@ define([ * then the newly selected features will be added to any that are * currently selected. */ - selectFeatures(features, replace = true) { + selectFeatures: function (features, replace = true) { try { const model = this; - const defaults = new Feature().defaults(); + // Create a features collection if one doesn't already exist if (!model.get("selectedFeatures")) { model.set("selectedFeatures", new Features()); } + // Don't update the selected features collection if the newly selected + // features are identical. + const currentFeatures = model.get("selectedFeatures"); + if ( + currentFeatures.length === features.length && + currentFeatures.containsFeatures(features) + ) { + return; + } + // If no feature is passed to this function (and replace is true), // then empty the Features collection - if (!features || !Array.isArray(features)) { - features = []; - } + features = !features || !Array.isArray(features) ? [] : features; - // If feature is a Feature model, get the attributes to update the - // model. - features.forEach(function (feature, i) { - if (feature instanceof Feature) { - feature = feature.attributes; - } - features[i] = _.extend(_.clone(defaults), feature); - }); + // Convert the feature objects that are types specific to the map view + // (Cesium) to a generic Feature model + features = model.convertFeatures(features); // Update the Feature model with the new selected feature information. - const options = { - remove: replace, - }; - model.get("selectedFeatures").set(features, options); - } catch (error) { - console.log( - "Failed to select a Feature in a Map model" + - ". Error details: " + - error - ); + const newAttrs = features.map(function (feature) { + return Object.assign( + {}, + new Feature().defaults(), + feature.attributes + ); + }); + model.get("selectedFeatures").set(newAttrs, { remove: replace }); + } catch (e) { + console.log("Failed to select a Feature in a Map model.", e); } }, + /** + * Convert an array of feature objects to an array of Feature models. + * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|[]} features - An + * array of feature objects selected directly from the map view. + * @returns {Feature[]} An array of Feature models. + * @since x.x.x + */ + convertFeatures: function (features) { + const attrs = features.map(function (feature) { + if (!feature) return null; + if (feature instanceof Feature) return feature.attributes; + return this.get("layers").getFeatureAttributes(features)?.[0]; + }); + return attrs.map((attr) => new Feature(attr)); + }, + /** * Reset the layers to the default layers. This will set a new MapAssets * collection on the layer attribute. diff --git a/src/js/models/maps/assets/Cesium3DTileset.js b/src/js/models/maps/assets/Cesium3DTileset.js index bd560b9e3..8cbf0e687 100644 --- a/src/js/models/maps/assets/Cesium3DTileset.js +++ b/src/js/models/maps/assets/Cesium3DTileset.js @@ -82,7 +82,8 @@ define( cesiumModel: null, cesiumOptions: {}, colorPalette: new AssetColorPalette(), - icon: '' + icon: '', + featureType: Cesium.Cesium3DTileFeature } ); }, @@ -320,22 +321,44 @@ define( * @returns {Object} An object containing key-value mapping of property names to * properties. */ - getPropertiesFromFeature(feature) { - try { - let properties = {}; - feature.getPropertyNames().forEach(function (propertyName) { - properties[propertyName] = feature.getProperty(propertyName) - }) - properties = this.addCustomProperties(properties) - return properties - } - catch (error) { - console.log( - 'There was an error getting properties from a A Cesium 3D Tile feature' + - '. Error details: ' + error + '. Returning an empty object.' - ); - return {} - } + getPropertiesFromFeature: function(feature) { + if (!this.usesFeatureType(feature)) return null + let properties = {}; + feature.getPropertyNames().forEach(function (propertyName) { + properties[propertyName] = feature.getProperty(propertyName) + }) + properties = this.addCustomProperties(properties) + return properties + }, + + /** + * Return the label for a feature from a Cesium 3D tileset + * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature + * @returns {string} The label + */ + getLabelFromFeature: function (feature) { + if (!this.usesFeatureType(feature)) return null + return feature.getProperty('name') || feature.getProperty('label') || null + }, + + /** + * Return the Cesium3DTileset model for a feature from a Cesium 3D tileset + * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature + * @returns {Cesium3DTileset} The model + */ + getCesiumModelFromFeature: function (feature) { + if (!this.usesFeatureType(feature)) return null + return feature.primitive + }, + + /** + * Return the ID used by Cesium for a feature from a Cesium 3D tileset + * @param {Cesium.Cesium3DTileFeature} feature A Cesium 3D Tile feature + * @returns {string} The ID + */ + getIDFromFeature: function (feature) { + if (!this.usesFeatureType(feature)) return null + return feature.pickId ? feature.pickId.key : null }, /** diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 14c4dda95..1f423e084 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -93,6 +93,7 @@ define( colorPalette: new AssetColorPalette(), icon: '', outlineColor: null, + featureType: Cesium.Entity } ); }, @@ -263,6 +264,20 @@ define( }) }, + /** + * Try to find Entity object that comes from an object passed from the + * Cesium map. This is useful when the map is clicked and the map + * returns an object that may or may not be an Entity. + * @param {Object} mapObject - An object returned from the Cesium map + * @returns {Cesium.Entity} - The Entity object if found, otherwise null. + */ + getEntityFromMapObject(mapObject) { + const entityType = this.get("featureType") + if (mapObject instanceof entityType) return mapObject + if (mapObject.id instanceof entityType) return mapObject.id + return null + }, + /** * Given a feature from a Cesium Vector Data source, returns any properties that are set * on the feature, similar to an attributes table. @@ -271,23 +286,51 @@ define( * properties. */ getPropertiesFromFeature(feature) { - try { - const featureProps = feature.properties - let properties = {} - if (featureProps) { - properties = feature.properties.getValue(new Date()) - } - properties = this.addCustomProperties(properties) - return properties - } - catch (error) { - console.log( - 'There was an error getting properties from a Cesium Entity' + - '. Error details: ' + error + - '. Returning an empty object.' - ); - return {} + feature = this.getEntityFromMapObject(feature) + if (!feature) return null + const featureProps = feature.properties + let properties = {} + if (featureProps) { + properties = feature.properties.getValue(new Date()) } + properties = this.addCustomProperties(properties) + return properties + }, + + /** + * Return the label for a feature from a DataSource model + * @param {Cesium.Entity} feature A Cesium Entity + * @returns {string} The label + */ + getLabelFromFeature: function (feature) { + feature = this.getEntityFromMapObject(feature) + if (!feature) return null + return feature.name + }, + + /** + * Return the DataSource model for a feature from a Cesium DataSource + * model + * @param {Cesium.Entity} feature A Cesium Entity + * @returns {Cesium.GeoJsonDataSource|Cesium.CzmlDataSource} The model + */ + getCesiumModelFromFeature: function (feature) { + feature = this.getEntityFromMapObject(feature) + if (!feature) return null + // TODO: Test - does feature.id give the entity this work for all datasources ? + // A picked feature object's ID gives the Cesium.Entity + return feature.entityCollection.owner + }, + + /** + * Return the ID used by Cesium for a feature from a DataSource model + * @param {Cesium.Entity} feature A Cesium Entity + * @returns {string} The ID + */ + getIDFromFeature: function (feature) { + feature = this.getEntityFromMapObject(feature) + if (!feature) return null + return feature.id }, /** diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index 529efb71d..c274b095c 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -80,6 +80,9 @@ define( * @property {MapConfig#FeatureTemplate} [featureTemplate] Configuration for * content and layout of the Feature Info panel - the panel that shows information * about a selected feature from a vector asset ({@link FeatureInfoView}). + * @property {Cesium.Entity|Cesium.3DTilesetFeature} [featureType] For vector + * and 3d tileset assets, the object type that cesium uses to represent features + * from the asset. Null for imagery and terrain assets. * @property {MapConfig#CustomProperties} [customProperties] Configuration that * allows for the definition of custom feature properties, potentially based on * other properties. For example, a custom property could be a formatted version @@ -109,6 +112,7 @@ define( colorPalette: null, customProperties: {}, featureTemplate: {}, + featureType: null, notification: {}, status: null, statusDetails: null @@ -378,7 +382,7 @@ define( /** * Given a feature object from a Feature model, checks if it is part of the * selectedFeatures collection. See featureObject property from - * {@link Feature#defaults}. + * {@link Feature#defaults}. For vector and 3d tile models only. * @param {*} feature - An object that a Map widget uses to represent this feature * in the map, e.g. a Cesium.Entity or a Cesium.Cesium3DTileFeature * @returns {boolean} Returns true if the given feature is part of the @@ -390,6 +394,62 @@ define( return map.get('selectedFeatures').containsFeature(feature) }, + /** + * Checks if a feature from the map (a Cesium object) is the type of + * feature that this map asset model contains. For example, if a + * Cesium3DTilesetFeature is passed to this function, this function + * will return true if it is a Cesium3DTileset model, and false if it + * is a CesiumVectorData model. + * @param {Cesium.Cesium3DTilesetFeature|Cesium.Entity} feature + * @returns {boolean} true if the feature is an instance of the feature + * type set on the asset model, false otherwise. + */ + usesFeatureType: function(feature) { + const ft = this.get("featureType"); + if (!feature || !ft) return false + if (!feature instanceof ft) return false + return true + }, + + /** + * Given a feature object from a Feature model, checks if it is part of the + * selectedFeatures collection. See featureObject property from + * {@link Feature#defaults}. For vector and 3d tile models only. + * @param {*} feature - An object that a Map widget uses to represent this feature + * in the map, e.g. a Cesium.Entity or a Cesium.Cesium3DTileFeature + * @returns {boolean} Returns true if the given feature is part of the + * selectedFeatures collection in this asset + */ + containsFeature: function (feature) { + if (!this.usesFeatureType(feature)) return false + if (!this.getCesiumModelFromFeature) return false + const cesiumModel = this.getCesiumModelFromFeature(feature) + if (!cesiumModel) return false + if (this === cesiumModel) return true + return false + }, + + /** + * Given a feature object from a Feature model, returns the attributes + * needed to create a Feature model. For vector and 3d tile models only. + * @param {*} feature - An object that a Map widget uses to represent this feature + * in the map, e.g. a Cesium.Entity or a Cesium.Cesium3DTileFeature + * @returns {Object} An object with properties, mapAsset, featureID, featureObject, + * and label properties. Returns null if the feature is not the correct type + * for this asset model. + */ + getFeatureAttributes: function (feature) { + if (!this.usesFeatureType(feature)) return null + if (!this.getCesiumModelFromFeature) return null + return { + properties: this.getPropertiesFromFeature(feature), + mapAsset: this, + featureID: this.getIDFromFeature(feature), + featureObject: feature, + label: this.getLabelFromFeature(feature), + } + }, + /** * Given a set of properties from a Feature from this Map Asset model, add any * custom properties to the properties object and return it. diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 2db008bdc..27578fad4 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -442,8 +442,13 @@ define( // event to update styling of map assets with selected features, and tells the // parent map view to open the feature details panel. view.inputHandler.setInputAction(function (movement) { - var pickedFeature = scene.pick(movement.position); - view.updateSelectedFeatures([pickedFeature]) + const pickedFeature = scene.pick(movement.position); + const action = view.model.get('clickFeatureAction'); + if (action === 'showDetails') { + view.model.selectFeatures([pickedFeature]) + } else if (action === 'zoom') { + view.flyTo(pickedFeature) + } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } @@ -455,96 +460,6 @@ define( } }, - /** - * Given a feature from a vector layer (e.g. a Cesium3DTileFeature), gets any - * properties that are associated with that feature, the MapAsset model that - * contains the feature, and the ID that Cesium uses to identify it, and updates - * the Features collection that is set on the Map's `selectedFeatures` attribute - * with a new Feature model. NOTE: This currently only works with 3D tile - * features. - * @param {Cesium.Cesium3DTileFeature[]} features - An array of Cesium3DTileFeatures to - * select - */ - updateSelectedFeatures: function (features) { - - try { - const view = this - const layers = view.model.get('layers') - - // Don't update the selected features collection if the newly selected - // features are identical - const oldFeatures = view.model.get('selectedFeatures').getFeatureObjects() - const noChange = _.isEqual(_.sortBy(features), _.sortBy(oldFeatures)) - if (noChange) { - return; - } - - // Properties of the selected features to pass to the Map model's - // selectFeatures function. Passing null will empty the map's selectedFeatures - // collection - let featuresAttrs = features ? [] : null - if (!features || !Array.isArray(features)) { - features = [] - } - - features.forEach(function (feature) { - if (feature) { - // To find corresponding MapAsset model in the layers collection - let cesiumModel = null - // Attributes to make a new Feature model - const attrs = { - properties: {}, - mapAsset: null, - featureID: null, - featureObject: feature, - label: null, - } - if (feature instanceof Cesium.Cesium3DTileFeature) { - // Cesium.Cesium3DTileFeature.primitive gives the Cesium.Cesium3DTileset - cesiumModel = feature.primitive - attrs.featureID = feature.pickId ? feature.pickId.key : null - // Search for a property to use as a label - attrs.label = feature.getProperty('name') || feature.getProperty('label') || null - } else { - // TODO: Test - does feature.id give the entity this work for all datasources ? - // A picked feature object's ID gives the Cesium.Entity - attrs.featureObject = feature.id - // Gives the parent DataSource - cesiumModel = attrs.featureObject.entityCollection.owner - attrs.featureID = attrs.featureObject.id - attrs.label = attrs.featureObject.name - } - - attrs.mapAsset = layers.findWhere({ - cesiumModel: cesiumModel - }) - - if ( - attrs.mapAsset && - typeof attrs.mapAsset.getPropertiesFromFeature === 'function' - ) { - attrs.properties = attrs.mapAsset.getPropertiesFromFeature( - attrs.featureObject - ) - } - - featuresAttrs.push(attrs) - } - }) - - // Pass the new information to the Map's selectFeatures function, which will - // update the selectFeatures collection set on the Map model - view.model.selectFeatures(featuresAttrs) - - } - catch (error) { - console.log( - 'There was an error updating the selected features collection from a ' + - 'CesiumWidgetView. Error details: ' + error - ); - } - }, - /** * Move the camera position and zoom to the specified target entity or position on * the map, using a nice animation. This function starts the flying/zooming @@ -595,9 +510,8 @@ define( try { const view = this; - if (typeof options !== 'object') { - options = {} - } + if (typeof options !== 'object') options = {} + // A target is required if (!target) { @@ -633,17 +547,24 @@ define( // There's no native way of getting the bounding sphere or location from a // 3DTileFeature! if (target instanceof Feature) { - // If the target is a Feature, get the Bounding Sphere for the Feature - // and call this function again. - const feature = target.get('featureObject') - let featureBoundingSphere = new Cesium.BoundingSphere(); + // If the object saved in the Feature is an Entity, then this + // function will get the bounding sphere for the entity on the + // next run. + view.flyTo(target.get('featureObject'), options) + return + } + + // If the target is a Cesium Entity, then get the bounding sphere for the + // entity and call this function again. + const entity = target instanceof Cesium.Entity ? target : target.id; + if (entity instanceof Cesium.Entity) { + let entityBoundingSphere = new Cesium.BoundingSphere(); view.dataSourceDisplay.getBoundingSphere( - feature, false, featureBoundingSphere + entity, false, entityBoundingSphere ) setTimeout(() => { - view.flyTo(featureBoundingSphere, options) + view.flyTo(entityBoundingSphere, options) }, 0); - return } @@ -654,11 +575,8 @@ define( } } - catch (error) { - console.log( - 'There was an error navigating to a target position in a CesiumWidgetView' + - '. Error details: ' + error - ); + catch (e) { + console.log('Failed to navigate to a target in Cesium.', e); } }, diff --git a/src/js/views/maps/MapView.js b/src/js/views/maps/MapView.js index b7147ac9e..d26fa5085 100644 --- a/src/js/views/maps/MapView.js +++ b/src/js/views/maps/MapView.js @@ -162,7 +162,10 @@ define( if (this.model.get('showScaleBar')) { this.renderScaleBar(); } - if (this.model.get('showFeatureInfo')) { + if ( + this.model.get('showFeatureInfo') & + this.model.get('clickFeatureAction') === 'showDetails' + ) { this.renderFeatureInfo(); } From b4b745d3878a118737124a8405c9ac3b2dc3dc36 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 18 Apr 2023 08:55:03 -0400 Subject: [PATCH 49/79] Fix zooming behavior & feature info bugs in Cesium Problems found during testing of changes made for issue #1720 --- src/js/models/maps/AssetColorPalette.js | 10 ++++++---- src/js/models/maps/Map.js | 3 ++- src/js/models/maps/assets/CesiumVectorData.js | 17 ++++++++++------- src/js/models/maps/assets/MapAsset.js | 18 +++++++----------- src/js/views/maps/CesiumWidgetView.js | 5 +++-- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/js/models/maps/AssetColorPalette.js b/src/js/models/maps/AssetColorPalette.js index a47198009..91f382f87 100644 --- a/src/js/models/maps/AssetColorPalette.js +++ b/src/js/models/maps/AssetColorPalette.js @@ -158,7 +158,8 @@ define([ const colorPalette = this; // As a backup, use the default color - let color = this.getDefaultColor(); + const defaultColor = colorPalette.getDefaultColor(); + let color = defaultColor; // The name of the property to conditionally color the features by const prop = colorPalette.get("property"); @@ -180,6 +181,7 @@ define([ } else if (type === "continuous") { color = this.getContinuousColor(propValue); } + color = color || defaultColor; return color; }, @@ -193,11 +195,11 @@ define([ // just needs to match one of the values in the list of color // conditions. If it matches, then return the color associated with that // value. - const colorMatch = colors.findWhere({ value: propValue }); + const colors = this.get("colors"); + const colorMatch = colors.findWhere({ value: value }); if (colorMatch) { - color = colorMatch.get("color"); + return colorMatch.get("color"); } - return color; }, /** diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 3393409ed..31768cbb7 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -297,10 +297,11 @@ define([ * @since x.x.x */ convertFeatures: function (features) { + const model = this; const attrs = features.map(function (feature) { if (!feature) return null; if (feature instanceof Feature) return feature.attributes; - return this.get("layers").getFeatureAttributes(features)?.[0]; + return model.get("layers").getFeatureAttributes(features)?.[0]; }); return attrs.map((attr) => new Feature(attr)); }, diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 1f423e084..cfb2f3463 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -278,6 +278,14 @@ define( return null }, + /** + * @inheritdoc + */ + usesFeatureType: function (feature) { + const entity = this.getEntityFromMapObject(feature) + return this.constructor.__super__.usesFeatureType.call(this, entity) + }, + /** * Given a feature from a Cesium Vector Data source, returns any properties that are set * on the feature, similar to an attributes table. @@ -317,8 +325,6 @@ define( getCesiumModelFromFeature: function (feature) { feature = this.getEntityFromMapObject(feature) if (!feature) return null - // TODO: Test - does feature.id give the entity this work for all datasources ? - // A picked feature object's ID gives the Cesium.Entity return feature.entityCollection.owner }, @@ -447,11 +453,8 @@ define( model.trigger('appearanceChanged') } - catch (error) { - console.log( - 'There was an error updating CesiumVectorData model styles' + - '. Error details: ' + error - ); + catch (e) { + console.log('Failed to update CesiumVectorData model styles.', e); } }, diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index c274b095c..33ad05c8a 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -425,7 +425,7 @@ define( if (!this.getCesiumModelFromFeature) return false const cesiumModel = this.getCesiumModelFromFeature(feature) if (!cesiumModel) return false - if (this === cesiumModel) return true + if (this.get('cesiumModel') == cesiumModel) return true return false }, @@ -749,17 +749,13 @@ define( try { const model = this const colorPalette = model.get('colorPalette') - if (colorPalette) { - return colorPalette.getColor(properties) - } else { - return new AssetColorPalette().getDefaultColor() - } + return ( + colorPalette?.getColor(properties) || + new AssetColorPalette().getDefaultColor() + ) } - catch (error) { - console.log( - 'There was an error getting a color for a MapAsset model' + - '. Error details: ' + error - ); + catch (e) { + console.log('Failed to a color in a MapAsset model', e); } }, diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 27578fad4..0e6de2a1e 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -512,7 +512,6 @@ define( const view = this; if (typeof options !== 'object') options = {} - // A target is required if (!target) { return @@ -550,7 +549,9 @@ define( // If the object saved in the Feature is an Entity, then this // function will get the bounding sphere for the entity on the // next run. - view.flyTo(target.get('featureObject'), options) + setTimeout(() => { + view.flyTo(target.get('featureObject'), options) + }, 0); return } From dd993b7a1e7ca560807f8f78d36bd539327a2ba9 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 18 Apr 2023 18:13:01 -0400 Subject: [PATCH 50/79] Add ability to highlight geohash on map icon hover Also modularize methods related to styling in CesiumVectorData Issue #2068 --- src/js/collections/maps/Geohashes.js | 33 ++ src/js/models/connectors/Map-Search.js | 25 +- src/js/models/maps/Geohash.js | 10 + src/js/models/maps/Map.js | 15 +- src/js/models/maps/assets/CesiumGeohash.js | 26 ++ src/js/models/maps/assets/CesiumVectorData.js | 285 ++++++++++++------ src/js/models/maps/assets/MapAsset.js | 4 +- src/js/views/search/SearchResultView.js | 11 + 8 files changed, 315 insertions(+), 94 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 6aab7c192..9358487cf 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -320,6 +320,20 @@ define([ }; }, + /** + * Return the geohashes as a GeoJSON FeatureCollection, where each geohash + * is represented as a GeoJSON Point. + * @returns {Object} GeoJSON FeatureCollection. + */ + toGeoJSONPoints: function () { + return { + type: "FeatureCollection", + features: this.map(function (geohash) { + return geohash.toGeoJSONPoint(); + }), + }; + }, + /** * Return the geohashes as a CZML document, where each geohash is * represented as a CZML Polygon (rectangle) and a CZML Label. @@ -342,6 +356,25 @@ define([ return czmlHeader.concat(czmlData); }, + + /** + * Find the parent geohash from this collection that contains the provided + * geohash hashString. If the hashString is already in the collection, + * return that geohash. Otherwise, find the geohash that contains the + * hashString. + * @param {string} hashString - Geohash hashString. + * @returns {Geohash} Parent geohash. + */ + findParentByHashString: function (hashString) { + if (!hashString || hashString.length === 0) return null; + // First check if the hashString is already in the collection + let geohash = this.findWhere({ hashString: hashString }); + if (geohash) return geohash; + geohash = this.find((gh) => { + return gh.isParentOf(hashString); + }); + return geohash; + }, } ); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index cf740b18d..9c94aae26 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -153,6 +153,10 @@ define([ this.showGeoHashLayer(); }); + // When the search result should be shown on the map (e.g. a user hovers + // over the map icon), highlight the GeoHash on the map. + this.listenTo(searchResults, "change:showOnMap", this.selectGeohash); + // When the user is panning/zooming in the map, hide the GeoHash layer // to indicate that the map is not up to date with the search results, // which are about to be updated. @@ -200,8 +204,10 @@ define([ disconnect: function () { const map = this.get("map"); const searchResults = this.get("searchResults"); - this.stopListening(searchResults, "reset"); + this.stopListening(searchResults, "update reset"); + this.stopListening(searchResults, "change:showOnMap"); this.stopListening(map, "moveStart moveEnd"); + this.stopListening(searchResults, "request"); this.set("isConnected", false); }, @@ -284,6 +290,23 @@ define([ searchResults.setFacet(null); } }, + + /** + * Highlight the geohashes for the given search result on the map, or + * remove highlighting if the search result is not selected. + * @param {SolrResult} searchResult - The search result to highlight. + */ + selectGeohash: function (searchResult) { + // remove highlighting on geohashes by clearing all selected features + if (!searchResult.get("showOnMap")) { + this.get("map").selectFeatures(null); + return; + } + // Get the highest precision geohashes for the given search result + // and pass them to the geohash layer to highlight them. + const geohashes9 = searchResult.get("geohash_9"); + this.get("geohashLayer").selectGeohashes(geohashes9); + }, } ); }); diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index c47e88d68..828d0a37c 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -200,6 +200,16 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( return this.get("hashString").slice(0, -1); }, + /** + * Checks if this geohash contains the given geohash + * @param {string} hashString The hashString of the geohash to check. + */ + isParentOf: function (hashString) { + if (this.isEmpty()) return false; + if (hashString.length < this.get("hashString").length) return false; + return hashString.startsWith(this.get("hashString")); + }, + /** * Get the data from this model that is needed to create geometries for various * formats of geospacial data, like GeoJSON and CZML. diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 31768cbb7..d234e4c90 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -261,6 +261,8 @@ define([ // features are identical. const currentFeatures = model.get("selectedFeatures"); if ( + features && + currentFeatures && currentFeatures.length === features.length && currentFeatures.containsFeatures(features) ) { @@ -291,8 +293,8 @@ define([ /** * Convert an array of feature objects to an array of Feature models. - * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|[]} features - An - * array of feature objects selected directly from the map view. + * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]} features - An + * array of feature objects selected directly from the map view, or * @returns {Feature[]} An array of Feature models. * @since x.x.x */ @@ -301,6 +303,14 @@ define([ const attrs = features.map(function (feature) { if (!feature) return null; if (feature instanceof Feature) return feature.attributes; + // if this is already an object with feature attributes, return it + if ( + feature.hasOwnProperty("mapAsset") && + feature.hasOwnProperty("properties") + ) { + return feature; + } + // Otherwise, assume it's a Cesium object and get the feature attributes return model.get("layers").getFeatureAttributes(features)?.[0]; }); return attrs.map((attr) => new Feature(attr)); @@ -330,6 +340,7 @@ define([ const layers = this.get("layers") || this.resetLayers(); return layers.addAsset(layer, this); }, + } ); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 42c60a434..bf83dca58 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -52,6 +52,8 @@ define([ * @property {AssetColorPalette} colorPalette The color palette for the * layer. * @property {AssetColor} outlineColor The outline color for the layer. + * @property {AssetColor} highlightColor The color to use for features + * that are selected/highlighted. * @property {boolean} showLabels Whether to show labels for the layer. */ defaults: function () { @@ -81,6 +83,9 @@ define([ outlineColor: new AssetColor({ color: "#DFFAFAED", }), + highlightColor: new AssetColor({ + color: "#f3e227", + }), showLabels: true, }); }, @@ -259,6 +264,27 @@ define([ ); } }, + + /** + * Find the geohash Entity on the map and add it to the selected + * features. + * @param {*} geohash + */ + selectGeohashes: function (geohashes) { + const toSelect = [...new Set(geohashes.map((geohash) => { + const parent = this.get("geohashes").findParentByHashString(geohash); + return parent?.get("hashString"); + }, this))]; + const entities = this.get("cesiumModel").entities.values; + const selected = entities.filter((entity) => { + const hashString = this.getPropertiesFromFeature(entity).hashString; + return toSelect.includes(hashString); + }); + const featureAttrs = selected.map((feature) => { + return this.getFeatureAttributes(feature); + }); + this.get("mapModel").selectFeatures(featureAttrs); + }, } ); }); diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index cfb2f3463..248bebaab 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -122,6 +122,7 @@ define( } this.createCesiumModel(); + } catch (error) { console.log( @@ -278,12 +279,28 @@ define( return null }, + /** + * @inheritdoc + * @since x.x.x + */ + getFeatureAttributes: function (feature) { + feature = this.getEntityFromMapObject(feature) + return MapAsset.prototype.getFeatureAttributes.call(this, feature) + }, + /** * @inheritdoc */ usesFeatureType: function (feature) { - const entity = this.getEntityFromMapObject(feature) - return this.constructor.__super__.usesFeatureType.call(this, entity) + // This method could be passed the entity directly, or the object + // returned from Cesium on a click event (where the entity is in the + // id property). + if(!feature) return false + const baseMethod = MapAsset.prototype.usesFeatureType + let result = baseMethod.call(this, feature) + if (result) return result + result = baseMethod.call(this, feature.id) + return result }, /** @@ -348,113 +365,203 @@ define( const model = this; const cesiumModel = this.get('cesiumModel') - const opacity = this.get('opacity') const entities = cesiumModel.entities.values // Suspending events while updating a large number of entities helps // performance. cesiumModel.entities.suspendEvents() - // If the asset isn't visible at all, don't bother setting up colors. Just set - // every feature to hidden. + // If the asset isn't visible, just hide all entities and update the + // visibility property to indicate that layer is hidden if (!model.isVisible()) { cesiumModel.entities.show = false - // Indicate that the layer is hidden if the opacity is zero by updating the - // visibility property - if (model.get('opacity') === 0) { - model.set('visible', false); - } + if (model.get('opacity') === 0) model.set('visible', false); } else { cesiumModel.entities.show = true - for (var i = 0; i < entities.length; i++) { - - const entity = entities[i]; - const properties = model.getPropertiesFromFeature(entity) + this.styleEntities(entities) + } - let outlineColor = null - let featureOpacity = opacity - let outline = false - // For polylines - let lineWidth = 3 - // For billboard pins and points. We could make size configurable. Size - // could also be set according to a vector property - let markerSize = 25 - - // If the feature is selected, set the opacity to 1, and add an outline - if (model.featureIsSelected(entity)) { - featureOpacity = 1 - outline = true - // TODO: This colour should be configurable in the Map model - outlineColor = Cesium.Color.WHITE - lineWidth = 7 - markerSize = 34 - } else { - outlineColor = model.get("outlineColor")?.get("color"); - if(outlineColor) { - outline = true; - outlineColor = new Cesium.Color( - outlineColor.red, outlineColor.green, outlineColor.blue, outlineColor.alpha - ); - } - } + cesiumModel.entities.resumeEvents() - const rgba = model.getColor(properties) - const alpha = rgba.alpha * featureOpacity - - // If alpha is 0 then the feature is hidden, don't bother setting up - // colors. - let color = null - if (alpha === 0) { - entity.show = false - } else { - entity.show = true - color = new Cesium.Color( - rgba.red, rgba.green, rgba.blue, alpha - ) - } + // Let the map and/or other parent views know that a change has been + // made that requires the map to be re-rendered + model.trigger('appearanceChanged') - if (entity.polygon) { - entity.polygon.material = color - entity.polygon.outline = outline; - entity.polygon.outlineColor = outlineColor - entity.polygon.outlineWidth = outline ? 2 : 0 - } - if (entity.billboard) { - if (!model.pinBuilder) { - model.pinBuilder = new Cesium.PinBuilder() - } - entity.billboard.image = model.pinBuilder.fromColor(color, markerSize).toDataURL() - // To convert the automatically created billboards to points instead: - // entity.billboard = undefined; - // entity.point = new Cesium.PointGraphics(); - } - if (entity.point) { - entity.point.color = color - entity.point.outlineColor = outlineColor - entity.point.outlineWidth = outline ? 2 : 0 - // Points look better a little smaller than billboards - entity.point.pixelSize = (markerSize * 0.5); - } - if (entity.polyline) { - entity.polyline.material = color - entity.polyline.width = lineWidth - } - if (entity.label) { - // TODO... - } + } + catch (e) { + console.log('Failed to update CesiumVectorData model styles.', e); + } + }, + /** + * Update the styles for a set of entities + * @param {Array} entities - The entities to update + * @since x.x.x + */ + styleEntities: function (entities) { + + // Map of entity types to style functions + const entityStyleMap = { + polygon: this.stylePolygon, + polyline: this.stylePolyline, + billboard: this.styleBillboard, + point: this.stylePoint, + }; + + entities.forEach(entity => { + const styles = this.getStyles(entity); + if (!styles) { + entity.show = false; + return; + } + entity.show = true; + for (const [type, styleFunction] of Object.entries(entityStyleMap)) { + if (entity[type]) { + styleFunction.call(this, entity, styles); } } + }, this); + }, - cesiumModel.entities.resumeEvents() + /** + * Update the styles for a polygon entity + * @param {Cesium.Entity} entity - The entity to update + * @param {Object} styles - Styles to apply, as returned by getStyles + * @since x.x.x + */ + stylePolygon: function (entity, styles) { + entity.polygon.material = styles.color + entity.polygon.outline = styles.outline; + entity.polygon.outlineColor = styles.outlineColor + entity.polygon.outlineWidth = styles.outline ? 2 : 0 + }, - // Let the map and/or other parent views know that a change has been made that - // requires the map to be re-rendered - model.trigger('appearanceChanged') + /** + * Update the styles for a point entity + * @param {Cesium.Entity} entity - The entity to update + * @param {Object} styles - Styles to apply, as returned by getStyles + * @since x.x.x + */ + stylePoint: function (entity, styles) { + entity.point.color = styles.color + entity.point.outlineColor = styles.outlineColor + entity.point.outlineWidth = styles.outline ? 2 : 0 + entity.point.pixelSize = styles.pointSize + }, + + /** + * Update the styles for a polyline entity + * @param {Cesium.Entity} entity - The entity to update + * @param {Object} styles - Styles to apply, as returned by getStyles + * @since x.x.x + */ + stylePolyline: function (entity, styles) { + entity.polyline.material = styles.color + entity.polyline.width = styles.lineWidth + }, + /** + * Update the styles for a billboard entity + * @param {Cesium.Entity} entity - The entity to update + * @param {Object} styles - Styles to apply, as returned by getStyles + * @since x.x.x + */ + styleBillboard: function (entity, styles) { + if (!this.pinBuilder) { + this.pinBuilder = new Cesium.PinBuilder() } - catch (e) { - console.log('Failed to update CesiumVectorData model styles.', e); + entity.billboard.image = this.pinBuilder.fromColor( + styles.color, styles.markerSize).toDataURL() + // To convert the automatically created billboards to points instead: + // entity.billboard = undefined; + // entity.point = new Cesium.PointGraphics(); + }, + + /** + * Update the styles for a label entity + * @param {Cesium.Entity} entity - The entity to update + * @param {Object} styles - Styles to apply, as returned by getStyles + * @since x.x.x + */ + styleLabel: function (entity, styles) { + // TODO... + }, + + /** + * Covert a Color model to a Cesium Color + * @param {Color} color A Color model + * @returns {Cesium.Color|null} A Cesium Color or null if the color is + * invalid + * @since x.x.x + */ + colorToCesiumColor: function (color) { + color = color?.get ? color.get("color") : color; + if(!color) return null + return new Cesium.Color( + color.red, color.green, color.blue, color.alpha + ) + }, + + /** + * Return the color for a feature based on the colorPalette and filters + * attributes. + * @param {Cesium.Entity} entity A Cesium Entity + * @returns {Cesium.Color|null} A Cesium Color or null if the color is + * invalid or alpha is 0 + * @since x.x.x + */ + colorForEntity: function (entity) { + const properties = this.getPropertiesFromFeature(entity); + const color = this.colorToCesiumColor(this.getColor(properties)); + const alpha = color.alpha * this.get("opacity"); + if (alpha === 0) return null; + color.alpha = alpha; + return this.colorToCesiumColor(color); + }, + + /** + * Return the styles for a selected feature + * @param {Cesium.Entity} entity A Cesium Entity + * @returns {Object} An object containing the styles for the feature + * @since x.x.x + */ + getSelectedStyles: function (entity) { + const highlightColor = this.colorToCesiumColor( + this.get("highlightColor") + ); + return { + "color": highlightColor || this.colorForEntity(entity), + "outlineColor": Cesium.Color.WHITE, + "outline": true, + "lineWidth": 7, + "markerSize": 34, + "pointSize": 17 + } + }, + + /** + * Return the styles for a feature + * @param {Cesium.Entity} entity A Cesium Entity + * @returns {Object} An object containing the styles for the feature + * @since x.x.x + */ + getStyles: function (entity) { + if(!entity) return null + if (this.featureIsSelected(entity)) { + return this.getSelectedStyles(entity) + } + const color = this.colorForEntity(entity); + if (!color) { return null } + const outlineColor = this.colorToCesiumColor( + this.get("outlineColor")?.get("color") + ); + return { + "color": color, + "outlineColor": outlineColor, + "outline": outlineColor ? true : false, + "lineWidth": 3, + "markerSize": 25, + "pointSize": 13, } }, diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index 33ad05c8a..11efafff0 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -439,8 +439,8 @@ define( * for this asset model. */ getFeatureAttributes: function (feature) { - if (!this.usesFeatureType(feature)) return null - if (!this.getCesiumModelFromFeature) return null + if (!this.usesFeatureType(feature)) return null; + if (!this.getCesiumModelFromFeature) return null; return { properties: this.getPropertiesFromFeature(feature), mapAsset: this, diff --git a/src/js/views/search/SearchResultView.js b/src/js/views/search/SearchResultView.js index 44a3c14f0..707719d76 100644 --- a/src/js/views/search/SearchResultView.js +++ b/src/js/views/search/SearchResultView.js @@ -68,6 +68,8 @@ define([ events: { "click .result-selection": "toggleSelected", "click .download": "download", + "mouseover .open-marker": "toggleShowOnMap", + "mouseout .open-marker": "toggleShowOnMap", }, /** @@ -375,6 +377,15 @@ define([ this.model.toggle(); }, + /** + * Toggles the showOnMap state of the model when the user hovers over + * or leaves the map marker icon + * @param {Event} e - The mouseover or mouseout event + */ + toggleShowOnMap: function (e) { + this.model.set("showOnMap", e.type === "mouseover") + }, + /** * Navigates the app to the metadata page for a result item that was * clicked on From 457bdbff98b40a5f77e4895e0cbb6224956f5121 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 18 Apr 2023 20:38:13 -0400 Subject: [PATCH 51/79] Somewhat improve CesiumMap + Search performance - Reduce redundant Solr requests - Reduce geohash precision when there are too many geohashes to display - Reduce geohash precision when the SpatialFilter query string is too long Relates to #2119 --- src/js/collections/maps/Geohashes.js | 36 ++++++++++++ src/js/models/connectors/Map-Search.js | 2 +- src/js/models/filters/SpatialFilter.js | 15 ++++- src/js/models/maps/assets/CesiumGeohash.js | 64 ++++++++++++++++------ 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 9358487cf..643d95408 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -299,6 +299,42 @@ define([ return this; }, + /** + * Reduce the precision of the geohashes in the collection by a certain + * number of levels. This will remove geohashes from the collection and + * add new geohashes with lower precision. The properties of the + * geohashes will be summarized using the provided propertySummaries. + * @param {Number} by - Number of levels to reduce precision by. + * @param {Object} propertySummaries - To keep properties in the resulting + * geohashes, provide methods to summarize the properties of the child + * geohashes. The keys of this object should be the names of the + * properties to keep, and the values should be functions that take an + * array of values and return a single value. + */ + reducePrecision: function (by = 1, propertySummaries = {}) { + // Group the geohashes by their parent geohash. + const groups = this.getGroups(); + // Combine the geohashes in each group into a single geohash with lower + // precision. + const reduced = Object.keys(groups).map((groupID) => { + const parent = new Geohash({ hashString: groupID }); + const children = groups[groupID]; + const properties = {}; + Object.keys(propertySummaries).forEach((key) => { + const values = children.map((child) => { + return child.get(key); + }); + // log("values", values); + properties[key] = propertySummaries[key](values); + }); + parent.set("properties", properties); + return parent; + }); + // Remove the original geohashes and add the new ones. + this.reset(reduced); + return this; + }, + /** * Get the unique geohash precision levels present in the collection. */ diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 9c94aae26..5260e58c4 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -168,7 +168,7 @@ define([ this.listenTo(map, "moveEnd", function () { this.showGeoHashLayer(); this.updateFacet(); - searchResults.trigger("reset"); + // searchResults.trigger("reset"); }); // When a new search is being performed, hide the GeoHash layer to diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 3353ee3aa..509155401 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -30,6 +30,10 @@ define([ * @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(), { @@ -46,6 +50,7 @@ define([ operator: "OR", fieldsOperator: "OR", matchSubstring: false, + maxGeohashValues: 100, }); }, @@ -118,7 +123,7 @@ define([ updateFilterFromExtent: function () { try { this.validateCoordinates(); - const geohashes = new Geohashes(); + let geohashes = new Geohashes(); geohashes.addGeohashesByExtent( (bounds = { north: this.get("north"), @@ -130,6 +135,14 @@ define([ (overwrite = true) ); geohashes.consolidate(); + // If there are too many geohashes, reduce the precision so that the + // search string is not too long + const limit = this.get("maxGeohashValues") + if (typeof limit === "number") { + while (geohashes.length > limit) { + geohashes = geohashes.reducePrecision(1) + } + } this.set({ fields: this.precisionsToFields(geohashes.getPrecisions()), values: geohashes.getAllHashStrings(), diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index bf83dca58..92b2fef83 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -55,6 +55,10 @@ define([ * @property {AssetColor} highlightColor The color to use for features * that are selected/highlighted. * @property {boolean} showLabels Whether to show labels for the layer. + * @property {number} maxGeoHashes The maximum number of geohashes to + * render at a time for performance reasons. If the number of geohashes + * exceeds this number, then the precision will be decreased until the + * number of geohashes is less than or equal to this number. */ defaults: function () { return Object.assign(CesiumVectorData.prototype.defaults(), { @@ -87,6 +91,7 @@ define([ color: "#f3e227", }), showLabels: true, + maxGeoHashes: 1000, }); }, @@ -192,6 +197,45 @@ define([ } }, + /** + * Get the geohash collection and optionally reduce it to only those + * geohashes that are within the current map extent, and to no more than + * the specified amount. + * @param {boolean} [limitToExtent=true] Whether to limit the geohashes + * to those that are within the current map extent. + * @returns {Geohashes} The geohashes to display. + */ + getGeohashes(limitToExtent = true) { + let geohashes = this.get("geohashes"); + if (limitToExtent) { + geohashes = this.getGeohashesForExtent(); + } + const limit = this.get("maxGeoHashes"); + if(typeof limit === 'number' && geohashes.length > limit) { + geohashes = this.reduceGeohashes(geohashes, limit); + } + return geohashes; + }, + + /** + * Reduce the number of geohashes to no more than the specified number. + * @param {Geohashes} geohashes The geohashes to reduce. + * @param {number} limitToNum The maximum number of geohashes to return. + * @returns {Geohashes} The reduced geohashes. + */ + reduceGeohashes: function (geohashes, limitToNum = 1000) { + // For now assume that we will want to summarize the combined property + // of interest by summing the values. + const propertySummaries = {}; + propertySummaries[this.getPropertyOfInterest()] = function (vals) { + return vals.reduce((a, b) => a + b, 0); + } + while (geohashes.length > limitToNum) { + geohashes = geohashes.clone().reducePrecision(1, propertySummaries); + } + return geohashes; + }, + /** * Get the geohashes that are currently in the map's extent. * @returns {Geohashes} The geohashes in the current extent. @@ -210,10 +254,8 @@ define([ * @returns {Object} The GeoJSON representation of the geohashes. */ getGeoJSON: function (limitToExtent = true) { - if (!limitToExtent) { - return this.get("geohashes")?.toGeoJSON(); - } - return this.getGeohashesForExtent()?.toGeoJSON(); + const geohashes = this.getGeohashes(limitToExtent); + return geohashes.toGeoJSON(); }, /** @@ -223,11 +265,9 @@ define([ * @returns {Object} The CZML representation of the geohashes. */ getCZML: function (limitToExtent = true) { - if (!limitToExtent) { - return this.get("geohashes")?.toCZML(); - } + const geohashes = this.getGeohashes(limitToExtent); const label = this.getPropertyOfInterest(); - return this.getGeohashesForExtent()?.toCZML(label); + return geohashes.toCZML(label); }, /** @@ -288,11 +328,3 @@ 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); -// } -// } From 5bff0df39cd2a7367aa827a3446099719b735b2f Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 19 Apr 2023 09:42:00 -0400 Subject: [PATCH 52/79] Add Map opts for hiding home button & layer list Issue #1720 --- src/js/models/AppModel.js | 2 +- src/js/models/connectors/Map-Search.js | 3 +-- src/js/models/maps/Map.js | 13 ++++++++++++- src/js/views/maps/ToolbarView.js | 17 ++++++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index 964ff8ec8..cdd5fe59f 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -108,7 +108,7 @@ define(['jquery', 'underscore', 'backbone'], * @since 2.22.0 */ catalogSearchMapOptions: { - showToolbar: false, + showLayerList: false, layers: [ { "label": "Satellite imagery", diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 5260e58c4..6c27ae45f 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -168,7 +168,7 @@ define([ this.listenTo(map, "moveEnd", function () { this.showGeoHashLayer(); this.updateFacet(); - // searchResults.trigger("reset"); + searchResults.trigger("reset"); }); // When a new search is being performed, hide the GeoHash layer to @@ -271,7 +271,6 @@ define([ const geohashLayer = this.get("geohashLayer"); const counts = this.getGeohashCounts(); const modelAttrs = this.facetCountsToGeohashAttrs(counts); - // const totalCount = this.getTotalNumberOfResults(); // TODO geohashLayer.replaceGeohashes(modelAttrs); }, diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index d234e4c90..fdb5d2cb3 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -42,6 +42,11 @@ define([ * @property {Boolean} [showToolbar=true] - Whether or not to show the * side bar with layer list, etc. If true, the {@link MapView} will render * a {@link ToolbarView}. + * @property {Boolean} [showLayerList=true] - Whether or not to show the + * layer list in the toolbar. If true, the {@link ToolbarView} will + * render a {@link LayerListView}. + * @property {Boolean} [showHomeButton=true] - Whether or not to show the + * home button in the toolbar. * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is * open when the map is initialized. Set to false by default, so that the * toolbar is hidden by default. @@ -136,7 +141,11 @@ define([ * (cesium) with a Feature model when a user selects a geographical * feature on the map (e.g. by clicking) * @property {Boolean} [showToolbar=true] - Whether or not to show the - * side bar with layer list, etc. True by default. + * side bar with layer list and other tools. True by default. + * @property {Boolean} [showLayerList=true] - Whether or not to include + * the layer list in the toolbar. True by default. + * @property {Boolean} [showHomeButton=true] - Whether or not to show the + * home button in the toolbar. True by default. * @property {Boolean} [toolbarOpen=false] - Whether or not the toolbar is * open when the map is initialized. Set to false by default, so that the * toolbar is hidden by default. @@ -182,6 +191,8 @@ define([ terrains: new MapAssets(), selectedFeatures: new Features(), showToolbar: true, + showLayerList: true, + showHomeButton: true, toolbarOpen: false, showScaleBar: true, showFeatureInfo: true, diff --git a/src/js/views/maps/ToolbarView.js b/src/js/views/maps/ToolbarView.js index 7df0627b4..ad50f2da7 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -7,6 +7,7 @@ define( 'underscore', 'backbone', 'text!templates/maps/toolbar.html', + 'models/maps/Map', // Sub-views 'views/maps/LayerListView' ], @@ -15,6 +16,7 @@ define( _, Backbone, Template, + Map, // Sub-views LayerListView ) { @@ -193,9 +195,22 @@ define( this[key] = value; } } - if(this.model && this.model.get('toolbarOpen') === true) { + if (!this.model || !(this.model instanceof Map)) { + this.model = new Map(); + } + if(this.model.get('toolbarOpen') === true) { this.isOpen = true; } + if (this.model.get("showLayerList") === false) { + this.sections = this.sections.filter( + (section) => section.label !== "Layers" + ); + } + if (this.model.get("showHomeButton") === false) { + this.sections = this.sections.filter( + (section) => section.label !== "Home" + ); + } } catch (e) { console.log('A ToolbarView failed to initialize. Error message: ' + e); } From 70fde2b83a05985697578e945d78b5fcf6b95d37 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 19 Apr 2023 17:38:41 -0400 Subject: [PATCH 53/79] Improvements to catalog search styles [WIP] Issue #1720 --- src/css/catalog-search-view.css | 280 ++++++++++----------- src/css/metacatui-common.css | 4 +- src/js/templates/search/catalogSearch.html | 44 ++-- src/js/views/search/CatalogSearchView.js | 52 ++-- 4 files changed, 183 insertions(+), 197 deletions(-) diff --git a/src/css/catalog-search-view.css b/src/css/catalog-search-view.css index 76fa45363..95d8d6e69 100644 --- a/src/css/catalog-search-view.css +++ b/src/css/catalog-search-view.css @@ -1,198 +1,186 @@ -/****************************************** -** CatalogSearchView *** -******************************************/ - -/* -TODO: - - transfer over any other styles specific to this component - - make sure there are not conflicts with the old data catalog view - - see if there are any theme-specific overrides for this search view that we can eliminate. - - switch what we can to CSS variables - */ - -.catalog-search-view { - height: 100%; +/* ------ CSS VARS ------ */ +/* variables for the catalog search view ("cs") only */ +:root { + --cs-panel-shadow: 0px 0px 14px rgba(67, 68, 87, 0.35); + --cs-padding-top-bottom: 0.8rem; + --cs-padding-left-right: 1rem; + --cs-panel-padding: var(--cs-padding-top-bottom) var(--cs-padding-left-right); } -.catalog-search-inner { - height: 100%; +/* ------ CATALOG ELEMENTS ------ */ + +.catalog { display: grid; - justify-content: stretch; - align-items: stretch; - grid-template-columns: auto 1fr 1fr; + grid-template-columns: min-content 1fr 1fr; + grid-template-areas: + "filters results map"; grid-template-rows: 100%; + height: 100%; + overflow: hidden; } -.catalog-search-view .filter-groups-container { - width: 215px; - padding: var(--pad); - padding-bottom: 3rem; +.catalog__filters { + box-sizing: border-box; + grid-area: filters; overflow: scroll; + padding: var(--cs-panel-padding); + box-shadow: var(--cs-panel-shadow); } -.catalog-search-body.mapMode { - height: 100vh; - width: 100vw; - padding-bottom: 0px; +.catalog__results { + box-sizing: border-box; + grid-area: results; + overflow: scroll; + padding: var(--cs-panel-padding); + /* so that absolutely positioned map toggle is placed relative to this */ + position: relative; + /* position children */ display: grid; - align-items: stretch; - justify-content: stretch; - overflow: hidden; -} - -.catalog-search-body.mapMode .search-results-view .result-row:last-child { - margin-bottom: 100px; + width: 100%; + grid-template-columns: auto min-content; + grid-template-rows: min-content min-content 1fr; + grid-template-areas: + "summary summary" + "pager sorter" + "results results"; } - -.search-results-container { - overflow-y: scroll; - height: 100%; +.catalog__summary{ + grid-area: summary; } - -.search-results-panel-container { - display: grid; - grid-auto-columns: 1fr; - grid-template-columns: max-content 1fr; - grid-template-rows: min-content min-content min-content 1fr; - gap: 0px 0px; - grid-template-areas: - "map-toggle-container map-toggle-container" - "title-container title-container" - "pager-container sorter-container" - "search-results-container search-results-container"; +.catalog__pager{ + grid-area: pager; } - -.search-results-container { - grid-area: search-results-container; +.catalog_sorter{ + grid-area: sorter; } - -.pager-container { - grid-area: pager-container; +.catalog__results-list{ + grid-area: results; } -.sorter-container { - grid-area: sorter-container; - justify-self: end; - padding-right: var(--pad); -} -.title-container { - grid-area: title-container; +.catalog__map { + grid-area: map; + display: grid; + box-shadow: var(--cs-panel-shadow); + /* so that .catalog__map-filter-toggle is positioned relative to map */ + position: relative; } -.map-toggle-container { - grid-area: map-toggle-container; -} +/* MAP CONTROLS */ -.catalog-search-body.mapMode .search-results-panel-container .map-toggle-container { - display: none; +.catalog__map-toggle { + position: absolute; + right: var(--cs-padding-top-bottom); + top: var(--cs-padding-left-right); + /* remove button styles */ + border: none; + outline: none; + /* TODO: clean up styles and use vars */ + background-color: #19B36A; + color: white; + padding: 0.3rem 0.5rem; + cursor: pointer; + box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15), 0 1px 14px -6px rgba(0, 0, 0, 0.28); + border-radius: 0.5rem; + letter-spacing: 0.02em; } -.catalog-search-body.listMode .catalog-search-inner { - grid-template-columns: auto 1fr 0; +.catalog__map-toggle:hover { + background-color: #1E9E5A; + color: white; + /* make a smaller darker box shadow than in the non-hover state */ + box-shadow: 0 1px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 5px -2px rgba(0, 0, 0, 0.3); } -.catalog-search-view .cesium-widget-view { - width: inherit; - margin-left: 0; +.catalog__map-filter-toggle{ + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 1rem; + display: flex; + z-index: 1; } -.search-results-view .result-row { - padding: var(--pad); +.map-filter-toggle__label { + color: white; } -.catalog-search-view .no-search-results { - padding: var(--pad); - text-align: center; +.catalog__map-filter-toggle input[type=checkbox]{ + margin: 0; + margin-right: 0.5rem; + transform: scale(1.2); + order: -1; + margin-right: 0.5rem; } +/* ------ PAGE LAYOUT ------ */ +/* organize the page elements that are outside of the catalog search view */ +/*body*/ +.catalog-search-view-body { + display: grid; + grid-template-columns: 100vw; + grid-template-rows: min-content 1fr; + grid-template-areas: + "nav-header" + "content"; + overflow: hidden; + height: 100vh; + padding: 0; + margin: 0; +} -.map-panel-container { +.catalog-search-view-body #Navbar { + grid-area: nav-header; position: relative; + z-index: 100; } -/* When map is hidden... */ -.listMode .map-panel-container { - position: unset; +.catalog-search-view-body #HeaderContainer, +.catalog-search-view-body #Navbar { + box-shadow: var(--cs-panel-shadow); } -.listMode .catalog-search-inner { - position: relative; +.catalog-search-view-body .navbar-inner { + margin: 0; } -.catalog-search-body.listMode .map-panel-container { - display: block; +.catalog-search-view-body #HeaderContainer { + grid-area: nav-header; } -.listMode .map-container { - display: none; +.catalog-search-view-body #Content { + grid-area: content; + padding: 0 !important; + margin: 0 !important; + height: 100%; } - - -/* map controls */ - -.map-controls { - position: absolute; - top: 1rem; - left: 1rem; - z-index: 1; - display: grid; - grid-template-columns: auto auto; - gap: 1rem; - align-items: center; -} -.listMode .map-controls { - right: 1.1rem; - left: auto; - top: 0.3rem; - gap: 0; +.catalog-search-view-body #Footer { + display: none; + position: relative; } -.show-hide-map-button { - background-color: #19B36A; - color: white; - padding: 0.3rem 0.5rem; - cursor: pointer; - box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15), 0 1px 14px -6px rgba(0, 0, 0, 0.28); - border-radius: 0.5rem; - letter-spacing: 0.02em; -} +/* ------ LIST MODE ------ */ +/* catalog is styled as map mode by default. Modifications needed for list-mode +(map hidden) are below */ -.show-hide-map-button:hover { - background-color: #1E9E5A; - color: white; - /* make a smaller darker box shadow than in the non-hover state */ - box-shadow: 0 1px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 5px -2px rgba(0, 0, 0, 0.3); +.catalog-search-view-body.catalog--list-mode { + overflow: scroll; + height: auto; } -/* Spatial Filter Toggle */ -.spatial-filter{ - display: grid; - grid-template-columns: auto auto; - background: var(--map-col-bkg, black); - color: var(--map-col-text, white); - border-radius: 0.5rem; - opacity: 0.8; - top: 0.5rem; - padding: 0.3rem 0.5rem; - box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15), 0 1px 14px -6px rgba(0, 0, 0, 0.28); +.catalog--list-mode #Footer { + display: block; } -.listMode .spatial-filter { + +.catalog--list-mode .catalog__map { display: none; } -.spatial-filter-label{ - margin: 0; -} -/* rule is specific to overwrite bootstrap */ -input[type=checkbox].spatial-filter-checkbox{ - margin: 0; - margin-right: 0.5rem; - /* make it look nicer */ - transform: scale(1.2); - /* put it to the left of the label */ - order: -1; - margin-right: 0.5rem; -} \ No newline at end of file +.catalog--list-mode .catalog { + grid-template-columns: min-content auto; + grid-template-areas: + "filters results"; + overflow: scroll; +} diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index f5dcdb110..1519b7d60 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -545,11 +545,11 @@ text-shadow: none; .data-tag-icon.crimson g{ fill: #800000; } - /****************************************** ** Results and Result Rows *** ******************************************/ .result-row{ + box-sizing: border-box; padding: 10px 20px; width: auto; border-top: 1px solid #EEE; @@ -6850,7 +6850,7 @@ body.mapMode{ .filter-groups.vertical .filters-header{ margin: 0px; border-bottom: 1px dashed #CCC; - margin-bottom: 1.5rem; + margin-bottom: 1.5rem; padding-bottom: 10px; } #portal-filters{ diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html index ac061e3c4..ad287d7f0 100644 --- a/src/js/templates/search/catalogSearch.html +++ b/src/js/templates/search/catalogSearch.html @@ -1,27 +1,19 @@ -
    -
    -
    -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +
    -
    -
    - - Hide Map - - -
    - - -
    -
    -
    -
    -
    +
    diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 2aaf328e3..418762d97 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -54,7 +54,7 @@ define([ * @type {string} * @since 2.22.0 */ - className: "catalog-search-view", + className: "catalog", /** * The template to use for this view's element @@ -133,54 +133,54 @@ define([ sorterView: null, /** - * The CSS class to add to the body of the CatalogSearch. + * The CSS class to add to the body element when this view is rendered. * @type {string} * @since 2.22.0 - * @default "catalog-search-body" + * @default "catalog-search-view-body", */ - bodyClass: "catalog-search-body", + bodyClass: "catalog-search-view-body", /** * The jQuery selector for the FilterGroupsView container * @type {string} * @since 2.22.0 */ - filterGroupsContainer: ".filter-groups-container", + filterGroupsContainer: ".catalog__filters", /** * The query selector for the SearchResultsView container * @type {string} * @since 2.22.0 */ - searchResultsContainer: ".search-results-container", + searchResultsContainer: ".catalog__results-list", /** * The query selector for the CesiumWidgetView container * @type {string} * @since 2.22.0 */ - mapContainer: ".map-container", + mapContainer: ".catalog__map", /** * The query selector for the PagerView container * @type {string} * @since 2.22.0 */ - pagerContainer: ".pager-container", + pagerContainer: ".catalog__pager", /** * The query selector for the SorterView container * @type {string} * @since 2.22.0 */ - sorterContainer: ".sorter-container", + sorterContainer: ".catalog__sorter", /** * The query selector for the title container * @type {string} * @since 2.22.0 */ - titleContainer: ".title-container", + titleContainer: ".catalog__summary", /** * The query selector for button that is used to either show or hide the @@ -188,7 +188,12 @@ define([ * @type {string} * @since 2.22.0 */ - showHideMapButton: ".show-hide-map-button", + toggleMapButton: ".catalog__map-toggle", + + mapFilterToggle: ".catalog__map-filter-toggle", + + mapModeClass: "catalog--map-mode", + listModeClass: "catalog--list-mode", /** * The events this view will listen to and the associated function to @@ -197,10 +202,9 @@ define([ * @since 2.22.0 */ events: function () { - const e = { - "click .spatial-filter": "toggleMapFilter", - } - e[`click ${this.showHideMapButton}`] = "toggleMode"; + const e = {} + e[`click ${this.mapFilterToggle}`] = "toggleMapFilter"; + e[`click ${this.toggleMapButton}`] = "toggleMode"; return e; }, @@ -546,7 +550,7 @@ define([ renderMap: function () { try { // Add the map to the page and render it - this.$(this.mapContainer).empty().append(this.mapView.el); + this.$(this.mapContainer).append(this.mapView.el); this.mapView.render(); } catch (e) { console.error("Couldn't render map in search. ", e); @@ -625,17 +629,19 @@ define([ // the current mode newMode = newMode != "map" && newMode != "list" ? null : newMode; newMode = newMode || (this.mode == "map" ? "list" : "map"); + const mapClass = this.mapModeClass; + const listClass = this.listModeClass; if (newMode == "list") { this.mode = "list"; - classList.remove("mapMode"); - classList.add("listMode"); + classList.remove(mapClass); + classList.add(listClass); } else { this.mode = "map"; - classList.remove("listMode"); - classList.add("mapMode"); + classList.remove(listClass); + classList.add(mapClass); } - this.updateShowHideMapButton(); + this.updateToggleMapButton(); } catch (e) { console.error("Couldn't toggle search mode. ", e); } @@ -645,9 +651,9 @@ define([ * Change the content of the map toggle button to indicate whether * clicking it will show or hide the map. */ - updateShowHideMapButton: function () { + updateToggleMapButton: function () { try { - const mapToggle = this.el.querySelector(this.showHideMapButton); + const mapToggle = this.el.querySelector(this.toggleMapButton); if(!mapToggle) return; if (this.mode == "map") { mapToggle.innerHTML = 'Hide Map ' From d6b82d5a2a5e7186cacc45996df0834f64adeeb1 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 20 Apr 2023 14:36:13 -0400 Subject: [PATCH 54/79] Fix styles in new catalog & routing to old catalog - Use all new classes in the Catalog Search View to resolve CSS conflicts and to make styling between themes, and between the old and new catalog, consistent - Move all Catalog Search styles out of the common CSS file to the catalog search CSS file - Improve the layout and styling of the catalog - Fix an issue with routing to the old data catalog view when the mode is specific in the URL Issues #1720, #2065 --- src/css/catalog-search-view.css | 90 +++++++++++++--------- src/css/metacatui-common.css | 8 +- src/js/routers/router.js | 9 +-- src/js/templates/search/catalogSearch.html | 2 +- src/js/themes/arctic/css/metacatui.css | 34 +++++++- src/js/themes/arctic/routers/router.js | 9 +-- src/js/themes/dataone/css/metacatui.css | 11 --- src/js/themes/default/css/metacatui.css | 9 ++- src/js/themes/knb/css/metacatui.css | 27 +------ src/js/views/search/CatalogSearchView.js | 49 +++++++++--- 10 files changed, 143 insertions(+), 105 deletions(-) diff --git a/src/css/catalog-search-view.css b/src/css/catalog-search-view.css index 95d8d6e69..dc68c4a3d 100644 --- a/src/css/catalog-search-view.css +++ b/src/css/catalog-search-view.css @@ -1,10 +1,16 @@ /* ------ CSS VARS ------ */ /* variables for the catalog search view ("cs") only */ :root { + --cs-button-bkg: #19B36A; + --cs-button-text: white; --cs-panel-shadow: 0px 0px 14px rgba(67, 68, 87, 0.35); - --cs-padding-top-bottom: 0.8rem; - --cs-padding-left-right: 1rem; - --cs-panel-padding: var(--cs-padding-top-bottom) var(--cs-padding-left-right); + --cs-element-shadow: 0px 0px 5px rgba(67, 68, 87, 0.2); + --cs-padding-xsmall: 0.2rem; + --cs-padding-small: 0.5rem; + --cs-padding-medium: 0.8rem; + --cs-padding-large: 1.1rem; + --cs-panel-padding: var(--cs-padding-medium) var(--cs-padding-large); + --cs-border-radius: 0.5rem; } /* ------ CATALOG ELEMENTS ------ */ @@ -25,6 +31,7 @@ overflow: scroll; padding: var(--cs-panel-padding); box-shadow: var(--cs-panel-shadow); + padding-top: 2.1rem; } .catalog__results { @@ -44,16 +51,20 @@ "pager sorter" "results results"; } -.catalog__summary{ + +.catalog__summary { grid-area: summary; } -.catalog__pager{ + +.catalog__pager { grid-area: pager; } -.catalog_sorter{ + +.catalog_sorter { grid-area: sorter; } -.catalog__results-list{ + +.catalog__results-list { grid-area: results; } @@ -70,46 +81,51 @@ .catalog__map-toggle { position: absolute; - right: var(--cs-padding-top-bottom); - top: var(--cs-padding-left-right); - /* remove button styles */ + right: var(--cs-padding-medium); + top: var(--cs-padding-large); border: none; outline: none; - /* TODO: clean up styles and use vars */ - background-color: #19B36A; - color: white; - padding: 0.3rem 0.5rem; - cursor: pointer; - box-shadow: 0 1px 3px -1px rgba(0, 0, 0, 0.15), 0 1px 14px -6px rgba(0, 0, 0, 0.28); - border-radius: 0.5rem; + background-color: var(--cs-button-bkg); + color: var(--cs-button-text); + padding: var(--cs-padding-xsmall) var(--cs-padding-small); + box-shadow: var(--cs-element-shadow); + border-radius: var(--cs-border-radius); letter-spacing: 0.02em; } .catalog__map-toggle:hover { - background-color: #1E9E5A; - color: white; - /* make a smaller darker box shadow than in the non-hover state */ - box-shadow: 0 1px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 5px -2px rgba(0, 0, 0, 0.3); + filter: brightness(0.9); } -.catalog__map-filter-toggle{ +.catalog__map-filter-toggle { position: absolute; left: 50%; transform: translateX(-50%); bottom: 1rem; - display: flex; z-index: 1; + display: flex; + background: var(--map-col-bkg-lighter, black); + opacity: 0.8; + padding: var(--cs-padding-small) var(--cs-padding-medium); + border-radius: var(--map-border-radius, --cs-border-radius); + align-items: center; } .map-filter-toggle__label { - color: white; + color: var(--map-col-text, white); + margin: 0; + line-height: 1; + font-size: 0.7rem; + letter-spacing: 0.06em; + font-weight: 600; + text-transform: uppercase; } -.catalog__map-filter-toggle input[type=checkbox]{ +/* the rule has high specificity to override bootstrap rules */ +.catalog__map-filter-toggle input[type=checkbox] { margin: 0; - margin-right: 0.5rem; - transform: scale(1.2); order: -1; + transform: scale(1.35); margin-right: 0.5rem; } @@ -117,7 +133,7 @@ /* organize the page elements that are outside of the catalog search view */ /*body*/ -.catalog-search-view-body { +.catalog-search-body { display: grid; grid-template-columns: 100vw; grid-template-rows: min-content 1fr; @@ -130,33 +146,33 @@ margin: 0; } -.catalog-search-view-body #Navbar { +.catalog-search-body #Navbar { grid-area: nav-header; position: relative; z-index: 100; } -.catalog-search-view-body #HeaderContainer, -.catalog-search-view-body #Navbar { +.catalog-search-body #HeaderContainer, +.catalog-search-body #Navbar { box-shadow: var(--cs-panel-shadow); } -.catalog-search-view-body .navbar-inner { +.catalog-search-body .navbar-inner { margin: 0; } -.catalog-search-view-body #HeaderContainer { +.catalog-search-body #HeaderContainer { grid-area: nav-header; } -.catalog-search-view-body #Content { +.catalog-search-body #Content { grid-area: content; padding: 0 !important; margin: 0 !important; height: 100%; } -.catalog-search-view-body #Footer { +.catalog-search-body #Footer { display: none; position: relative; } @@ -165,7 +181,7 @@ /* catalog is styled as map mode by default. Modifications needed for list-mode (map hidden) are below */ -.catalog-search-view-body.catalog--list-mode { +.catalog-search-body.catalog--list-mode { overflow: scroll; height: auto; } @@ -183,4 +199,4 @@ grid-template-areas: "filters results"; overflow: scroll; -} +} \ No newline at end of file diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 1519b7d60..1bcb57c58 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -131,9 +131,8 @@ article.container { } .mapMode #Content{ width: 100%; - margin: 0; - padding: 0; - padding-top: var(--header-height, 0); + margin: 0px; + padding: 0px; } footer{ background-color: #FFFFFF; @@ -6667,7 +6666,8 @@ body.mapMode{ } .filter .ui-slider-handle{ width: 7px; - height: 16px; + height: 15px; + margin-top: -1px; } .filters-title{ text-transform: uppercase; diff --git a/src/js/routers/router.js b/src/js/routers/router.js index 2f2076416..734999507 100644 --- a/src/js/routers/router.js +++ b/src/js/routers/router.js @@ -317,14 +317,11 @@ function ($, _, Backbone) { MetacatUI.appSearchModel.set('additionalCriteria', [query]); } - // Check for a search mode URL parameter - if((typeof mode !== "undefined") && mode) - MetacatUI.appView.dataCatalogView.mode = mode; - require(['views/DataCatalogView'], function(DataCatalogView){ - if(!MetacatUI.appView.dataCatalogView) + if (!MetacatUI.appView.dataCatalogView) { MetacatUI.appView.dataCatalogView = new DataCatalogView(); - + } + if (mode) MetacatUI.appView.dataCatalogView.mode = mode; MetacatUI.appView.showView(MetacatUI.appView.dataCatalogView); }); }, diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html index ad287d7f0..67d5c7f51 100644 --- a/src/js/templates/search/catalogSearch.html +++ b/src/js/templates/search/catalogSearch.html @@ -8,7 +8,7 @@
    - + a:hover, /* SEARCH PAGE CSS -------------------------------------------------- */ +.mapMode #Content{ +max-width: 100%; +padding-top: 57px; +padding-left: 0px; +} /*-- Results header --*/ .result-header{ diff --git a/src/js/themes/knb/css/metacatui.css b/src/js/themes/knb/css/metacatui.css index e513de2d3..89862093d 100644 --- a/src/js/themes/knb/css/metacatui.css +++ b/src/js/themes/knb/css/metacatui.css @@ -1,12 +1,3 @@ -/* KNB CSS vars --------------------------------------------------- */ - -/* Footer height when it is fixed in place in the data catalog */ -:root { - --fixed-footer-height: 1.2em; - --header-height: 0; -} - @font-face { font-family: "Lato"; src: url('../../../../font/Lato-Light.ttf') format('truetype'); /* Safari, Android, iOS */ @@ -48,9 +39,6 @@ .mapMode > section > article{ padding-bottom: 0px; } - .catalog-search-body #Content { - height: calc(100% - var(--fixed-footer-height)); - } a { color: #006699; @@ -340,13 +328,12 @@ height: 250px; /* Keeps footer down */ } - .catalog-search-body #Footer{ + .mapMode #Footer{ position: fixed; bottom: 0; background-color: #3F3F3F; color: #FFF; - min-height: var(--fixed-footer-height); - height: var(--fixed-footer-height); + height: 1.2em; transition: min-height 1s ease-out; -webkit-transition: min-height 1s ease-out; transition-delay: .5s; @@ -355,16 +342,6 @@ padding-top: 3px; } - .DataCatalog #Content { - padding-top: 0; - } - .DataCatalog:not(.mapMode) #Content { - padding-top: 2rem; - } - .catalog-search-body #Content { - padding-top: 0; - } - footer#Footer div#FooterHeading { max-width: 490px; } diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 418762d97..757e9fa3e 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -9,7 +9,7 @@ define([ "views/search/SorterView", "text!templates/search/catalogSearch.html", "models/connectors/Map-Search-Filters", - "text!" + MetacatUI.root + "/css/catalog-search-view.css" + "text!" + MetacatUI.root + "/css/catalog-search-view.css", ], function ( $, Backbone, @@ -26,12 +26,14 @@ define([ /** * @class CatalogSearchView + * @classdesc The data catalog search view for the repository. This view + * displays a Cesium map, search results, and search filters. * @name CatalogSearchView * @classcategory Views * @extends Backbone.View * @constructor * @since 2.22.0 - * TODO: Add screenshot and description + * TODO: Add screenshot */ return Backbone.View.extend( /** @lends CatalogSearchView.prototype */ { @@ -136,9 +138,9 @@ define([ * The CSS class to add to the body element when this view is rendered. * @type {string} * @since 2.22.0 - * @default "catalog-search-view-body", + * @default "catalog-search-body", */ - bodyClass: "catalog-search-view-body", + bodyClass: "catalog-search-body", /** * The jQuery selector for the FilterGroupsView container @@ -190,9 +192,28 @@ define([ */ toggleMapButton: ".catalog__map-toggle", + /** + * The query selector for the button that is used to turn on or off + * spatial filtering by map extent. + * @type {string} + * @since x.x.x + */ mapFilterToggle: ".catalog__map-filter-toggle", + /** + * The CSS class (not selector) to add to the body element when the map is + * visible. + * @type {string} + * @since x.x.x + */ mapModeClass: "catalog--map-mode", + + /** + * The CSS class (not selector) to add to the body element when the map is + * hidden. + * @type {string} + * @since x.x.x + */ listModeClass: "catalog--list-mode", /** @@ -202,7 +223,7 @@ define([ * @since 2.22.0 */ events: function () { - const e = {} + const e = {}; e[`click ${this.mapFilterToggle}`] = "toggleMapFilter"; e[`click ${this.toggleMapButton}`] = "toggleMode"; return e; @@ -225,7 +246,6 @@ define([ * @since x.x.x */ initialize: function (options) { - this.cssID = "catalogSearchView"; MetacatUI.appModel.addCSS(CatalogSearchViewCSS, this.cssID); @@ -305,7 +325,14 @@ define([ */ setupView: function () { try { - document.querySelector("body").classList.add(this.bodyClass); + // The body class modifies the entire page layout to accommodate the + // catalog search view + if (!this.isSubView) { + MetacatUI.appModel.set("headerType", "default"); + document.querySelector("body").classList.add(this.bodyClass); + } else { + // TODO: Set up styling for sub-view version of the catalog + } this.toggleMode(this.mode); @@ -654,11 +681,13 @@ define([ updateToggleMapButton: function () { try { const mapToggle = this.el.querySelector(this.toggleMapButton); - if(!mapToggle) return; + if (!mapToggle) return; if (this.mode == "map") { - mapToggle.innerHTML = 'Hide Map ' + mapToggle.innerHTML = + 'Hide Map '; } else { - mapToggle.innerHTML = ' Show Map ' + mapToggle.innerHTML = + ' Show Map '; } } catch (e) { console.log("Couldn't update map toggle. ", e); From 88b4696f96d5a2ef5a3979f1a828e34415e41444 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 21 Apr 2023 16:43:08 -0400 Subject: [PATCH 55/79] Modify filter styles for new catalog Issue #2065 --- src/css/catalog-search-view.css | 6 +-- src/css/metacatui-common.css | 74 ++++++++++++++++++++++++--------- src/js/models/AppModel.js | 2 +- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/css/catalog-search-view.css b/src/css/catalog-search-view.css index dc68c4a3d..94878190e 100644 --- a/src/css/catalog-search-view.css +++ b/src/css/catalog-search-view.css @@ -7,8 +7,8 @@ --cs-element-shadow: 0px 0px 5px rgba(67, 68, 87, 0.2); --cs-padding-xsmall: 0.2rem; --cs-padding-small: 0.5rem; - --cs-padding-medium: 0.8rem; - --cs-padding-large: 1.1rem; + --cs-padding-medium: 0.9rem; + --cs-padding-large: 1.6rem; --cs-panel-padding: var(--cs-padding-medium) var(--cs-padding-large); --cs-border-radius: 0.5rem; } @@ -31,7 +31,7 @@ overflow: scroll; padding: var(--cs-panel-padding); box-shadow: var(--cs-panel-shadow); - padding-top: 2.1rem; + margin-left: -5px; } .catalog__results { diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 1bcb57c58..3f994e50c 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -6326,6 +6326,9 @@ body.mapMode{ .filter.btn{ margin-bottom: 0px; } +.filter-groups.vertical .filter-input-contain { + margin-bottom: 0; +} .filter-contain > label{ font-size: 13px; font-weight: bold; @@ -6576,12 +6579,17 @@ body.mapMode{ padding-right: 20px; max-width: 350px; min-width: 200px; + box-sizing: border-box; } -.filter-groups.vertical .filter-group .filter{ - padding-right: 0px; - min-width: 0px; - max-width: 100%; - margin-bottom: 1rem; + +.filter-groups.vertical .filter-group .filter:not(.annotation-filter), +.filter-groups.vertical .filters-header { + margin: 0; + border-bottom: 1px solid #dfdfdf; + padding: 1.2rem 0; +} +.filter-groups.vertical .filter { + padding-right: 0; } .filter-groups .filter .btn:not(.btn-filter-editor){ box-shadow: none; @@ -6609,6 +6617,16 @@ body.mapMode{ font-weight: bold; margin-bottom: 0.5rem; } +.filter-groups.vertical .filter > label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.015em; + color: #1d1d1d; +} +.filter-groups.vertical .filter > label .icon { + font-size: 1.1rem; + margin-right: 0.55rem; +} .filter-group-links{ border-top: 1px solid #DDD; clear: both; @@ -6658,16 +6676,18 @@ body.mapMode{ } .filter .ui-slider{ position: relative; - height: 5px; - margin-top: 20px; - margin-bottom: 20px; - width: 90%; - width: calc(100% - 20px); + height: 6px; + margin-top: 15px; + margin-bottom: 17px; + border-radius: 4px; + border: 0px; + background: #CCC; + width: calc(100% - 10px); } .filter .ui-slider-handle{ width: 7px; height: 15px; - margin-top: -1px; + margin-top: -1px; } .filters-title{ text-transform: uppercase; @@ -6676,6 +6696,25 @@ body.mapMode{ font-weight: normal; margin-top: 0px; } + +.catalog h5.result-header-count{ + letter-spacing: 0.015em; + margin-left: 0.6rem; + margin-bottom: 1rem; +} + +/* vertical filter groups */ +.filter-groups.vertical { + display: grid; + width: 100%; + box-sizing: border-box; +} +.filter-groups.vertical .filters-container { + display: grid; + grid-auto-rows: min-content; + width: 100%; +} + .filter-groups:not(.vertical) .filters-title{ display: none; } @@ -6764,22 +6803,19 @@ body.mapMode{ width: calc(40% - 50px); text-align: center; display: inline-block; + vertical-align: middle; + color: #7c7a7a; } .filter.date .min, .filter.numeric .min{ float: left; + margin-right: 0.7rem; } .filter.date .max, .filter.numeric .max{ float: right; margin-left: 10px; } -.filter .ui-slider{ - min-height: 6px; - border-radius: 4px; - border: 0px; - background: #CCC; -} .filter.boolean input{ height: auto; font-size: 3.2em; @@ -6816,7 +6852,7 @@ body.mapMode{ font-size: 100%; box-sizing: border-box; border-color: #ccc; - border-radius: 2px; + border-radius: 3px; } .filter-groups .filter.annotation-filter .dropdown.multiple.ui { @@ -6851,7 +6887,6 @@ body.mapMode{ margin: 0px; border-bottom: 1px dashed #CCC; margin-bottom: 1.5rem; - padding-bottom: 10px; } #portal-filters{ border: 1px solid #CCC; @@ -7045,6 +7080,7 @@ body.mapMode{ } .can-toggle.can-toggle-small label { font-size: 1em; + margin-bottom: 0; } .can-toggle.can-toggle-small label .can-toggle-switch { height: 2.5em; diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index cdd5fe59f..ba395a007 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -1691,7 +1691,7 @@ define(['jquery', 'underscore', 'backbone'], { filterType: "ToggleFilter", fields: ["documents"], - label: "Only results with data files", + label: "Only results with data", trueLabel: "True", falseLabel: "False", trueValue: "*", From fa9584b9a7a608229848b1bc00d74d3b2eb573c0 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 3 May 2023 17:11:43 -0400 Subject: [PATCH 56/79] Optimize calculating geohashes for a map view - Work around the issue where we request a high precision of geohashes that cover a very large area and cause the browser to crash - Calculate precision from bounding box rather than height - Also add other minor performance improvements to related methods - Implement new methods in SpatialFilter model - New methods not yet implemented in CesiumGeohash layer model Issue: #2119 --- src/js/collections/maps/Geohashes.js | 407 ++++++++++++++++++++++--- src/js/models/filters/SpatialFilter.js | 43 ++- src/js/models/maps/Geohash.js | 14 +- src/js/views/maps/CesiumWidgetView.js | 189 ++++++------ 4 files changed, 499 insertions(+), 154 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 643d95408..536fbcea9 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -118,7 +118,13 @@ define([ * @param {number} precision - Geohash precision level. * @returns {string[]} Array of geohash hashStrings. */ - getHashStringsByExtent: function (bounds, precision) { + getHashStringsForBounds: function (bounds, precision) { + if (precision < 1) { + throw new Error("Precision must be greater than or equal to 1"); + } + if (!this.boundsAreValid(bounds)) { + throw new Error("Bounds are invalid"); + } let hashStrings = []; bounds = this.splitBoundingBox(bounds); bounds.forEach(function (bb) { @@ -150,7 +156,7 @@ define([ * @param {string} attr The key of the property in the properties object * in each geohash model. */ - getAttr(attr) { + getAttr: function (attr) { return this.models.map((geohash) => geohash.get(attr)); }, @@ -175,22 +181,310 @@ define([ }, /** - * 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. + * Add geohashes to the collection based on a bounding box. * @param {Object} bounds - Bounding box with north, south, east, and west * properties. - * @param {number} height - Altitude in meters to use to calculate the - * geohash precision level. + * @param {boolean} [consolidate=false] - Whether to consolidate the + * geohashes into the smallest set of geohashes that cover the same area. + * This will be performed before creating the new models in order to + * improve performance. + * @param {number} [maxGeohashes=Infinity] - The maximum number of + * geohashes to add to the collection. This will limit the precision of + * the geohashes for larger bounding boxes. Depending on constraints such + * as the min and max precision, and the size of the bounding box, the + * actual number of geohashes added may sometimes exceed this number. + * @param {number} [minPrecision=1] - The minimum precision of the + * geohashes to add to the collection. + * @param {number} [maxPrecision=6] - The maximum precision of the + * geohashes to add to the collection. * @param {boolean} [overwrite=false] - Whether to overwrite the current * collection. */ - addGeohashesByExtent: function (bounds, height, overwrite = false) { - const precision = this.heightToPrecision(height); - const hashStrings = this.getHashStringsByExtent(bounds, precision); + addGeohashesByBounds: function ( + bounds, + consolidate = false, + maxGeohashes = Infinity, + minPrecision = 1, + maxPrecision = 6, + overwrite = false + ) { + let hashStrings = []; + if (consolidate) { + hashStrings = this.getFewestHashStringsForBounds( + bounds, + minPrecision, + maxPrecision, + maxGeohashes + ); + } else { + const area = this.getArea(bounds); + const precision = this.getMaxPrecision( + area, + maxGeohashes, + minPrecision, + maxPrecision + ); + hashStrings = this.getHashStringsForBounds(bounds, precision); + } this.addGeohashesByHashString(hashStrings, overwrite); }, + /** + * Get the area in degrees squared of a geohash "tile" for a given + * precision level. The area is considered the product of the geohash's + * latitude and longitude error margins. + * @param {number} precision - The precision level to get the area for. + * @returns {number} The area in degrees squared. + */ + getGeohashArea: function (precision) { + // Number of bits used for encoding both coords + const totalBits = precision * 5; + const lonBits = Math.floor(totalBits / 2); + const latBits = totalBits - lonBits; + // Lat and long precision in degrees. + const latPrecision = 180 / 2 ** latBits; + const lonPrecision = 360 / 2 ** lonBits; + return latPrecision * lonPrecision; + }, + + /** + * For a range of precisions levels, get the area in degrees squared for + * geohash "tiles" at each precision level. See {@link getGeohashArea}. + * @param {Number} minPrecision - The minimum precision level for which to + * calculate the area. + * @param {Number} maxPrecision - The maximum precision level for which to + * calculate the area. + * @returns {Object} An object with the precision level as the key and the + * area in degrees as the value. + */ + getGeohashAreas: function (minPrecision = 1, maxPrecision = 6) { + if (!this.precisionAreas) this.precisionAreas = {}; + for (let i = minPrecision; i <= maxPrecision; i++) { + if (!this.precisionAreas[i]) { + this.precisionAreas[i] = this.getGeohashArea(i); + } + } + return this.precisionAreas; + }, + + /** + * Get the area of a bounding box in degrees. + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @returns {Number} The area of the bounding box in degrees. + */ + getBoundingBoxArea: function (bounds) { + const { north, south, east, west } = bounds; + const latDiff = north - south; + const lonDiff = east - west; + return latDiff * lonDiff; + }, + + /** + * Check that a bounds object is valid for the purposes of other methods + * in this Collection. + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @returns {boolean} Whether the bounds object is valid. + */ + boundsAreValid: function (bounds) { + // For now just check that there is a coordinate for each direction. + return ( + bounds && + bounds.north !== undefined && + bounds.south !== undefined && + bounds.east !== undefined && + bounds.west !== undefined + ); + }, + + /** + * Given a bounding box, estimate the maximum geohash precision that can + * be used to cover the area without exceeding a specified number of + * geohashes. The goal is to find the smallest and most precise geohashes + * possible without surpassing the maximum allowed number of geohashes. + * @param {Number} area - The area of the bounding box in degrees squared. + * @param {Number} maxGeohashes - The maximum number of geohashes that can + * be used to cover the area. + * @param {Number} absMin - The absolute minimum precision level to + * consider (optional, default: 1). + * @param {Number} absMax - The absolute maximum precision level to + * consider (optional, default: 6). + * @returns {Number} The maximum precision level that can be used to cover + * the area without surpassing the given number of geohashes. + */ + getMaxPrecision: function (area, maxGeohashes, absMin = 1, absMax = 6) { + const ghAreas = this.getGeohashAreas(absMin, absMax); + + // Start from the most precise level + let precision = absMax; + let conditionMet = false; + + // Work down to the lowest precision level + while (precision >= absMin) { + // Num of geohashes needed to cover the bounding box area + const geohashesNeeded = area / ghAreas[precision]; + if (geohashesNeeded <= maxGeohashes) { + conditionMet = true; + break; + } + precision--; + } + + if (!conditionMet) { + console.warn( + `The area is too large to cover with fewer than ${maxGeohashes} ` + + `geohashes at the min precision level (${absMin}). Returning ` + + `the min precision level, which may result in too many geohashes.` + ); + } + + return precision; + }, + + /** + * Calculate the smallest possible geohash precision level that has + * geohash "tiles" larger than a given area. + * @param {Number} area - The area of the bounding box in degrees squared. + * @param {Number} absMin - The absolute minimum precision level to + * consider (optional, default: 1). + * @param {Number} absMax - The absolute maximum precision level to + * consider (optional, default: 6). + * @returns {Number} The minimum precision level that can be used to cover + * the area with a single geohash. + */ + getMinPrecision: function (area, absMin = 1, absMax = 6) { + const ghAreas = this.getGeohashAreas(absMin, absMax); + + // If area is a huge number and is larger than the largest geohash area, + // return the min precision + if (area >= ghAreas[absMin]) return absMin; + + // Start from the least precise level & work up + let precision = absMin; + while (precision < absMax) { + if (ghAreas[precision] >= area && ghAreas[precision + 1] < area) { + break; + } + precision++; + } + + return precision; + }, + + /** + * Get the optimal range of precision levels to consider using for a given + * bounding box. See {@link getMaxPrecision} and {@link getMinPrecision}. + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @param {Number} maxGeohashes - The maximum number of geohashes that can + * be used to cover the area. + * @param {Number} absMin - The absolute minimum precision level to + * consider (optional, default: 1). + * @param {Number} absMax - The absolute maximum precision level to + * consider (optional, default: 6). + * @returns {Array} An array with the min and max precision levels to + * consider. + */ + getOptimalPrecisionRange: function ( + bounds, + maxGeohashes = Infinity, + absMin = 1, + absMax = 6 + ) { + const area = this.getBoundingBoxArea(bounds); + const minP = this.getMinPrecision(area, absMin, absMax); + if (minP === absMax || maxGeohashes === Infinity) return [minP, absMax]; + return [minP, this.getMaxPrecision(area, maxGeohashes, minP, absMax)]; + }, + + getFewestHashStringsForBounds: function ( + bounds, + minPrecision = 1, + maxPrecision = 6, // 6 because we only index up to 6 (I think, TODO) + maxGeohashes = Infinity + ) { + // Check the inputs + if (!bounds || !this.boundsAreValid(bounds)) return []; + if (minPrecision < 1) minPrecision = 1; + if (minPrecision > maxPrecision) minPrecision = maxPrecision; + + // Skip precision levels that result in too many geohashes or that have + // geohash "tiles" larger than the bounding box. + [minPrecision, maxPrecision] = this.getOptimalPrecisionRange( + bounds, + maxGeohashes, + minPrecision, + maxPrecision + ); + + const base32 = [..."0123456789bcdefghjkmnpqrstuvwxyz"]; + const { north, south, east, west } = bounds; + const optimalSet = new Set(); + + // If the bounds cover the world, return the base set of geohashes + if (north >= 90 && south <= -90 && east >= 180 && west <= -180) { + return base32; + } + + // Checks if the given bounds are fully within the bounding box + function isFullyContained(n, e, s, w) { + return s >= south && w >= west && n <= north && e <= east; + } + + // Checks if the given bounds are fully outside the bounding box + function isFullyOutside(n, e, s, w) { + return s > north || w > east || n < south || e < west; + } + + // Checks if a hash is fully contained, fully outside, or overlapping + // the bounding box + function hashPlacement(hash) { + let [s, w, n, e] = nGeohash.decode_bbox(hash); + if (isFullyOutside(n, e, s, w)) return "outside"; + else if (isFullyContained(n, e, s, w)) return "inside"; + else return "overlap"; + } + + // Start with all hashes at minPrecision + let precision = minPrecision; + + let hashes = this.getHashStringsForBounds(bounds, precision); + + while (precision < maxPrecision && hashes.length > 0) { + // If hash is part overlapping but not fully contained, check the + // children; If hash is fully contained, it's one of the optimal + // geohashes. Hashes fully outside the bounding box ignored. + let overlapHashes = []; + for (const hash of hashes) { + let placement = hashPlacement(hash); + if (placement == "overlap") overlapHashes.push(hash); + else if (placement == "inside") optimalSet.add(hash); + } + + // At the next highest precision level, check the children of the + // hashes that are partially overlapping the bounding box. + hashes = overlapHashes.flatMap((hash) => { + return base32.map((char) => { + return hash + char; + }); + }); + precision++; + } + + // Since want precision to be at least maxPrecision, we can add any + // remaining hashes at maxPrecision that at least partly overlap the + // bounding box. + if (precision == maxPrecision) { + for (const hash of hashes) { + let placement = hashPlacement(hash); + if (placement == "inside") optimalSet.add(hash); + } + } + + return Array.from(optimalSet); + }, + /** * Add geohashes to the collection based on an array of geohash * hashStrings. @@ -218,7 +512,7 @@ define([ let hashes = []; precisions.forEach((precision) => { hashes = hashes.concat( - this.getHashStringsByExtent(bounds, precision) + this.getHashStringsForBounds(bounds, precision) ); }); const subsetModels = this.filter((geohash) => { @@ -245,13 +539,22 @@ define([ * 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. + * @param {number} [level=1] - The level of the parent geohash to use to + * group the geohashes. Defaults to 1, i.e. the parent geohash is one + * level up. * @returns {Object} Object with groupIDs as keys and arrays of Geohash * models as values. */ - getGroups: function () { - return this.groupBy((geohash) => { - return geohash.get("groupID"); + getGroups: function (level = 1) { + const groups = {}; + this.forEach((geohash) => { + const groupID = geohash.getGroupID(level); + if (!groups[groupID]) { + groups[groupID] = []; + } + groups[groupID].push(geohash); }); + return groups; }, /** @@ -261,15 +564,22 @@ define([ * models as values. */ getCompleteGroups: function () { - const groups = this.getGroups(); + const allGroups = {}; const completeGroups = {}; - Object.keys(groups).forEach((groupID) => { - if (groups[groupID].length === 32) { - completeGroups[groupID] = groups[groupID]; + for (let i = 0; i < this.length; i++) { + const geohash = this.at(i); + const groupID = geohash.getGroupID(); + if (groupID) { + if (!allGroups[groupID]) { + allGroups[groupID] = []; + } + allGroups[groupID].push(geohash); + if (allGroups[groupID].length === 32 && !completeGroups[groupID]) { + completeGroups[groupID] = allGroups[groupID]; + } } - }); - delete completeGroups[""]; - delete completeGroups[null]; + } + return completeGroups; }, @@ -281,29 +591,41 @@ define([ * consolidation. */ consolidate: function () { - let changed = true; - while (changed) { - changed = false; - const toMerge = this.getCompleteGroups(); + let toMerge; + + if (this.length <= 1) return this; + + do { + toMerge = this.getCompleteGroups(); let toRemove = []; let toAdd = []; + Object.keys(toMerge).forEach((groupID) => { const parent = new Geohash({ hashString: groupID }); - toRemove = toRemove.concat(toMerge[groupID]); + toRemove.push(...toMerge[groupID]); toAdd.push(parent); - changed = true; }); - this.remove(toRemove, { silent: true }); - this.add(toAdd, { silent: true }); - } + + if (toRemove.length > 0) { + this.remove(toRemove, { silent: true }); + } + + if (toAdd.length > 0) { + this.add(toAdd, { silent: true }); + } + } while (Object.keys(toMerge).length > 0); + return this; }, /** + * TODO: Given the more efficient ways to calculate geohashes for a + * bounding box, I think this method is redundant. + * * Reduce the precision of the geohashes in the collection by a certain * number of levels. This will remove geohashes from the collection and - * add new geohashes with lower precision. The properties of the - * geohashes will be summarized using the provided propertySummaries. + * add new geohashes with lower precision. The properties of the geohashes + * will be summarized using the provided propertySummaries. * @param {Number} by - Number of levels to reduce precision by. * @param {Object} propertySummaries - To keep properties in the resulting * geohashes, provide methods to summarize the properties of the child @@ -312,26 +634,33 @@ define([ * array of values and return a single value. */ reducePrecision: function (by = 1, propertySummaries = {}) { - // Group the geohashes by their parent geohash. - const groups = this.getGroups(); + const groups = this.getGroups(by); + // Combine the geohashes in each group into a single geohash with lower // precision. - const reduced = Object.keys(groups).map((groupID) => { + const reduced = []; + + Object.keys(groups).forEach((groupID) => { const parent = new Geohash({ hashString: groupID }); const children = groups[groupID]; const properties = {}; + Object.keys(propertySummaries).forEach((key) => { const values = children.map((child) => { return child.get(key); }); - // log("values", values); properties[key] = propertySummaries[key](values); }); + parent.set("properties", properties); - return parent; + reduced.push(parent); }); + // Remove the original geohashes and add the new ones. - this.reset(reduced); + if (reduced.length > 0) { + this.reset(reduced); + } + return this; }, @@ -382,7 +711,7 @@ define([ { id: "document", version: "1.0", - name: "Geohashes" + name: "Geohashes", }, ]; diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 509155401..3a86c2d5c 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -82,10 +82,9 @@ define([ * 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 - * + * */ validateCoordinates: function (silent = true) { - if (!this.hasCoordinates()) return; if (this.get("east") > 180) { this.set("east", 180, { silent: silent }); @@ -112,6 +111,20 @@ define([ this.listenTo(this, extentEvents, this.updateFilterFromExtent); }, + /** + * Convert the coordinate attributes to a bounds object + * @returns {object} An object with north, south, east, and west props + * @since x.x.x + */ + getBounds: function () { + return { + north: this.get("north"), + south: this.get("south"), + east: this.get("east"), + west: this.get("west"), + }; + }, + /** * 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 @@ -124,25 +137,7 @@ define([ try { this.validateCoordinates(); let 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(); - // If there are too many geohashes, reduce the precision so that the - // search string is not too long - const limit = this.get("maxGeohashValues") - if (typeof limit === "number") { - while (geohashes.length > limit) { - geohashes = geohashes.reducePrecision(1) - } - } + geohashes.addGeohashesByBounds(this.getBounds(), true, 5000, true); this.set({ fields: this.precisionsToFields(geohashes.getPrecisions()), values: geohashes.getAllHashStrings(), @@ -189,7 +184,7 @@ define([ 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 }))); + 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 @@ -198,7 +193,9 @@ define([ } // Merge into the minimal num. of geohashes to reduce query size - geohashes.consolidate(); + // TODO: we may still want this option for when spatial filters are + // built other than with the current view extent from the map. + // geohashes = geohashes.consolidate(); const precisions = geohashes.getPrecisions(); // Just use a regular Filter if there is only one level of geohash diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 828d0a37c..5a8751725 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -190,14 +190,15 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( }, /** - * 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. + * Get the group ID of the geohash at the specified level. + * @param {number} level - The number of levels to go up from the current geohash. + * @returns {string} The group ID of the geohash at the specified level. */ - getGroupID: function () { + getGroupID: function (level = 1) { if (this.isEmpty()) return ""; - return this.get("hashString").slice(0, -1); + const hashString = this.get("hashString"); + const newLength = Math.max(0, hashString.length - level); + return hashString.slice(0, newLength); }, /** @@ -331,7 +332,6 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( verticalOrigin: "CENTER", heightReference: "CLAMP_TO_GROUND", disableDepthTestDistance: 10000000, - }), (feature["position"] = { cartesian: ecefPosition }); } diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 0e6de2a1e..ae8abf6b0 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -648,104 +648,123 @@ define( }, /** - * Update the 'currentViewExtent' attribute in the Map model with the north, - * south, east, and west-most lat/long that define a bounding box around the - * currently visible area of the map. Also gives the height/altitude of the - * camera in meters. + * Update the 'currentViewExtent' attribute in the Map model with the + * bounding box of the currently visible area of the map. */ updateViewExtent: function () { - try { - const view = this; - const camera = view.camera; - const scene = view.scene; - - // Get the height in meters - const height = camera.positionCartographic.height + try { this.model.set('currentViewExtent', this.getViewExtent()) } + catch (e) { console.log('Failed to update the Map view extent.', e) } + }, - // This will be the bounding box of the visible area - let coords = { - north: null, south: null, east: null, west: null, height: height - } + /** + * Get the north, south, east, and west-most lat/long that define a + * bounding box around the currently visible area of the map. Also gives + * the height/ altitude of the camera in meters. + * @returns {MapConfig#ViewExtent} The current view extent. + */ + getViewExtent: function () { + const view = this; + const scene = view.scene; + const camera = view.camera; + // Get the height in meters + const height = camera.positionCartographic.height - // First try getting the visible bounding box using the simple method - if (!view.scratchRectangle) { - // Store the rectangle that we use for the calculation (reduces pressure on - // garbage collector system since this function is called often). - view.scratchRectangle = new Cesium.Rectangle(); - } - var rect = camera.computeViewRectangle( - scene.globe.ellipsoid, view.scratchRectangle - ); - coords.north = Cesium.Math.toDegrees(rect.north) - coords.east = Cesium.Math.toDegrees(rect.east) - coords.south = Cesium.Math.toDegrees(rect.south) - coords.west = Cesium.Math.toDegrees(rect.west) - - // Check if the resulting coordinates cover the entire globe (happens if some of - // the sky is visible) - - const fullGlobeCoverage = coords.west === -180 && coords.east === 180 && - coords.south === -90 && coords.north === 90 - - // See if we can limit the bounding box to a smaller extent - if (fullGlobeCoverage) { - - // Find points at the top, bottom, right, and left corners of the globe - const edges = view.findEdges() - - // Get the midPoint between the top and bottom points on the globe. Use this - // to decide if the northern or southern hemisphere is more in view. - let midPoint = view.findMidpoint(edges.top, edges.bottom) - if (midPoint) { - - // Get the latitude of the mid point - const midPointLat = view.getDegreesFromCartesian(midPoint).latitude - - // Get the latitudes of all the edge points so that we can calculate the - // southern and northern most coordinate - const edgeLatitudes = [] - Object.values(edges).forEach(function (point) { - if (point) { - edgeLatitudes.push( - view.getDegreesFromCartesian(point).latitude - ) - } - }) + // This will be the bounding box of the visible area + let coords = { + north: null, south: null, east: null, west: null, height: height + } - if (midPointLat > 0) { - // If the midPoint is in the northern hemisphere, limit the southern part - // of the bounding box to the southern most edge point latitude - coords.south = Math.min(...edgeLatitudes) - } else { - // Vice versa for the southern hemisphere - coords.north = Math.max(...edgeLatitudes) + // First try getting the visible bounding box using the simple method + if (!view.scratchRectangle) { + // Store the rectangle that we use for the calculation (reduces pressure on + // garbage collector system since this function is called often). + view.scratchRectangle = new Cesium.Rectangle(); + } + var rect = camera.computeViewRectangle( + scene.globe.ellipsoid, view.scratchRectangle + ); + coords.north = Cesium.Math.toDegrees(rect.north) + coords.east = Cesium.Math.toDegrees(rect.east) + coords.south = Cesium.Math.toDegrees(rect.south) + coords.west = Cesium.Math.toDegrees(rect.west) + + // Check if the resulting coordinates cover the entire globe (happens + // if some of the sky is visible). If so, limit the bounding box to a + // smaller extent + if (view.coversGlobe(coords)) { + + // Find points at the top, bottom, right, and left corners of the globe + const edges = view.findEdges() + + // Get the midPoint between the top and bottom points on the globe. Use this + // to decide if the northern or southern hemisphere is more in view. + let midPoint = view.findMidpoint(edges.top, edges.bottom) + if (midPoint) { + + // Get the latitude of the mid point + const midPointLat = view.getDegreesFromCartesian(midPoint).latitude + + // Get the latitudes of all the edge points so that we can calculate the + // southern and northern most coordinate + const edgeLatitudes = [] + Object.values(edges).forEach(function (point) { + if (point) { + edgeLatitudes.push( + view.getDegreesFromCartesian(point).latitude + ) } + }) + + if (midPointLat > 0) { + // If the midPoint is in the northern hemisphere, limit the southern part + // of the bounding box to the southern most edge point latitude + coords.south = Math.min(...edgeLatitudes) + } else { + // Vice versa for the southern hemisphere + coords.north = Math.max(...edgeLatitudes) } + } - // If not focused directly on one of the poles, then also limit the east and - // west sides of the bounding box - const northPointLat = view.getDegreesFromCartesian(edges.top).latitude - const southPointLat = view.getDegreesFromCartesian(edges.bottom).latitude + // If not focused directly on one of the poles, then also limit the east and + // west sides of the bounding box + const northPointLat = view.getDegreesFromCartesian(edges.top).latitude + const southPointLat = view.getDegreesFromCartesian(edges.bottom).latitude - if (northPointLat > 25 && southPointLat < -25) { - if (edges.right) { - coords.east = view.getDegreesFromCartesian(edges.right).longitude - } - if (edges.left) { - coords.west = view.getDegreesFromCartesian(edges.left).longitude - } + if (northPointLat > 25 && southPointLat < -25) { + if (edges.right) { + coords.east = view.getDegreesFromCartesian(edges.right).longitude + } + if (edges.left) { + coords.west = view.getDegreesFromCartesian(edges.left).longitude } } + } - view.model.set('currentViewExtent', coords) + return coords + }, - } - catch (error) { - console.log( - 'Failed to update the Map view extent from a CesiumWidgetView' + - '. Error details: ' + error - ); - } + /** + * Check if a given bounding box covers the entire globe. + * @param {Object} coords - An object with the north, south, east, and + * west coordinates of a bounding box + * @param {Number} latAllowance - The number of degrees latitude to + * allow as a buffer. If the north and south coords range from -90 to + * 90, minus this buffer * 2, then it is considered to cover the globe. + * @param {Number} lonAllowance - The number of degrees longitude to + * allow as a buffer. + * @returns {Boolean} Returns true if the bounding box covers the entire + * globe, false otherwise. + */ + coversGlobe: function (coords, latAllowance = 0.5, lonAllowance = 1) { + const maxLat = 90 - latAllowance; + const minLat = -90 + latAllowance; + const maxLon = 180 - lonAllowance; + const minLon = -180 + lonAllowance; + + return coords.west <= minLon && + coords.east >= maxLon && + coords.south <= minLat && + coords.north >= maxLat }, /** From be0f0619be5eba851eb6e6e0df385739584c16d0 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 4 May 2023 16:42:05 -0400 Subject: [PATCH 57/79] Make CesiumGeohash compatible with new methods + Other minor fixes and improvements: - Add configurable collection-wide min and max precision values in Geohashes (set max to 9 rather than 6) - Always validate precisions and bounds in methods that use them in Geohashes collection - Account for bounding boxes that cross the antimeridian or have north < south when calculating area - Fix issue where the search results remained in "loading" state when the search was cancelled (e.g. the request URL didn't change, happens when the Cesium camera view has not changed sufficiently) - Decrease limit of number of geohashes in spatial filter to respect Solr's boolean clauses limit - Fix issue where pager was hidden when there were 25-50 results - Remove old CesiumGeohashes view - Remove unused methods from CesiumGeohash model & Geohashes collection Issues: #2119, #1720 --- src/js/collections/maps/Geohashes.js | 268 ++++++++++-------- src/js/models/connectors/Filters-Map.js | 11 +- src/js/models/connectors/Filters-Search.js | 3 + .../models/connectors/Map-Search-Filters.js | 67 ++++- src/js/models/connectors/Map-Search.js | 23 +- src/js/models/filters/SpatialFilter.js | 33 ++- src/js/models/maps/assets/CesiumGeohash.js | 45 +-- src/js/views/maps/CesiumGeohashes.js | 127 --------- src/js/views/search/SearchResultsPagerView.js | 2 +- 9 files changed, 290 insertions(+), 289 deletions(-) delete mode 100644 src/js/views/maps/CesiumGeohashes.js diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 536fbcea9..564928fbc 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -41,38 +41,48 @@ define([ }, /** - * 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. - */ - getPrecisionHeightMap: function () { - return { - 1: 6800000, - 2: 2400000, - 3: 550000, - 4: 120000, - 5: 7000, - 6: 0, - }; + * Initialize the collection and set the min and max precision levels. + * @param {Geohash[]} models - Array of Geohash models. + * @param {Object} options - Options to pass to the collection. + * @param {number} [options.maxPrecision=9] - The maximum precision level + * to use when adding geohashes to the collection. + * @param {number} [options.minPrecision=1] - The minimum precision level + * to use when adding geohashes to the collection. + */ + initialize: function (models, options) { + // Set the absolute min and max precision levels. Min should not be + // lower than 1. Max must be greater than or equal to min. + this.MAX_PRECISION = options?.maxPrecision || 9; + this.MIN_PRECISION = options?.minPrecision || 1; + if (this.MIN_PRECISION < 1) this.MIN_PRECISION = 1; + if (this.MAX_PRECISION < this.MIN_PRECISION) { + this.MAX_PRECISION = this.MIN_PRECISION; + } }, /** - * Get the geohash precision level to use for a given height. - * @param {number} [height] - Altitude to use to calculate the geohash - * precision, in meters. - * @returns {number} Geohash precision level. - */ - heightToPrecision: function (height) { - try { - 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 precision, returning 1" + e); - return 1; - } + * Ensure that a precision or list of precisions is valid. A valid precision + * is a positive number in the range of the MIN_PRECISION and MAX_PRECISION + * set on the collection. + * @param {number|number[]} precision - Precision level or array of + * precision levels. + * @param {boolean} [fix=true] - Whether to fix the precision by setting it + * to the min or max precision if it is out of range. If false, then the function + * will throw an error if the precision is invalid. + * @returns {number|number[]} The precision level or array of precision + * levels, corrected if needed and if fix is true. + */ + validatePrecision: function (p, fix = true) { + if (Array.isArray(p)) p.map((pr) => this.validatePrecision(pr, fix)); + const min = this.MIN_PRECISION; + const max = this.MAX_PRECISION; + const isValid = typeof p === "number" && p >= min && p <= max; + if (isValid) return p; + if (fix) return p < min ? min : max; + throw new Error( + `Precision must be a number between ${min} and ${max}` + + ` (inclusive), but got ${p}` + ); }, /** @@ -119,9 +129,7 @@ define([ * @returns {string[]} Array of geohash hashStrings. */ getHashStringsForBounds: function (bounds, precision) { - if (precision < 1) { - throw new Error("Precision must be greater than or equal to 1"); - } + this.validatePrecision(precision, false); if (!this.boundsAreValid(bounds)) { throw new Error("Bounds are invalid"); } @@ -144,6 +152,7 @@ define([ getAllHashStrings: function (precision) { const hashes = this.map((geohash) => geohash.get("hashString")); if (precision) { + this.validatePrecision(precision, false); return hashes.filter((hash) => hash.length === precision); } else { return hashes; @@ -193,20 +202,23 @@ define([ * the geohashes for larger bounding boxes. Depending on constraints such * as the min and max precision, and the size of the bounding box, the * actual number of geohashes added may sometimes exceed this number. - * @param {number} [minPrecision=1] - The minimum precision of the - * geohashes to add to the collection. - * @param {number} [maxPrecision=6] - The maximum precision of the - * geohashes to add to the collection. * @param {boolean} [overwrite=false] - Whether to overwrite the current * collection. + * @param {number} [minPrecision] - The minimum precision of the + * geohashes to add to the collection, defaults to the min precision + * level set on the collection. + * @param {number} [maxPrecision] - The maximum precision of the + * geohashes to add to the collection, defaults to the max precision + * level set on the collection. + * */ addGeohashesByBounds: function ( bounds, consolidate = false, maxGeohashes = Infinity, - minPrecision = 1, - maxPrecision = 6, - overwrite = false + overwrite = false, + minPrecision = this.MIN_PRECISION, + maxPrecision = this.MAX_PRECISION ) { let hashStrings = []; if (consolidate) { @@ -217,7 +229,7 @@ define([ maxGeohashes ); } else { - const area = this.getArea(bounds); + const area = this.getBoundingBoxArea(bounds); const precision = this.getMaxPrecision( area, maxGeohashes, @@ -237,6 +249,7 @@ define([ * @returns {number} The area in degrees squared. */ getGeohashArea: function (precision) { + precision = this.validatePrecision(precision); // Number of bits used for encoding both coords const totalBits = precision * 5; const lonBits = Math.floor(totalBits / 2); @@ -251,13 +264,20 @@ define([ * For a range of precisions levels, get the area in degrees squared for * geohash "tiles" at each precision level. See {@link getGeohashArea}. * @param {Number} minPrecision - The minimum precision level for which to - * calculate the area. + * calculate the area, defaults to the min precision level set on the + * collection. * @param {Number} maxPrecision - The maximum precision level for which to - * calculate the area. + * calculate the area, defaults to the max precision level set on the + * collection. * @returns {Object} An object with the precision level as the key and the * area in degrees as the value. */ - getGeohashAreas: function (minPrecision = 1, maxPrecision = 6) { + getGeohashAreas: function ( + minPrecision = this.MIN_PRECISION, + maxPrecision = this.MAX_PRECISION + ) { + minPrecision = this.validatePrecision(minPrecision); + maxPrecision = this.validatePrecision(maxPrecision); if (!this.precisionAreas) this.precisionAreas = {}; for (let i = minPrecision; i <= maxPrecision; i++) { if (!this.precisionAreas[i]) { @@ -274,10 +294,21 @@ define([ * @returns {Number} The area of the bounding box in degrees. */ getBoundingBoxArea: function (bounds) { + if (!this.boundsAreValid(bounds)) { + console.warn( + `Bounds are invalid: ${JSON.stringify(bounds)}. ` + + `Returning the globe's area for the given bounding box.` + ); + return 360 * 180; + } const { north, south, east, west } = bounds; - const latDiff = north - south; - const lonDiff = east - west; - return latDiff * lonDiff; + + // Account for cases where east < west or north < south (because of + // ability to rotate globe and pan across the dateline in a 3D globe) + const latDiff = north < south ? 180 - (south - north) : north - south; + const lonDiff = east < west ? 360 - (west - east) : east - west; + + return Math.abs(latDiff * lonDiff); }, /** @@ -288,13 +319,20 @@ define([ * @returns {boolean} Whether the bounds object is valid. */ boundsAreValid: function (bounds) { - // For now just check that there is a coordinate for each direction. return ( bounds && - bounds.north !== undefined && - bounds.south !== undefined && - bounds.east !== undefined && - bounds.west !== undefined + typeof bounds.north === "number" && + typeof bounds.south === "number" && + typeof bounds.east === "number" && + typeof bounds.west === "number" && + bounds.north <= 90 && + bounds.north >= -90 && + bounds.south >= -90 && + bounds.south <= 90 && + bounds.east <= 180 && + bounds.east >= -180 && + bounds.west >= -180 && + bounds.west <= 180 ); }, @@ -306,14 +344,21 @@ define([ * @param {Number} area - The area of the bounding box in degrees squared. * @param {Number} maxGeohashes - The maximum number of geohashes that can * be used to cover the area. - * @param {Number} absMin - The absolute minimum precision level to - * consider (optional, default: 1). - * @param {Number} absMax - The absolute maximum precision level to - * consider (optional, default: 6). + * @param {Number} [absMin] - The absolute minimum precision level to + * consider. Defaults to the min set on the collection). + * @param {Number} [absMax] - The absolute maximum precision level to + * consider. Defaults to the max set on the collection. * @returns {Number} The maximum precision level that can be used to cover * the area without surpassing the given number of geohashes. */ - getMaxPrecision: function (area, maxGeohashes, absMin = 1, absMax = 6) { + getMaxPrecision: function ( + area, + maxGeohashes, + absMin = this.MIN_PRECISION, + absMax = this.MAX_PRECISION + ) { + absMin = this.validatePrecision(absMin); + absMax = this.validatePrecision(absMax); const ghAreas = this.getGeohashAreas(absMin, absMax); // Start from the most precise level @@ -347,13 +392,19 @@ define([ * geohash "tiles" larger than a given area. * @param {Number} area - The area of the bounding box in degrees squared. * @param {Number} absMin - The absolute minimum precision level to - * consider (optional, default: 1). + * consider. Defaults to the min set on the collection. * @param {Number} absMax - The absolute maximum precision level to - * consider (optional, default: 6). + * consider. Defaults to the max set on the collection. * @returns {Number} The minimum precision level that can be used to cover * the area with a single geohash. */ - getMinPrecision: function (area, absMin = 1, absMax = 6) { + getMinPrecision: function ( + area, + absMin = this.MIN_PRECISION, + absMax = this.MAX_PRECISION + ) { + absMin = this.validatePrecision(absMin); + absMax = this.validatePrecision(absMax); const ghAreas = this.getGeohashAreas(absMin, absMax); // If area is a huge number and is larger than the largest geohash area, @@ -380,33 +431,63 @@ define([ * @param {Number} maxGeohashes - The maximum number of geohashes that can * be used to cover the area. * @param {Number} absMin - The absolute minimum precision level to - * consider (optional, default: 1). + * consider. Defaults to the min set on the collection. * @param {Number} absMax - The absolute maximum precision level to - * consider (optional, default: 6). + * consider. Defaults to the max set on the collection. * @returns {Array} An array with the min and max precision levels to * consider. */ getOptimalPrecisionRange: function ( bounds, maxGeohashes = Infinity, - absMin = 1, - absMax = 6 + absMin = this.MIN_PRECISION, + absMax = this.MAX_PRECISION ) { + if (!this.boundsAreValid(bounds)) { + console.warn( + `Bounds are invalid: ${JSON.stringify(bounds)}. ` + + `Returning the min and max allowable precision levels.` + ); + return [absMin, absMax]; + } + absMin = this.validatePrecision(absMin); + absMax = this.validatePrecision(absMax); const area = this.getBoundingBoxArea(bounds); const minP = this.getMinPrecision(area, absMin, absMax); if (minP === absMax || maxGeohashes === Infinity) return [minP, absMax]; return [minP, this.getMaxPrecision(area, maxGeohashes, minP, absMax)]; }, + /** + * Get the fewest number of geohashes that can be used to cover a given + * bounding box. This will return the optimal set of potentially + * mixed-precision geohashes that cover the bounding box at the highest + * precision possible without exceeding the maximum number of geohashes. + * @param {Object} bounds - Bounding box with north, south, east, and west + * properties. + * @param {Number} [minPrecision] - The minimum precision level to + * consider when calculating the optimal set of geohashes. Defaults to the + * min precision level set on the collection. + * @param {Number} [maxPrecision] - The maximum precision level to + * consider when calculating the optimal set of geohashes. Defaults to the + * max precision level set on the collection. + * @param {Number} [maxGeohashes=Infinity] - The maximum number of + * geohashes to add to the collection. This will limit the precision of + * the geohashes for larger bounding boxes. Depending on constraints such + * as the min and max precision, and the size of the bounding box, the + * actual number of geohashes added may sometimes exceed this number. + * @returns {string[]} Array of geohash hashStrings. + */ getFewestHashStringsForBounds: function ( bounds, - minPrecision = 1, - maxPrecision = 6, // 6 because we only index up to 6 (I think, TODO) + minPrecision = this.MIN_PRECISION, + maxPrecision = this.MAX_PRECISION, maxGeohashes = Infinity ) { // Check the inputs - if (!bounds || !this.boundsAreValid(bounds)) return []; - if (minPrecision < 1) minPrecision = 1; + if (!this.boundsAreValid(bounds)) return []; + minPrecision = this.validatePrecision(minPrecision); + maxPrecision = this.validatePrecision(maxPrecision); if (minPrecision > maxPrecision) minPrecision = maxPrecision; // Skip precision levels that result in too many geohashes or that have @@ -508,6 +589,13 @@ define([ * @returns {Geohashes} Subset of geohashes. */ getSubsetByBounds: function (bounds) { + if (!this.boundsAreValid(bounds)) { + console.warn( + `Bounds are invalid: ${JSON.stringify(bounds)}. ` + + `Returning an empty Geohashes collection.` + ); + return new Geohashes(); + } const precisions = this.getPrecisions(); let hashes = []; precisions.forEach((precision) => { @@ -618,52 +706,6 @@ define([ return this; }, - /** - * TODO: Given the more efficient ways to calculate geohashes for a - * bounding box, I think this method is redundant. - * - * Reduce the precision of the geohashes in the collection by a certain - * number of levels. This will remove geohashes from the collection and - * add new geohashes with lower precision. The properties of the geohashes - * will be summarized using the provided propertySummaries. - * @param {Number} by - Number of levels to reduce precision by. - * @param {Object} propertySummaries - To keep properties in the resulting - * geohashes, provide methods to summarize the properties of the child - * geohashes. The keys of this object should be the names of the - * properties to keep, and the values should be functions that take an - * array of values and return a single value. - */ - reducePrecision: function (by = 1, propertySummaries = {}) { - const groups = this.getGroups(by); - - // Combine the geohashes in each group into a single geohash with lower - // precision. - const reduced = []; - - Object.keys(groups).forEach((groupID) => { - const parent = new Geohash({ hashString: groupID }); - const children = groups[groupID]; - const properties = {}; - - Object.keys(propertySummaries).forEach((key) => { - const values = children.map((child) => { - return child.get(key); - }); - properties[key] = propertySummaries[key](values); - }); - - parent.set("properties", properties); - reduced.push(parent); - }); - - // Remove the original geohashes and add the new ones. - if (reduced.length > 0) { - this.reset(reduced); - } - - return this; - }, - /** * Get the unique geohash precision levels present in the collection. */ diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index bfde139c9..8b98a3469 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -33,6 +33,9 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( * @property {boolean} isConnected Whether the connector is currently * listening to the Map model for changes. Set automatically when the * connector is started or stopped. + * @property {function} onMoveEnd A function to call when the map is + * finished moving. This function will be called with the connector as + * 'this'. * @since x.x.x */ defaults: function () { @@ -41,6 +44,7 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( spatialFilters: [], map: new Map(), isConnected: false, + onMoveEnd: this.updateSpatialFilters, }; }, @@ -187,7 +191,12 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( this.listenTo(map, "moveStart", function () { this.get("filters").trigger("changing"); }); - this.listenTo(map, "moveEnd", this.updateSpatialFilters); + this.listenTo(map, "moveEnd", function () { + const moveEndFunc = this.get("onMoveEnd"); + if (typeof moveEndFunc === "function") { + moveEndFunc.call(this); + } + }); this.set("isConnected", true); } catch (e) { console.log("Error starting Filter-Map listeners: ", e); diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 7732e61ba..4f44363b1 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -144,6 +144,9 @@ define([ // do anything. This function may have been triggered by a change event // on a filter that doesn't affect the query at all if (!searchResults.hasChanged()) { + // Trigger a reset event to indicate the search is complete (e.g. for + // the UI) + searchResults.trigger("reset"); return; } diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index 2fe18d66b..fc5f0332b 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -214,6 +214,7 @@ define([ * so that they work together. */ connect: function () { + this.coordinateMoveEndSearch(); this.getConnectors().forEach((connector) => connector.connect()); }, @@ -227,6 +228,54 @@ define([ this.get("filtersMapConnector").disconnect(resetSpatialFilter); this.get("filtersSearchConnector").disconnect(); this.get("mapSearchConnector").disconnect(); + this.resetMoveEndSearch(); + }, + + /** + * Coordinate behaviour between the two map related sub-connectors when + * the map extent changes. This is necessary to reduce the number of + * search queries. We keep the moveEnd behaviour within the sub-connectors + * so that each sub-connector still functions independently from this + * coordinating connector. + */ + coordinateMoveEndSearch: function () { + // Undo any previous coordination, if any + this.resetMoveEndSearch(); + + const map = this.get("map"); + const mapConnectors = this.getMapConnectors(); + + // Stop the sub-connectors from doing anything on moveEnd by setting + // their method they call on moveEnd to null + mapConnectors.forEach((connector) => { + connector.set("onMoveEnd", null); + }); + + // Set the single moveEnd listener here, and run the default moveEnd + // behaviour for each sub-connector. This effectively triggers only one + // search per moveEnd. + this.listenTo(map, "moveEnd", function () { + mapConnectors.forEach((connector) => { + const moveEndFunc = connector.defaults().onMoveEnd; + if (typeof moveEndFunc === "function") { + moveEndFunc.call(connector); + } + }); + }); + }, + + /** + * Undo the coordination of the two map related sub-connectors when the + * map extent changes. Reset the moveEnd behaviour of the sub-connectors + * to their defaults. + * @see coordinateMoveEndSearch + */ + resetMoveEndSearch: function () { + this.stopListening(this.get("map"), "moveEnd"); + const mapConnectors = this.getMapConnectors(); + mapConnectors.forEach((connector) => { + connector.set("onMoveEnd", connector.defaults().onMoveEnd); + }); }, /** @@ -238,7 +287,14 @@ define([ * remove any spatial constraints from the search. */ disconnectFiltersMap: function (resetSpatialFilter = false) { - this.get("filtersMapConnector").disconnect(resetSpatialFilter); + const [mapSearch, filtersMap] = this.getMapConnectors(); + + if (mapSearch.get("isConnected")) { + this.resetMoveEndSearch(); + mapSearch.set("onMoveEnd", mapSearch.defaults().onMoveEnd); + } + + filtersMap.disconnect(resetSpatialFilter); }, /** @@ -247,7 +303,12 @@ define([ * the extent of the map view. */ connectFiltersMap: function () { - this.get("filtersMapConnector").connect(); + const [mapSearch, filtersMap] = this.getMapConnectors(); + + if (mapSearch.get("isConnected")) { + this.coordinateMoveEndSearch(); + } + filtersMap.connect(); }, /** @@ -265,7 +326,7 @@ define([ */ removeSpatialFilter: function () { this.get("filtersMapConnector").removeSpatialFilter(); - } + }, } ); }); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 6c27ae45f..ad1bfcf13 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -22,11 +22,15 @@ define([ * @type {object} * @property {SolrResults} searchResults * @property {Map} map + * @property {function} onMoveEnd A function to call when the map is + * finished moving. This function will be called with the connector as + * 'this'. */ defaults: function () { return { searchResults: null, map: null, + onMoveEnd: this.onMoveEnd }; }, @@ -166,9 +170,10 @@ define([ // layer again and update the search results (thereby updating the // facet counts on the GeoHash layer) this.listenTo(map, "moveEnd", function () { - this.showGeoHashLayer(); - this.updateFacet(); - searchResults.trigger("reset"); + const moveEndFunc = this.get("onMoveEnd"); + if (typeof moveEndFunc === "function") { + moveEndFunc.call(this); + } }); // When a new search is being performed, hide the GeoHash layer to @@ -181,6 +186,18 @@ define([ this.set("isConnected", true); }, + /** + * Functions to perform when the map has finished moving. This is separated into its own method + * so that external models can manipulate the behavior of this function. + * See {@link MapSearchFiltersConnector#onMoveEnd} + */ + onMoveEnd: function () { + const searchResults = this.get("searchResults"); + const map = this.get("map"); + this.showGeoHashLayer(); + this.updateFacet(); + }, + /** * Make the geoHashLayer invisible. * @fires CesiumGeohash#change:visible diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 3a86c2d5c..aa5c58298 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -50,7 +50,9 @@ define([ operator: "OR", fieldsOperator: "OR", matchSubstring: false, - maxGeohashValues: 100, + // 1024 is the default limit in Solr for boolean clauses, limit even + // more to allow for other filters + maxGeohashValues: 900, }); }, @@ -125,6 +127,20 @@ define([ }; }, + /** + * Returns true if the bounds set on the filter covers the entire earth + * @returns {boolean} + */ + coversEarth: function () { + const bounds = this.getBounds(); + return ( + bounds.north === 90 && + bounds.south === -90 && + bounds.east === 180 && + bounds.west === -180 + ); + }, + /** * 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 @@ -136,8 +152,17 @@ define([ updateFilterFromExtent: function () { try { this.validateCoordinates(); - let geohashes = new Geohashes(); - geohashes.addGeohashesByBounds(this.getBounds(), true, 5000, true); + + // 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(); + const limit = this.get("maxGeohashValues"); + geohashes.addGeohashesByBounds(bounds, true, limit, true); this.set({ fields: this.precisionsToFields(geohashes.getPrecisions()), values: geohashes.getAllHashStrings(), @@ -192,10 +217,10 @@ define([ return ""; } - // Merge into the minimal num. of geohashes to reduce query size // TODO: we may still want this option for when spatial filters are // built other than with the current view extent from the map. // geohashes = geohashes.consolidate(); + const precisions = geohashes.getPrecisions(); // Just use a regular Filter if there is only one level of geohash diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 92b2fef83..bada7f34b 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -91,7 +91,7 @@ define([ color: "#f3e227", }), showLabels: true, - maxGeoHashes: 1000, + maxGeoHashes: 4000, }); }, @@ -151,13 +151,11 @@ define([ * @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); - } + const limit = this.get("maxGeoHashes"); + const geohashes = this.get("geohashes") + const bounds = this.get("mapModel").get("currentViewExtent"); + const area = geohashes.getBoundingBoxArea(bounds); + return this.get("geohashes").getMaxPrecision(area, limit); }, /** @@ -210,29 +208,6 @@ define([ if (limitToExtent) { geohashes = this.getGeohashesForExtent(); } - const limit = this.get("maxGeoHashes"); - if(typeof limit === 'number' && geohashes.length > limit) { - geohashes = this.reduceGeohashes(geohashes, limit); - } - return geohashes; - }, - - /** - * Reduce the number of geohashes to no more than the specified number. - * @param {Geohashes} geohashes The geohashes to reduce. - * @param {number} limitToNum The maximum number of geohashes to return. - * @returns {Geohashes} The reduced geohashes. - */ - reduceGeohashes: function (geohashes, limitToNum = 1000) { - // For now assume that we will want to summarize the combined property - // of interest by summing the values. - const propertySummaries = {}; - propertySummaries[this.getPropertyOfInterest()] = function (vals) { - return vals.reduce((a, b) => a + b, 0); - } - while (geohashes.length > limitToNum) { - geohashes = geohashes.clone().reducePrecision(1, propertySummaries); - } return geohashes; }, @@ -296,12 +271,8 @@ define([ model.set("cesiumOptions", cesiumOptions); // Create the model like a regular GeoJSON data source CesiumVectorData.prototype.createCesiumModel.call(this, recreate); - } catch (error) { - console.log( - "There was an error creating a CesiumGeohash model" + - ". Error details: ", - error - ); + } catch (e) { + console.log("Error creating a CesiumGeohash model. ", e); } }, diff --git a/src/js/views/maps/CesiumGeohashes.js b/src/js/views/maps/CesiumGeohashes.js deleted file mode 100644 index 92c9183a5..000000000 --- a/src/js/views/maps/CesiumGeohashes.js +++ /dev/null @@ -1,127 +0,0 @@ -define(["backbone", - "cesium", - "nGeohash", - "models/maps/assets/CesiumGeohash"], - function(Backbone, Cesium, geohash, CesiumGeohash){ - - /** - * @class CesiumGeohashes - * @name CesiumGeohashes - * @classcategory Views/Maps - * @extends Backbone.View - * @classdesc Draws geohash boxes and their associated count/density in a Cesium {@link CesiumWidgetView}. Uses the {@link CesiumGeohash} Map Asset for the geohash data. - * @since 2.22.0 - */ - return Backbone.View.extend(/** @lends CesiumGeohashes.prototype */{ - - cesiumViewer: null, - - /** - * A reference to the CesiumGeohash MapAsset model that is rendered in this view - * @type {CesiumGeohash} - * @since 2.22.0 - */ - cesiumGeohash: null, - - render: function(){ - - //If there is no CesiumGeohash model, exit without rendering - if(!this.cesiumGeohash){ - return; - } - - this.entities = this.cesiumGeohash.get("cesiumModel").entities; - - if(this.cesiumGeohash.get('status') == "ready"){ - this.drawGeohashes(); - } - - //When the status changes, re-render this view - this.listenTo(this.cesiumGeohash, "change:status", this.drawGeohashes); - - this.listenToMovement(); - - }, - - /** - * Listens to Cesium Camera movement so the geohash level can change when the camera zooms in - * @since 2.22.0 - */ - listenToMovement: function(){ - //Listen to camera movement to change the geohash level - let view = this; - this.cesiumViewer.scene.camera.moveEnd.addEventListener(function () { - //Get the position of the Cesium camera - let c = Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(view.cesiumViewer.scene.camera.position.x, view.cesiumViewer.scene.camera.position.y, view.cesiumViewer.scene.camera.position.z)) - //Set the geohash level based on the camera position height - view.cesiumGeohash.setGeohashLevel(c.height); - }); - }, - - /** - * Draws the geohash polygons on the Cesium map (via {@link CesiumGeohash}) associated with this view. Draws a number representing the - * count/density associated with that polygon (e.g. number of datasets in that area). - * @since 2.22.0 - */ - drawGeohashes: function(){ - - let polygon, - entities = this.entities, - dataSource = this.dataSource, - viewer = this.cesiumViewer, - hue = this.cesiumGeohash.get("hue"); - - //If there is no CesiumGeohash model, exit without rendering - if(!this.cesiumGeohash){ - return; - } - - //Remove all the Entities from the Cesium layer - entities.removeAll(); - - let counts = this.cesiumGeohash.get("geohashCounts"); - - for(let i=0; i < counts.length; i+=2){ - - let hash = counts[i], - bbox = geohash.decode_bbox(hash), - count = counts[i+1], - alpha = count/this.cesiumGeohash.get("totalCount") + 0.5; - - let polygon = entities.add({ - polygon : { - hierarchy : Cesium.Cartesian3.fromDegreesArray([ - bbox[1], bbox[0], - bbox[3], bbox[0], - bbox[3], bbox[2], - bbox[1], bbox[2] ]), - height : 1000, - material : Cesium.Color.fromHsl(hue/360, 0.5, 0.6, alpha), - outline : true, - outlineColor : Cesium.Color.WHITE - }, - show: true - }); - - let label = entities.add({ - position : Cesium.Cartesian3.fromDegrees((bbox[3]+bbox[1])/2, (bbox[2]+bbox[0])/2), - label : { - text : count.toString(), - font : '14pt monospace', - style: Cesium.LabelStyle.FILL, - outlineWidth : 0, - scaleByDistance : new Cesium.NearFarScalar(1.5e2, 5.0, 8.0e6, 0.7) - }, - show: true - }); - - } - - viewer.dataSourceDisplay.dataSources.add(this.cesiumGeohash.get("cesiumModel")) - - } - - - }); - -}); \ No newline at end of file diff --git a/src/js/views/search/SearchResultsPagerView.js b/src/js/views/search/SearchResultsPagerView.js index d3524b529..fb3aee3ca 100644 --- a/src/js/views/search/SearchResultsPagerView.js +++ b/src/js/views/search/SearchResultsPagerView.js @@ -149,7 +149,7 @@ define(["backbone"], function (Backbone) { return; } - if (this.searchResults.getNumPages() < 2) { + if (this.searchResults.getNumPages() < 1) { this.hide(); return; } From 033418851e4787fa6c1d9c51907630adad9abf7e Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 4 May 2023 20:20:41 -0400 Subject: [PATCH 58/79] Fix calculation of whether geohashes are in view - Account for view extents that cross the prime meridian - Tweak the max num of geohashes to display in Geohash layer Issues: #2119, #1720 --- src/js/collections/maps/Geohashes.js | 50 +++++++++++++--------- src/js/models/maps/assets/CesiumGeohash.js | 2 +- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 564928fbc..9e1c9c22b 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -135,9 +135,9 @@ define([ } let hashStrings = []; bounds = this.splitBoundingBox(bounds); - bounds.forEach(function (bb) { + bounds.forEach(function (b) { hashStrings = hashStrings.concat( - nGeohash.bboxes(bb.south, bb.west, bb.north, bb.east, precision) + nGeohash.bboxes(b.south, b.west, b.north, b.east, precision) ); }); return hashStrings; @@ -302,12 +302,10 @@ define([ return 360 * 180; } const { north, south, east, west } = bounds; - - // Account for cases where east < west or north < south (because of - // ability to rotate globe and pan across the dateline in a 3D globe) - const latDiff = north < south ? 180 - (south - north) : north - south; + // Account for cases where east < west, due to the bounds crossing the + // prime meridian const lonDiff = east < west ? 360 - (west - east) : east - west; - + const latDiff = north - south; return Math.abs(latDiff * lonDiff); }, @@ -499,38 +497,49 @@ define([ maxPrecision ); + // Base32 is the set of characters used to encode geohashes const base32 = [..."0123456789bcdefghjkmnpqrstuvwxyz"]; - const { north, south, east, west } = bounds; - const optimalSet = new Set(); + + // In case the bounding box crosses the prime meridian, split it in two + const allBounds = this.splitBoundingBox(bounds); // If the bounds cover the world, return the base set of geohashes - if (north >= 90 && south <= -90 && east >= 180 && west <= -180) { + if (bounds.north >= 90 && bounds.south <= -90 && bounds.east >= 180 && bounds.west <= -180) { return base32; } // Checks if the given bounds are fully within the bounding box - function isFullyContained(n, e, s, w) { + function fullyContained(n, e, s, w, north, east, south, west) { return s >= south && w >= west && n <= north && e <= east; } - // Checks if the given bounds are fully outside the bounding box - function isFullyOutside(n, e, s, w) { - return s > north || w > east || n < south || e < west; + // Checks if the given bounds are fully outside the bounding box, assuming that + function fullyOutside(n, e, s, w, north, east, south, west) { + return n < south || s > north || e < west || w > east; } // Checks if a hash is fully contained, fully outside, or overlapping // the bounding box function hashPlacement(hash) { let [s, w, n, e] = nGeohash.decode_bbox(hash); - if (isFullyOutside(n, e, s, w)) return "outside"; - else if (isFullyContained(n, e, s, w)) return "inside"; - else return "overlap"; + let outside = []; + for (const b of allBounds) { + if (fullyContained(n, e, s, w, b.north, b.east, b.south, b.west)) { + return "inside"; + } else if ( + fullyOutside(n, e, s, w, b.north, b.east, b.south, b.west) + ) { + outside.push(true); + } + } + if (outside.length === allBounds.length) return "outside"; + return "overlap"; } // Start with all hashes at minPrecision let precision = minPrecision; - let hashes = this.getHashStringsForBounds(bounds, precision); + const optimalSet = new Set(); while (precision < maxPrecision && hashes.length > 0) { // If hash is part overlapping but not fully contained, check the @@ -558,8 +567,9 @@ define([ // bounding box. if (precision == maxPrecision) { for (const hash of hashes) { - let placement = hashPlacement(hash); - if (placement == "inside") optimalSet.add(hash); + if (hashPlacement(hash) != "outside") { + optimalSet.add(hash); + } } } diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index bada7f34b..9d84ed973 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -91,7 +91,7 @@ define([ color: "#f3e227", }), showLabels: true, - maxGeoHashes: 4000, + maxGeoHashes: 3000, }); }, From e8ba9aeef3be8f54cc0fb10d227b334d3272dd1d Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 4 May 2023 22:21:59 -0400 Subject: [PATCH 59/79] UI enhancements for the new CatalogSearchView Allow collapsing the filters panel, other minor improvements. Relates to #1720 --- src/css/catalog-search-view.css | 199 +++++++++++++------ src/js/templates/search/catalogSearch.html | 5 +- src/js/views/search/CatalogSearchView.js | 210 +++++++++++++++------ 3 files changed, 298 insertions(+), 116 deletions(-) diff --git a/src/css/catalog-search-view.css b/src/css/catalog-search-view.css index 94878190e..7ae72b830 100644 --- a/src/css/catalog-search-view.css +++ b/src/css/catalog-search-view.css @@ -1,18 +1,72 @@ /* ------ CSS VARS ------ */ /* variables for the catalog search view ("cs") only */ :root { - --cs-button-bkg: #19B36A; + --cs-button-bkg: #212429; --cs-button-text: white; + --cs-panel-shadow: 0px 0px 14px rgba(67, 68, 87, 0.35); - --cs-element-shadow: 0px 0px 5px rgba(67, 68, 87, 0.2); + --cs-element-shadow: 0px 0px 5px rgba(67, 68, 87, 0.4); + --cs-padding-xsmall: 0.2rem; --cs-padding-small: 0.5rem; --cs-padding-medium: 0.9rem; --cs-padding-large: 1.6rem; --cs-panel-padding: var(--cs-padding-medium) var(--cs-padding-large); + + --cs-toggle-width: 1.9rem; + --cs-toggle-height: 2.5rem; --cs-border-radius: 0.5rem; } + +/* ------ PAGE LAYOUT ------ */ +/* organize the page elements that are outside of the catalog search view */ + +/*body*/ +.catalog-search-body { + display: grid; + grid-template-columns: 100vw; + grid-template-rows: min-content 1fr; + grid-template-areas: + "nav-header" + "content"; + overflow: hidden; + height: 100vh; + padding: 0; + margin: 0; +} + +.catalog-search-body #Navbar { + grid-area: nav-header; + position: relative; + z-index: 100; +} + +.catalog-search-body #HeaderContainer, +.catalog-search-body #Navbar { + box-shadow: var(--cs-panel-shadow); +} + +.catalog-search-body .navbar-inner { + margin: 0; +} + +.catalog-search-body #HeaderContainer { + grid-area: nav-header; +} + +.catalog-search-body #Content { + grid-area: content; + padding: 0 !important; + margin: 0 !important; + height: 100%; +} + +.catalog-search-body #Footer { + display: none; + position: relative; +} + /* ------ CATALOG ELEMENTS ------ */ .catalog { @@ -54,10 +108,12 @@ .catalog__summary { grid-area: summary; + margin-left: var(--cs-padding-medium); } .catalog__pager { grid-area: pager; + margin-left: var(--cs-padding-medium); } .catalog_sorter { @@ -68,7 +124,6 @@ grid-area: results; } - .catalog__map { grid-area: map; display: grid; @@ -77,26 +132,69 @@ position: relative; } -/* MAP CONTROLS */ +/* ------ MAP CONTROLS ------ */ -.catalog__map-toggle { +/* the toggle buttons and their labels */ +.catalog__map-toggle, .catalog__filters-toggle, +.catalog__toggle-map-label, .catalog__toggle-filters-label { position: absolute; - right: var(--cs-padding-medium); top: var(--cs-padding-large); + box-shadow: var(--cs-element-shadow); + letter-spacing: 0.02em; +} + +/* buttons */ +.catalog__map-toggle, .catalog__filters-toggle { + top: var(--cs-padding-small); + height: var(--cs-toggle-height, 2.5rem); + width: var(--cs-toggle-width, 1.9rem); border: none; outline: none; + background: white; + font-size: 1.5rem; +} + +.catalog__map-toggle { + right: -1px; + border-radius: var(--cs-border-radius) 0 0 var(--cs-border-radius); +} + +.catalog__filters-toggle { + left: -1px; + text-indent: -1px; + border-radius: 0 var(--cs-border-radius) var(--cs-border-radius) 0; +} + +.catalog__map-toggle:hover, .catalog__filters-toggle:hover { + filter: brightness(0.9); +} + +/* labels */ +.catalog__toggle-map-label, .catalog__toggle-filters-label { + display: none; + /* show on hover only */ + top: 0.9rem; background-color: var(--cs-button-bkg); color: var(--cs-button-text); padding: var(--cs-padding-xsmall) var(--cs-padding-small); - box-shadow: var(--cs-element-shadow); border-radius: var(--cs-border-radius); - letter-spacing: 0.02em; } -.catalog__map-toggle:hover { - filter: brightness(0.9); +.catalog__toggle-map-label { + right: calc(var(--cs-padding-small) + var(--cs-toggle-width)); + } +.catalog__toggle-filters-label { + left: calc(var(--cs-padding-small) + var(--cs-toggle-width)); +} + +.catalog__map-toggle:hover~.catalog__toggle-map-label, +.catalog__filters-toggle:hover~.catalog__toggle-filters-label { + display: block; +} + +/* Spatial filter toggle: The toggle to search by map extent or not */ .catalog__map-filter-toggle { position: absolute; left: 50%; @@ -129,74 +227,51 @@ margin-right: 0.5rem; } -/* ------ PAGE LAYOUT ------ */ -/* organize the page elements that are outside of the catalog search view */ - -/*body*/ -.catalog-search-body { - display: grid; - grid-template-columns: 100vw; - grid-template-rows: min-content 1fr; - grid-template-areas: - "nav-header" - "content"; - overflow: hidden; - height: 100vh; - padding: 0; - margin: 0; -} - -.catalog-search-body #Navbar { - grid-area: nav-header; - position: relative; - z-index: 100; -} - -.catalog-search-body #HeaderContainer, -.catalog-search-body #Navbar { - box-shadow: var(--cs-panel-shadow); -} - -.catalog-search-body .navbar-inner { - margin: 0; -} - -.catalog-search-body #HeaderContainer { - grid-area: nav-header; -} - -.catalog-search-body #Content { - grid-area: content; - padding: 0 !important; - margin: 0 !important; - height: 100%; -} - -.catalog-search-body #Footer { - display: none; - position: relative; -} -/* ------ LIST MODE ------ */ +/* ------ MAP HIDDEN (formerly "LIST MODE") ------ */ /* catalog is styled as map mode by default. Modifications needed for list-mode (map hidden) are below */ -.catalog-search-body.catalog--list-mode { +.catalog-search-body.catalog--map-hidden { overflow: scroll; height: auto; } -.catalog--list-mode #Footer { +.catalog--map-hidden #Footer { display: block; } -.catalog--list-mode .catalog__map { +.catalog--map-hidden .catalog__map { display: none; } -.catalog--list-mode .catalog { +.catalog--map-hidden .catalog { grid-template-columns: min-content auto; grid-template-areas: "filters results"; overflow: scroll; +} + +/* ------ FILTERS HIDDEN ------ */ +/* catalog is styled with filters hidden by default. Modifications needed for +filters visible are below */ + +.catalog--filters-hidden .catalog__filters { + display: none; +} + +.catalog--filters-hidden .catalog { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "results map"; +} + +/* ------ FILTERS HIDDEN, MAP HIDDEN ------ */ +/* when both filters and map are hidden, the results panel should take up the +entire width of the page */ + +.catalog--map-hidden.catalog--filters-hidden .catalog { + grid-template-columns: 1fr; + grid-template-areas: + "results"; } \ No newline at end of file diff --git a/src/js/templates/search/catalogSearch.html b/src/js/templates/search/catalogSearch.html index 67d5c7f51..ea4de5f67 100644 --- a/src/js/templates/search/catalogSearch.html +++ b/src/js/templates/search/catalogSearch.html @@ -1,10 +1,13 @@
    + +
    Hide Filters
    - + +
    Hide Map
    diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 757e9fa3e..6016b5d22 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -77,13 +77,20 @@ define([
    `, /** - * The search mode to use. This can be set to either `map` or `list`. List - * mode will hide all map features. - * @type string - * @since 2.22.0 - * @default "map" + * Whether the map is displayed or hidden. + * @type boolean + * @since x.x.x + * @default true + */ + mapVisible: true, + + /** + * Whether the filters are displayed or hidden. + * @type boolean + * @since x.x.x + * @default true */ - mode: "map", + filtersVisible: true, /** * Whether to limit the search to the extent of the map. If true, the @@ -192,6 +199,32 @@ define([ */ toggleMapButton: ".catalog__map-toggle", + /** + * The query selector for the label that is used to describe the + * {@link CatalogSearchView#toggleMapButton}. + * @type {string} + * @since x.x.x + * @default "#toggle-map-label" + */ + toggleMapLabel: "#toggle-map-label", + + /** + * The query selector for the button that is used to either show or hide + * the filters. + * @type {string} + * @since x.x.x + */ + toggleFiltersButton: ".catalog__filters-toggle", + + /** + * The query selector for the label that is used to describe the + * {@link CatalogSearchView#toggleFiltersButton}. + * @type {string} + * @since x.x.x + * @default "#toggle-map-label" + */ + toggleFiltersLabel: "#toggle-filters-label", + /** * The query selector for the button that is used to turn on or off * spatial filtering by map extent. @@ -202,19 +235,19 @@ define([ /** * The CSS class (not selector) to add to the body element when the map is - * visible. + * hidden. * @type {string} * @since x.x.x */ - mapModeClass: "catalog--map-mode", + hideMapClass: "catalog--map-hidden", /** - * The CSS class (not selector) to add to the body element when the map is - * hidden. + * The CSS class (not selector) to add to the body element when the + * filters are hidden. * @type {string} * @since x.x.x */ - listModeClass: "catalog--list-mode", + hideFiltersClass: "catalog--filters-hidden", /** * The events this view will listen to and the associated function to @@ -225,7 +258,8 @@ define([ events: function () { const e = {}; e[`click ${this.mapFilterToggle}`] = "toggleMapFilter"; - e[`click ${this.toggleMapButton}`] = "toggleMode"; + e[`click ${this.toggleMapButton}`] = "toggleMapVisibility"; + e[`click ${this.toggleFiltersButton}`] = "toggleFiltersVisibility"; return e; }, @@ -276,7 +310,7 @@ define([ */ render: function () { // Set the search mode - either map or list - this.setMode(); + this.setMapVisibility(); // Set up the view for styling and layout this.setupView(); @@ -296,27 +330,27 @@ define([ * Sets the search mode (map or list) * @since 2.22.0 */ - setMode: function () { + setMapVisibility: function () { try { - // Get the search mode - either "map" or "list" if ( - (typeof this.mode === "undefined" || !this.mode) && + typeof this.mapVisible === "undefined" && MetacatUI.appModel.get("enableCesium") ) { - this.mode = "map"; + this.mapVisible = true; } // Use map mode on tablets and browsers only. TODO: should we set a // listener for window resize? if ($(window).outerWidth() <= 600) { - this.mode = "list"; + this.mapVisible = false; } } catch (e) { console.error( "Error setting the search mode, defaulting to list:" + e ); - this.mode = "list"; + this.mapVisible = false; } + this.toggleMapVisibility(this.mapVisible); }, /** @@ -334,8 +368,6 @@ define([ // TODO: Set up styling for sub-view version of the catalog } - this.toggleMode(this.mode); - // Add LinkedData to the page this.addLinkedData(); @@ -566,7 +598,7 @@ define([ this.mapView = new MapView({ model: this.model.get("map") }); } catch (e) { console.error("Couldn't create map in search. ", e); - this.toggleMode("list"); + this.toggleMapVisibility(false); } }, @@ -581,7 +613,7 @@ define([ this.mapView.render(); } catch (e) { console.error("Couldn't render map in search. ", e); - this.toggleMode("list"); + this.toggleMapVisibility(false); } }, @@ -642,58 +674,130 @@ define([ }, /** - * Toggles between map and list search mode - * @param {string} newMode - Optionally provide the desired new mode to - * switch to. If not provided, the opposite of the current mode will be - * used. - * @since 2.22.0 + * Shows or hide the filters + * @param {boolean} show - Optionally provide the desired choice of + * whether the filters should be shown (true) or hidden (false). If not + * provided, the opposite of the current mode will be used. + * @since x.x.x */ - toggleMode: function (newMode) { + toggleFiltersVisibility: function (show) { try { const classList = document.querySelector("body").classList; // If the new mode is not provided, the new mode is the opposite of // the current mode - newMode = newMode != "map" && newMode != "list" ? null : newMode; - newMode = newMode || (this.mode == "map" ? "list" : "map"); - const mapClass = this.mapModeClass; - const listClass = this.listModeClass; - - if (newMode == "list") { - this.mode = "list"; - classList.remove(mapClass); - classList.add(listClass); + show = typeof show == "boolean" ? show : !this.filtersVisible; + const hideFiltersClass = this.hideFiltersClass; + + if (show) { + this.filtersVisible = true; + classList.remove(hideFiltersClass); } else { - this.mode = "map"; - classList.remove(listClass); - classList.add(mapClass); + this.filtersVisible = false; + classList.add(hideFiltersClass); } - this.updateToggleMapButton(); + this.updateToggleFiltersLabel(); + } catch (e) { + console.error("Couldn't toggle filter visibility. ", e); + } + }, + + /** + * Show or hide the map + * @param {boolean} show - Optionally provide the desired choice of + * whether the filters should be shown (true) or hidden (false). If not + * provided, the opposite of the current mode will be used. (Set to true + * to show map, false to hide it.) + * @since x.x.x + */ + toggleMapVisibility: function (show) { + try { + // If the new mode is not provided, the new mode is the opposite of + // the current mode + show = typeof show == "boolean" ? show : !this.mapVisible; + const classList = document.querySelector("body").classList; + const hideMapClass = this.hideMapClass; + + if (show) { + this.mapVisible = true; + classList.remove(hideMapClass); + } else { + this.mapVisible = false; + classList.add(hideMapClass); + } + this.updateToggleMapLabel(); } catch (e) { console.error("Couldn't toggle search mode. ", e); } }, /** - * Change the content of the map toggle button to indicate whether - * clicking it will show or hide the map. + * Change the content of the map toggle label to indicate whether + * clicking the button will show or hide the map. */ - updateToggleMapButton: function () { + updateToggleMapLabel: function () { try { - const mapToggle = this.el.querySelector(this.toggleMapButton); - if (!mapToggle) return; - if (this.mode == "map") { - mapToggle.innerHTML = - 'Hide Map '; + const toggleMapLabel = this.el.querySelector(this.toggleMapLabel); + const toggleMapButton = this.el.querySelector(this.toggleMapButton); + if (this.mapVisible) { + if (toggleMapLabel) { + toggleMapLabel.innerHTML = + 'Hide Map '; + } + if (toggleMapButton) { + toggleMapButton.innerHTML = + ''; + } } else { - mapToggle.innerHTML = - ' Show Map '; + if (toggleMapLabel) { + toggleMapLabel.innerHTML = + ' Show Map '; + } + if (toggleMapButton) { + toggleMapButton.innerHTML = ''; + } } } catch (e) { console.log("Couldn't update map toggle. ", e); } }, + /** + * Change the content of the filters toggle label to indicate whether + * clicking the button will show or hide the filters. + */ + updateToggleFiltersLabel: function () { + try { + const toggleFiltersLabel = this.el.querySelector( + this.toggleFiltersLabel + ); + const toggleFiltersButton = this.el.querySelector( + this.toggleFiltersButton + ); + if (this.filtersVisible) { + if (toggleFiltersLabel) { + toggleFiltersLabel.innerHTML = + 'Hide Filters '; + } + if (toggleFiltersButton) { + toggleFiltersButton.innerHTML = + ''; + } + } else { + if (toggleFiltersLabel) { + toggleFiltersLabel.innerHTML = + ' Show Filters '; + } + if (toggleFiltersButton) { + toggleFiltersButton.innerHTML = + ''; + } + } + } catch (e) { + console.log("Couldn't update filters toggle. ", e); + } + }, + /** * Toggles the map filter on and off * @param {boolean} newSetting - Optionally provide the desired new mode @@ -727,7 +831,7 @@ define([ MetacatUI.appModel.removeCSS(this.cssID); document .querySelector("body") - .classList.remove(this.bodyClass, `${this.mode}Mode`); + .classList.remove(this.bodyClass, this.hideMapClass); // Remove the JSON-LD from the page document.getElementById("jsonld")?.remove(); From aed57940b38887eed29b0370dbd87a4159564c15 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 5 May 2023 15:58:16 -0400 Subject: [PATCH 60/79] Standardize formatting in BooleanFilterView --- src/js/views/filters/BooleanFilterView.js | 140 +++++++++++----------- 1 file changed, 68 insertions(+), 72 deletions(-) diff --git a/src/js/views/filters/BooleanFilterView.js b/src/js/views/filters/BooleanFilterView.js index 7bf1e7d2b..800c4adfe 100644 --- a/src/js/views/filters/BooleanFilterView.js +++ b/src/js/views/filters/BooleanFilterView.js @@ -1,79 +1,75 @@ /*global define */ -define(['jquery', 'underscore', 'backbone', - 'models/filters/BooleanFilter', - 'views/filters/FilterView', - 'text!templates/filters/booleanFilter.html'], - function($, _, Backbone, BooleanFilter, FilterView, Template) { - 'use strict'; +define([ + "jquery", + "underscore", + "backbone", + "models/filters/BooleanFilter", + "views/filters/FilterView", + "text!templates/filters/booleanFilter.html", +], function ($, _, Backbone, BooleanFilter, FilterView, Template) { + "use strict"; /** - * @class BooleanFilterView - * @classdesc Render a view of a single BooleanFilter model - * @classcategory Views/Filters - */ + * @class BooleanFilterView + * @classdesc Render a view of a single BooleanFilter model + * @classcategory Views/Filters + */ var BooleanFilterView = FilterView.extend( - /** @lends BooleanFilterView.prototype */{ - - /** - * A BooleanFilter model to be rendered in this view - * @type {BooleanFilter} */ - model: null, - - className: "filter boolean", - - template: _.template(Template), - - events: { - "click input[type='checkbox']" : "updateModel" - }, - - initialize: function (options) { - - if( !options || typeof options != "object" ){ - var options = {}; - } - - this.model = options.model || new BooleanFilter(); - - }, - - render: function () { - this.$el.html( this.template( this.model.toJSON() ) ); - - this.listenTo( this.model, "change:values", this.updateCheckbox ); - }, - - /** - * Gets the value of the checkbox and updates the BooleanFilter model - */ - updateModel: function(){ - - //Find out if the checkbox has been checked or not - var isChecked = this.$("input[type='checkbox']").prop("checked"); - - //Set the boolean value on the model - this.model.set("values", [isChecked]); - - }, - - /** - * Updates the checked property of the checkbox based on the model value - */ - updateCheckbox: function(){ - - //Get the value from the model - var modelValue = this.model.get("values")[0]; - - //If the model value is falsey, then set to false - if( !modelValue ){ - modelValue = false; - } - - //Update the checkbox based on the model value - this.$("input[type='checkbox']").prop("checked", modelValue); - + /** @lends BooleanFilterView.prototype */ { + /** + * A BooleanFilter model to be rendered in this view + * @type {BooleanFilter} */ + model: null, + + className: "filter boolean", + + template: _.template(Template), + + events: { + "click input[type='checkbox']": "updateModel", + }, + + initialize: function (options) { + if (!options || typeof options != "object") { + var options = {}; + } + + this.model = options.model || new BooleanFilter(); + }, + + render: function () { + this.$el.html(this.template(this.model.toJSON())); + + this.listenTo(this.model, "change:values", this.updateCheckbox); + }, + + /** + * Gets the value of the checkbox and updates the BooleanFilter model + */ + updateModel: function () { + //Find out if the checkbox has been checked or not + var isChecked = this.$("input[type='checkbox']").prop("checked"); + + //Set the boolean value on the model + this.model.set("values", [isChecked]); + }, + + /** + * Updates the checked property of the checkbox based on the model value + */ + updateCheckbox: function () { + //Get the value from the model + var modelValue = this.model.get("values")[0]; + + //If the model value is falsey, then set to false + if (!modelValue) { + modelValue = false; + } + + //Update the checkbox based on the model value + this.$("input[type='checkbox']").prop("checked", modelValue); + }, } - - }); + ); return BooleanFilterView; }); From a797bb26c81f773010e5b5849109e04103e70e17 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 5 May 2023 16:33:46 -0400 Subject: [PATCH 61/79] Make filters optionally collapsible - Set them to collapsible for the new catalog view - Fix bugs with the toggleFilter and BooleanFilter - Update the default search filters Issues #1720, #1520 --- src/css/metacatui-common.css | 37 ++++++++- src/js/models/AppModel.js | 7 +- src/js/models/filters/SpatialFilter.js | 2 +- src/js/models/maps/assets/CesiumGeohash.js | 2 +- src/js/templates/filters/booleanFilter.html | 5 +- src/js/templates/filters/filterLabel.html | 3 + src/js/views/filters/BooleanFilterView.js | 27 +++--- src/js/views/filters/FilterGroupView.js | 20 ++++- src/js/views/filters/FilterGroupsView.js | 16 +++- src/js/views/filters/FilterView.js | 92 +++++++++++++++++++-- src/js/views/filters/ToggleFilterView.js | 12 +-- src/js/views/search/CatalogSearchView.js | 1 + 12 files changed, 186 insertions(+), 38 deletions(-) diff --git a/src/css/metacatui-common.css b/src/css/metacatui-common.css index 3f994e50c..38f58f5d5 100644 --- a/src/css/metacatui-common.css +++ b/src/css/metacatui-common.css @@ -6586,7 +6586,7 @@ body.mapMode{ .filter-groups.vertical .filters-header { margin: 0; border-bottom: 1px solid #dfdfdf; - padding: 1.2rem 0; + padding: 0.95rem 0; } .filter-groups.vertical .filter { padding-right: 0; @@ -6837,6 +6837,41 @@ body.mapMode{ width: 80%; } +/**** collapsible filter components ****/ + +/* the button to show/hide the filter */ +.collapse-toggle { + /* make a size variable */ + --size: 1.55rem; + display: flex; + margin-left: auto; + padding: 0; + height: var(--size); + width: var(--size); + border: none; + background: #ebeff3; + border-radius: 50%; + justify-content: center; + align-items: center; +} + +.collapse-toggle > .icon { + font-size: 1.3rem !important; + margin: 0 !important; + transition: transform 0.1s ease-in-out; + height: 100%; +} +.filter.collapsed { + padding-bottom: 0.4rem !important; +} +.filter.collapsed label ~ div { + display: none; +} +.filter.collapsed .collapse-toggle > .icon { + /* flip it around */ + transform: rotate(180deg); +} + /* Overrides for default Bootstrap styling for the searchable select component when we use it as a Portal data search filter. These overrides make diff --git a/src/js/models/AppModel.js b/src/js/models/AppModel.js index ba395a007..f3030899e 100644 --- a/src/js/models/AppModel.js +++ b/src/js/models/AppModel.js @@ -1691,9 +1691,10 @@ define(['jquery', 'underscore', 'backbone'], { filterType: "ToggleFilter", fields: ["documents"], - label: "Only results with data", - trueLabel: "True", - falseLabel: "False", + label: "Data Files", + placeholder: "Only results with data", + trueLabel: "Required", + falseLabel: null, trueValue: "*", matchSubstring: false, icon: "table", diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index aa5c58298..9ddd59bd0 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -52,7 +52,7 @@ define([ matchSubstring: false, // 1024 is the default limit in Solr for boolean clauses, limit even // more to allow for other filters - maxGeohashValues: 900, + maxGeohashValues: 500, }); }, diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 9d84ed973..56699d97d 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -91,7 +91,7 @@ define([ color: "#f3e227", }), showLabels: true, - maxGeoHashes: 3000, + maxGeoHashes: 900, }); }, diff --git a/src/js/templates/filters/booleanFilter.html b/src/js/templates/filters/booleanFilter.html index f11072be4..a240239ff 100644 --- a/src/js/templates/filters/booleanFilter.html +++ b/src/js/templates/filters/booleanFilter.html @@ -5,9 +5,6 @@ <% } else { %> <% } %> - <% if(icon){ - print(''); - } %> - <%=label%> + <%=placeholder || label%>
    diff --git a/src/js/templates/filters/filterLabel.html b/src/js/templates/filters/filterLabel.html index 09f9a5a61..8c1349b58 100644 --- a/src/js/templates/filters/filterLabel.html +++ b/src/js/templates/filters/filterLabel.html @@ -8,4 +8,7 @@ } else { print(label) } %> + <% if(collapsible){ + print(``); + } %> \ No newline at end of file diff --git a/src/js/views/filters/BooleanFilterView.js b/src/js/views/filters/BooleanFilterView.js index 800c4adfe..205b19b89 100644 --- a/src/js/views/filters/BooleanFilterView.js +++ b/src/js/views/filters/BooleanFilterView.js @@ -25,21 +25,24 @@ define([ template: _.template(Template), - events: { - "click input[type='checkbox']": "updateModel", - }, - - initialize: function (options) { - if (!options || typeof options != "object") { - var options = {}; + /** + * @inheritdoc + */ + events: function(){ + try { + const events = FilterView.prototype.events.call(this); + events["click input[type='checkbox']"] = "updateModel"; + return events + } + catch (e) { + console.log('Failed to create events for BooleanFilterView: ' + e); + return {}; } - - this.model = options.model || new BooleanFilter(); }, - render: function () { - this.$el.html(this.template(this.model.toJSON())); - + render: function (templateVars) { + FilterView.prototype.render.call(this, templateVars); + this.stopListening(this.model, "change:values"); this.listenTo(this.model, "change:values", this.updateCheckbox); }, diff --git a/src/js/views/filters/FilterGroupView.js b/src/js/views/filters/FilterGroupView.js index 1c9ca7f62..05b9ebccc 100644 --- a/src/js/views/filters/FilterGroupView.js +++ b/src/js/views/filters/FilterGroupView.js @@ -47,7 +47,16 @@ define(['jquery', 'underscore', 'backbone', * @type {boolean} * @since 2.17.0 */ - edit: false, + edit: false, + + /** + * If set to true, then all filters within this group will be collapsible. + * See {@link FilterView#collapsible} + * @type {boolean} + * @since x.x.x + * @default false + */ + collapsible: false, initialize: function (options) { @@ -65,6 +74,10 @@ define(['jquery', 'underscore', 'backbone', this.edit = true } + if (options.collapsible && typeof options.collapsible === "boolean") { + this.collapsible = options.collapsible; + } + }, render: function () { @@ -94,7 +107,8 @@ define(['jquery', 'underscore', 'backbone', var viewOptions = { model: filter, mode: filterMode, - editorView: this.editorView + editorView: this.editorView, + collapsible: this.collapsible } //Some filters are handled specially @@ -121,7 +135,7 @@ define(['jquery', 'underscore', 'backbone', break; case "BooleanFilter": // TODO: Set up "edit" and "uiBuilder" mode for BooleanFilters - var filterView = new BooleanFilterView({ model: filter }); + var filterView = new BooleanFilterView(viewOptions); break; case "ChoiceFilter": var filterView = new ChoiceFilterView(viewOptions); diff --git a/src/js/views/filters/FilterGroupsView.js b/src/js/views/filters/FilterGroupsView.js index e61a19e51..8e40ed76e 100644 --- a/src/js/views/filters/FilterGroupsView.js +++ b/src/js/views/filters/FilterGroupsView.js @@ -69,6 +69,15 @@ define(['jquery', 'underscore', 'backbone', * @since 2.17.0 */ edit: false, + + /** + * If set to true, then all filters within this group will be collapsible. + * See {@link FilterView#collapsible} + * @type {boolean} + * @since x.x.x + * @default false + */ + collapsible: false, /** * The initial query to use when the view is first rendered. This is a text value @@ -119,6 +128,10 @@ define(['jquery', 'underscore', 'backbone', this.initialQuery = options.initialQuery; } + if (options.collapsible && typeof options.collapsible === "boolean") { + this.collapsible = options.collapsible; + } + }, /** @@ -229,7 +242,8 @@ define(['jquery', 'underscore', 'backbone', var filterGroupView = new FilterGroupView({ model: filterGroup, edit: this.edit, - editorView: this.editorView + editorView: this.editorView, + collapsible: this.collapsible }); //Render the FilterGroupView diff --git a/src/js/views/filters/FilterView.js b/src/js/views/filters/FilterView.js index 5368ee697..3f64d7d59 100644 --- a/src/js/views/filters/FilterView.js +++ b/src/js/views/filters/FilterView.js @@ -64,7 +64,41 @@ define(['jquery', 'underscore', 'backbone', * @type {string} * @since 2.17.0 */ - uiBuilderClass: "ui-build", + uiBuilderClass: "ui-build", + + /** + * Whether the filter is collapsible. If true, the filter will have a button that + * toggles the collapsed state. + * @type {boolean} + * @since x.x.x + */ + collapsible: false, + + /** + * The class to add to the filter when it is collapsed. + * @type {string} + * @since x.x.x + * @default "collapsed" + */ + collapsedClass: "collapsed", + + /** + * The class used for the button that toggles the collapsed state of the filter. + * @type {string} + * @since x.x.x + * @default "collapse-toggle" + */ + collapseToggleClass: "collapse-toggle", + + /** + * The current state of the filter, if it is {@link FilterView#collapsible}. + * Whatever this value is set to at initialization, will be how the filter is + * initially rendered. + * @type {boolean} + * @since x.x.x + * @default true + */ + collapsed: true, /** * The class used for input elements where the user can change UI attributes when this @@ -86,20 +120,21 @@ define(['jquery', 'underscore', 'backbone', "click .btn": "handleChange", "keydown input": "handleTyping" } - events["change ." + this.uiInputClass] = "updateUIAttribute" + events["change ." + this.uiInputClass] = "updateUIAttribute"; + events[`click .${this.collapseToggleClass}`] = "toggleCollapse"; return events } catch (error) { console.log( 'There was an error setting the events object in a FilterView' + ' Error details: ' + error ); } - }, + }, /** * Function executed whenever a new FilterView is created. * @param {Object} [options] - A literal object of options to set on this View */ - initialize: function (options) { + initialize: function (options) { try { if (!options || typeof options != "object") { @@ -132,6 +167,10 @@ define(['jquery', 'underscore', 'backbone', this.model = options.model || new this.modelClass(); + if (options.collapsible && typeof options.collapsible === "boolean") { + this.collapsible = options.collapsible; + } + } catch (error) { @@ -155,8 +194,14 @@ define(['jquery', 'underscore', 'backbone', var templateVars = this.model.toJSON() } - // Pass the mode (e.g. "edit", "uiBuilder") to the template - templateVars = _.extend(templateVars, { mode: this.mode } ) + // Pass the mode (e.g. "edit", "uiBuilder") to the template, as well + // as the variables related to collapsibility. + const viewVars = { + mode: this.mode, + collapsible: this.collapsible, + collapseToggleClass: this.collapseToggleClass + } + templateVars = _.extend(templateVars, viewVars) // Render the filter HTML (without label or icon) this.$el.html( this.template( templateVars ) ); @@ -184,6 +229,11 @@ define(['jquery', 'underscore', 'backbone', if(["edit", "uiBuilder"].includes(this.mode)){ this.$el.find("input").addClass("ignore-changes") } + + // If the filter is collapsible, set the initial collapsed state + if(this.collapsible && typeof this.collapsed === "boolean"){ + this.toggleCollapse(this.collapsed) + } } catch (error) { @@ -371,7 +421,35 @@ define(['jquery', 'underscore', 'backbone', '. Error details: ' + error ); } - } + }, + + /** + * Toggle the collapsed state of the filter. If collapse is a boolean, then set the + * collapsed state to that value. Otherwise, set it to the opposite of whichever + * state is currently set. + * @param {boolean} [collapse] Whether to collapse the filter. If not provided, the + * filter will be collapsed if it is currently expanded, and vice versa. + * @since x.x.x + */ + toggleCollapse: function (collapse) { + try { + // If collapse is a boolean, then set the collapsed state to that value. + // Otherwise, set it to the opposite of whichever state is currently set. + if (typeof collapse !== "boolean") { + collapse = !this.collapsed + } + if (collapse) { + this.el.classList.add(this.collapsedClass) + this.collapsed = true + } else { + this.el.classList.remove(this.collapsedClass) + this.collapsed = false + } + } + catch (e) { + console.log("Could not un/collapse filter.", e); + } + }, diff --git a/src/js/views/filters/ToggleFilterView.js b/src/js/views/filters/ToggleFilterView.js index d92a55083..14c2a99c0 100644 --- a/src/js/views/filters/ToggleFilterView.js +++ b/src/js/views/filters/ToggleFilterView.js @@ -37,7 +37,8 @@ define(['jquery', 'underscore', 'backbone', events: function () { try { var events = FilterView.prototype.events.call(this); - events["change input.toggle-checkbox"] = "updateModel"; + events["click input[type='checkbox']"] = "updateModel"; + return events } catch (error) { @@ -49,13 +50,14 @@ define(['jquery', 'underscore', 'backbone', /** * @inheritdoc */ - render: function () { + render: function (templateVars = {}) { try { - var templateVars = this.model.toJSON(); + templateVars = _.extend(this.model.toJSON(), templateVars); templateVars.id = this.model.cid; - if( !this.model.get("falseLabel") ){ + if (!this.model.get("falseLabel")) { + console.log("No falseLabel set on this ToggleFilter model"); //If the value is the same as the trueValue, the checkbox should be checked templateVars.checked = (this.model.get("values")[0] == this.model.get("trueValue"))? true : false; @@ -170,7 +172,7 @@ define(['jquery', 'underscore', 'backbone', * The filter value is grabbed from the checkbox element in this view. * */ - updateModel: function(){ + updateModel: function () { //Check if the checkbox is checked var isChecked = this.$("input").prop("checked"); diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 6016b5d22..6c44bb086 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -434,6 +434,7 @@ define([ vertical: true, parentView: this, initialQuery: this.initialQuery, + collapsible: true, }); // Add the FilterGroupsView element to this view From e96d5461765ad32b534ce42f9410b73c39d24866 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 5 May 2023 18:11:39 -0400 Subject: [PATCH 62/79] Add py script for generating basic test files --- test/scripts/README.md | 24 +++++++++++ test/scripts/generate-tests.py | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 test/scripts/README.md create mode 100644 test/scripts/generate-tests.py diff --git a/test/scripts/README.md b/test/scripts/README.md new file mode 100644 index 000000000..df2a7d870 --- /dev/null +++ b/test/scripts/README.md @@ -0,0 +1,24 @@ +# Test Generator Script + +The `generate-tests.py` script helps create unit test starter files for MetacatUI. It generates test files with the basic structure and imports the necessary modules based on the file paths provided in the script. + +## How to use + +1. Update the `test_files` list in the `generate-tests.py` script with the relative paths of the files you want to create tests for (e.g., "collections/maps/Geohashes.js"). + +2. Run the script from the "test/scripts" directory: + +```bash +cd test/scripts +python3 generate-tests.py +``` + +3. The script will create test files in the "test/js/specs/unit" directory with the appropriate folder structure. It will also print the paths of the created test files, which you'll need to add to the `test/config/tests.json` file. + +4. Open the generated test files and add your test cases as needed. + +## Notes + +- The script assumes that the project repository is cloned to your local machine. +- It generates test files based on the relative paths provided in the `test_files` list. +- When a file name contains dashes, the corresponding module name will be in camel case (e.g., "Map-Search-Filters.js" -> "MapSearchFilters"). \ No newline at end of file diff --git a/test/scripts/generate-tests.py b/test/scripts/generate-tests.py new file mode 100644 index 000000000..3612949c5 --- /dev/null +++ b/test/scripts/generate-tests.py @@ -0,0 +1,73 @@ +import os +import re + +# Update these paths to those that you would like to create test files for. +test_files = [ + "collections/maps/Geohashes.js", + "models/connectors/Filters-Map.js", + "models/connectors/Filters-Search.js", + "models/connectors/Map-Search-Filters.js", + "models/connectors/Map-Search.js", + "models/filters/SpatialFilter.js", + "models/maps/Geohash.js", + "models/maps/assets/CesiumGeohash.js", +] + +test_template = """ +define([ + "{import_path}", +], function ({module_name}) {{ + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("{module_name} Test Suite", function () {{ + /* Set up */ + beforeEach(function () {{}}); + + /* Tear down */ + afterEach(function () {{}}); + + describe("Initialization", function () {{ + it("should create a {module_name} instance", function () {{ + new {module_name}().should.be.instanceof({module_name}); + }}); + }}); + }}); +}}); +""" + +def camel_case(name): + return "".join(word.capitalize() for word in name.split("-")) + +script_dir = os.path.dirname(os.path.realpath(__file__)) +project_root = os.path.join(script_dir, '..', '..') + +created_paths = [] + +for test_file in test_files: + test_path = os.path.join(project_root, 'test', 'js', 'specs', 'unit', test_file.replace(".js", ".spec.js")) + dir_path = os.path.dirname(test_path) + + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + module_path = os.path.join("src", "js", test_file) + module_name = camel_case(test_file.split("/")[-1].replace(".js", "")) + + relative_module_path = "../../../../../../../../" + module_path + relative_module_path = re.sub(r"\.js$", "", relative_module_path) + + with open(test_path, "w") as f: + content = test_template.format( + import_path=relative_module_path, module_name=module_name + ) + f.write(content.strip()) + + created_paths.append(f'./{os.path.relpath(test_path, project_root)}') + +print("Unit test starter files created.") +print("\nRemember to add these paths to the `test/config/tests.json` file! 🚀🎉") +for path in created_paths: + path = re.sub(r"^\./test", ".", path) + print(f'"{path}",') From 658e0ddba80da1c016b849e86745cb7cf105655a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 5 May 2023 18:13:00 -0400 Subject: [PATCH 63/79] Add initial test files for new catalog view files Issue #2120 --- test/README.md | 2 + test/config/tests.json | 14 +++++-- .../unit/collections/maps/Geohashes.spec.js | 21 ++++++++++ .../models/connectors/Filters-Map.spec.js | 21 ++++++++++ .../models/connectors/Filters-Search.spec.js | 21 ++++++++++ .../connectors/Map-Search-Filters.spec.js | 21 ++++++++++ .../unit/models/connectors/Map-Search.spec.js | 21 ++++++++++ .../unit/models/filters/SpatialFilter.spec.js | 21 ++++++++++ .../js/specs/unit/models/maps/Geohash.spec.js | 21 ++++++++++ .../unit/models/maps/assets/CesiumGeohash.js | 40 ------------------- .../models/maps/assets/CesiumGeohash.spec.js | 21 ++++++++++ 11 files changed, 180 insertions(+), 44 deletions(-) create mode 100644 test/js/specs/unit/collections/maps/Geohashes.spec.js create mode 100644 test/js/specs/unit/models/connectors/Filters-Map.spec.js create mode 100644 test/js/specs/unit/models/connectors/Filters-Search.spec.js create mode 100644 test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js create mode 100644 test/js/specs/unit/models/connectors/Map-Search.spec.js create mode 100644 test/js/specs/unit/models/filters/SpatialFilter.spec.js create mode 100644 test/js/specs/unit/models/maps/Geohash.spec.js delete mode 100644 test/js/specs/unit/models/maps/assets/CesiumGeohash.js create mode 100644 test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js diff --git a/test/README.md b/test/README.md index c875dc3b1..c49c67fd2 100644 --- a/test/README.md +++ b/test/README.md @@ -38,6 +38,8 @@ Adding new tests ---------------- The test suite to be run is defined in `test/config/tests.json`. Paths to new spec test files are added to the `unit` or `integration` array. Tests are run in order, so add the new test in the position that makes most sense. +⭐️⭐️⭐️ 🆕 **The script `test/scripts/generate-tests.py` can be used to generate new test files with the basic elements set up to start creating unit tests. See the [README](scripts/README.md) for more information.** ⭐️⭐️⭐️ + Running tests ------------- diff --git a/test/config/tests.json b/test/config/tests.json index d15c872ff..e66646549 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -1,5 +1,14 @@ { "unit": [ + "./js/specs/unit/collections/maps/Geohashes.spec.js", + "./js/specs/unit/models/connectors/Filters-Map.spec.js", + "./js/specs/unit/models/connectors/Filters-Search.spec.js", + "./js/specs/unit/models/connectors/Map-Search-Filters.spec.js", + "./js/specs/unit/models/connectors/Map-Search.spec.js", + "./js/specs/unit/models/filters/SpatialFilter.spec.js", + "./js/specs/unit/models/maps/Geohash.spec.js", + "./js/specs/unit/models/maps/assets/CesiumGeohash.spec.js", + "./js/specs/unit/collections/SolrResults.js", "./js/specs/unit/models/Search.js", "./js/specs/unit/models/filters/Filter.js", @@ -18,11 +27,8 @@ "./js/specs/unit/models/metadata/eml211/EMLOtherEntity.spec.js", "./js/specs/unit/models/metadata/eml211/EMLParty.spec.js", "./js/specs/unit/models/metadata/eml211/EMLTemporalCoverage.spec.js", - "./js/specs/unit/models/maps/assets/CesiumGeohash.js", "./js/specs/unit/models/maps/assets/CesiumImagery.js", "./js/specs/unit/common/Utilities.spec.js" ], - "integration": [ - "./js/specs/integration/collections/SolrResults.js" - ] + "integration": ["./js/specs/integration/collections/SolrResults.js"] } diff --git a/test/js/specs/unit/collections/maps/Geohashes.spec.js b/test/js/specs/unit/collections/maps/Geohashes.spec.js new file mode 100644 index 000000000..920330765 --- /dev/null +++ b/test/js/specs/unit/collections/maps/Geohashes.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/collections/maps/Geohashes", +], function (Geohashes) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("Geohashes Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a Geohashes instance", function () { + new Geohashes().should.be.instanceof(Geohashes); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/Filters-Map.spec.js b/test/js/specs/unit/models/connectors/Filters-Map.spec.js new file mode 100644 index 000000000..dea6cf322 --- /dev/null +++ b/test/js/specs/unit/models/connectors/Filters-Map.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/Filters-Map", +], function (FiltersMap) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("FiltersMap Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a FiltersMap instance", function () { + new FiltersMap().should.be.instanceof(FiltersMap); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/Filters-Search.spec.js b/test/js/specs/unit/models/connectors/Filters-Search.spec.js new file mode 100644 index 000000000..44f69b677 --- /dev/null +++ b/test/js/specs/unit/models/connectors/Filters-Search.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/Filters-Search", +], function (FiltersSearch) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("FiltersSearch Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a FiltersSearch instance", function () { + new FiltersSearch().should.be.instanceof(FiltersSearch); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js b/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js new file mode 100644 index 000000000..49bce6472 --- /dev/null +++ b/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/Map-Search-Filters", +], function (MapSearchFilters) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("MapSearchFilters Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a MapSearchFilters instance", function () { + new MapSearchFilters().should.be.instanceof(MapSearchFilters); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/Map-Search.spec.js b/test/js/specs/unit/models/connectors/Map-Search.spec.js new file mode 100644 index 000000000..e4e348fd9 --- /dev/null +++ b/test/js/specs/unit/models/connectors/Map-Search.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/Map-Search", +], function (MapSearch) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("MapSearch Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a MapSearch instance", function () { + new MapSearch().should.be.instanceof(MapSearch); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/filters/SpatialFilter.spec.js b/test/js/specs/unit/models/filters/SpatialFilter.spec.js new file mode 100644 index 000000000..566c8dbf4 --- /dev/null +++ b/test/js/specs/unit/models/filters/SpatialFilter.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/filters/SpatialFilter", +], function (Spatialfilter) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("Spatialfilter Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a Spatialfilter instance", function () { + new Spatialfilter().should.be.instanceof(Spatialfilter); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/Geohash.spec.js b/test/js/specs/unit/models/maps/Geohash.spec.js new file mode 100644 index 000000000..be3cee5d4 --- /dev/null +++ b/test/js/specs/unit/models/maps/Geohash.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/maps/Geohash", +], function (Geohash) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("Geohash Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a Geohash instance", function () { + new Geohash().should.be.instanceof(Geohash); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/assets/CesiumGeohash.js b/test/js/specs/unit/models/maps/assets/CesiumGeohash.js deleted file mode 100644 index 3586dcde7..000000000 --- a/test/js/specs/unit/models/maps/assets/CesiumGeohash.js +++ /dev/null @@ -1,40 +0,0 @@ -define([ - "../../../../../../../../../src/js/models/maps/assets/CesiumGeohash", - "../../../../../../../../../src/components/cesium/Cesium" - ], function (CesiumGeohash, Cesium) { - - // Configure the Chai assertion library - var should = chai.should(); - var expect = chai.expect; - - describe("CesiumGeohash Test Suite", function () { - /* Set up */ - beforeEach(function () { - - }) - - /* Tear down */ - afterEach(function () { - - }) - - describe("Initialization", function () { - it("should create a CesiumGeohash model", function () { - (new CesiumGeohash()).should.be.instanceof(CesiumGeohash) - }); - }); - - describe("Working with Geohashes", function () { - - it("gets the geohash level", function () { - - let g = new CesiumGeohash(); - g.setGeohashLevel(20000); - g.get("geohashLevel").should.eql(5); - - }) - - }); - - }); - }); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js b/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js new file mode 100644 index 000000000..a737c3dbc --- /dev/null +++ b/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/maps/assets/CesiumGeohash", +], function (Cesiumgeohash) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("Cesiumgeohash Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a Cesiumgeohash instance", function () { + new Cesiumgeohash().should.be.instanceof(Cesiumgeohash); + }); + }); + }); +}); \ No newline at end of file From 962be159b3d200fc24c4159c73878b3b57283282 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 5 May 2023 18:36:17 -0400 Subject: [PATCH 64/79] tiny fixes --- src/js/collections/Filters.js | 8 ++++++++ src/js/models/connectors/Map-Search.js | 6 ++++++ src/js/views/filters/ToggleFilterView.js | 1 - 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/js/collections/Filters.js b/src/js/collections/Filters.js index 90a070cd2..e066095ac 100644 --- a/src/js/collections/Filters.js +++ b/src/js/collections/Filters.js @@ -478,6 +478,14 @@ define([ values: ["METADATA"], matchSubstring: false, }, + // If we need to exclude portals and collections, use this filter: + // { + // fields: ["formatId"], + // values: ["dataone.org/collections", "dataone.org/portals"], + // exclude: true, + // matchSubstring: true, + // operator: "OR", + // } ]); var query = catalogFilters.getGroupQuery(catalogFilters.models, "AND"); return query; diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index ad1bfcf13..cbdc9e841 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -51,6 +51,12 @@ define([ * added. A geohash layer is required for this connector to work. */ initialize: function (attrs, options) { + if (!this.map || !options.map) { + this.set("map", new Map()); + } + if (!this.searchResults || !options.searchResults) { + this.set("searchResults", new SearchResults()); + } const add = options?.addGeohashLayer ?? true; this.findAndSetGeohashLayer(add); }, diff --git a/src/js/views/filters/ToggleFilterView.js b/src/js/views/filters/ToggleFilterView.js index 14c2a99c0..4bd686438 100644 --- a/src/js/views/filters/ToggleFilterView.js +++ b/src/js/views/filters/ToggleFilterView.js @@ -57,7 +57,6 @@ define(['jquery', 'underscore', 'backbone', templateVars.id = this.model.cid; if (!this.model.get("falseLabel")) { - console.log("No falseLabel set on this ToggleFilter model"); //If the value is the same as the trueValue, the checkbox should be checked templateVars.checked = (this.model.get("values")[0] == this.model.get("trueValue"))? true : false; From 5ba933698782ae2e2cec7f995fca042128fea72b Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 5 May 2023 19:00:00 -0400 Subject: [PATCH 65/79] one more tiny fix --- src/js/models/connectors/Map-Search.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index cbdc9e841..8fba69d54 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -3,8 +3,7 @@ define([ "backbone", "models/maps/Map", "collections/SolrResults", - "models/maps/assets/CesiumGeohash", -], function (Backbone, Map, SearchResults, Geohash) { +], function (Backbone, Map, SearchResults) { "use strict"; /** @@ -51,10 +50,10 @@ define([ * added. A geohash layer is required for this connector to work. */ initialize: function (attrs, options) { - if (!this.map || !options.map) { + if (!this.get("map")) { this.set("map", new Map()); } - if (!this.searchResults || !options.searchResults) { + if (!this.get("searchResults")) { this.set("searchResults", new SearchResults()); } const add = options?.addGeohashLayer ?? true; From e5fe3219ef8256c1ced316d9d0f89157a688fcb0 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 22 May 2023 09:47:41 -0400 Subject: [PATCH 66/79] Standardize test filenames --- test/config/tests.json | 14 +++++++------- .../{SolrResults.js => SolrResults.spec.js} | 0 .../{SolrResults.js => SolrResults.spec.js} | 0 .../{CitationModel.js => CitationModel.spec.js} | 0 .../unit/models/{Search.js => Search.spec.js} | 0 .../models/filters/{Filter.js => Filter.spec.js} | 0 .../{NumericFilter.js => NumericFilter.spec.js} | 0 .../{CesiumImagery.js => CesiumImagery.spec.js} | 0 8 files changed, 7 insertions(+), 7 deletions(-) rename test/js/specs/integration/collections/{SolrResults.js => SolrResults.spec.js} (100%) rename test/js/specs/unit/collections/{SolrResults.js => SolrResults.spec.js} (100%) rename test/js/specs/unit/models/{CitationModel.js => CitationModel.spec.js} (100%) rename test/js/specs/unit/models/{Search.js => Search.spec.js} (100%) rename test/js/specs/unit/models/filters/{Filter.js => Filter.spec.js} (100%) rename test/js/specs/unit/models/filters/{NumericFilter.js => NumericFilter.spec.js} (100%) rename test/js/specs/unit/models/maps/assets/{CesiumImagery.js => CesiumImagery.spec.js} (100%) diff --git a/test/config/tests.json b/test/config/tests.json index e66646549..2697ad6b0 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -9,11 +9,11 @@ "./js/specs/unit/models/maps/Geohash.spec.js", "./js/specs/unit/models/maps/assets/CesiumGeohash.spec.js", - "./js/specs/unit/collections/SolrResults.js", - "./js/specs/unit/models/Search.js", - "./js/specs/unit/models/filters/Filter.js", - "./js/specs/unit/models/filters/NumericFilter.js", - "./js/specs/unit/models/CitationModel.js", + "./js/specs/unit/collections/SolrResults.spec.js", + "./js/specs/unit/models/Search.spec.js", + "./js/specs/unit/models/filters/Filter.spec.js", + "./js/specs/unit/models/filters/NumericFilter.spec.js", + "./js/specs/unit/models/CitationModel.spec.js", "./js/specs/unit/collections/ProjectList.spec.js", "./js/specs/unit/models/project/Project.spec.js", "./js/specs/unit/models/metadata/eml211/EML211.spec.js", @@ -27,8 +27,8 @@ "./js/specs/unit/models/metadata/eml211/EMLOtherEntity.spec.js", "./js/specs/unit/models/metadata/eml211/EMLParty.spec.js", "./js/specs/unit/models/metadata/eml211/EMLTemporalCoverage.spec.js", - "./js/specs/unit/models/maps/assets/CesiumImagery.js", + "./js/specs/unit/models/maps/assets/CesiumImagery.spec.js", "./js/specs/unit/common/Utilities.spec.js" ], - "integration": ["./js/specs/integration/collections/SolrResults.js"] + "integration": ["./js/specs/integration/collections/SolrResults.spec.js"] } diff --git a/test/js/specs/integration/collections/SolrResults.js b/test/js/specs/integration/collections/SolrResults.spec.js similarity index 100% rename from test/js/specs/integration/collections/SolrResults.js rename to test/js/specs/integration/collections/SolrResults.spec.js diff --git a/test/js/specs/unit/collections/SolrResults.js b/test/js/specs/unit/collections/SolrResults.spec.js similarity index 100% rename from test/js/specs/unit/collections/SolrResults.js rename to test/js/specs/unit/collections/SolrResults.spec.js diff --git a/test/js/specs/unit/models/CitationModel.js b/test/js/specs/unit/models/CitationModel.spec.js similarity index 100% rename from test/js/specs/unit/models/CitationModel.js rename to test/js/specs/unit/models/CitationModel.spec.js diff --git a/test/js/specs/unit/models/Search.js b/test/js/specs/unit/models/Search.spec.js similarity index 100% rename from test/js/specs/unit/models/Search.js rename to test/js/specs/unit/models/Search.spec.js diff --git a/test/js/specs/unit/models/filters/Filter.js b/test/js/specs/unit/models/filters/Filter.spec.js similarity index 100% rename from test/js/specs/unit/models/filters/Filter.js rename to test/js/specs/unit/models/filters/Filter.spec.js diff --git a/test/js/specs/unit/models/filters/NumericFilter.js b/test/js/specs/unit/models/filters/NumericFilter.spec.js similarity index 100% rename from test/js/specs/unit/models/filters/NumericFilter.js rename to test/js/specs/unit/models/filters/NumericFilter.spec.js diff --git a/test/js/specs/unit/models/maps/assets/CesiumImagery.js b/test/js/specs/unit/models/maps/assets/CesiumImagery.spec.js similarity index 100% rename from test/js/specs/unit/models/maps/assets/CesiumImagery.js rename to test/js/specs/unit/models/maps/assets/CesiumImagery.spec.js From 53c5e647764c9784470e1a1a34219ea7578a35aa Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 22 May 2023 13:26:07 -0400 Subject: [PATCH 67/79] Add tests for Geohashes collections Plus minor modifications/fixes to Geohashes Issue #1720 --- src/js/collections/maps/Geohashes.js | 8 +- src/js/models/maps/assets/CesiumGeohash.js | 2 +- .../unit/collections/maps/Geohashes.spec.js | 212 +++++++++++++++++- 3 files changed, 210 insertions(+), 12 deletions(-) diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index 9e1c9c22b..ed9e9b32a 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -73,7 +73,9 @@ define([ * levels, corrected if needed and if fix is true. */ validatePrecision: function (p, fix = true) { - if (Array.isArray(p)) p.map((pr) => this.validatePrecision(pr, fix)); + if (Array.isArray(p)) { + return p.map((p) => this.validatePrecision(p, fix)); + } const min = this.MIN_PRECISION; const max = this.MAX_PRECISION; const isValid = typeof p === "number" && p >= min && p <= max; @@ -775,14 +777,14 @@ define([ }, /** - * Find the parent geohash from this collection that contains the provided + * Find the geohash from this collection that contains the provided * geohash hashString. If the hashString is already in the collection, * return that geohash. Otherwise, find the geohash that contains the * hashString. * @param {string} hashString - Geohash hashString. * @returns {Geohash} Parent geohash. */ - findParentByHashString: function (hashString) { + getContainingGeohash: function (hashString) { if (!hashString || hashString.length === 0) return null; // First check if the hashString is already in the collection let geohash = this.findWhere({ hashString: hashString }); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 56699d97d..a6984ed69 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -283,7 +283,7 @@ define([ */ selectGeohashes: function (geohashes) { const toSelect = [...new Set(geohashes.map((geohash) => { - const parent = this.get("geohashes").findParentByHashString(geohash); + const parent = this.get("geohashes").getContainingGeohash(geohash); return parent?.get("hashString"); }, this))]; const entities = this.get("cesiumModel").entities.values; diff --git a/test/js/specs/unit/collections/maps/Geohashes.spec.js b/test/js/specs/unit/collections/maps/Geohashes.spec.js index 920330765..e711ed40b 100644 --- a/test/js/specs/unit/collections/maps/Geohashes.spec.js +++ b/test/js/specs/unit/collections/maps/Geohashes.spec.js @@ -1,21 +1,217 @@ -define([ - "../../../../../../../../src/js/collections/maps/Geohashes", -], function (Geohashes) { +define(["../../../../../../../../src/js/collections/maps/Geohashes"], function ( + Geohashes +) { // Configure the Chai assertion library - var should = chai.should(); - var expect = chai.expect; + const should = chai.should(); + const expect = chai.expect; describe("Geohashes Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.precision_min = 1; + this.precision_max = 12; + this.geohashes = new Geohashes([], { + minPrecision: this.precision_min, + maxPrecision: this.precision_max, + }); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.precision_min = null; + this.precision_max = null; + this.geohashes = null; + }); describe("Initialization", function () { it("should create a Geohashes instance", function () { new Geohashes().should.be.instanceof(Geohashes); }); + + it("should set the min and max precision levels", function () { + this.geohashes.MIN_PRECISION.should.equal(this.precision_min); + this.geohashes.MAX_PRECISION.should.equal(this.precision_max); + }); + }); + + describe("Validation", function () { + it("should validate a valid precision", function () { + this.geohashes.validatePrecision(5).should.equal(5); + }); + + it("should fix a precision that is too high", function () { + this.geohashes.validatePrecision(13).should.equal(12); + }); + + it("should fix a precision that is too low", function () { + this.geohashes.validatePrecision(-1).should.equal(1); + }); + + it("should throw an error for an invalid precision if fix is false", function () { + expect(() => { + this.geohashes.validatePrecision(-1, false); + }).to.throw(); + }); + + it("should handle precision arrays", function () { + this.geohashes + .validatePrecision([1, 2, 3]) + .should.deep.equal([1, 2, 3]); + }); + + it("should validate a valid bounding box", function () { + const bounds = { north: 80, south: -80, east: 170, west: 160 }; + this.geohashes.boundsAreValid(bounds).should.be.true; + }); + + it("should invalidate a bounding box with invalid bounds", function () { + const bounds = { north: 80, south: -80, east: 170, west: 190 }; + this.geohashes.boundsAreValid(bounds).should.be.false; + }); + + it("should invalidate a bounding box with missing bounds", function () { + const bounds = { north: 80, south: -80, east: 170 }; + this.geohashes.boundsAreValid(bounds).should.be.false; + }); + + it("should invalidate a bounding box with non-number bounds", function () { + const bounds = { north: 80, south: -80, east: 170, west: "west" }; + this.geohashes.boundsAreValid(bounds).should.be.false; + }); + }); + + describe("Bounds", function () { + it("should split a bounding box that crosses the prime meridian", function () { + const bounds = { north: 80, south: -80, east: -170, west: 170 }; + const expected = [ + { north: 80, south: -80, east: 180, west: 170 }, + { north: 80, south: -80, east: -170, west: -180 }, + ]; + this.geohashes.splitBoundingBox(bounds).should.deep.equal(expected); + }); + + it("should not split a bounding box that does not cross the prime meridian", function () { + const bounds = { north: 80, south: -80, east: 170, west: 160 }; + const expected = [{ north: 80, south: -80, east: 170, west: 160 }]; + this.geohashes.splitBoundingBox(bounds).should.deep.equal(expected); + }); + + it("should get the area of a geohash tile", function () { + const precision = 5; + const expected = 0.0019311904907226562; + this.geohashes.getGeohashArea(precision).should.equal(expected); + }); + + it("should get the area of a geohash tile for a range of precisions", function () { + const minPrecision = 1; + const maxPrecision = 3; + const area1 = (180 * 360) / 32; + const area2 = area1 / 32; + const area3 = area2 / 32; + const expected = { + 1: area1, + 2: area2, + 3: area3, + }; + this.geohashes + .getGeohashAreas(minPrecision, maxPrecision) + .should.deep.equal(expected); + }); + + it("should get the area of the world", function () { + const bounds = { north: 90, south: -90, east: 180, west: -180 }; + const expected = 360 * 180; + this.geohashes.getBoundingBoxArea(bounds).should.equal(expected); + }); + + it("should get the area of a small bounding box", function () { + const bounds = { north: 45, south: 44, east: 45, west: 44 }; + const expected = 1; + this.geohashes.getBoundingBoxArea(bounds).should.equal(expected); + }); + }); + + describe("Precision", function () { + it("should get the max precision for a small area", function () { + const area = 1; + const maxGeohashes = Infinity; + const expected = 12; + this.geohashes + .getMaxPrecision(area, maxGeohashes) + .should.equal(expected); + }); + + it("should get the max precision for a large area", function () { + const area = 360 * 180; + const maxGeohashes = 32 * 32; + const expected = 2; + this.geohashes + .getMaxPrecision(area, maxGeohashes) + .should.equal(expected); + }); + + it("should get the min precision for a small area", function () { + const area = 1; + const expected = 3; + this.geohashes.getMinPrecision(area).should.equal(expected); + }); + + it("should get the min precision for a large area", function () { + const area = 360 * 180; + const expected = 1; + this.geohashes.getMinPrecision(area).should.equal(expected); + }); + + it("should get the unique precision levels in the collection", function () { + const geohashes = new Geohashes([ + { hashString: "a" }, + { hashString: "ab" }, + { hashString: "abc" }, + ]); + const expected = [1, 2, 3]; + geohashes.getPrecisions().should.deep.equal(expected); + }); + }); + + describe("Geohash Generation & Retrieval", function () { + it("should return the geohashes as a GeoJSON FeatureCollection", function () { + const geohashes = new Geohashes([{ hashString: "gw" }]); + const expected = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [-22.5, 78.75], + [-11.25, 78.75], + [-11.25, 84.375], + [-22.5, 84.375], + [-22.5, 78.75], + ], + ], + }, + properties: { + hashString: "gw", + }, + }, + ], + }; + geohashes.toGeoJSON().should.deep.equal(expected); + }); + it("should find a geohash that contains the provided hashString", function () { + const geohashes = new Geohashes([{ hashString: "gw" }]); + const expected = geohashes.at(0); + geohashes.getContainingGeohash("gwa").should.deep.equal(expected); + }); + + it("should find a geohash that is the provided hashString", function () { + const geohashes = new Geohashes([{ hashString: "gw" }]); + const expected = geohashes.at(0); + geohashes.getContainingGeohash("gw").should.deep.equal(expected); + }); }); }); -}); \ No newline at end of file +}); From 5b672044d821e4d2efe5bc3da2b88bfc122a5a0f Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 22 May 2023 13:57:49 -0400 Subject: [PATCH 68/79] Add tests for Filters-Map connector Fix the remove spatial filters method Issue #1720 --- src/js/models/connectors/Filters-Map.js | 6 +- .../models/connectors/Filters-Map.spec.js | 70 ++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index 8b98a3469..995da7710 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -133,9 +133,13 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( removeSpatialFilter: function () { const spatialFilters = this.get("spatialFilters"); if (spatialFilters?.length) { + this.stopListening( + this.get("filters"), + "add remove", + this.findAndSetSpatialFilters + ); spatialFilters.forEach((filter) => { filter.collection.remove(filter); - filter.destroy(); }); } this.set("spatialFilters", []); diff --git a/test/js/specs/unit/models/connectors/Filters-Map.spec.js b/test/js/specs/unit/models/connectors/Filters-Map.spec.js index dea6cf322..bd14a86dc 100644 --- a/test/js/specs/unit/models/connectors/Filters-Map.spec.js +++ b/test/js/specs/unit/models/connectors/Filters-Map.spec.js @@ -7,15 +7,79 @@ define([ describe("FiltersMap Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.filtersMap = new FiltersMap(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.filtersMap = null; + }); describe("Initialization", function () { it("should create a FiltersMap instance", function () { new FiltersMap().should.be.instanceof(FiltersMap); }); + + it("should add a spatial filter to the Filters collection", function () { + const filters = this.filtersMap.get("filters"); + filters.length.should.equal(1); + filters.at(0).get("filterType").should.equal("SpatialFilter"); + }); + + it("should set the spatialFilters on the model", function () { + const spatialFilters = this.filtersMap.get("spatialFilters"); + spatialFilters.length.should.equal(1); + spatialFilters[0].get("filterType").should.equal("SpatialFilter"); + }); + }); + + describe("Spatial Filter", function () { + it("should remove the spatial filter from the Filters collection", function () { + this.filtersMap.removeSpatialFilter(); + const filters = this.filtersMap.get("filters"); + filters.length.should.equal(0); + }); + + it("should reset the spatial filter values to their defaults", function () { + const spatialFilters = this.filtersMap.get("spatialFilters"); + spatialFilters[0].set("values", ["test"]); + this.filtersMap.resetSpatialFilter(); + spatialFilters[0].get("values").should.deep.equal([]); + }); + + it("should update the spatial filter extent", function () { + const map = this.filtersMap.get("map"); + const spatialFilters = this.filtersMap.get("spatialFilters"); + const extent = { north: 1, south: 2, east: 3, west: 4 }; + map.set("currentViewExtent", extent); + this.filtersMap.updateSpatialFilters(); + spatialFilters[0].get("north").should.equal(1); + spatialFilters[0].get("south").should.equal(2); + spatialFilters[0].get("east").should.equal(3); + spatialFilters[0].get("west").should.equal(4); + }); + }); + + describe("Connect/Disconnect", function () { + it("should connect to the map", function () { + this.filtersMap.connect(); + this.filtersMap.get("isConnected").should.equal(true); + }); + + it("should disconnect from the map", function () { + this.filtersMap.connect(); + this.filtersMap.disconnect(); + this.filtersMap.get("isConnected").should.equal(false); + }); + + it("should disconnect from the map and reset the spatial filter", function () { + this.filtersMap.connect(); + this.filtersMap.disconnect(true); + this.filtersMap.get("isConnected").should.equal(false); + const spatialFilters = this.filtersMap.get("spatialFilters"); + spatialFilters[0].get("values").should.deep.equal([]); + }); }); }); -}); \ No newline at end of file +}); From a7a9f123f3e23206b57464b68bb9f23a901c1932 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 24 May 2023 14:23:28 -0400 Subject: [PATCH 69/79] Add rest of tests for Geohash-related map models - Add types to models - Minor fixes found during testing Issue #1720 --- src/js/collections/Filters.js | 9 ++ src/js/collections/SolrResults.js | 8 ++ src/js/models/connectors/Filters-Map.js | 7 + src/js/models/connectors/Filters-Search.js | 46 ++----- src/js/models/connectors/Map-Search.js | 9 ++ src/js/models/maps/Map.js | 8 ++ src/js/models/maps/assets/CesiumGeohash.js | 8 +- src/js/models/maps/assets/CesiumVectorData.js | 7 +- test/config/tests.json | 17 ++- .../models/connectors/Filters-Search.spec.js | 29 +++- .../connectors/Map-Search-Filters.spec.js | 69 +++++++++- .../unit/models/connectors/Map-Search.spec.js | 74 +++++++++- .../js/specs/unit/models/maps/Geohash.spec.js | 128 +++++++++++++++++- .../models/maps/assets/CesiumGeohash.spec.js | 103 +++++++++++++- 14 files changed, 441 insertions(+), 81 deletions(-) diff --git a/src/js/collections/Filters.js b/src/js/collections/Filters.js index e066095ac..4bfc983d2 100644 --- a/src/js/collections/Filters.js +++ b/src/js/collections/Filters.js @@ -33,6 +33,15 @@ define([ */ var Filters = Backbone.Collection.extend( /** @lends Filters.prototype */ { + + /** + * The name of this type of collection + * @type {string} + * @since x.x.x + * @default "Filters" + */ + type: "Filters", + /** * If the search results must always match one of the ids in the id * filters, then the id filters will be added to the query with an AND diff --git a/src/js/collections/SolrResults.js b/src/js/collections/SolrResults.js index 6dd78be57..1851bfeb2 100644 --- a/src/js/collections/SolrResults.js +++ b/src/js/collections/SolrResults.js @@ -15,6 +15,14 @@ define(['jquery', 'underscore', 'backbone', 'models/SolrHeader', 'models/SolrRes // Reference to this collection's model. model: SolrResult, + + /** + * The name of this type of collection. + * @type {string} + * @default "SolrResults" + * @since x.x.x + */ + type: "SolrResults", initialize: function(models, options) { diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index 995da7710..5f17652f8 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -22,6 +22,13 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( */ return Backbone.Model.extend( /** @lends FiltersMapConnector.prototype */ { + /** + * The type of Backbone.Model this is. + * @type {string} + * @since x.x.x + * @default "FiltersMapConnector" + */ + type: "FiltersMapConnector", /** * @type {object} * @property {Filter[]} filtersList An array of Filter models to diff --git a/src/js/models/connectors/Filters-Search.js b/src/js/models/connectors/Filters-Search.js index 4f44363b1..87423b583 100644 --- a/src/js/models/connectors/Filters-Search.js +++ b/src/js/models/connectors/Filters-Search.js @@ -20,6 +20,14 @@ define([ */ return Backbone.Model.extend( /** @lends FiltersSearchConnector.prototype */ { + /** + * The type of Backbone.Model this is. + * @type {string} + * @since x.x.x + * @default "FiltersSearchConnector + */ + type: "FiltersSearchConnector", + /** * @type {object} * @property {Filters} filters A Filters collection to use for this search @@ -37,44 +45,6 @@ define([ }; }, - /** - * Swap out the Filters and SearchResults models with new ones. Do not - * set the models directly, as this will not remove the listeners from - * the old models. - * (TODO: Create custom set methods for the Filters and SearchResults) - * @param {SolrResults|Filters[]} models - A model or array of models to - * update in this connector. - */ - updateModels(models) { - if (!models) return; - models = Array.isArray(models) ? models : [models]; - - const wasConnected = this.get("isConnected"); - this.disconnect(); - - const attrClassMap = { - filters: Filters, - searchResults: SearchResults, - }; - - models.forEach((model) => { - try { - for (const [attr, ModelClass] of Object.entries(attrClassMap)) { - if (model instanceof ModelClass) { - this.set(attr, model); - break; // If a match is found, no need to check other entries in attrClassMap - } - } - } catch (e) { - console.log("Error updating model", model, e); - } - }); - - if (wasConnected) { - this.connect(); - } - }, - /** * Sets listeners on the Filters and SearchResults to trigger a search * when the search changes diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 8fba69d54..46795ff46 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -17,6 +17,15 @@ define([ */ return Backbone.Model.extend( /** @lends MapSearchConnector.prototype */ { + + /** + * The type of Backbone.Model this is. + * @type {string} + * @since x.x.x + * @default "MapSearchConnector" + */ + type: "MapSearchConnector", + /** * @type {object} * @property {SolrResults} searchResults diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index fdb5d2cb3..e0c98192a 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -119,7 +119,15 @@ define([ * pitch: -90, * roll: 0 * } + */ + + /** + * The type of model this is. + * @type {String} + * @default "MapModel" + * @since x.x.x */ + type: "MapModel", /** * Overrides the default Backbone.Model.defaults() function to specify diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index a6984ed69..685e75be2 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -110,11 +110,7 @@ define([ this.startListening(); CesiumVectorData.prototype.initialize.call(this, assetConfig); } catch (error) { - console.log( - "There was an error initializing a CesiumVectorData model" + - ". Error details: " + - error - ); + console.log("Error initializing a CesiumVectorData model", error); } }, @@ -279,7 +275,7 @@ define([ /** * Find the geohash Entity on the map and add it to the selected * features. - * @param {*} geohash + * @param {string} geohash The geohash to select. */ selectGeohashes: function (geohashes) { const toSelect = [...new Set(geohashes.map((geohash) => { diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 248bebaab..ea50b3be3 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -106,6 +106,8 @@ define( initialize: function (assetConfig) { try { + if(!assetConfig) assetConfig = {}; + MapAsset.prototype.initialize.call(this, assetConfig); if (assetConfig.filters) { @@ -125,10 +127,7 @@ define( } catch (error) { - console.log( - 'There was an error initializing a CesiumVectorData model' + - '. Error details: ' + error - ); + console.log('Wrror initializing a CesiumVectorData model.', error); } }, diff --git a/test/config/tests.json b/test/config/tests.json index 2697ad6b0..cffe268a7 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -1,14 +1,5 @@ { "unit": [ - "./js/specs/unit/collections/maps/Geohashes.spec.js", - "./js/specs/unit/models/connectors/Filters-Map.spec.js", - "./js/specs/unit/models/connectors/Filters-Search.spec.js", - "./js/specs/unit/models/connectors/Map-Search-Filters.spec.js", - "./js/specs/unit/models/connectors/Map-Search.spec.js", - "./js/specs/unit/models/filters/SpatialFilter.spec.js", - "./js/specs/unit/models/maps/Geohash.spec.js", - "./js/specs/unit/models/maps/assets/CesiumGeohash.spec.js", - "./js/specs/unit/collections/SolrResults.spec.js", "./js/specs/unit/models/Search.spec.js", "./js/specs/unit/models/filters/Filter.spec.js", @@ -28,6 +19,14 @@ "./js/specs/unit/models/metadata/eml211/EMLParty.spec.js", "./js/specs/unit/models/metadata/eml211/EMLTemporalCoverage.spec.js", "./js/specs/unit/models/maps/assets/CesiumImagery.spec.js", + "./js/specs/unit/collections/maps/Geohashes.spec.js", + "./js/specs/unit/models/connectors/Filters-Map.spec.js", + "./js/specs/unit/models/connectors/Filters-Search.spec.js", + "./js/specs/unit/models/connectors/Map-Search-Filters.spec.js", + "./js/specs/unit/models/connectors/Map-Search.spec.js", + "./js/specs/unit/models/filters/SpatialFilter.spec.js", + "./js/specs/unit/models/maps/Geohash.spec.js", + "./js/specs/unit/models/maps/assets/CesiumGeohash.spec.js", "./js/specs/unit/common/Utilities.spec.js" ], "integration": ["./js/specs/integration/collections/SolrResults.spec.js"] diff --git a/test/js/specs/unit/models/connectors/Filters-Search.spec.js b/test/js/specs/unit/models/connectors/Filters-Search.spec.js index 44f69b677..a22bb1e22 100644 --- a/test/js/specs/unit/models/connectors/Filters-Search.spec.js +++ b/test/js/specs/unit/models/connectors/Filters-Search.spec.js @@ -7,15 +7,38 @@ define([ describe("FiltersSearch Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.filtersSearch = new FiltersSearch(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.filtersSearch = null; + }); describe("Initialization", function () { it("should create a FiltersSearch instance", function () { new FiltersSearch().should.be.instanceof(FiltersSearch); }); }); + + describe("Connect/Disconnect", function () { + it("should connect to the search results", function () { + this.filtersSearch.connect(); + this.filtersSearch.get("isConnected").should.equal(true); + }); + + it("should disconnect from the search results", function () { + this.filtersSearch.connect(); + this.filtersSearch.disconnect(); + this.filtersSearch.get("isConnected").should.equal(false); + }); + }); + describe("Trigger Search", function () { + it("should trigger a search", function () { + // TODO: Figure out how to test this + this.filtersSearch.triggerSearch(); + }); + }); }); -}); \ No newline at end of file +}); diff --git a/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js b/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js index 49bce6472..fef7699ea 100644 --- a/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js +++ b/test/js/specs/unit/models/connectors/Map-Search-Filters.spec.js @@ -1,21 +1,82 @@ define([ "../../../../../../../../src/js/models/connectors/Map-Search-Filters", -], function (MapSearchFilters) { + "../../../../../../../../src/js/models/maps/Map", + "../../../../../../../../src/js/collections/SolrResults", + "../../../../../../../../src/js/collections/Filters", +], function (MapSearchFilters, Map, SolrResults, Filters) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; describe("MapSearchFilters Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.mapSearchFilters = new MapSearchFilters(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.mapSearchFilters = null; + }); describe("Initialization", function () { it("should create a MapSearchFilters instance", function () { new MapSearchFilters().should.be.instanceof(MapSearchFilters); }); + + it("should set a map, search results, and filters", function () { + this.mapSearchFilters.get("map").type.should.equal("MapModel"); + this.mapSearchFilters + .get("searchResults") + .type.should.equal("SolrResults"); + this.mapSearchFilters.get("filters").type.should.equal("Filters"); + }); + + it("should set up connectors", function () { + const connectors = this.mapSearchFilters.getConnectors(); + connectors.length.should.equal(3); + connectors[0].type.should.equal("MapSearchConnector"); + connectors[1].type.should.equal("FiltersSearchConnector"); + connectors[2].type.should.equal("FiltersMapConnector"); + }); + }); + + describe("Connect/Disconnect", function () { + it("should connect to the search results", function () { + this.mapSearchFilters.connect(); + const connectors = this.mapSearchFilters.getConnectors(); + connectors.forEach((connector) => { + connector.get("isConnected").should.equal(true); + }); + }); + + it("should disconnect from the search results", function () { + this.mapSearchFilters.connect(); + this.mapSearchFilters.disconnect(); + const connectors = this.mapSearchFilters.getConnectors(); + connectors.forEach((connector) => { + connector.get("isConnected").should.equal(false); + }); + }); + }); + + describe("Coordinate MoveEnd Search", function () { + it("should coordinate the moveEnd search", function () { + this.mapSearchFilters.coordinateMoveEndSearch(); + const connectors = this.mapSearchFilters.getMapConnectors(); + connectors.forEach((connector) => { + expect(connector.get("onMoveEnd") === null).to.equal(true); + }); + }); + + it("should reset the moveEnd search behaviour", function () { + this.mapSearchFilters.coordinateMoveEndSearch(); + this.mapSearchFilters.resetMoveEndSearch(); + const connectors = this.mapSearchFilters.getMapConnectors(); + connectors.forEach((connector) => { + expect(typeof connector.get("onMoveEnd")).to.equal("function"); + }); + }); }); }); -}); \ No newline at end of file +}); diff --git a/test/js/specs/unit/models/connectors/Map-Search.spec.js b/test/js/specs/unit/models/connectors/Map-Search.spec.js index e4e348fd9..ac3b882ce 100644 --- a/test/js/specs/unit/models/connectors/Map-Search.spec.js +++ b/test/js/specs/unit/models/connectors/Map-Search.spec.js @@ -1,21 +1,87 @@ define([ "../../../../../../../../src/js/models/connectors/Map-Search", -], function (MapSearch) { + "../../../../../../../../src/js/models/maps/assets/CesiumGeohash", +], function (MapSearch, CesiumGeohash) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; describe("MapSearch Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.mapSearch = new MapSearch(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.mapSearch = null; + }); describe("Initialization", function () { it("should create a MapSearch instance", function () { new MapSearch().should.be.instanceof(MapSearch); }); }); + + describe("Connect/Disconnect", function () { + it("should connect", function () { + this.mapSearch.connect(); + this.mapSearch.get("isConnected").should.be.true; + }); + + it("should disconnect", function () { + this.mapSearch.connect(); + this.mapSearch.disconnect(); + this.mapSearch.get("isConnected").should.be.false; + }); + }); + + describe("Geohash Layer", function () { + it("should get the geohash layer", function () { + console.log(this.mapSearch.get("geohashLayer")); + this.mapSearch.get("geohashLayer").type.should.equal("CesiumGeohash"); + }); + + it("should set the geohash layer", function () { + var geohashLayer = new CesiumGeohash(); + this.mapSearch.set("geohashLayer", geohashLayer); + this.mapSearch.get("geohashLayer").should.equal(geohashLayer); + }); + }); + + describe("Geohash Counts", function () { + it("should get the geohash counts", function () { + this.mapSearch.getGeohashCounts().should.deep.equal([]); + }); + + it("should get the geohash counts", function () { + var searchResults = { + facetCounts: { + geohash_9: ["hash1", 1, "hash2", 2], + geohash_8: ["hash3", 3, "hash4", 4], + }, + }; + this.mapSearch.set("searchResults", searchResults); + this.mapSearch + .getGeohashCounts() + .should.deep.equal(["hash1", 1, "hash2", 2, "hash3", 3, "hash4", 4]); + }); + }); + + describe("Total Number of Results", function () { + it("should get the total number of results", function () { + expect(this.mapSearch.getTotalNumberOfResults() === null); + }); + + it("should get the total number of results", function () { + var searchResults = { + getNumFound: function () { + return 10; + }, + }; + this.mapSearch.set("searchResults", searchResults); + this.mapSearch.getTotalNumberOfResults().should.equal(10); + }); + }); }); -}); \ No newline at end of file +}); diff --git a/test/js/specs/unit/models/maps/Geohash.spec.js b/test/js/specs/unit/models/maps/Geohash.spec.js index be3cee5d4..75f0c45a3 100644 --- a/test/js/specs/unit/models/maps/Geohash.spec.js +++ b/test/js/specs/unit/models/maps/Geohash.spec.js @@ -1,21 +1,137 @@ -define([ - "../../../../../../../../src/js/models/maps/Geohash", -], function (Geohash) { +define(["../../../../../../../../src/js/models/maps/Geohash"], function ( + Geohash +) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; describe("Geohash Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.hashString = "9q8yy"; + this.properties = { + count: 21, + }; + this.geohash = new Geohash({ + hashString: this.hashString, + properties: this.properties, + }); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.geohash = null; + }); describe("Initialization", function () { it("should create a Geohash instance", function () { new Geohash().should.be.instanceof(Geohash); }); }); + + describe("Property Handling", function () { + it("should set the hashString property", function () { + this.geohash.get("hashString").should.equal(this.hashString); + }); + + it("should set the properties property", function () { + this.geohash.get("properties").should.equal(this.properties); + }); + + it("should identify properties", function () { + this.geohash.isProperty("count").should.be.true; + }); + + it("should get properties", function () { + this.geohash.getProperty("count").should.equal(21); + }); + + it("should add properties", function () { + this.geohash.addProperty("name", "test"); + this.geohash.getProperty("name").should.equal("test"); + }); + + it("should remove properties", function () { + this.geohash.removeProperty("count"); + expect(this.geohash.getProperty("count")).to.be.null; + }); + }); + + describe("Geometry", function () { + it("should get the bounds of the geohash", function () { + const bounds = this.geohash.getBounds(); + bounds.should.be.an("array"); + bounds.length.should.equal(4); + const expectedBounds = [ + 37.7490234375, -122.431640625, 37.79296875, -122.3876953125, + ]; + bounds.should.deep.equal(expectedBounds); + }); + + it("should get the center point of the geohash", function () { + const point = this.geohash.getPoint(); + point.should.be.an("object"); + point.latitude.should.equal(37.77099609375); + point.longitude.should.equal(-122.40966796875); + }); + + it("should get the precision of the geohash", function () { + this.geohash.getPrecision().should.equal(this.hashString.length); + }); + + it("should get the 32 child geohashes", function () { + const childGeohashes = this.geohash.getChildGeohashes(); + childGeohashes.should.be.an("array"); + childGeohashes.length.should.equal(32); + childGeohashes.forEach((geohash) => { + geohash.should.be.an.instanceof(Geohash); + // Every child should have a precision one greater than the parent. + geohash.getPrecision().should.equal(this.geohash.getPrecision() + 1); + // Every child should start with the same hashString as the parent. + geohash.get("hashString").startsWith(this.geohash.get("hashString")); + }); + }); + + it("should get the parent geohash", function () { + const parentGeohash = this.geohash.getParentGeohash(); + parentGeohash.should.be.an.instanceof(Geohash); + // The parent should have a precision one less than the child. + parentGeohash + .getPrecision() + .should.equal(this.geohash.getPrecision() - 1); + // The parent should start with the same hashString as the child. + this.geohash + .get("hashString") + .startsWith(parentGeohash.get("hashString")).should.be.true; + }); + + it("should convert geodetic coordinates to ECEF", function () { + const coord = [-122.40966796875, 37.77099609375]; + const ecef = this.geohash.geodeticToECEF(coord); + ecef.should.be.an("array"); + ecef.length.should.equal(3); + ecef[0].should.be.a("number"); + ecef[1].should.be.a("number"); + ecef[2].should.be.a("number"); + }); + }); + + describe("Serialization", function () { + it("should serialize to JSON", function () { + const json = this.geohash.toJSON(); + json.should.be.an("object"); + json.hashString.should.equal(this.hashString); + json.properties.should.deep.equal(this.properties); + }); + + it("should serialize to CZML", function () { + const czml = this.geohash.toCZML(); + czml.should.be.an("array"); + czml.length.should.equal(1); + czml[0].should.be.an("object"); + czml[0].id.should.equal(this.hashString); + czml[0].polygon.should.be.an("object"); + }); + }); }); -}); \ No newline at end of file +}); diff --git a/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js b/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js index a737c3dbc..9eab3db57 100644 --- a/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js +++ b/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js @@ -1,21 +1,110 @@ define([ "../../../../../../../../src/js/models/maps/assets/CesiumGeohash", -], function (Cesiumgeohash) { + "../../../../../../../../src/js/collections/maps/Geohashes", + "../../../../../../../../src/js/models/maps/Map", +], function (CesiumGeohash, Geohashes) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; - describe("Cesiumgeohash Test Suite", function () { + describe("CesiumGeohash Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.map = new Map(); + this.model = new CesiumGeohash(); + this.model.set("mapModel", this.map); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.model = null; + }); describe("Initialization", function () { - it("should create a Cesiumgeohash instance", function () { - new Cesiumgeohash().should.be.instanceof(Cesiumgeohash); + it("should create a CesiumGeohash instance", function () { + new CesiumGeohash().should.be.instanceof(CesiumGeohash); + }); + + it("should configure labels", function () { + this.model.get("type").should.equal("CzmlDataSource"); + const noLabelModel = new CesiumGeohash({ showLabels: false }); + noLabelModel.get("type").should.equal("GeoJsonDataSource"); + }); + }); + + describe("getGeohashes", function () { + it("should return the geohashes", function () { + const geohashes = this.model.getGeohashes(); + geohashes.type.should.equal("Geohashes"); + }); + + it("should return the geohashes in the current extent", function () { + const geohashes = this.model.getGeohashesForExtent(); + geohashes.type.should.equal("Geohashes"); + }); + }); + + describe("Output Formats", function () { + it("should return the GeoJSON", function () { + const geojson = this.model.getGeoJSON(); + geojson.type.should.equal("FeatureCollection"); + }); + + it("should return the CZML", function () { + const czml = this.model.getCZML(); + czml.should.be.an("array"); + czml[0].should.have.property("id"); + czml[0].should.have.property("name"); + czml[0].should.have.property("version"); + }); + }); + + describe("Cesium", function () { + it("should create a Cesium model", function () { + this.model.get("cesiumModel").should.be.an("object"); + }); + }); + + describe("Geohash Layer Specific", function () { + it("should replace the geohashes", function () { + this.model.replaceGeohashes([ + { hashString: "9q" }, + { hashString: "9r" }, + { hashString: "9x" }, + ]); + this.model.get("geohashes").length.should.equal(3); + }); + + it("should empty the geohashes", function () { + this.model.replaceGeohashes(); + this.model.get("geohashes").length.should.equal(0); + }); + + it("should get the precision", function () { + this.model.replaceGeohashes(); + this.model.set("maxGeoHashes", 32); + this.map.set("currentViewExtent", { + north: 90, + south: -90, + east: 180, + west: -180, + }); + this.model.getPrecision().should.equal(1); + }); + + it("should get the property of interest", function () { + this.model.getPropertyOfInterest().should.equal("count"); + }); + + it("should calculate the min and max vals for the color palette", function () { + this.model.replaceGeohashes([ + { hashString: "9q", count: 10 }, + { hashString: "9r", count: 50 }, + ]); + this.model.updateColorRangeValues(); + this.model.get("colorPalette").get("minVal").should.equal(10); + this.model.get("colorPalette").get("maxVal").should.equal(50); }); }); }); -}); \ No newline at end of file +}); From d29c21d770b0073441ecc6d83462467c2a30660a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 24 May 2023 21:02:42 -0400 Subject: [PATCH 70/79] Add docs for: CatalogSearchView, Filters, Cesium - Add guide for customizing the CatalogSearchView - Add guide for configuring Cesium in portals - Update the general Cesium guide - Other minor doc updates - Minor CSS tweak Issue #1720 --- docs/_includes/nav.html | 1 + docs/guides/catalog-view-config.md | 32 +++++++ docs/guides/filters/configuring-filters.md | 30 +++++- docs/guides/index.md | 12 ++- docs/guides/maps/cesium-for-portals.md | 41 ++++++++ docs/guides/maps/cesium.md | 103 ++++++++------------- src/css/metacatui-common.css | 4 +- src/js/models/AppModel.js | 2 +- 8 files changed, 152 insertions(+), 73 deletions(-) create mode 100644 docs/guides/catalog-view-config.md create mode 100644 docs/guides/maps/cesium-for-portals.md diff --git a/docs/_includes/nav.html b/docs/_includes/nav.html index 1baeea757..f3bc2a142 100644 --- a/docs/_includes/nav.html +++ b/docs/_includes/nav.html @@ -16,6 +16,7 @@

    API

    Guides

    Access Policies Search Filters + Catalog Search View Cesium Map

    Help

    diff --git a/docs/guides/catalog-view-config.md b/docs/guides/catalog-view-config.md new file mode 100644 index 000000000..78ffdb65e --- /dev/null +++ b/docs/guides/catalog-view-config.md @@ -0,0 +1,32 @@ +--- +layout: guide +title: Configuring the Catalog Search View +id: catalog-view-config +toc: true +--- + +This page provides instructions on how to customize a the main search page for a MetacatUI repository. This page is rendered by the Catalog Search View and includes a 3D map and a set of search filters. The map and filters can be set to suit the needs of the repository. + +The 3D map uses the `cesium.js` library. For more information about Cesium and how to configure a Cesium Map model in general, see the general [Cesium guide](/guides/maps/cesium.html). + +With the x.x.x release, MetacatUI introduced a new [`CatalogSearchView`](/docs/CatalogSearchView.html) that renders the main search page. This new view replaces the `DataCatalogView` that used Google Maps. The `DataCatalogView` will be deprecated in a future release, but to give time for repositories to migrate to the new `CatalogSearchView`, the `DataCatalogView` will remain the default view for the time being. + +To enable the new `CatalogSearchView`, set the following properties in your [configuration file](/docs/AppConfig.html): + +```js +{ + "useDeprecatedDataCatalogView": false, + "enableCesium": true, + "cesiumToken": "YOUR-CESIUM-ION-TOKEN" +} +``` + +The `cesiumToken` only needs to be set in order to enable access to layers and assets from [Cesium Ion](https://cesium.com/learn/ion/global-base-layers/). See the general [Cesium guide](/docs/guides/maps/cesium) for more information. + +## Customizing the search filters + +The default filters to use on the left hand side of the Catalog Search View are set in the [`defaultFilterGroups`](/docs/AppConfig.html#defaultFilterGroups) property of the [configuration file](/docs/AppConfig.html). This property is an array of objects that define the filters to use. See the guide about [customizing search filters](/guides/filters/configuring-filters.html) for more information. + +## Map config + +Options for Search View's map are set in the [`catalogSearchMapOptions`](docs/AppConfig.html#catalogSearchMapOptions) property of the [configuration file](/docs/AppConfig.html). This property is the same object used to define any `Map` model in MetacatUI. See the API docs for [`Map`](/docs/MapConfig.html) for complete documentation of the options. \ No newline at end of file diff --git a/docs/guides/filters/configuring-filters.md b/docs/guides/filters/configuring-filters.md index ee3c0152f..65b54a00d 100644 --- a/docs/guides/filters/configuring-filters.md +++ b/docs/guides/filters/configuring-filters.md @@ -1,15 +1,39 @@ --- layout: guide -title: Configuring custom filters +title: 🔎 Configuring Search Filters id: configuring-filters --- -## How to hide a field from the custom filter builder +## Search Filters + +In MetacatUI, search filters are models that define a Solr field, values to use in a query for that field, and options for how to display the filter in the UI. Filters are used in the [`CatalogSearchView`](/docs/CatalogSearchView.html) and the [`PortalDataView`](/docs/PortalDataView.html). + +Filters which are combined to create a collection of data for a Portal can be built interactively using the [`QueryBuilderView`](/docs/QueryBuilderView.html) in the Portal Editor. + +Custom search filters which users can use to subset a collection of portal data further can be designed and added to the portal in [`FilterEditorView`](/docs/FilterEditorView.html) in the Portal Editor. + +Filters that are displayed in the repository-level `CatalogSearchView` are configured in the repository's [`config`](/docs/AppModel.html) file. See the [`Catalog Search View`](/guides/catalog-view-config.html) guide for more information. + +## The parts of a filter model + +Filters are defined in the [collections and portals XML schema repo](https://github.com/DataONEorg/collections-portals-schemas). See the [`FilterType`](https://github.com/DataONEorg/collections-portals-schemas/blob/48db8394506f5523597def6c9212aea3bfdee103/schemas/collections.xsd#L152-L210) to learn about the most essential parts of a filter model. + +Filters are represented in MetacatUI by the [`Filter`](/docs/Filter.html) model and all of it's extended types. + +### Filter groups + +Filters can be grouped to create nested queries such as `((field1:value1 OR field1:value2) AND field2:value3)`. They can also be grouped to display related filters together in the UI. See the [`FilterGroup`](/docs/FilterGroup.html) model for more information. + +## Custom Search Filters in Portals + +This section gives information on how to configure the options that are available for users to create custom search filters in the [`CustomFilterBuilderView`](/docs/CustomFilterBuilderView.html) in the Portal Editor. + +### How to hide a field from the custom filter builder Add the Solr field name to [`AppConfig.collectionQueryExcludeFields`](https://nceas.github.io/metacatui/docs/AppConfig.html#collectionQueryExcludeFields). This will also hide the field from the Query Builder. -## Adding a new Solr field to the custom filter builder +### Adding a new Solr field to the custom filter builder When a new Solr field is added to the Solr schema, it will automatically get added to the `General`, or default, category in the custom filter builder and it can be used with any filter type (free text, dropdown, year slider, etc). There are several places to configure the Solr field so that it works as intended: diff --git a/docs/guides/index.md b/docs/guides/index.md index 34772f12a..868e856a5 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -1,8 +1,12 @@ # MetacatUI Guides The following is a list of How To guides for customizing the display and functionality -of your MetacatUI application. Is something missing? [Email us](mailto:metacat-dev@ecoinformatics.org) or join us on [Slack](https://slack.dataone.org/) and we'll add it. +of your MetacatUI application. -- Access Policies -- Search Filters -- Cesium Map +- 👥 Access Policies +- 🔎 Search Filters +- 📑 Catalog Search View +- 🌎 Cesium Map +- 📍 Cesium Map for Portals + +ℹ️ Is something missing? [Email us](mailto:metacat-dev@ecoinformatics.org) or join us on [Slack](https://slack.dataone.org/) and we'll add it! \ No newline at end of file diff --git a/docs/guides/maps/cesium-for-portals.md b/docs/guides/maps/cesium-for-portals.md new file mode 100644 index 000000000..53f6c4e60 --- /dev/null +++ b/docs/guides/maps/cesium-for-portals.md @@ -0,0 +1,41 @@ +--- +layout: guide +title: Configuring Cesium Maps for Portals +id: cesium-for-portals +toc: true +--- + +This page outlines the process of integrating a Cesium Map into a [Portal document](https://github.com/DataONEorg/collections-portals-schemas/blob/master/schemas/portals.xsd). + +For background on Cesium, as well as detailed guidelines on how to customize a Cesium Map model, please refer to our [Cesium guide](cesium). + +## How to Configure a Cesium Map Section within a Portal Document + +To integrate a Cesium map visualization into a portal XML document, you need to define the map's appearance and layering structure using JSON. This JSON configuration is then embedded into an `
    - <% if(typeof MetacatUI.mapKey == "string" && MetacatUI.mapKey.length){ %>
    <% if (typeof geohash_9 !== "undefined" && geohash_9 !== null){ print(''); @@ -45,7 +44,6 @@ } %>
    - <% } %> <% if(hasProv){ print('
    '); diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 2a234f005..5f23aa7c9 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -69,7 +69,7 @@ define([ * The template to use in case there is a major error in rendering the * view. * @type {string} - * @since 2.x.x + * @since x.x.x */ errorTemplate: `