From 76f40f0eb232d0a7ba85a52bd794871f443d25a8 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 23 Aug 2023 17:34:58 -0400 Subject: [PATCH 01/24] Create structure for tool for drawing on map [WIP] - Create the initial DrawTool view and layout methods Issue #2180 --- src/js/views/maps/CesiumWidgetView.js | 41 ++++++ src/js/views/maps/DrawTool.js | 200 ++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/js/views/maps/DrawTool.js diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index a9837adeb..b57aa1e0b 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -1148,6 +1148,21 @@ define( } }, + /** + * Add a new asset (layer) to the map model and render it on the map + * @param {Object} mapAsset - The properties of the map model to create + * and add to the map + * @returns {MapAsset} Returns the newly created MapAsset model + * @since x.x.x + */ + addNewAsset: function (mapAsset) { + // TODO: Set a listener on the layers collection for add events, and + // call this function when a new layer is added + const newAsset = this.model.addAsset(mapAsset); + this.addAsset(newAsset); + return newAsset + }, + /** * Finds the function that is configured for the given asset model type in the * {@link CesiumWidgetView#mapAssetRenderFunctions} array, then renders the asset @@ -1211,6 +1226,31 @@ define( } }, + /** + * Remove an asset (layer) from the map model and remove it from the map + * @param {MapAsset} mapAsset - The MapAsset model to remove from the map + * @since x.x.x + */ + removeAsset: function (mapAsset) { + // TODO: Set a listener on the layers collection for remove events, and + // call this function when a new layer is removed + try { + if (!mapAsset) { + return + } + // TODO: Implement this! + // this.model.removeAsset(mapAsset) + // Remove the layer from the map + // ... + } + catch (error) { + console.log( + 'There was an error removing an asset from a CesiumWidgetView' + + '. Error details: ' + error + ); + } + }, + /** * Renders peaks and valleys in the 3D version of the map, given a terrain model. * If a terrain model has already been set on the map, this will replace it. @@ -1218,6 +1258,7 @@ define( * use for the map */ updateTerrain: function (cesiumModel) { + // TODO: Add listener to the map model for when the terrain changes this.scene.terrainProvider = cesiumModel this.requestRender(); }, diff --git a/src/js/views/maps/DrawTool.js b/src/js/views/maps/DrawTool.js new file mode 100644 index 000000000..6e6e27838 --- /dev/null +++ b/src/js/views/maps/DrawTool.js @@ -0,0 +1,200 @@ +"use strict"; + +define(["backbone", "cesium"], function (Backbone, Cesium) { + // TODO <- Does Cesium need to be a dependency? + /** + * @class DrawTool + * @classdesc Functionality for drawing an arbitrary polygon on a Cesium map + * using the mouse. + * @classcategory Views/Maps + * @name DrawTool + * @extends Backbone.View + * @screenshot views/maps/DrawTool.png + * @since x.x.x + * @constructs DrawTool + */ + var DrawTool = Backbone.View.extend( + /** @lends DrawTool.prototype */ { + /** + * The type of View this is + * @type {string} + */ + type: "DrawTool", + + /** + * The HTML classes to use for this view's element + * @type {string} + */ + className: "draw-tool", + + /** + * Whether or not the draw tool is currently active. If not active, it + * will not listen for mouse clicks. + * @type {boolean} + */ + activated: false, + + /** + * The Cesium map view to draw on + * @type {CesiumWidgetView} + */ + mapView: undefined, + + /** + * The CesiumVectorData model that we will use to store the drawn polygon + * @type { + */ + mapData: undefined, + + /** + * Initializes the DrawTool + * @param {Object} options + */ + initialize: function (options) { + this.mapView = options.mapView; + this.activated = options.activated || false; + this.makeAsset(); + if (this.activated) { + this.activate(); + } + }, + + /** + * Creates the polygon object that will be modified as a user draws on the + * map. Saves it to the polygon property. + */ + makeAsset: function () { + this.mapData = this.mapView.addNewAsset({ + type: "GeoJsonDataSource", + cesiumOptions: { + data: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: {}, + geometry: { + coordinates: [], + type: "Polygon", + }, + }, + ], + }, + }, + }); + }, + + /** + * Removes the polygon object from the map + */ + removeAsset: function () { + if (this.mapData) { + this.mapView.removeAsset(this.mapData); + } + }, + + /** + * Renders the DrawTool + * @returns {DrawTool} Returns the view + */ + render: function () { + if (!this.mapView) { + this.handleNoMapView(); + return; + } + this.renderToolbar(); + this.startListeners(); + }, + + /** + * What to do when this view doesn't have a map view to draw on + */ + handleNoMapView: function () { + console.warn("No map view provided to DrawTool"); + }, + + /** + * Create and insert the buttons for drawing and clearing the polygon + */ + renderToolbar: function () { + // TODO: At a minimum we need buttons to: Start drawing, Clear drawing + }, + + /** + * Starts the listeners for the draw tool + */ + startListeners: function () { + this.stopListening(); + // TODO: We should either make a general method in the map that gives + // the coordinates of the mouse click, or we should add the Cesium event + // handler here. + this.listenTo(this.mapView, "click", this.handleClick); + }, + + /** + * Stops the listeners for the draw tool + */ + stopListeners: function () { + this.stopListening(this.mapView); + }, + + /** + * Handles a click on the map. If the draw tool is active, it will add the + * coordinates of the click to the polygon being drawn. + * @param {Event} event - The click event + */ + handleClick: function (event) { + if (!this.activated) { + return; + } + var coords = this.mapView.getMouseCoords(event); // <- TODO: This method doesn't exist yet + this.addCoordinate(coords); + }, + + /** + * Adds a coordinate to the polygon being drawn + * @param {Array} coords - The coordinates to add + */ + addCoordinate: function (coords) { + // TODO: Something like this... We may also want to add a general method + // to the VectorData model that allows us to add a coordinate, but this + // will be specific to the GeoJsonDataSource + const geoJsonDataSource = this.mapData.get("cesiumModel"); + const geoJsonFeature = geoJsonDataSource.entities.values[0]; + const coordinates = geoJsonFeature.geometry.coordinates; + coordinates.push(coords); + geoJsonFeature.geometry.coordinates = coordinates; + geoJsonDataSource.entities.values[0] = geoJsonFeature; + this.mapData.updateAppearance(); + }, + + /** + * Activates the draw tool. This means that it will listen for mouse + * clicks on the map and draw a polygon based on those clicks. + */ + activate: function () { + this.activated = true; + this.startListeners(); + }, + + /** + * Deactivates the draw tool. This means that it will no longer listen for + * mouse clicks on the map. + */ + deactivate: function () { + this.activated = false; + this.stopListeners(); + }, + + /** + * Clears the polygon that is being drawn + */ + onClose: function () { + this.removeAsset(); + this.deactivate(); + }, + } + ); + + return DrawTool; +}); From ddb9543debfa43b4fa248c17be7f2b365b256d33 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 24 Aug 2023 13:06:45 -0400 Subject: [PATCH 02/24] Update Cesium on add/remove layers collection - Add listeners for when layers are added or removed from the MapAsset collection and update the layers rendered on the Cesium map and the layers displayed in the layer list. Issue #2180, #1923, #1775 --- src/js/models/connectors/Map-Search.js | 2 - src/js/models/maps/Map.js | 21 +++++- src/js/views/maps/CesiumWidgetView.js | 100 ++++++++++++++++++------- src/js/views/maps/LayerListView.js | 30 ++++++++ 4 files changed, 120 insertions(+), 33 deletions(-) diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 827593203..62958b408 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -206,8 +206,6 @@ define([ * See {@link MapSearchFiltersConnector#onMoveEnd} */ onMoveEnd: function () { - const searchResults = this.get("searchResults"); - const map = this.get("map"); this.showGeoHashLayer(); this.updateFacet(); }, diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 6b7390d08..9ae10fd0b 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -351,16 +351,31 @@ define([ * 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 + * @todo Enable adding a terrain asset. + * @param {Object | MapAsset} asset - A map asset model or object with * attributes to set on a new map asset model. * @returns {MapAsset} The new layer model. * @since 2.25.0 */ - addAsset: function (layer) { + addAsset: function (asset) { const layers = this.get("layers") || this.resetLayers(); - return layers.addAsset(layer, this); + return layers.addAsset(asset, this); }, + /** + * Remove a layer from the map. + * @param {MapAsset} asset - The layer model to remove from the map. + * @since x.x.x + */ + removeAsset: function (asset) { + if(!asset) return; + const layers = this.get("layers"); + if(!layers) return; + // Remove by ID because the model is passed directly. Not sure if this + // is a bug in the MapAssets collection or Backbone? + if (layers) layers.remove(asset.cid); + } + } ); diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index b57aa1e0b..ae5cbdbf9 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -81,23 +81,30 @@ define( * @property {string} renderFunction The name of the function in the view that * will add the asset to the map and render it, when passed the cesiumModel * attribute from the MapAsset model + * @property {string} removeFunction The name of the function in the view that + * will remove the asset from the map, when passed the cesiumModel attribute from + * the MapAsset model */ mapAssetRenderFunctions: [ { types: ['Cesium3DTileset'], - renderFunction: 'add3DTileset' + renderFunction: 'add3DTileset', + removeFunction: 'remove3DTileset' }, { types: ['GeoJsonDataSource', 'CzmlDataSource'], - renderFunction: 'addVectorData' + renderFunction: 'addVectorData', + removeFunction: 'removeVectorData' }, { types: ['BingMapsImageryProvider', 'IonImageryProvider', 'TileMapServiceImageryProvider', 'WebMapTileServiceImageryProvider', 'WebMapServiceImageryProvider', 'OpenStreetMapImageryProvider'], - renderFunction: 'addImagery' + renderFunction: 'addImagery', + removeFunction: 'removeImagery' }, { types: ['CesiumTerrainProvider'], - renderFunction: 'updateTerrain' + renderFunction: 'updateTerrain', + removeFunction: null } ], @@ -194,7 +201,6 @@ define( // raised. view.camera.percentChanged = 0.1 - // Disable HDR lighting for better performance and to avoid changing imagery colors. view.scene.highDynamicRange = false; view.scene.globe.enableLighting = false; @@ -251,6 +257,14 @@ define( const view = this; + // Listen for addition or removal of layers + // TODO: Add similar listeners for terrain + const layers = view.model.get('layers') + view.stopListening(layers, 'add'); + view.listenTo(layers, 'add', view.addAsset); + view.stopListening(layers, 'remove'); + view.listenTo(layers, 'remove', view.removeAsset); + // Zoom functions executed after each scene render view.scene.postRender.addEventListener(function () { view.postRender(); @@ -1156,10 +1170,10 @@ define( * @since x.x.x */ addNewAsset: function (mapAsset) { - // TODO: Set a listener on the layers collection for add events, and - // call this function when a new layer is added + if(!mapAsset) return const newAsset = this.model.addAsset(mapAsset); - this.addAsset(newAsset); + // The add event on the layers collection will trigger the addAsset + // function below, which will render the asset in the map return newAsset }, @@ -1227,27 +1241,25 @@ define( }, /** - * Remove an asset (layer) from the map model and remove it from the map - * @param {MapAsset} mapAsset - The MapAsset model to remove from the map + * When an asset is removed from the map model, remove it from the map. + * @param {MapAsset} mapAsset - The MapAsset model removed from the map * @since x.x.x */ - removeAsset: function (mapAsset) { - // TODO: Set a listener on the layers collection for remove events, and - // call this function when a new layer is removed - try { - if (!mapAsset) { - return - } - // TODO: Implement this! - // this.model.removeAsset(mapAsset) - // Remove the layer from the map - // ... - } - catch (error) { - console.log( - 'There was an error removing an asset from a CesiumWidgetView' + - '. Error details: ' + error - ); + removeAsset: function (mapAsset, b, c) { + if (!mapAsset) return + // Get the cesium model from the asset + const cesiumModel = mapAsset.get('cesiumModel') + if (!cesiumModel) return + // Find the remove function for this type of asset + const removeFunctionName = this.mapAssetRenderFunctions.find(function (option) { + return option.types.includes(mapAsset.get('type')) + })?.removeFunction + const removeFunction = this[removeFunctionName] + // If there is a function for this type of asset, call it + if (removeFunction && typeof removeFunction === 'function') { + removeFunction.call(this, cesiumModel) + } else { + console.log('No remove function found for this type of asset', mapAsset); } }, @@ -1272,6 +1284,16 @@ define( this.scene.primitives.add(cesiumModel) }, + /** + * Remove a 3D tileset from the map. + * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model to + * remove from the map + * @since x.x.x + */ + remove3DTileset: function (cesiumModel) { + this.scene.primitives.remove(cesiumModel) + }, + /** * Renders vector data (excluding 3D tilesets) in the Map. * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source @@ -1281,6 +1303,16 @@ define( this.dataSourceCollection.add(cesiumModel) }, + /** + * Remove vector data (excluding 3D tilesets) from the Map. + * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source + * model to remove from the map + * @since x.x.x + */ + removeVectorData: function (cesiumModel) { + this.dataSourceCollection.remove(cesiumModel) + }, + /** * Renders imagery in the Map. * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to render @@ -1290,12 +1322,24 @@ define( this.sortImagery() }, + /** + * Remove imagery from the Map. + * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to remove + * from the map + * @since x.x.x + */ + removeImagery: function (cesiumModel) { + console.log('Removing imagery from map', cesiumModel); + console.log('Imagery layers', this.scene.imageryLayers); + this.scene.imageryLayers.remove(cesiumModel) + }, + /** * Arranges the imagery that is rendered the Map according to the order * that the imagery is arranged in the layers collection. * @since 2.21.0 */ - sortImagery() { + sortImagery: function() { try { const imageryInMap = this.scene.imageryLayers const imageryModels = this.model.get('layers').getAll('CesiumImagery') diff --git a/src/js/views/maps/LayerListView.js b/src/js/views/maps/LayerListView.js index e55ce24ee..f53e300c4 100644 --- a/src/js/views/maps/LayerListView.js +++ b/src/js/views/maps/LayerListView.js @@ -79,12 +79,42 @@ define( this[key] = value; } } + this.setListeners(); } catch (e) { console.log('A LayerListView failed to initialize. Error message: ' + e); } }, + /** + * Remove any event listeners on the collection + * @since x.x.x + */ + removeListeners: function () { + try { + if (this.collection) { + this.stopListening(this.collection); + } + } catch (e) { + console.log('Failed to remove listeners:', e); + } + }, + + /** + * Add or remove items from the list when the collection changes + * @since x.x.x + */ + setListeners: function () { + try { + if (this.collection) { + this.listenTo(this.collection, 'add', this.render); + this.listenTo(this.collection, 'remove', this.render); + } + } catch (e) { + console.log('Failed to set listeners:', e); + } + }, + /** * Renders this view * @return {LayerListView} Returns the rendered view element From ef5afacd764d5acc545c57c398d0a55377952f67 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 24 Aug 2023 17:32:17 -0400 Subject: [PATCH 03/24] Expand on DrawTool methods, add to ToolbarView - Still a WIP - Make draw tool work directly with Map model, independent of widget - Rename DrawTool to DrawToolView Issue #2180 --- src/js/views/maps/CesiumWidgetView.js | 18 +-- .../maps/{DrawTool.js => DrawToolView.js} | 108 +++++++++++------- src/js/views/maps/ToolbarView.js | 14 ++- 3 files changed, 82 insertions(+), 58 deletions(-) rename src/js/views/maps/{DrawTool.js => DrawToolView.js} (58%) diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index ae5cbdbf9..b30fb0f4e 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -528,6 +528,9 @@ define( } else if (action === 'zoom') { view.flyTo(pickedFeature) } + // TODO: Make the click actions more configurable. On every click, + // add the coordinates to the map model's clickedCoordinates. + // (keep a history of the last 10 clicks?) }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } @@ -1162,21 +1165,6 @@ define( } }, - /** - * Add a new asset (layer) to the map model and render it on the map - * @param {Object} mapAsset - The properties of the map model to create - * and add to the map - * @returns {MapAsset} Returns the newly created MapAsset model - * @since x.x.x - */ - addNewAsset: function (mapAsset) { - if(!mapAsset) return - const newAsset = this.model.addAsset(mapAsset); - // The add event on the layers collection will trigger the addAsset - // function below, which will render the asset in the map - return newAsset - }, - /** * Finds the function that is configured for the given asset model type in the * {@link CesiumWidgetView#mapAssetRenderFunctions} array, then renders the asset diff --git a/src/js/views/maps/DrawTool.js b/src/js/views/maps/DrawToolView.js similarity index 58% rename from src/js/views/maps/DrawTool.js rename to src/js/views/maps/DrawToolView.js index 6e6e27838..dfc4ec029 100644 --- a/src/js/views/maps/DrawTool.js +++ b/src/js/views/maps/DrawToolView.js @@ -1,7 +1,6 @@ "use strict"; -define(["backbone", "cesium"], function (Backbone, Cesium) { - // TODO <- Does Cesium need to be a dependency? +define(["backbone"], function (Backbone) { /** * @class DrawTool * @classdesc Functionality for drawing an arbitrary polygon on a Cesium map @@ -35,25 +34,31 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { activated: false, /** - * The Cesium map view to draw on - * @type {CesiumWidgetView} + * The Cesium map model to draw on. This must be the same model that the + * mapWidget is using. + * @type {Map} */ - mapView: undefined, + model: undefined, /** - * The CesiumVectorData model that we will use to store the drawn polygon + * The CesiumVectorData model that we will use to store the drawn + * polygon(s) * @type { */ - mapData: undefined, + drawLayer: undefined, /** * Initializes the DrawTool * @param {Object} options */ initialize: function (options) { - this.mapView = options.mapView; + this.model = this.model; + if (!this.model) { + this.handleNoMapModel(); + return + } this.activated = options.activated || false; - this.makeAsset(); + this.makeDrawLayer(); if (this.activated) { this.activate(); } @@ -63,9 +68,13 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { * Creates the polygon object that will be modified as a user draws on the * map. Saves it to the polygon property. */ - makeAsset: function () { - this.mapData = this.mapView.addNewAsset({ + makeDrawLayer: function () { + if (!this.model) return + this.drawLayer = this.model.addAsset({ type: "GeoJsonDataSource", + hideInLayerList: true, // <- TODO: Look for this property in the + // layer list view. If it's true, don't show it. Document it in the + // map config docs. cesiumOptions: { data: { type: "FeatureCollection", @@ -73,10 +82,10 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { { type: "Feature", properties: {}, - geometry: { - coordinates: [], - type: "Polygon", - }, + "geometry": { + "coordinates": [], + "type": "Polygon" + } }, ], }, @@ -87,10 +96,9 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { /** * Removes the polygon object from the map */ - removeAsset: function () { - if (this.mapData) { - this.mapView.removeAsset(this.mapData); - } + removeDrawLayer: function () { + if (!this.model) return + this.model.removeAsset(this.model); }, /** @@ -98,8 +106,8 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { * @returns {DrawTool} Returns the view */ render: function () { - if (!this.mapView) { - this.handleNoMapView(); + if (!this.model) { + this.handleNoMapModel(); return; } this.renderToolbar(); @@ -109,15 +117,31 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { /** * What to do when this view doesn't have a map view to draw on */ - handleNoMapView: function () { - console.warn("No map view provided to DrawTool"); + handleNoMapModel: function () { + console.warn("No map model provided to DrawTool"); }, /** * Create and insert the buttons for drawing and clearing the polygon */ renderToolbar: function () { - // TODO: At a minimum we need buttons to: Start drawing, Clear drawing + // TODO: At a minimum we need buttons to: Start drawing, Clear drawing. + // Just some place holder buttons for now: + const view = this; + const el = this.el; + const drawButton = document.createElement("button"); + drawButton.innerHTML = "Draw"; + drawButton.addEventListener("click", function () { + view.activate(); + }); + el.appendChild(drawButton); + const clearButton = document.createElement("button"); + clearButton.innerHTML = "Clear"; + clearButton.addEventListener("click", function () { + view.removeDrawLayer(); + }); + el.appendChild(clearButton); + }, /** @@ -125,30 +149,28 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { */ startListeners: function () { this.stopListening(); - // TODO: We should either make a general method in the map that gives - // the coordinates of the mouse click, or we should add the Cesium event - // handler here. - this.listenTo(this.mapView, "click", this.handleClick); + // TODO: Make a general method in the map widget that gives the + // coordinates of the mouse click + this.listenTo(this.model, "change:clickedCoordinates", this.handleClick); }, /** * Stops the listeners for the draw tool */ stopListeners: function () { - this.stopListening(this.mapView); + this.stopListening(this.model); }, /** * Handles a click on the map. If the draw tool is active, it will add the * coordinates of the click to the polygon being drawn. - * @param {Event} event - The click event + * @param {Number[]} coordinates - The most recently clicked coordinates */ - handleClick: function (event) { + handleClick: function (coordinates) { if (!this.activated) { return; } - var coords = this.mapView.getMouseCoords(event); // <- TODO: This method doesn't exist yet - this.addCoordinate(coords); + this.addCoordinate(coordinates); }, /** @@ -159,13 +181,19 @@ define(["backbone", "cesium"], function (Backbone, Cesium) { // TODO: Something like this... We may also want to add a general method // to the VectorData model that allows us to add a coordinate, but this // will be specific to the GeoJsonDataSource - const geoJsonDataSource = this.mapData.get("cesiumModel"); - const geoJsonFeature = geoJsonDataSource.entities.values[0]; - const coordinates = geoJsonFeature.geometry.coordinates; - coordinates.push(coords); - geoJsonFeature.geometry.coordinates = coordinates; - geoJsonDataSource.entities.values[0] = geoJsonFeature; - this.mapData.updateAppearance(); + const layer = this.drawLayer; + const geoJSON = layer.get("cesiumOptions")?.data + const coordinates = geoJSON?.features[0]?.geometry?.coordinates + if (!coordinates) { + // Create new coordinates array + geoJSON.features[0].geometry.coordinates = [coords] + } else { + // Add to existing coordinates array + coordinates.push(coords) + } + layer.set("cesiumOptions", { data: geoJSON }) + // TODO: In all MapAsset models, listen for changes to the cesiumOptions + // object and re-create the cesiumModel when it changes. }, /** diff --git a/src/js/views/maps/ToolbarView.js b/src/js/views/maps/ToolbarView.js index ad50f2da7..e17974027 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -8,8 +8,9 @@ define( 'backbone', 'text!templates/maps/toolbar.html', 'models/maps/Map', - // Sub-views - 'views/maps/LayerListView' + // Sub-views - TODO: import these as needed + 'views/maps/LayerListView', + 'views/maps/DrawToolView' ], function ( $, @@ -18,7 +19,8 @@ define( Template, Map, // Sub-views - LayerListView + LayerListView, + DrawTool ) { /** @@ -172,6 +174,12 @@ define( action: function (view, model) { model.trigger('flyHome') } + }, + { + label: 'Draw', + icon: 'pencil', + view: DrawTool, + viewOptions: {} } ], From 24c80506b9ffd63e684c5b28579749705a097ba1 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 31 Aug 2023 16:36:38 -0400 Subject: [PATCH 04/24] Improve Cesium Map models, views, & collections - Add MapInteraction, GeoPoint and GeoBoundingBox models - Make the CesiumWidgetView methods smaller and more modular - Move Map model attributes to the MapInteraction model (selectedFeatures, currentPosition, currentScale, and currentViewExtent) - In MapAsset models, listen for changes to cesiumOptions and update Cesium model - In map connectors, don't use new models as defaults to avoid instantiating new models unnecessarily - Pass GeoPoint and GeoScale models directly to the ScaleBarView Issues #2189, #2180, #2187 --- src/js/collections/maps/Geohashes.js | 119 +- src/js/models/connectors/Filters-Map.js | 33 +- .../models/connectors/Map-Search-Filters.js | 17 +- src/js/models/connectors/Map-Search.js | 8 +- src/js/models/filters/SpatialFilter.js | 32 +- src/js/models/maps/GeoBoundingBox.js | 185 ++ src/js/models/maps/GeoPoint.js | 76 + src/js/models/maps/GeoScale.js | 62 + src/js/models/maps/Map.js | 164 +- src/js/models/maps/MapInteraction.js | 332 ++ src/js/models/maps/assets/CesiumGeohash.js | 46 +- src/js/models/maps/assets/MapAsset.js | 140 +- src/js/views/maps/CesiumWidgetView.js | 2766 +++++++++-------- src/js/views/maps/DrawToolView.js | 2 - src/js/views/maps/FeatureInfoView.js | 4 +- src/js/views/maps/LayerNavigationView.js | 9 +- src/js/views/maps/MapView.js | 106 +- src/js/views/maps/ScaleBarView.js | 72 +- src/js/views/maps/ToolbarView.js | 2 +- src/js/views/search/CatalogSearchView.js | 2 +- 20 files changed, 2454 insertions(+), 1723 deletions(-) create mode 100644 src/js/models/maps/GeoBoundingBox.js create mode 100644 src/js/models/maps/GeoPoint.js create mode 100644 src/js/models/maps/GeoScale.js create mode 100644 src/js/models/maps/MapInteraction.js diff --git a/src/js/collections/maps/Geohashes.js b/src/js/collections/maps/Geohashes.js index f606140b1..8c2846f45 100644 --- a/src/js/collections/maps/Geohashes.js +++ b/src/js/collections/maps/Geohashes.js @@ -125,21 +125,22 @@ define([ * 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 + * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west * properties. * @param {number} precision - Geohash precision level. * @returns {string[]} Array of geohash hashStrings. */ getHashStringsForBounds: function (bounds, precision) { this.validatePrecision(precision, false); - if (!this.boundsAreValid(bounds)) { + if (!bounds.isValid()) { throw new Error("Bounds are invalid"); } let hashStrings = []; - bounds = this.splitBoundingBox(bounds); + bounds = bounds.split(); bounds.forEach(function (b) { + const c = b.getCoords(); hashStrings = hashStrings.concat( - nGeohash.bboxes(b.south, b.west, b.north, b.east, precision) + nGeohash.bboxes(c.south, c.west, c.north, c.east, precision) ); }); return hashStrings; @@ -171,29 +172,10 @@ define([ 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. - * @param {Object} bounds - Bounding box object with north, south, east, - * and west properties. - * @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 }, - { north, south, east, west: -180 }, - ]; - } else { - return [{ north, south, east, west }]; - } - }, /** * Add geohashes to the collection based on a bounding box. - * @param {Object} bounds - Bounding box with north, south, east, and west + * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west * properties. * @param {boolean} [consolidate=false] - Whether to consolidate the * geohashes into the smallest set of geohashes that cover the same area. @@ -231,7 +213,7 @@ define([ maxGeohashes ); } else { - const area = this.getBoundingBoxArea(bounds); + const area = bounds.getArea(); const precision = this.getMaxPrecision( area, maxGeohashes, @@ -289,53 +271,6 @@ define([ 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) { - 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; - // 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); - }, - - /** - * 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) { - return ( - bounds && - 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 - ); - }, - /** * Given a bounding box, estimate the maximum geohash precision that can * be used to cover the area without exceeding a specified number of @@ -426,8 +361,8 @@ define([ /** * 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 {GeoBoundingBox} 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 @@ -443,7 +378,7 @@ define([ absMin = this.MIN_PRECISION, absMax = this.MAX_PRECISION ) { - if (!this.boundsAreValid(bounds)) { + if (!bounds.isValid()){ console.warn( `Bounds are invalid: ${JSON.stringify(bounds)}. ` + `Returning the min and max allowable precision levels.` @@ -452,7 +387,7 @@ define([ } absMin = this.validatePrecision(absMin); absMax = this.validatePrecision(absMax); - const area = this.getBoundingBoxArea(bounds); + const area = bounds.getArea(); const minP = this.getMinPrecision(area, absMin, absMax); if (minP === absMax || maxGeohashes === Infinity) return [minP, absMax]; return [minP, this.getMaxPrecision(area, maxGeohashes, minP, absMax)]; @@ -463,7 +398,7 @@ define([ * 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 + * @param {GeoBoundingBox} 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 @@ -485,7 +420,7 @@ define([ maxGeohashes = Infinity ) { // Check the inputs - if (!this.boundsAreValid(bounds)) return []; + if (!bounds.isValid()) return []; minPrecision = this.validatePrecision(minPrecision); maxPrecision = this.validatePrecision(maxPrecision); if (minPrecision > maxPrecision) minPrecision = maxPrecision; @@ -502,35 +437,23 @@ define([ // Base32 is the set of characters used to encode geohashes const base32 = [..."0123456789bcdefghjkmnpqrstuvwxyz"]; - // 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 (bounds.north >= 90 && bounds.south <= -90 && bounds.east >= 180 && bounds.west <= -180) { + if (bounds.coversEarth()) { return base32; } - // Checks if the given bounds are fully within the bounding box - 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, 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 + // the bounding box. In case the bounding box crosses the prime + // meridian, split it in two + const allBounds = bounds.split(); function hashPlacement(hash) { let [s, w, n, e] = nGeohash.decode_bbox(hash); let outside = []; for (const b of allBounds) { - if (fullyContained(n, e, s, w, b.north, b.east, b.south, b.west)) { + if (bounds.boundsAreFullyContained(n, e, s, w)) { return "inside"; } else if ( - fullyOutside(n, e, s, w, b.north, b.east, b.south, b.west) - ) { + bounds.boundsAreFullyOutside(n, e, s, w)) { outside.push(true); } } @@ -596,12 +519,12 @@ define([ /** * 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 + * @param {GeoBoundingBox} bounds - Bounding box with north, south, east, and west * properties. * @returns {Geohashes} Subset of geohashes. */ getSubsetByBounds: function (bounds) { - if (!this.boundsAreValid(bounds)) { + if (!bounds || !bounds.isValid()) { console.warn( `Bounds are invalid: ${JSON.stringify(bounds)}. ` + `Returning an empty Geohashes collection.` diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index 505d89a62..b3afd7772 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -47,9 +47,9 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( */ defaults: function () { return { - filters: new Filters([], { catalogSearch: true }), + filters: null, spatialFilters: [], - map: new Map(), + map: null, isConnected: false, onMoveEnd: this.updateSpatialFilters, }; @@ -68,6 +68,12 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( */ initialize: function (attr, options) { try { + if (!this.get("filters")) { + this.set("filters", new Filters([], { catalogSearch: true })); + } + if (!this.get("map")) { + this.set("map", new Map()); + } const add = options?.addSpatialFilter ?? true; this.findAndSetSpatialFilters(add); } catch (e) { @@ -177,8 +183,9 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( if (resetSpatialFilter) { this.resetSpatialFilter(); } + const interactions = this.get("map")?.get("interactions"); this.stopListening(this.get("filters"), "add remove"); - this.stopListening(this.get("map"), "moveEnd moveStart"); + this.stopListening(interactions, "moveEnd moveStart"); this.set("isConnected", false); } catch (e) { console.log("Error stopping Filter-Map listeners: ", e); @@ -186,23 +193,24 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( }, /** - * 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. + * Starts listening to the Map Interaction model for changes in the + * 'viewExtent' attribute, and calls the updateSpatialFilters function + * when changes are detected. This method needs to be called for the + * connector to work. */ connect: function () { try { this.disconnect(); const map = this.get("map"); + const interactions = map.get("interactions"); // 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.listenTo(interactions, "moveStart", function () { this.get("filters").trigger("changing"); }); - this.listenTo(map, "moveEnd", function () { + this.listenTo(interactions, "moveEnd", function () { const moveEndFunc = this.get("onMoveEnd"); if (typeof moveEndFunc === "function") { moveEndFunc.call(this); @@ -220,7 +228,12 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( updateSpatialFilters: function () { try { const map = this.get("map"); - const extent = map.get("currentViewExtent"); + // TODO: If we update the spatialFilter to use the GeoBoundingBox + // model (instead of north, south, east, west attributes), then we can + // point directly to the MapInteraction model's 'viewExtent' attribute + // instead of getting the extent from the map. They will stay in sync + // automatically. + const extent = map.get("interactions").get("viewExtent").toJSON(); const spatialFilters = this.get("spatialFilters"); if (!spatialFilters?.length) { diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index cc7557aca..d37bed28d 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -35,14 +35,14 @@ define([ /** * The default values for this model. * @type {object} - * @property {Map} map - * @property {SolrResults} searchResults - * @property {Filters} filters + * @property {Map} map - The Map model to use for this connector. + * @property {SolrResults} searchResults - The SolrResults model to use + * @property {Filters} filters - The Filters model to use for this */ defaults: function () { return { - map: new Map(), - searchResults: new SearchResults(), + map: null, + searchResults: null, filterGroups: [], filters: null, }; @@ -83,7 +83,7 @@ define([ initialize: function (attrs, options = {}) { if (!options) options = {}; const app = MetacatUI.appModel; - const map = options.map || app.get("catalogSearchMapOptions"); + const map = options.map || app.get("catalogSearchMapOptions") || {}; const searchResults = options.searchResults || null; const filterGroups = options.filterGroups || app.get("defaultFilterGroups"); @@ -242,6 +242,7 @@ define([ this.resetMoveEndSearch(); const map = this.get("map"); + const interactions = map.get("interactions"); const mapConnectors = this.getMapConnectors(); // Stop the sub-connectors from doing anything on moveEnd by setting @@ -253,7 +254,7 @@ define([ // 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 () { + this.listenTo(interactions, "moveEnd", function () { mapConnectors.forEach((connector) => { const moveEndFunc = connector.defaults().onMoveEnd; if (typeof moveEndFunc === "function") { @@ -270,7 +271,7 @@ define([ * @see coordinateMoveEndSearch */ resetMoveEndSearch: function () { - this.stopListening(this.get("map"), "moveEnd"); + this.stopListening(this.get("map").get("interactions"), "moveEnd"); const mapConnectors = this.getMapConnectors(); mapConnectors.forEach((connector) => { connector.set("onMoveEnd", connector.defaults().onMoveEnd); diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index 62958b408..ae7b5f266 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -163,6 +163,7 @@ define([ const searchResults = this.get("searchResults"); const map = this.get("map"); + const interactions = map.get("interactions"); // Pass the facet counts to the GeoHash layer when the search results // are returned. @@ -178,12 +179,12 @@ define([ // 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); + this.listenTo(interactions, "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.listenTo(interactions, "moveEnd", function () { const moveEndFunc = this.get("onMoveEnd"); if (typeof moveEndFunc === "function") { moveEndFunc.call(this); @@ -235,10 +236,11 @@ define([ */ disconnect: function () { const map = this.get("map"); + const interactions = map?.get("interactions"); const searchResults = this.get("searchResults"); this.stopListening(searchResults, "update reset"); this.stopListening(searchResults, "change:showOnMap"); - this.stopListening(map, "moveStart moveEnd"); + this.stopListening(interactions, "moveStart moveEnd"); this.stopListening(searchResults, "request"); this.set("isConnected", false); }, diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index d380387fe..6d2e9719e 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -1,11 +1,9 @@ define([ "underscore", "jquery", - "backbone", "models/filters/Filter", - "collections/maps/Geohashes", - "collections/Filters", -], function (_, $, Backbone, Filter, Geohashes, Filters) { + "collections/maps/Geohashes" +], function (_, $, Filter, Geohashes) { /** * @classdesc A SpatialFilter represents a spatial constraint on the query to * be executed. @@ -117,16 +115,26 @@ define([ /** * Convert the coordinate attributes to a bounds object - * @returns {object} An object with north, south, east, and west props + * @param {string} [as="object"] - The format to return the bounds in. + * Defaults to "object". Can set to GeoBoundingBox to return a Backbone + * model instead. + * @returns {object|GeoBoundingBox} An object with north, south, east, and west props or + * a GeoBoundingBox model * @since 2.25.0 */ - getBounds: function () { - return { + getBounds: function (as="object") { + const coords = { north: this.get("north"), south: this.get("south"), east: this.get("east"), west: this.get("west"), }; + if (as === "GeoBoundingBox") { + const GeoBoundingBox = require("models/maps/GeoBoundingBox"); + return new GeoBoundingBox(coords); + } else { + return coords; + } }, /** @@ -135,13 +143,7 @@ define([ * @since 2.25.0 */ coversEarth: function () { - const bounds = this.getBounds(); - return ( - bounds.north === 90 && - bounds.south === -90 && - bounds.east === 180 && - bounds.west === -180 - ); + const bounds = this.getBounds("GeoBoundingBox").coversEarth(); }, /** @@ -163,7 +165,7 @@ define([ } const geohashes = new Geohashes(); - const bounds = this.getBounds(); + const bounds = this.getBounds("GeoBoundingBox"); const limit = this.get("maxGeohashValues"); geohashes.addGeohashesByBounds(bounds, true, limit, true); this.set({ diff --git a/src/js/models/maps/GeoBoundingBox.js b/src/js/models/maps/GeoBoundingBox.js new file mode 100644 index 000000000..93dc4959c --- /dev/null +++ b/src/js/models/maps/GeoBoundingBox.js @@ -0,0 +1,185 @@ +"use strict"; + +define(["backbone"], function (Backbone) { + /** + * @class GeoBoundingBox + * @classdesc The GeoBoundingBox model stores the geographical boundaries for + * an area on the Earth's surface. It includes the northernmost, southernmost, + * easternmost, and westernmost latitudes and longitudes, as well as an + * optional height parameter. + * @classcategory Models/Maps + * @name GeoBoundingBox + * @since x.x.x + * @extends Backbone.Model + */ + var GeoBoundingBox = Backbone.Model.extend( + /** @lends GeoBoundingBox.prototype */ { + /** + * The type of model this is. + * @type {String} + */ + type: "GeoBoundingBox", + + /** + * Overrides the default Backbone.Model.defaults() function to specify + * default attributes for the GeoBoundingBox. + * @property {number|null} north The northernmost latitude of the bounding + * box. Should be a number between -90 and 90. + * @property {number|null} south The southernmost latitude of the bounding + * box. Should be a number between -90 and 90. + * @property {number|null} east The easternmost longitude of the bounding + * box. Should be a number between -180 and 180. + * @property {number|null} west The westernmost longitude of the bounding + * box. Should be a number between -180 and 180. + * @property {number|null} [height] The height of the camera above the + * bounding box. Represented in meters above sea level. This attribute is + * optional and can be null. + */ + defaults: function () { + return { + north: null, + south: null, + east: null, + west: null, + height: null, + }; + }, + + // /** + // * Run when a new GeoBoundingBox is created. + // * @param {Object} attrs - An object specifying configuration options for + // * the GeoBoundingBox. If any config option is not specified, the default + // * will be used instead (see {@link GeoBoundingBox#defaults}). + // */ + // initialize: function (attrs, options) { + // try { + // // ... + // } catch (e) { + // console.log("Error initializing a GeoBoundingBox model", e); + // } + // }, + + /** + * Splits the given bounding box if it crosses the prime meridian. + * Returns one or two new GeoBoundingBox models. + * @returns {GeoBoundingBox[]} An array of GeoBoundingBox models. One if + * the bounding box does not cross the prime meridian, two if it does. + */ + split: function () { + const { north, south, east, west } = this.getCoords(); + if (east < west) { + return [ + new GeoBoundingBox({ north, south, east: 180, west }), + new GeoBoundingBox({ north, south, east, west: -180 }), + ]; + } else { + return [this.clone()]; + } + }, + + /** + * Get the area of this bounding box in degrees. + * @returns {Number} The area of the bounding box in degrees. Will return + * the globe's area if the bounding box is invalid. + */ + getArea: function () { + if (!this.isValid()) { + 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 } = this.attributes; + // 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); + }, + + /** + * Return the four sides of the bounding box as an array. + * @returns {Object} An object with the northernmost, southernmost, + * easternmost, and westernmost coordinates of the bounding box. + */ + getCoords: function () { + return { + north: this.get("north"), + south: this.get("south"), + east: this.get("east"), + west: this.get("west"), + }; + }, + + /** + * Check if the bounding box covers the entire Earth. + * @returns {Boolean} True if the bounding box covers the entire Earth, + * false otherwise. + */ + coversEarth: function () { + const { north, south, east, west } = this.getCoords(); + return north >= 90 && south <= -90 && east >= 180 && west <= -180; + }, + + /** + * Check if another bounding box is fully contained within this bounding + * box. + * @param {Number} n - The northernmost latitude of the bounding box. + * @param {Number} e - The easternmost longitude of the bounding box. + * @param {Number} s - The southernmost latitude of the bounding box. + * @param {Number} w - The westernmost longitude of the bounding box. + * @returns {Boolean} True if the other bounding box is fully contained + * within this bounding box, false otherwise. + */ + boundsAreFullyContained: function (n, e, s, w) { + const { north, south, east, west } = this.getCoords(); + return s >= south && w >= west && n <= north && e <= east; + }, + + /** + * Check if another bounding box is fully outside of this bounding box. + * @param {Number} n - The northernmost latitude of the bounding box. + * @param {Number} e - The easternmost longitude of the bounding box. + * @param {Number} s - The southernmost latitude of the bounding box. + * @param {Number} w - The westernmost longitude of the bounding box. + * @returns {Boolean} True if the other bounding box is fully outside this + * bounding box, false otherwise. + */ + boundsAreFullyOutside: function (n, e, s, w) { + const { north, south, east, west } = this.getCoords(); + return n < south || s > north || e < west || w > east; + }, + + /** + * Validate the model attributes + * @param {Object} attrs - The model's attributes + */ + validate: function (attrs, options) { + const bounds = attrs; + const isValid = + bounds && + 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; + if (!isValid) { + return ( + "Bounds must include a number between -90 and 90 for north " + + "and south, and between -180 and 180 for east and west." + ); + } + }, + } + ); + + return GeoBoundingBox; +}); diff --git a/src/js/models/maps/GeoPoint.js b/src/js/models/maps/GeoPoint.js new file mode 100644 index 000000000..adb826709 --- /dev/null +++ b/src/js/models/maps/GeoPoint.js @@ -0,0 +1,76 @@ +"use strict"; + +define(["backbone"], function (Backbone) { + /** + * @class GeoPoint + * @classdesc The GeoPoint model stores geographical coordinates including + * latitude, longitude, and height in meters above sea level. + * @classcategory Models/Maps + * @name GeoPoint + * @since x.x.x + * @extends Backbone.Model + */ + var GeoPoint = Backbone.Model.extend( + /** @lends GeoPoint.prototype */ { + /** + * The type of model this is. + * @type {String} + */ + type: "GeoPoint", + + /** + * Overrides the default Backbone.Model.defaults() function to specify + * default attributes for the GeoPoint. + * @returns {Object} The default attributes + * @property {number} latitude - The latitude of the point in degrees + * @property {number} longitude - The longitude of the point in degrees + * @property {number} height - The height of the point in meters above sea + * level + */ + defaults: function () { + return { + latitude: null, + longitude: null, + height: null + }; + }, + + // /** + // * Run when a new GeoPoint is created. + // * @param {Object} attrs - An object specifying configuration options for + // * the GeoPoint. If any config option is not specified, the default will + // * be used instead (see {@link GeoPoint#defaults}). + // */ + // initialize: function (attrs, options) { + // try { + // // ... + // } catch (e) { + // console.log("Error initializing a GeoPoint model", e); + // } + // }, + + /** + * Validate the model attributes + * @param {Object} attrs - The model's attributes + */ + validate: function(attrs) { + if (attrs.latitude < -90 || attrs.latitude > 90) { + return "Invalid latitude. Must be between -90 and 90."; + } + + if (attrs.longitude < -180 || attrs.longitude > 180) { + return "Invalid longitude. Must be between -180 and 180."; + } + + // Assuming height is in meters and can theoretically be below sea + // level. Adjust the height constraints as needed for your specific + // application. + if (typeof attrs.height !== 'number') { + return "Invalid height. Must be a number."; + } + } + } + ); + + return GeoPoint; +}); diff --git a/src/js/models/maps/GeoScale.js b/src/js/models/maps/GeoScale.js new file mode 100644 index 000000000..ecfd43623 --- /dev/null +++ b/src/js/models/maps/GeoScale.js @@ -0,0 +1,62 @@ +"use strict"; + +define(["backbone"], function (Backbone) { + /** + * @class GeoScale + * @classdesc The GeoScale model stores the relative scale of the map in + * meters vs. pixels. This can be used to convert between the two. + * @classcategory Models/Maps + * @name GeoScale + * @since x.x.x + * @extends Backbone.Model + */ + var GeoScale = Backbone.Model.extend( + /** @lends GeoScale.prototype */ { + /** + * The type of model this is. + * @type {String} + */ + type: "GeoScale", + + /** + * Overrides the default Backbone.Model.defaults() function to specify + * default attributes for the GeoScale. + */ + defaults: function () { + return { + pixel: 1, + meters: null, + }; + }, + + // /** + // * Run when a new GeoScale is created. + // * @param {Object} attrs - An object specifying configuration options + // * for the GeoScale. If any config option is not specified, the default will be + // * used instead (see {@link GeoScale#defaults}). + // */ + // initialize: function (attrs, options) { + // try { + // // ... + // } catch (e) { + // console.log("Error initializing a GeoScale model", e); + // } + // }, + + /** + * Validate the model attributes + * @param {Object} attrs - The model's attributes + */ + validate: function (attrs, options) { + if (attrs.pixel < 0) { + return "Invalid pixel scale. Must be greater than 0."; + } + if (attrs.meters < 0) { + return "Invalid meters scale. Must be greater than 0."; + } + }, + } + ); + + return GeoScale; +}); diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 9ae10fd0b..341c73962 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -4,10 +4,9 @@ define([ "jquery", "underscore", "backbone", - "collections/maps/Features", - "models/maps/Feature", "collections/maps/MapAssets", -], function ($, _, Backbone, Features, Feature, MapAssets) { + "models/maps/MapInteraction", +], function ($, _, Backbone, MapAssets, Interactions) { /** * @class MapModel * @classdesc The Map Model contains all of the settings and options for a @@ -119,8 +118,8 @@ define([ * pitch: -90, * roll: 0 * } - */ - + */ + /** * The type of model this is. * @type {String} @@ -143,11 +142,6 @@ define([ * 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 and other tools. True by default. * @property {Boolean} [showLayerList=true] - Whether or not to include @@ -161,20 +155,6 @@ define([ * 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. * @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 @@ -197,29 +177,12 @@ define([ }, ]), terrains: new MapAssets(), - selectedFeatures: new Features(), showToolbar: true, showLayerList: true, showHomeButton: 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, - }, clickFeatureAction: "showDetails", }; }, @@ -245,6 +208,7 @@ define([ this.set("terrains", new MapAssets(config.terrains)); } } + this.setUpInteractions(); } catch (error) { console.log( "There was an error initializing a Map model" + @@ -255,84 +219,55 @@ define([ }, /** - * 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 - * 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. + * Set or replace the MapInteraction model on the map. + * @returns {MapInteraction} The new interactions model. + * @since x.x.x */ - selectFeatures: function (features, replace = true) { - try { - const model = this; - - // 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 ( - features && - currentFeatures && - currentFeatures.length === features.length && - currentFeatures.containsFeatures(features) - ) { - return; - } + setUpInteractions: function () { + const interactions = new Interactions({ + mapModel: this, + }); + this.set("interactions", interactions); + return interactions; + }, - // If no feature is passed to this function (and replace is true), - // then empty the Features collection - features = !features || !Array.isArray(features) ? [] : features; + /** + * Select features on the map. Updates the selectedFeatures attribute on + * the MapInteraction model. + * @param {Feature[]} features - An array of Feature models to select. + * since x.x.x + */ + selectFeatures: function (features) { + this.get("interactions")?.selectFeatures(features); + }, - // Convert the feature objects that are types specific to the map view - // (Cesium) to a generic Feature model - features = model.convertFeatures(features); + /** + * Get the currently selected features on the map. + * @returns {Features} The selected Feature collection. + * @since x.x.x + */ + getSelectedFeatures: function () { + return this.get("interactions")?.get("selectedFeatures"); + }, - // Update the Feature model with the new selected feature information. - 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); - } + /** + * Indicate that the map widget view should navigate to a given target. + * This is accomplished by setting the zoom target on the MapInteraction + * model. The map widget listens to this change and updates the camera + * position accordingly. + * @param {Feature|MapAsset|GeoBoundingBox|Object} target The target to + * zoom to. See {@link CesiumWidgetView#flyTo} for more details on types + * of targets. + */ + zoomTo: function (target) { + this.get("interactions")?.set("zoomTarget", target); }, /** - * Convert an array of feature objects to an array of Feature models. - * @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 2.25.0 + * Indicate that the map widget view should navigate to the home position. */ - convertFeatures: function (features) { - const model = this; - 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)); + flyHome: function () { + this.zoomTo(this.get("homePosition")); }, /** @@ -368,14 +303,13 @@ define([ * @since x.x.x */ removeAsset: function (asset) { - if(!asset) return; + if (!asset) return; const layers = this.get("layers"); - if(!layers) return; + if (!layers) return; // Remove by ID because the model is passed directly. Not sure if this // is a bug in the MapAssets collection or Backbone? if (layers) layers.remove(asset.cid); - } - + }, } ); diff --git a/src/js/models/maps/MapInteraction.js b/src/js/models/maps/MapInteraction.js new file mode 100644 index 000000000..b7dc2efd4 --- /dev/null +++ b/src/js/models/maps/MapInteraction.js @@ -0,0 +1,332 @@ +"use strict"; + +define([ + "backbone", + "collections/maps/Features", + "models/maps/Feature", + "models/maps/GeoBoundingBox", + "models/maps/GeoPoint", + "models/maps/GeoScale", +], function (Backbone, Features, Feature, GeoBoundingBox, GeoPoint, GeoScale) { + /** + * @class MapInteraction + * @classdesc The Map Interaction stores information about user interaction + * with a map, including the current position of the mouse, the feature that + * the mouse is currently hovering over, and the position on the map that the + * user has clicked, as well as the current view extent of the map. + * @classcategory Models/Maps + * @name MapInteraction + * @since x.x.x + * @extends Backbone.Model + */ + var MapInteraction = Backbone.Model.extend( + /** @lends MapInteraction.prototype */ { + /** + * The type of model this is. + * @type {String} + */ + type: "MapInteraction", + + /** + * Overrides the default Backbone.Model.defaults() function to specify + * default attributes for the Map. + * @returns {Object} The default attributes for the Map. + * @property {GeoPoint} mousePosition - The current position of the mouse + * on the map. + * @property {GeoPoint} clickedPosition - The position on the map that the + * user last clicked. + * @property {GeoScale} scale - The current scale of the map in + * pixels:meters. + * @property {GeoBoundingBox} viewExtent - The current extent of the map + * view. + * @property {Features} hoveredFeatures - The feature that the mouse is + * currently hovering over. + * @property {Features} clickedFeatures - The feature that the user last + * clicked. + * @property {Features} selectedFeatures - The feature that is currently + * selected. + * @property {Boolean} firstInteraction - Whether or not the user has + * interacted with the map yet. This is set to true when the user has + * clicked, hovered, panned, or zoomed the map. The only action that is + * ignored is mouse movement over the map. + * @property {String} previousAction - The previous action that was + * performed on the map. This may be any of the labels in the Cesium + * ScreenSpaceEventType enumeration: + * {@link https://cesium.com/learn/cesiumjs/ref-doc/global.html#ScreenSpaceEventType} + * @property {Feature|MapAsset|GeoBoundingBox} zoomTarget - The feature or + * map asset that the map should zoom to. The map widget should listen to + * this property and zoom to the specified feature or map asset when this + * property is set. The property should be cleared after the map widget + * has zoomed to the specified feature or map asset. + * + * TODO + * * @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. + * + * * @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) + */ + defaults: function () { + return { + mousePosition: new GeoPoint(), + clickedPosition: new GeoPoint(), + scale: new GeoScale(), + viewExtent: new GeoBoundingBox(), + hoveredFeatures: new Features(), + clickedFeatures: new Features(), + selectedFeatures: new Features(), + firstInteraction: false, // <- "hasInteracted"? + previousAction: null, + zoomTarget: null, + }; + }, + + /** + * Run when a new Map is created. + * @param {MapConfig} attrs - An object specifying configuration options + * for the map. If any config option is not specified, the default will be + * used instead (see {@link MapInteraction#defaults}). + */ + initialize: function (attrs, options) { + try { + this.connectEvents(); + } catch (e) { + console.log("Error initializing a Map Interaction model", e); + } + }, + + /** + * Connects the MapInteraction model to events from the map widget. + */ + connectEvents: function () { + this.listenForFirstInteraction(); + this.listenTo(this, "change:previousAction", this.handleClick); + }, + + /** + * Listens for the first interaction with the map (click, hover, pan, or + * zoom) and sets the 'firstInteraction' attribute to true when it occurs. + */ + listenForFirstInteraction: function () { + if (model.get("firstInteraction")) return; + const listener = new Backbone.Model(); + const model = this; + listener.listenTo( + this, + "change:previousAction", + function (m, eventType) { + if (eventType != "MOUSE_MOVE") { + model.set("firstInteraction", true); + listener.stopListening(); + listener.destroy(); + } + } + ); + }, + + /** + * Handles a mouse click on the map. If the user has clicked on a feature, + * the feature is set as the 'clickedFeatures' attribute. If the map is + * configured to show details when a feature is clicked, the feature is + * also set as the 'selectedFeatures' attribute. + * @param {MapInteraction} m - The MapInteraction model. + * @param {String} action - The type of mouse click event that occurred. + * All except LEFT_CLICK are ignored. + */ + handleClick: function (m, action) { + if (action !== "LEFT_CLICK") return; + // Clone the models in hovered features and set them as clicked features + const hoveredFeatures = this.get("hoveredFeatures").models; + this.setClickedFeatures(hoveredFeatures); + if (this.get("mapModel")?.get("clickFeatureAction") === "showDetails") { + this.selectFeatures(hoveredFeatures); + } + }, + + /** + * Sets the position of the mouse on the map. Creates a new GeoPoint model + * if one doesn't already exist on the mousePosition attribute. + * @param {Object} position - An object with 'longitude' and 'latitude' + * properties. + * @returns {GeoPoint} The mouse position as a GeoPoint model. + */ + setMousePosition: function (position) { + let mousePosition = this.get("mousePosition"); + if (!mousePosition) { + mousePosition = new GeoPoint(); + this.set("mousePosition", mousePosition); + } + mousePosition.set(position); + return mousePosition; + }, + + /** + * Set the pixel:meter scale of the map. Creates a new GeoScale model if + * one doesn't already exist on the scale attribute. + * @param {Object} scale - An object with 'meters' and 'pixels' + * properties. + * @returns {GeoScale} The scale as a GeoScale model. + */ + setScale: function (scale) { + let scaleModel = this.get("scale"); + if (!scaleModel) { + scaleModel = new GeoScale(); + this.set("scale", scaleModel); + } + scaleModel.set(scale); + return scaleModel; + }, + + /** + * Set the extent of the map view. Creates a new GeoBoundingBox model if + * one doesn't already exist on the viewExtent attribute. + * @param {Object} extent - An object with 'north', 'east', 'south', and + * 'west' properties. + * @returns {GeoBoundingBox} The view extent as a GeoBoundingBox model. + */ + setViewExtent: function (extent) { + let viewExtent = this.get("viewExtent"); + if (!viewExtent) { + viewExtent = new GeoBoundingBox(); + this.set("viewExtent", viewExtent); + } + viewExtent.set(extent); + return viewExtent; + }, + + /** + * Set the feature that the mouse is currently hovering over. + * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]} features - + * An array of feature objects selected directly from the map view. + */ + setHoveredFeatures: function (features) { + this.setFeatures(features, "hoveredFeatures", true); + }, + + /** + * Set the feature that the user last clicked. + * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]|Object[]} + * features - An array of feature objects selected directly from the map + * view. + */ + setClickedFeatures: function (features) { + this.setFeatures(features, "clickedFeatures", true); + }, + + /** + * Set the feature that is currently selected. + * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[|Object[]]} + * features - An array of feature objects selected directly from the map + * view. + */ + selectFeatures: function (features) { + this.setFeatures(features, "selectedFeatures", true); + }, + + /** + * Set features on either the hoveredFeatures, clickedFeatures, or + * selectedFeatures attribute. If the replace parameter is true, then the + * features will replace the current features on the attribute. + * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]|Object[]} + * features - An array of feature objects selected directly from the map + * view. + * @param {'hoveredFeatures'|'clickedFeatures'|'selectedFeatures'} type - + * The type of feature to set. + * @param {Boolean} [replace=true] - Whether or not to replace the current + * features on the attribute with the new features. + */ + setFeatures: function (features, type, replace = true) { + try { + const model = this; + + // Create a features collection if one doesn't already exist + if (!model.get(type)) model.set(type, new Features()); + + // Remove any null or undefined features + if (Array.isArray(features)) features = features.filter((f) => f); + // Remove any default features (which are empty models) + if (features instanceof Features) { + features = features.filter((f) => !f.isDefault()); + } + // If no feature is passed to this function (and replace is true), + if (!features || features.length === 0) { + if (replace) model.get(type).set([], { remove: true }); + return; + } + + // Ignore if new features are identical to the current features + const currentFeatures = model.get(type); + if ( + features && + currentFeatures && + currentFeatures.length === features.length && + currentFeatures.containsFeatures(features) + ) { + return; + } + + // Convert the feature objects, which may be types specific to the map + // widget (Cesium), to a generic Feature model + features = model.convertFeatures(features); + + // Update the Feature model with the new selected feature information. + const newAttrs = features.map(function (feature) { + return Object.assign( + {}, + new Feature().defaults(), + feature.attributes + ); + }); + model.get(type).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|Feature[]} features - + * An array of feature objects selected directly from the map view, or + * @returns {Feature[]} An array of Feature models. + * @since 2.25.0 + */ + convertFeatures: function (features) { + if (!features) return []; + if (!features.map) features = [features]; + const mapModel = this.get("mapModel"); + 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 mapModel.get("layers").getFeatureAttributes(features)?.[0]; + }); + return attrs.map((attr) => new Feature(attr)); + }, + } + ); + + return MapInteraction; +}); diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index c07e8e72c..b4f23328f 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -153,8 +153,7 @@ define([ getPrecision: function () { const limit = this.get("maxGeoHashes"); const geohashes = this.get("geohashes") - const bounds = this.get("mapModel").get("currentViewExtent"); - const area = geohashes.getBoundingBoxArea(bounds); + const area = this.getViewExtent().getArea(); return this.get("geohashes").getMaxPrecision(area, limit); }, @@ -220,12 +219,19 @@ define([ * @since 2.25.0 */ getGeohashesForExtent: function () { - const extent = this.get("mapModel")?.get("currentViewExtent"); - const bounds = Object.assign({}, extent); - delete bounds.height; + const bounds = this.getViewExtent()?.clone(); + if (!bounds) return this.get("geohashes"); return this.get("geohashes")?.getSubsetByBounds(bounds); }, + /** + * Get the current map extent from the Map Interaction model. + * @returns {GeoBoundingBox} The current map extent + */ + getViewExtent: function () { + return this.get("mapModel")?.get("interactions")?.get("viewExtent") + }, + /** * Returns the GeoJSON representation of the geohashes. * @param {Boolean} [limitToExtent = true] - Set to false to return the @@ -289,19 +295,23 @@ define([ * @since 2.25.0 */ selectGeohashes: function (geohashes) { - const toSelect = [...new Set(geohashes.map((geohash) => { - const parent = this.get("geohashes").getContainingGeohash(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); + try { + const toSelect = [...new Set(geohashes.map((geohash) => { + const parent = this.get("geohashes").getContainingGeohash(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); + } catch (e) { + console.log("Error selecting geohashes", e); + } }, } ); diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index cccbcf4b0..0441f4b46 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -325,44 +325,61 @@ define( this.set('colorPalette', new AssetColorPalette(assetConfig.colorPalette)) } + // Fetch the icon, if there is one + if (assetConfig.icon) { + if (model.isSVG(assetConfig.icon)) { + model.updateIcon(assetConfig.icon) + } else { + // If the string is not an SVG then assume it is a PID and try to fetch + // the SVG file. fetchIcon will update the icon when complete. + model.fetchIcon(assetConfig.icon) + } + } + + this.setListeners(); + } + catch (e) { + console.log('Error initializing a MapAsset model', e); + } + }, + + /** + * Set all of the listeners for this model + * @since x.x.x + */ + setListeners: function () { + try { + // The map asset cannot be visible on the map if there was an error loading // the asset + this.stopListening(this, 'change:status') this.listenTo(this, 'change:status', function (model, status) { if (status === 'error') { this.set('visible', false) } }) + this.stopListening(this, 'change:visible') this.listenTo(this, 'change:visible', function (model, visible) { if (this.get('status') === 'error') { this.set('visible', false) } }) - // Fetch the icon, if there is one - if (assetConfig.icon) { - if (model.isSVG(assetConfig.icon)) { - model.updateIcon(assetConfig.icon) - } else { - // If the string is not an SVG then assume it is a PID and try to fetch - // the SVG file. fetchIcon will update the icon when complete. - model.fetchIcon(assetConfig.icon) - } - } - // Update the style of the asset to highlight the selected features when // features from this asset are selected in the map. if (typeof this.updateAppearance === 'function') { const setSelectFeaturesListeners = function () { - const mapModel = this.get('mapModel') + const mapModel = this.get('mapModel'); if (!mapModel) { return } - const selectedFeatures = mapModel.get('selectedFeatures') + const interactions = mapModel.get('interactions'); + const selectedFeatures = mapModel.getSelectedFeatures(); this.stopListening(selectedFeatures, 'update'); this.listenTo(selectedFeatures, 'update', this.updateAppearance) - this.stopListening(mapModel, 'change:selectedFeatures') - this.listenTo(mapModel, 'change:selectedFeatures', function () { + this.stopListening(interactions, 'change:selectedFeatures') + this.listenTo(interactions, 'change:selectedFeatures', function () { this.updateAppearance() setSelectFeaturesListeners() }) @@ -372,13 +389,16 @@ define( this.stopListening(this, 'change:mapModel', setSelectFeaturesListeners) this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) } + + // Listen for changes to the cesiumOptions object + this.stopListening(this, 'change:cesiumOptions'); + this.listenTo(this, 'change:cesiumOptions', function () { + this.createCesiumModel(true) + }) + } catch (e) { + console.log("Error setting MapAsset Listeners.", e); } - catch (error) { - console.log( - 'There was an error initializing a MapAsset model' + - '. Error details: ' + error - ); - } + }, /** @@ -407,7 +427,7 @@ define( featureIsSelected: function (feature) { const map = this.get('mapModel') if (!map) { return false } - return map.get('selectedFeatures').containsFeature(feature) + return map.getSelectedFeatures(); }, /** @@ -796,6 +816,16 @@ define( } }, + /** + * Indicate that the map widget should navigate to a given feature from + * this MapAsset. + * @param {Feature} feature The feature to navigate to. + * @since x.x.x + */ + zoomTo: function (target) { + this.get('mapModel')?.zoomTo(target) + }, + /** * Checks that the visible attribute is set to true and that the opacity attribute * is greater than zero. If both conditions are met, returns true. @@ -821,72 +851,6 @@ define( } }, - // /** - // * 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 MapAsset attributes - // */ - // parse: function (input) { - - // try { - - // var modelJSON = {}; - - // return modelJSON - - // } - // catch (error) { - // console.log( - // 'There was an error parsing a MapAsset 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 { - // // Required attributes: type, url, label, description (all strings) - // } - // catch (error) { - // console.log( - // 'There was an error validating a MapAsset model' + - // '. Error details: ' + error - // ); - // } - // }, - - // /** - // * Creates a string using the values set on this model's attributes. - // * @return {string} The MapAsset string - // */ - // serialize: function () { - // try { - // var serializedMapAsset = ''; - - // return serializedMapAsset; - // } - // catch (error) { - // console.log( - // 'There was an error serializing a MapAsset model' + - // '. Error details: ' + error - // ); - // } - // }, - }); return MapAsset; diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index b30fb0f4e..fd8ea913c 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -1,1422 +1,1558 @@ -'use strict'; - -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'cesium', - 'models/maps/Map', - 'models/maps/assets/MapAsset', - 'models/maps/assets/Cesium3DTileset', - 'models/maps/Feature', - 'text!templates/maps/cesium-widget-view.html' - ], - function ( - $, - _, - Backbone, - Cesium, - Map, - MapAsset, - Cesium3DTileset, - Feature, - Template - ) { - - /** - * @class CesiumWidgetView - * @classdesc An interactive 2D and/or 3D map/globe rendered using CesiumJS. This view - * comprises the globe without any of the UI elements like the scalebar, layer list, - * etc. - * @classcategory Views/Maps - * @name CesiumWidgetView - * @extends Backbone.View - * @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 */{ - - /** - * The type of View this is - * @type {string} - */ - type: 'CesiumWidgetView', - - /** - * The HTML classes to use for this view's element. Note that the first child - * element added to this view by cesium will have the class "cesium-widget". - * @type {string} - */ - className: 'cesium-widget-view', - - /** - * The model that this view uses - * @type {Map} - */ - model: null, - - /** - * The primary HTML template for this view - * @type {Underscore.template} - */ - template: _.template(Template), - - /** - * An array of objects the match a Map Asset's type property to the function in - * this view that adds and renders that asset on the map, given the Map Asset - * model. Each object in the array has two properties: 'types' and - * 'renderFunction'. - * @type {Object[]} - * @property {string[]} types The list of types that can be added to the map given - * the renderFunction - * @property {string} renderFunction The name of the function in the view that - * will add the asset to the map and render it, when passed the cesiumModel - * attribute from the MapAsset model - * @property {string} removeFunction The name of the function in the view that - * will remove the asset from the map, when passed the cesiumModel attribute from - * the MapAsset model - */ - mapAssetRenderFunctions: [ - { - types: ['Cesium3DTileset'], - renderFunction: 'add3DTileset', - removeFunction: 'remove3DTileset' - }, - { - types: ['GeoJsonDataSource', 'CzmlDataSource'], - renderFunction: 'addVectorData', - removeFunction: 'removeVectorData' - }, - { - types: ['BingMapsImageryProvider', 'IonImageryProvider', 'TileMapServiceImageryProvider', 'WebMapTileServiceImageryProvider', 'WebMapServiceImageryProvider', 'OpenStreetMapImageryProvider'], - renderFunction: 'addImagery', - removeFunction: 'removeImagery' - }, - { - types: ['CesiumTerrainProvider'], - renderFunction: 'updateTerrain', - removeFunction: null - } - ], - - /** - * The border color to use on vector features that a user clicks. - * See {@link https://cesium.com/learn/cesiumjs/ref-doc/Color.html?classFilter=color} - * @type {Cesium.Color} - */ - // TODO - Consider making this color configurable in the Map model - highlightBorderColor: Cesium.Color.WHITE, - - /** - * Executed when a new CesiumWidgetView is created - * @param {Object} [options] - A literal object with options to pass to the view - */ - initialize: function (options) { - try { - - // Set the Cesium Ion token (required for some map features) - Cesium.Ion.defaultAccessToken = MetacatUI.appModel.get('cesiumToken'); - - // Get all the options and apply them to this view - if (typeof options == 'object') { - for (const [key, value] of Object.entries(options)) { - this[key] = value; - } +"use strict"; + +define([ + "jquery", + "underscore", + "backbone", + "cesium", + "models/maps/Map", + "models/maps/assets/MapAsset", + "models/maps/assets/Cesium3DTileset", + "models/maps/Feature", + "text!templates/maps/cesium-widget-view.html", +], function ( + $, + _, + Backbone, + Cesium, + Map, + MapAsset, + Cesium3DTileset, + Feature, + Template +) { + /** + * @class CesiumWidgetView + * @classdesc An interactive 2D and/or 3D map/globe rendered using CesiumJS. + * This view comprises the globe without any of the UI elements like the + * scalebar, layer list, etc. + * @classcategory Views/Maps + * @name CesiumWidgetView + * @extends Backbone.View + * @screenshot views/maps/CesiumWidgetView.png + * @since 2.18.0 + * @constructs + * @fires MapInteraction#moved + * @fires MapInteraction#moveEnd + * @fires MapInteraction#moveStart + */ + var CesiumWidgetView = Backbone.View.extend( + /** @lends CesiumWidgetView.prototype */ { + /** + * The type of View this is + * @type {string} + */ + type: "CesiumWidgetView", + + /** + * The HTML classes to use for this view's element. Note that the first + * child element added to this view by cesium will have the class + * "cesium-widget". + * @type {string} + */ + className: "cesium-widget-view", + + /** + * The model that this view uses + * @type {Map} + */ + model: null, + + /** + * The primary HTML template for this view + * @type {Underscore.template} + */ + template: _.template(Template), + + /** + * An array of objects the match a Map Asset's type property to the + * function in this view that adds and renders that asset on the map, + * given the Map Asset model. Each object in the array has two properties: + * 'types' and 'renderFunction'. + * @type {Object[]} + * @property {string[]} types The list of types that can be added to the + * map given the renderFunction + * @property {string} renderFunction The name of the function in the view + * that will add the asset to the map and render it, when passed the + * cesiumModel attribute from the MapAsset model + * @property {string} removeFunction The name of the function in the view + * that will remove the asset from the map, when passed the cesiumModel + * attribute from the MapAsset model + */ + mapAssetRenderFunctions: [ + { + types: ["Cesium3DTileset"], + renderFunction: "add3DTileset", + removeFunction: "remove3DTileset", + }, + { + types: ["GeoJsonDataSource", "CzmlDataSource"], + renderFunction: "addVectorData", + removeFunction: "removeVectorData", + }, + { + types: [ + "BingMapsImageryProvider", + "IonImageryProvider", + "TileMapServiceImageryProvider", + "WebMapTileServiceImageryProvider", + "WebMapServiceImageryProvider", + "OpenStreetMapImageryProvider", + ], + renderFunction: "addImagery", + removeFunction: "removeImagery", + }, + { + types: ["CesiumTerrainProvider"], + renderFunction: "updateTerrain", + removeFunction: null, + }, + ], + + /** + * The border color to use on vector features that a user clicks. See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/Color.html?classFilter=color} + * @type {Cesium.Color} + */ + // TODO - Make this color configurable in the Map model + highlightBorderColor: Cesium.Color.WHITE, + + /** + * Executed when a new CesiumWidgetView is created + * @param {Object} [options] - A literal object with options to pass to + * the view + */ + initialize: function (options) { + try { + // Set the Cesium Ion token (required for some map features) + Cesium.Ion.defaultAccessToken = MetacatUI.appModel.get("cesiumToken"); + + // Get all the options and apply them to this view + if (typeof options == "object") { + for (const [key, value] of Object.entries(options)) { + this[key] = value; } + } - // Make sure that there is a Map model and that it has a selectedFeature - // attribute. The selectedFeature attribute is used to store information about - // the vector feature, if any, that is currently in focus on the map. - if (!this.model) { - this.model = new Map() - } - if (!this.model.get('selectedFeatures')) { - this.model.selectFeatures() - } + if (!this.model) { + this.model = new Map(); + } + if (!this.model.get("interactions")) { + this.model.setUpInteractions(); + } + this.interactions = this.model.get("interactions"); + // The selectedFeature attribute is used to store information about + // the vector feature, if any, that is currently in focus on the map. + if (!this.interactions.get("selectedFeatures")) { + this.interactions.selectFeatures(); + } + } catch (e) { + console.log("Failed to initialize a CesiumWidgetView. ", e); + } + }, + + /** + * Renders this view + * @return {CesiumWidgetView} Returns the rendered view element + */ + render: function () { + try { + // If Cesium features are disabled in the AppConfig, then exit without + // rendering anything. + if (!MetacatUI.appModel.get("enableCesium")) { + return; + } + // Save a reference to this view + const view = this; - } catch (e) { - console.log('Failed to initialize a CesiumWidgetView. Error message: ' + e); - } + // Insert the template into the view + view.$el.html(view.template({})); - }, + // Create the Cesium Widget + view.renderWidget(); - /** - * Renders this view - * @return {CesiumWidgetView} Returns the rendered view element - */ - render: function () { + // Configure the lighting on the globe + view.setLighting(); - try { + // Prepare Cesium to handle vector datasources (e.g. + // geoJsonDataSources) + view.setUpDataSourceDisplay(); - // If Cesium features are disabled in the AppConfig, then exit without rendering - // anything. - if (!MetacatUI.appModel.get('enableCesium')) { - return; - } + // Listeners for changes & events to the layers & map + view.setAssetListeners(); + view.setNavigationListeners(); + // Listen to Cesium screen space events and update Interactions model + view.setCameraListeners(); + view.setMouseListeners(); + // Listen to Interactions model and react when e.g. something is + // clicked + view.setInteractionListeners(); - // Save a reference to this view - const view = this; - - // Insert the template into the view - view.$el.html(view.template({})); - - // Ensure the view's main element has the given class name - view.el.classList.add(view.className); - - // Clock will be used for the timeline component, and for the clock.ontick - // event - view.clock = new Cesium.Clock({ shouldAnimate: false }) - - // Create the Cesium Widget and save a reference to it to the view - view.widget = new Cesium.CesiumWidget(view.el, { - clock: view.clock, - // 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, - // Need to change the following once we support a time/clock component. - // See https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/#handling-simulation-time-changes. - maximumRenderTimeChange: Infinity - }); + // Render the layers + view.addLayers(); - // Save references to parts of the widget that the view will access often - view.scene = view.widget.scene; - view.camera = view.widget.camera; - view.inputHandler = view.widget.screenSpaceEventHandler; + // Go to the home position, if one is set. + view.flyHome(0); - // Decrease the amount the camera must change before the changed event is - // raised. - view.camera.percentChanged = 0.1 + // Set the map up so that selected features may be highlighted + view.setUpSilhouettes(); - // Disable HDR lighting for better performance and to avoid changing imagery colors. - view.scene.highDynamicRange = false; - view.scene.globe.enableLighting = false; + return this; + } catch (e) { + console.log("Failed to render a CesiumWidgetView,", e); + // TODO: Render a fallback map or error message + } + }, + + /** + * Create the Cesium Widget and save a reference to it to the view + * @since x.x.x + * @returns {Cesium.CesiumWidget} The Cesium Widget + */ + renderWidget: function () { + const view = this; + // Clock for timeline component & updating data sources + view.clock = new Cesium.Clock({ shouldAnimate: false }); + + // Create the Cesium Widget and save a reference to it to the view + view.widget = new Cesium.CesiumWidget(view.el, { + clock: view.clock, + // 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, + // Need to change the following once we support a time/clock + // component. See + // https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/#handling-simulation-time-changes. + maximumRenderTimeChange: Infinity, + }); + + // Save references to parts of widget the view will access often + view.scene = view.widget.scene; + view.camera = view.widget.camera; + + return view.widget; + }, + + /** + * Create a DataSourceDisplay and DataSourceCollection for the Cesium + * widget, and listen to the clock tick to update the display. This is + * required to display vector data (e.g. GeoJSON) on the map. + * @since x.x.x + * @returns {Cesium.DataSourceDisplay} The Cesium DataSourceDisplay + */ + setUpDataSourceDisplay: function () { + const view = this; + view.dataSourceCollection = new Cesium.DataSourceCollection(); + view.dataSourceDisplay = new Cesium.DataSourceDisplay({ + scene: view.scene, + dataSourceCollection: view.dataSourceCollection, + }); + view.clock.onTick.removeEventListener( + view.updateDataSourceDisplay, + view + ); + view.clock.onTick.addEventListener(view.updateDataSourceDisplay, view); + return view.dataSourceDisplay; + }, + + /** + * Because the Cesium widget is configured to use explicit rendering (see + * {@link https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/}), + * we need to tell Cesium when to render a new frame if it's not one of + * the cases handle automatically. This function tells the Cesium scene to + * render, but is limited by the underscore.js debounce function to only + * happen a maximum of once every 50 ms (see + * {@link https://underscorejs.org/#debounce}). + */ + requestRender: _.debounce(function () { + this.scene.requestRender(); + }, 90), + + /** + * Functions called after each time the scene renders. If a zoom target + * has been set by the {@link CesiumWidgetView#flyTo} function, then calls + * the functions that calculates the bounding sphere and zooms to it + * (which required to visual elements to be rendered first.) + */ + postRender: function () { + try { + const view = this; + if (view.zoomTarget) { + view.completeFlight(view.zoomTarget, view.zoomOptions); + } + } catch (e) { + console.log("Error calling post render functions:", e); + } + }, + + /** + * Runs on every Cesium clock tick. Updates the display of the + * CesiumVectorData models in the scene. Similar to + * Cesium.DataSourceDisplay.update function, in that it runs update() on + * each DataSource and each DataSource's visualizer, except that it also + * updates each CesiumVectorData model's 'displayReady' attribute. (Sets + * to true when the asset is ready to be rendered in the map, false + * otherwise). Also re-renders the scene when the displayReady attribute + * changes. + */ + updateDataSourceDisplay: function () { + try { + const view = this; + const layers = view.model.get("layers"); - // Keep all parts of the globe lit regardless of what time the Cesium clock is - // set to. This avoids data and imagery appearing too dark. - view.scene.light = new Cesium.DirectionalLight({ - direction: new Cesium.Cartesian3(1, 0, 0) - }); - view.scene.preRender.addEventListener(function (scene, time) { - view.scene.light.direction = Cesium.Cartesian3.clone( - scene.camera.directionWC, view.scene.light.direction - ); - }); + var dataSources = view.dataSourceDisplay.dataSources; + if (!dataSources || !dataSources.length) { + return; + } - // Prepare Cesium to handle vector datasources (e.g. geoJsonDataSources) - view.dataSourceCollection = new Cesium.DataSourceCollection(); - view.dataSourceDisplay = new Cesium.DataSourceDisplay({ - scene: view.scene, - dataSourceCollection: view.dataSourceCollection, - }); - view.clock.onTick.addEventListener(function () { - view.updateDataSourceDisplay.call(view) - }) + let allReady = true; + const allReadyBefore = view.dataSourceDisplay._ready; - view.setListeners(); - view.addLayers(); + for (let i = 0, len = dataSources.length; i < len; i++) { + const time = view.clock.currentTime; + const dataSource = dataSources.get(i); + const visualizers = dataSource._visualizers; - // Go to the home position, if one is set. - view.flyHome(0) + const assetModel = layers.findWhere({ + cesiumModel: dataSource, + }); + let displayReadyNow = dataSource.update(time); - // If users are allowed to click on features for more details, - // initialize picking behavior on the map. - if (view.model.get('showFeatureInfo')) { - view.initializePicking() + for (let x = 0; x < visualizers.length; x++) { + displayReadyNow = visualizers[x].update(time) && displayReadyNow; } - return this + assetModel.set("displayReady", displayReadyNow); + allReady = displayReadyNow && allReady; } - catch (error) { - console.log( - 'Failed to render a CesiumWidgetView. Error details: ' + error - ); - } - }, - - /** - * Set all of the listeners for the CesiumWidgetView. This function is - * called during the render function. - * @since 2.26.0 - */ - setListeners: function () { + // If any dataSource has switched display states, then re-render the + // scene. + if (allReady !== allReadyBefore) { + view.scene.requestRender(); + } + // The dataSourceDisplay must be set to 'ready' to get bounding + // spheres for dataSources + view.dataSourceDisplay._ready = allReady; + } catch (e) { + console.log("Error updating the data source display.", e); + } + }, + + /** + * Configure the lighting on the globe. + */ + setLighting: function () { + const view = this; + // Disable HDR lighting for better performance & to keep imagery + // consistently lit. + view.scene.highDynamicRange = false; + view.scene.globe.enableLighting = false; + + // Keep all parts of the globe lit regardless of what time the Cesium + // clock is set to. This avoids data and imagery appearing too dark. + view.scene.light = new Cesium.DirectionalLight({ + direction: new Cesium.Cartesian3(1, 0, 0), + }); + view.scene.preRender.addEventListener(function (scene, time) { + view.scene.light.direction = Cesium.Cartesian3.clone( + scene.camera.directionWC, + view.scene.light.direction + ); + }); + }, + + /** + * Set up the Cesium scene and set listeners and behavior that enable + * users to click on vector features on the map to highlight them. + * @since x.x.x + */ + setUpSilhouettes: function () { + try { + // Save a reference to this view the Cesium scene + var view = this; + var scene = this.scene; + + // To add an outline to 3D tiles in Cesium, we 'silhouette' them. Set + // up the the scene to support silhouetting. + view.silhouettes = + Cesium.PostProcessStageLibrary.createEdgeDetectionStage(); + view.silhouettes.uniforms.color = view.highlightBorderColor; + view.silhouettes.uniforms.length = 0.02; + view.silhouettes.selected = []; + scene.postProcessStages.add( + Cesium.PostProcessStageLibrary.createSilhouetteStage([ + view.silhouettes, + ]) + ); + } catch (e) { + console.log("Error initializing picking in a CesiumWidgetView", e); + } + }, + + /** + * Listen for changes to the assets and update the map accordingly. + * @since x.x.x + */ + setAssetListeners: function () { + const view = this; + const model = view.model; + const layers = model.get("layers"); + + // Listen for addition or removal of layers TODO: Add similar listeners + // for terrain + view.stopListening(layers, "add"); + view.listenTo(layers, "add", view.addAsset); + view.stopListening(layers, "remove"); + view.listenTo(layers, "remove", view.removeAsset); + + // Each layer fires 'appearanceChanged' whenever the color, opacity, + // etc. has been updated. Re-render the scene when this happens. + view.stopListening(layers, "appearanceChanged"); + view.listenTo(layers, "appearanceChanged", view.requestRender); + + // Reset asset listeners if the layers collection is replaced + view.stopListening(model, "change:layers"); + view.listenTo(model, "change:layers", view.setAssetListeners); + }, + + /** + * Remove listeners for dynamic navigation. + * @since x.x.x + */ + removeNavigationListeners: function () { + this.stopListening(this.interactions, "change:zoomTarget", this.flyTo); + if (this.removePostRenderListener) this.removePostRenderListener(); + }, + + /** + * Set up listeners to allow for dynamic navigation. This includes zooming + * to the extent of a layer and zooming to the home position. Note that + * other views may trigger an event on the layer/asset model that + * indicates that the map should navigate to a given extent. + * @since x.x.x + */ + setNavigationListeners: function () { + this.removeNavigationListeners(); + // Zoom functions executed after each scene render + this.removePostRenderListener = this.scene.postRender.addEventListener( + this.postRender, + this + ); + this.listenTo(this.interactions, "change:zoomTarget", function () { + const target = this.interactions.get("zoomTarget"); + if (target) { + this.flyTo(target); + } + }); + }, + + /** + * Remove any previously set camera listeners. + * @since x.x.x + */ + removeCameraListeners: function () { + if (!this.cameraListeners) this.cameraListeners = []; + this.cameraListeners.forEach(function (removeListener) { + removeListener(); + }); + }, + + /** + * Listen to cesium camera events, and translate them to events on the + * interactions model. Also update the scale (pixels:meters) and the view + * extent when the camera has moved. + */ + setCameraListeners: function () { + try { const view = this; - - // Listen for addition or removal of layers - // TODO: Add similar listeners for terrain - const layers = view.model.get('layers') - view.stopListening(layers, 'add'); - view.listenTo(layers, 'add', view.addAsset); - view.stopListening(layers, 'remove'); - view.listenTo(layers, 'remove', view.removeAsset); - - // Zoom functions executed after each scene render - view.scene.postRender.addEventListener(function () { - view.postRender(); + const camera = view.camera; + const interactions = view.interactions; + + // Remove any previously set camera listeners + view.removeCameraListeners(); + // Amount camera must change before firing 'changed' event. + camera.percentChanged = 0.1; + + // Functions to run for each Cesium camera event + const cameraEvents = { + moveEnd: [], + moveStart: [], + changed: ["updateScale", "updateViewExtent"], + }; + // add a listener that triggers the same event on the interactions + // model, and runs any functions configured above. + Object.entries(cameraEvents).forEach(function ([label, functions]) { + const callback = function () { + interactions.trigger(label); + functions.forEach(function (func) { + view[func].call(view); + }); + }; + const remover = camera[label].addEventListener(callback, view); + view.cameraListeners.push(remover); }); - - // When the user first interacts with the map, update the model. - // Ignore the event where the user just moves the mouse over the map. - view.listenOnceForInteraction(function () { - view.model.set('firstInteraction', true); - }, ["MOUSE_MOVE"]); - - // 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 model when the camera angle/zoom level changes - if (view.model.get('showScaleBar')) { - view.updateCurrentScale() + } catch (e) { + console.log("Error updating the model on camera events", e); + } + }, + + /** + * Remove any previously set mouse listeners. + * @since x.x.x + */ + removeMouseListeners: function () { + if (this.mouseEventHandler) this.mouseEventHandler.destroy(); + }, + + /** + * Set up listeners for mouse events on the map. This includes listening + * for mouse clicks, mouse movement, and mouse hovering over features. + * These listeners simply update the interactions model with mouse events. + * @since x.x.x + */ + setMouseListeners: function () { + const view = this; + const events = Cesium.ScreenSpaceEventType; + + // Remove previous listeners if they exist. + view.removeMouseListeners; + // Create Cesium object that handles interactions with the map. + const handler = (view.mouseEventHandler = + new Cesium.ScreenSpaceEventHandler(view.scene.canvas)); + + // Every time the user interacts with the map, update the interactions + // model with the type of interaction that occurred. + Object.entries(events).forEach(function ([label, value]) { + handler.setInputAction(function (event) { + view.interactions.set("previousAction", label); + if (label == "MOUSE_MOVE") { + const position = event.position || event.endPosition; + view.setMousePosition(position); + view.setHoveredFeatures(position); } - }) - - 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() - - // When the appearance of a layer has been updated, then tell Cesium - // to re-render the scene. Each layer model triggers the - // 'appearanceChanged' function whenever the color, opacity, etc. - // has been updated in the associated Cesium model. - view.stopListening(view.model.get('layers'), 'appearanceChanged') - view.listenTo(view.model.get('layers'), 'appearanceChanged', view.requestRender) - - // Other views may trigger an event on the layer/asset model that - // indicates that the map should navigate to the extent of the data, - // or on the Map model to navigate to the home position. - view.stopListening(view.model.get('layers'), 'flyToExtent') - view.listenTo(view.model.get('layers'), 'flyToExtent', view.flyTo) - view.stopListening(view.model, 'flyHome') - view.listenTo(view.model, 'flyHome', view.flyHome) - }, - - /** - * Listen for any user interaction with the map. Once an interaction has - * occurred, run the callback function and stop listening for - * interactions. Useful for detecting the first user interaction with the - * map. - * @param {function} callback - The function to run once the interaction - * has occurred. - * @param {string[]} ignore - An array of Cesium.ScreenSpaceEventType - * labels to ignore. See - * {@link https://cesium.com/learn/cesiumjs/ref-doc/ScreenSpaceEventType.html} - * @since 2.26.0 - */ - listenOnceForInteraction: function ( - callback, - ignore = [] - ) { - const view = this; - const events = Cesium.ScreenSpaceEventType; - const inputHandler = new Cesium.ScreenSpaceEventHandler( - view.scene.canvas - ); - if (!ignore || !Array.isArray(ignore)) ignore = []; - - Object.entries(events).forEach(function ([label, value]) { - if (ignore.includes(label)) return; - inputHandler.setInputAction(function () { - callback(); - inputHandler.destroy(); - }, value); + }, value); + }); + }, + + /** + * When the mouse is moved over the map, update the interactions model + * with the current mouse position. + * @param {Object} event - The event object from Cesium + * @since x.x.x + */ + setMousePosition: function (position) { + if (!position) return; + const view = this; + const pickRay = view.camera.getPickRay(position); + const cartesian = view.scene.globe.pick(pickRay, view.scene); + let newPosition = null; + if (cartesian) { + newPosition = view.getDegreesFromCartesian(cartesian); + } + view.interactions.setMousePosition(newPosition); + }, + + setHoveredFeatures: function (position, delay = 200) { + const view = this; + const lastCall = this.setHoveredFeaturesLastCall || 0; + const now = new Date().getTime(); + if (now - lastCall < delay) return; + this.setHoveredFeaturesLastCall = now; + const pickedFeature = view.scene.pick(position); + view.interactions.setHoveredFeatures([pickedFeature]); + }, + + setInteractionListeners: function () { + // TODO: unset listeners too + const interactions = this.interactions; + const hoveredFeatures = interactions.get("hoveredFeatures"); + this.listenTo(hoveredFeatures, "change update", this.updateCursor); + // this.listenTo( interactions, "change update", + // this.handleClickedFeatures + // ); + }, + + /** + * Change the cursor to a pointer when the mouse is hovering over a + * feature. + * @param {Object|null} hoveredFeatures - The feature that the mouse is + * hovering over or null if the mouse is not hovering over a feature. + */ + // When mouse moves? maybe throttle mouse move... + updateCursor: function (hoveredFeatures) { + const view = this; + let cursorStyle = "default"; + if (hoveredFeatures && hoveredFeatures.length) { + cursorStyle = "pointer"; + } + view.el.style.cursor = cursorStyle; + }, + + // TODO + showSelectedFeatures: function () { + // Remove highlights from previously selected 3D tiles + view.silhouettes.selected = []; + // Highlight the newly selected 3D tiles + selectedFeatures + .getFeatureObjects("Cesium3DTileFeature") + .forEach(function (featureObject) { + view.silhouettes.selected.push(featureObject); }); - }, - - /** - * Add all of the model's layers to the map. This function is called - * during the render function. - * @since 2.26.0 - */ - addLayers: function () { - + }, + + /** + * Add all of the model's layers to the map. This function is called + * during the render function. + * @since 2.26.0 + */ + addLayers: function () { + const view = this; + + // Add each layer from the Map model to the Cesium widget. Render using + // the function configured in the View's mapAssetRenderFunctions + // property. Add in reverse order for layers to appear in the correct + // order on the map. + const layers = view.model.get("layers"); + _.each(layers.last(layers.length).reverse(), function (mapAsset) { + view.addAsset(mapAsset); + }); + + // The Cesium Widget will support just one terrain option to start. + // Later, we'll allow users to switch between terrains if there is more + // than one. + var terrains = view.model.get("terrains"); + var terrainModel = terrains ? terrains.first() : false; + if (terrainModel) { + view.addAsset(terrainModel); + } + }, + + /** + * 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 action by setting a zoomTarget and zoomOptions on the + * view and requesting the scene to render. The actual zooming is done by + * {@link CesiumWidgetView#completeFlight} after the scene has finished + * rendering. + * @param {MapAsset|Cesium.BoundingSphere|Object|Feature} target The + * target asset, bounding sphere, or location to change the camera focus + * to. If target is a MapAsset, then the bounding sphere from that asset + * will be used for the target destination. If target is an Object, it may + * contain any of the properties that are supported by the Cesium camera + * flyTo options, see + * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}. If + * the target is a Feature, then it must be a Feature of a + * CesiumVectorData layer (currently Cesium3DTileFeatures are not + * supported). The target can otherwise be a Cesium BoundingSphere, see + * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html} + * @param {object} options - For targets that are a bounding sphere or + * asset, options to pass to Cesium Camera.flyToBoundingSphere(). See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyToBoundingSphere}. + */ + flyTo: function (target, options) { + this.zoomTarget = target; + this.zoomOptions = options; + this.requestRender(); + }, + + /** + * This function is called by {@link CesiumWidgetView#postRender}; it + * should only be called once the target has been fully rendered in the + * scene. This function gets the bounding sphere, if required, and moves + * the scene to encompass the full extent of the target. + * @param {MapAsset|Cesium.BoundingSphere|Object|Feature|GeoPoint} target + * The target asset, bounding sphere, or location to change the camera + * focus to. If target is a MapAsset, then the bounding sphere from that + * asset will be used for the target destination. If target is an Object, + * it may contain any of the properties that are supported by the Cesium + * camera flyTo options, see + * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}. + * The object may also be a position with longitude, latitude, and height. + * If the target is a Feature, then it must be a Feature of a + * CesiumVectorData layer (currently Cesium3DTileFeatures are not + * supported). The target can otherwise be a Cesium BoundingSphere, see + * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html} + * @param {object} options - For targets that are a bounding sphere or + * asset, options to pass to Cesium Camera.flyToBoundingSphere(). See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyToBoundingSphere}. + * For other targets, options will be merged with the target object and + * passed to Cesium Camera.flyTo(). See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo} + */ + completeFlight: function (target, options) { + try { const view = this; + if (typeof options !== "object") options = {}; - // Add each layer from the Map model to the Cesium widget. Render - // using the function configured in the View's mapAssetRenderFunctions - // property. Add in reverse order for layers to appear in the correct - // order on the map. - const layers = view.model.get('layers') - _.each(layers.last(layers.length).reverse(), function (mapAsset) { - view.addAsset(mapAsset) - }); - - // The Cesium Widget will support just one terrain option to start. - // Later, we'll allow users to switch between terrains if there is - // more than one. - var terrains = view.model.get('terrains') - var terrainModel = terrains ? terrains.first() : false; - if (terrainModel) { - view.addAsset(terrainModel) + // A target is required + if (!target) { + return; } - }, - /** - * Because the Cesium widget is configured to use explicit rendering (see - * {@link https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/}), - * we need to tell Cesium when to render a new frame if it's not one of the cases - * handle automatically. This function tells the Cesium scene to render, but is - * limited by the underscore.js debounce function to only happen a maximum of once - * every 50 ms (see {@link https://underscorejs.org/#debounce}). - */ - requestRender: _.debounce(function () { - this.scene.requestRender() - }, 50), - - /** - * Functions called after each time the scene renders. If a zoom target has been - * set by the {@link CesiumWidgetView#flyTo} function, then calls the functions - * that calculates the bounding sphere and zooms to it (which required to visual - * elements to be rendered first.) - */ - postRender: function () { - try { - if (this.zoomTarget) { - this.completeFlight(this.zoomTarget, this.zoomOptions) - this.zoomTarget = null; - this.zoomOptions = null; - } + // If the target is a Bounding Sphere, use the camera's built-in + // function + if (target instanceof Cesium.BoundingSphere) { + view.camera.flyToBoundingSphere(target, options); + view.resetZoomTarget(); + return; } - catch (error) { - console.log( - 'There was an error calling post render functions in a CesiumWidgetView' + - '. Error details: ' + error - ); - } - }, - - /** - * Runs on every Cesium clock tick. Updates the display of the CesiumVectorData - * models in the scene. Similar to Cesium.DataSourceDisplay.update function, in - * that it runs update() on each DataSource and each DataSource's visualizer, - * except that it also updates each CesiumVectorData model's 'displayReady' - * attribute. (Sets to true when the asset is ready to be rendered in the map, - * false otherwise). Also re-renders the scene when the displayReady attribute - * changes. - */ - updateDataSourceDisplay: function () { - try { - const view = this; - const layers = view.model.get('layers') - - var dataSources = view.dataSourceDisplay.dataSources; - if (!dataSources || !dataSources.length) { - return - } - - let allReady = true; - const allReadyBefore = view.dataSourceDisplay._ready; - - for (let i = 0, len = dataSources.length; i < len; i++) { - - const time = view.clock.currentTime; - const dataSource = dataSources.get(i); - const visualizers = dataSource._visualizers; - - const assetModel = layers.findWhere({ - cesiumModel: dataSource - }) - const displayReadyBefore = assetModel.get('displayReady') - let displayReadyNow = dataSource.update(time) - - for (let x = 0; x < visualizers.length; x++) { - displayReadyNow = visualizers[x].update(time) && displayReadyNow; - } - - assetModel.set('displayReady', displayReadyNow) - allReady = displayReadyNow && allReady - - } - - // If any dataSource has switched display states, then re-render the scene. - if (allReady !== allReadyBefore) { - view.scene.requestRender() - } - // The dataSourceDisplay must be set to 'ready' to get bounding spheres for - // dataSources - view.dataSourceDisplay._ready = allReady - - } - catch (error) { - console.log( - 'There was an error updating the data source display in a CesiumWidgetView' + - '. Error details: ' + error - ); + // If the target is some type of map asset, then get a Bounding Sphere + // for that asset and call this function again. + if ( + target instanceof MapAsset && + typeof target.getBoundingSphere === "function" + ) { + // Pass the dataSourceDisplay for CesiumVectorData models + target + .getBoundingSphere(view.dataSourceDisplay) + .then(function (assetBoundingSphere) { + // Base value offset required to zoom in close enough to 3D + // tiles for them to render. + if ( + target instanceof Cesium3DTileset && + !Cesium.defined(options.offset) + ) { + options.offset = new Cesium.HeadingPitchRange( + 0.0, + -0.5, + assetBoundingSphere.radius + ); + } + view.flyTo(assetBoundingSphere, options); + }); + return; } - }, - - /** - * Set up the Cesium scene and set listeners and behavior that enable users to - * click on vector features on the map to view more information about them. - */ - initializePicking: function () { - try { - // Save a reference to this view the Cesium scene - var view = this; - var scene = this.scene - - // To add an outline to 3D tiles in Cesium, we 'silhouette' them. Set up the the - // scene to support silhouetting. - view.silhouettes = Cesium.PostProcessStageLibrary.createEdgeDetectionStage(); - view.silhouettes.uniforms.color = view.highlightBorderColor; - view.silhouettes.uniforms.length = 0.02; - view.silhouettes.selected = []; - scene.postProcessStages.add( - Cesium.PostProcessStageLibrary.createSilhouetteStage([view.silhouettes]) - ); - // When any Feature models in the Map model's selectedFeature collection are - // changed, added, or removed, update silhouetting of 3D tiles. - function setSelectedFeaturesListeners() { - const selectedFeatures = view.model.get('selectedFeatures') - view.stopListening(selectedFeatures, 'update') - view.listenTo(selectedFeatures, 'update', function () { - // Remove highlights from previously selected 3D tiles - view.silhouettes.selected = [] - // Highlight the newly selected 3D tiles - selectedFeatures - .getFeatureObjects('Cesium3DTileFeature') - .forEach(function (featureObject) { - view.silhouettes.selected.push(featureObject) - }) - }) - } - - setSelectedFeaturesListeners() - // If the Selected Features collection is ever completely replaced for any - // reason, make sure to reset the listeners onto the new collection - view.stopListening(view.model, 'change:selectedFeatures') - view.listenTo(view.model, 'change:selectedFeatures', setSelectedFeaturesListeners) - - // When a feature is clicked update the Map model's `selectedFeatures` - // collection with the newly selected features. This will also trigger an - // 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) { - 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) - } - // TODO: Make the click actions more configurable. On every click, - // add the coordinates to the map model's clickedCoordinates. - // (keep a history of the last 10 clicks?) - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + // Note: This doesn't work yet for Cesium3DTilesetFeatures - + // Cesium.BoundingSphereState gets stuck in "PENDING" and never + // resolves. There's no native way of getting the bounding sphere or + // location from a 3DTileFeature! + if (target instanceof Feature) { + // 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. + setTimeout(() => { + // TODO check if needed + view.flyTo(target.get("featureObject"), options); + }, 0); + return; } - catch (error) { - console.log( - 'There was an error initializing picking in a CesiumWidgetView' + - '. Error details: ' + error + + // 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( + entity, + false, + entityBoundingSphere ); + setTimeout(() => { + // TODO check if needed + view.flyTo(entityBoundingSphere, options); + }, 0); + return; } - }, - /** - * 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 - * action by setting a zoomTarget and zoomOptions on the view and requesting the - * scene to render. The actual zooming is done by - * {@link CesiumWidgetView#completeFlight} after the scene has finished rendering. - * @param {MapAsset|Cesium.BoundingSphere|Object|Feature} target The target asset, - * bounding sphere, or location to change the camera focus to. If target is a - * MapAsset, then the bounding sphere from that asset will be used for the target - * destination. If target is an Object, it may contain any of the properties that - * are supported by the Cesium camera flyTo options, see - * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}. If the - * target is a Feature, then it must be a Feature of a CesiumVectorData layer - * (currently Cesium3DTileFeatures are not supported). The target can otherwise be - * a Cesium BoundingSphere, see - * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html} - * @param {object} options - For targets that are a bounding sphere or asset, - * options to pass to Cesium Camera.flyToBoundingSphere(). See - * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyToBoundingSphere}. - */ - flyTo: function (target, options) { - this.zoomTarget = target; - this.zoomOptions = options; - this.requestRender(); - }, + if (target.type && target.type == "GeoPoint") { + view.flyTo(target.toJSON(), options); + return; + } - /** - * This function is called by {@link CesiumWidgetView#postRender}; it should only - * be called once the target has been fully rendered in the scene. This function - * gets the bounding sphere, if required, and moves the scene to encompass the - * full extent of the target. - * @param {MapAsset|Cesium.BoundingSphere|Object|Feature} target The target asset, - * bounding sphere, or location to change the camera focus to. If target is a - * MapAsset, then the bounding sphere from that asset will be used for the target - * destination. If target is an Object, it may contain any of the properties that - * are supported by the Cesium camera flyTo options, see - * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyTo}. If the - * target is a Feature, then it must be a Feature of a CesiumVectorData layer - * (currently Cesium3DTileFeatures are not supported). The target can otherwise be - * a Cesium BoundingSphere, see - * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html} - * @param {object} options - For targets that are a bounding sphere or asset, - * options to pass to Cesium Camera.flyToBoundingSphere(). See - * {@link https://cesium.com/learn/cesiumjs/ref-doc/Camera.html#flyToBoundingSphere}. - */ - completeFlight: function (target, options) { - - try { - - const view = this; - if (typeof options !== 'object') options = {} - - // A target is required - if (!target) { - return - } + if ( + typeof target === "object" && + typeof target.longitude === "number" && + typeof target.latitude === "number" + ) { + const pointTarget = view.positionToFlightTarget(target); + view.flyTo(pointTarget, options); + return; + } - // If the target is a Bounding Sphere, use the camera's built-in function - if (target instanceof Cesium.BoundingSphere) { - view.camera.flyToBoundingSphere(target, options) - return + // If not a Map Asset or a BoundingSphere, then the target must be an + // Object. Assume target are options for the Cesium camera flyTo + // function + if (typeof target === "object") { + // Merge the options with the target object, if there are any + // options + if (options && Object.keys(options).length) { + target = Object.assign(target, options); } + // Fly to the target + view.camera.flyTo(target); + view.resetZoomTarget(); + } + } catch (e) { + console.log("Failed to navigate to a target in Cesium.", e); + } + }, + + resetZoomTarget: function () { + const view = this; + view.zoomTarget = null; + view.interactions.set("zoomTarget", null); + view.zoomOptions = null; + }, + + /** + * Navigate to the homePosition that's set on the Map. + * @param {number} duration The duration of the flight in seconds. + */ + flyHome: function (duration) { + const home = this.model.get("homePosition"); + this.flyTo(home, { duration }); + }, + + /** + * Navigate to the homePosition that's set on the Map. + * @param {Object} position The position to navigate to. Must have + * longitude, latitude, and may have a height (elevation) in meters, + * heading, pitch, and roll in degrees. + * @param {number} duration The duration of the flight in seconds. + */ + positionToFlightTarget: function (position, duration) { + try { + if (!position) { + return null; + } - // If the target is some type of map asset, then get a Bounding Sphere for - // that asset and call this function again. - if (target instanceof MapAsset && typeof target.getBoundingSphere === 'function') { - // Pass the dataSourceDisplay for CesiumVectorData models - target.getBoundingSphere(view.dataSourceDisplay) - .then(function (assetBoundingSphere) { - // Base value offset required to zoom in close enough to 3D tiles for - // them to render. - if ((target instanceof Cesium3DTileset) && !Cesium.defined(options.offset)) { - options.offset = new Cesium.HeadingPitchRange( - 0.0, -0.5, assetBoundingSphere.radius - ) - } - view.flyTo(assetBoundingSphere, options) - }) - return + if ( + position && + Cesium.defined(position.longitude) && + Cesium.defined(position.latitude) + ) { + // Set a default height (elevation) if there isn't one set + if (!Cesium.defined(position.height)) { + position.height = 1000000; } - // Note: This doesn't work yet for Cesium3DTilesetFeatures - - // Cesium.BoundingSphereState gets stuck in "PENDING" and never resolves. - // There's no native way of getting the bounding sphere or location from a - // 3DTileFeature! - if (target instanceof Feature) { - // 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. - setTimeout(() => { - view.flyTo(target.get('featureObject'), options) - }, 0); - return - } + const target = {}; + target.destination = Cesium.Cartesian3.fromDegrees( + position.longitude, + position.latitude, + position.height + ); - // 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( - entity, false, entityBoundingSphere - ) - setTimeout(() => { - view.flyTo(entityBoundingSphere, options) - }, 0); - return + if ( + Cesium.defined(position.heading) && + Cesium.defined(position.pitch) && + Cesium.defined(position.roll) + ) { + target.orientation = { + heading: Cesium.Math.toRadians(position.heading), + pitch: Cesium.Math.toRadians(position.pitch), + roll: Cesium.Math.toRadians(position.roll), + }; } - - // If not a Map Asset or a BoundingSphere, then the target must be an Object. - // Assume target are options for the Cesium camera flyTo function - if (typeof target === 'object') { - view.camera.flyTo(target) + if (Cesium.defined(duration)) { + target.duration = duration; } + return target; } - catch (e) { - console.log('Failed to navigate to a target in Cesium.', e); - } - }, - - /** - * Navigate to the homePosition that's set on the Map. - * @param {number} duration The duration of the flight in seconds. - */ - flyHome: function (duration) { - try { - var position = this.model.get('homePosition') - - if (position && Cesium.defined(position.longitude) && Cesium.defined(position.latitude)) { - - // Set a default height (elevation) if there isn't one set - if (!Cesium.defined(position.height)) { - position.height = 1000000; - } - - const target = {} - target.destination = Cesium.Cartesian3.fromDegrees( - position.longitude, - position.latitude, - position.height - ) - - if ( - Cesium.defined(position.heading) && - Cesium.defined(position.pitch) && - Cesium.defined(position.roll) - ) { - target.orientation = { - heading: Cesium.Math.toRadians(position.heading), - pitch: Cesium.Math.toRadians(position.pitch), - roll: Cesium.Math.toRadians(position.roll) - } - } - if (Cesium.defined(duration)) { - target.duration = duration + } catch (e) { + console.log("Failed to convert a position to a flight target.", e); + return null; + } + }, + + /** + * Get the current positioning of the camera in the view. + * @returns {MapConfig#CameraPosition} Returns an object with the + * longitude, latitude, height, heading, pitch, and roll in the same + * format that the Map model uses for the homePosition (see + * {@link Map#defaults}) + */ + getCameraPosition: function () { + return this.getDegreesFromCartesian(this.camera.position); + }, + + /** + * Update the 'currentViewExtent' attribute in the Map model with the + * bounding box of the currently visible area of the map. + */ + updateViewExtent: function () { + try { + this.interactions.setViewExtent(this.getViewExtent()); + } catch (e) { + console.log("Failed to update the Map view extent.", e); + } + }, + + /** + * 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; + + // This will be the bounding box of the visible area + 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) { + // 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 + ); } + }); - this.flyTo(target); + 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); } } - catch (error) { - console.log( - 'There was an error navigating to the home position in a CesiumWidgetView' + - '. Error details: ' + error - ); - } - }, - /** - * Get the current positioning of the camera in the view. - * @returns {MapConfig#CameraPosition} Returns an object with the longitude, latitude, - * height, heading, pitch, and roll in the same format that the Map model uses - * for the homePosition (see {@link Map#defaults}) - */ - getCameraPosition: function () { - try { - return this.getDegreesFromCartesian(this.camera.position) + // 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; + } } - catch (error) { - console.log( - 'There was an error getting the current position in a CesiumWidgetView' + - '. Error details: ' + error + } + + return coords; + }, + + /** + * 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 + ); + }, + + /** + * Get longitude and latitude degrees from a cartesian point. + * @param {Cesium.Cartesian3} cartesian - The point to get degrees for + * @returns Returns an object with the longitude and latitude in degrees, + * as well as the height in meters + */ + getDegreesFromCartesian: function (cartesian) { + const cartographic = Cesium.Cartographic.fromCartesian(cartesian); + 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] ); } - }, - - /** - * Update the 'currentViewExtent' attribute in the Map model with the - * bounding box of the currently visible area of the map. - */ - updateViewExtent: function () { - try { this.model.set('currentViewExtent', this.getViewExtent()) } - catch (e) { console.log('Failed to update the Map view extent.', e) } - }, - - /** - * 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 () { + }); + return degrees; + }, + + /** + * Find four points that exist on the globe that are closest to the + * top-center, bottom-center, right-middle, and left-middle points of the + * screen. Note that these are not necessarily the northern, southern, + * eastern, and western -most points, since the map may be oriented in any + * direction (e.g. facing the north pole). + * + * @returns {Cesium.Cartesian3[]} Returns an object with the top, bottom, + * left, and right points of the globe. + */ + findEdges: function () { + try { const view = this; - const scene = view.scene; - const camera = view.camera; - // 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, height: height + const canvas = view.scene.canvas; + const maxX = canvas.clientWidth; + const maxY = canvas.clientHeight; + const midX = (maxX / 2) | 0; + const midY = (maxY / 2) | 0; + + // Points at the extreme edges of the cesium canvas. These may not be + // points on the globe (i.e. they could be in the sky) + const topCanvas = new Cesium.Cartesian2(midX, 0); + const rightCanvas = new Cesium.Cartesian2(maxX, midY); + const bottomCanvas = new Cesium.Cartesian2(midX, maxY); + const leftCanvas = new Cesium.Cartesian2(0, midY); + + // Find the real world coordinate that is closest to the canvas edge + // points + const points = { + top: view.findPointOnGlobe(topCanvas, bottomCanvas), + right: view.findPointOnGlobe(rightCanvas, leftCanvas), + bottom: view.findPointOnGlobe(bottomCanvas, topCanvas), + left: view.findPointOnGlobe(leftCanvas, rightCanvas), + }; + + return points; + } catch (error) { + console.log( + "There was an error finding the edge points in a CesiumWidgetView" + + ". Error details: " + + error + ); + } + }, + + /** + * Given two Cartesian3 points, compute the midpoint. + * @param {Cesium.Cartesian3} p1 The first point + * @param {Cesium.Cartesian3} p2 The second point + * @returns {Cesium.Cartesian3 | null} The midpoint or null if p1 or p2 is + * not defined. + */ + findMidpoint: function (p1, p2) { + try { + if (!p1 || !p2) { + return null; } + // Compute vector from p1 to p2 + let p1p2 = new Cesium.Cartesian3(0.0, 0.0, 0.0); + Cesium.Cartesian3.subtract(p2, p1, p1p2); + + // Compute vector to midpoint + let halfp1p2 = new Cesium.Cartesian3(0.0, 0.0, 0.0); + Cesium.Cartesian3.multiplyByScalar(p1p2, 0.5, halfp1p2); + + // Compute point half way between p1 and p2 + let p3 = new Cesium.Cartesian3(0.0, 0.0, 0.0); + p3 = Cesium.Cartesian3.add(p1, halfp1p2, p3); + + // Force point onto surface of ellipsoid + const midPt = Cesium.Cartographic.fromCartesian(p3); + const p3a = Cesium.Cartesian3.fromRadians( + midPt.longitude, + midPt.latitude, + 0.0 + ); - // 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 + return p3a; + } catch (error) { + console.log( + "There was an error finding a midpoint in a CesiumWidgetView" + + ". Error details: " + + error ); - 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) - } - } + } + }, + + /** + * Find a coordinate that exists on the surface of the globe between two + * Cartesian points. The points do not need to be withing the bounds of + * the globe/map (i.e. they can be points in the sky). Uses the Bresenham + * Algorithm to traverse pixels from the first coordinate to the second, + * until it finds a valid coordinate. + * @param {Cesium.Cartesian2} startCoordinates The coordinates to start + * searching, in pixels + * @param {Cesium.Cartesian2} endCoordinates The coordinates to stop + * searching, in pixels + * @returns {Cesium.Cartesian3 | null} Returns the x, y, z coordinates of + * the first real point, or null if a valid point was not found. + * + * @see {@link https://groups.google.com/g/cesium-dev/c/e2H7EefikAk} + */ + findPointOnGlobe: function (startCoordinates, endCoordinates) { + const view = this; + const camera = view.camera; + const ellipsoid = view.scene.globe.ellipsoid; + + if (!startCoordinates || !endCoordinates) { + return null; + } - // 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 + let coordinate = camera.pickEllipsoid(startCoordinates, ellipsoid); + + // Translate coordinates + let x1 = startCoordinates.x; + let y1 = startCoordinates.y; + const x2 = endCoordinates.x; + const y2 = endCoordinates.y; + // Define differences and error check + const dx = Math.abs(x2 - x1); + const dy = Math.abs(y2 - y1); + const sx = x1 < x2 ? 1 : -1; + const sy = y1 < y2 ? 1 : -1; + let err = dx - dy; + + coordinate = camera.pickEllipsoid({ x: x1, y: y1 }, ellipsoid); + if (coordinate) { + return coordinate; + } - 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 - } - } + // Main loop + while (!(x1 == x2 && y1 == y2)) { + const e2 = err << 1; + if (e2 > -dy) { + err -= dy; + x1 += sx; } - - return coords - }, - - /** - * 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 - }, - - /** - * Get longitude and latitude degrees from a cartesian point. - * @param {Cesium.Cartesian3} cartesian - The point to get degrees for - * @returns Returns an object with the longitude and latitude in degrees, as well - * as the height in meters - */ - getDegreesFromCartesian: function (cartesian) { - const cartographic = Cesium.Cartographic.fromCartesian(cartesian); - const degrees = { - height: cartographic.height + if (e2 < dx) { + err += dx; + y1 += sy; } - 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 - }, - /** - * Find four points that exist on the globe that are closest to the top-center, - * bottom-center, right-middle, and left-middle points of the screen. Note that - * these are not necessarily the northern, southern, eastern, and western -most - * points, since the map may be oriented in any direction (e.g. facing the north - * pole). - * - * @returns {Cesium.Cartesian3[]} Returns an object with the top, bottom, left, - * and right points of the globe. - */ - findEdges: function () { - try { - const view = this; - const canvas = view.scene.canvas - const maxX = canvas.clientWidth; - const maxY = canvas.clientHeight; - const midX = (maxX / 2) | 0; - const midY = (maxY / 2) | 0; - - // Points at the extreme edges of the cesium canvas. These may not be points on - // the globe (i.e. they could be in the sky) - const topCanvas = new Cesium.Cartesian2(midX, 0) - const rightCanvas = new Cesium.Cartesian2(maxX, midY) - const bottomCanvas = new Cesium.Cartesian2(midX, maxY) - const leftCanvas = new Cesium.Cartesian2(0, midY) - - // Find the real world coordinate that is closest to the canvas edge points - const points = { - top: view.findPointOnGlobe(topCanvas, bottomCanvas), - right: view.findPointOnGlobe(rightCanvas, leftCanvas), - bottom: view.findPointOnGlobe(bottomCanvas, topCanvas), - left: view.findPointOnGlobe(leftCanvas, rightCanvas), - } - - return points - } - catch (error) { - console.log( - 'There was an error finding the edge points in a CesiumWidgetView' + - '. Error details: ' + error - ); + coordinate = camera.pickEllipsoid({ x: x1, y: y1 }, ellipsoid); + if (coordinate) { + return coordinate; } - }, - - /** - * Given two Cartesian3 points, compute the midpoint. - * @param {Cesium.Cartesian3} p1 The first point - * @param {Cesium.Cartesian3} p2 The second point - * @returns {Cesium.Cartesian3 | null} The midpoint or null if p1 or p2 is not - * defined. - */ - findMidpoint: function (p1, p2) { - try { - if (!p1 || !p2) { - return null - } - // Compute vector from p1 to p2 - let p1p2 = new Cesium.Cartesian3(0.0, 0.0, 0.0); - Cesium.Cartesian3.subtract(p2, p1, p1p2); - - // Compute vector to midpoint - let halfp1p2 = new Cesium.Cartesian3(0.0, 0.0, 0.0); - Cesium.Cartesian3.multiplyByScalar(p1p2, 0.5, halfp1p2); - - // Compute point half way between p1 and p2 - let p3 = new Cesium.Cartesian3(0.0, 0.0, 0.0); - p3 = Cesium.Cartesian3.add(p1, halfp1p2, p3); + } - // Force point onto surface of ellipsoid - const midPt = Cesium.Cartographic.fromCartesian(p3); - const p3a = Cesium.Cartesian3.fromRadians(midPt.longitude, midPt.latitude, 0.0); + return null; + }, - return p3a - } - catch (error) { - console.log( - 'There was an error finding a midpoint in a CesiumWidgetView' + - '. Error details: ' + error - ); + /** + * Update the map model's currentScale attribute, which is used for the + * scale bar. Finds the distance between two pixels at the *bottom center* + * of the screen. + */ + updateScale: function () { + try { + const view = this; + let currentScale = { + pixels: null, + meters: null, + }; + const onePixelInMeters = view.pixelToMeters(); + if (onePixelInMeters || onePixelInMeters === 0) { + currentScale = { + pixels: 1, + meters: onePixelInMeters, + }; } - }, - - /** - * Find a coordinate that exists on the surface of the globe between two Cartesian - * points. The points do not need to be withing the bounds of the globe/map (i.e. - * they can be points in the sky). Uses the Bresenham Algorithm to traverse pixels - * from the first coordinate to the second, until it finds a valid coordinate. - * @param {Cesium.Cartesian2} startCoordinates The coordinates to start searching, - * in pixels - * @param {Cesium.Cartesian2} endCoordinates The coordinates to stop searching, in - * pixels - * @returns {Cesium.Cartesian3 | null} Returns the x, y, z coordinates of the - * first real point, or null if a valid point was not found. - * - * @see {@link https://groups.google.com/g/cesium-dev/c/e2H7EefikAk} - */ - findPointOnGlobe: function (startCoordinates, endCoordinates) { - + view.interactions.setScale(currentScale); + } catch (e) { + console.log("Error updating the scale from a CesiumWidgetView", e); + } + }, + + /** + * Finds the geodesic distance (in meters) between two points that are 1 + * pixel apart at the bottom, center of the Cesium canvas. Adapted from + * TerriaJS. See + * {@link https://github.com/TerriaJS/terriajs/blob/main/lib/ReactViews/Map/Legend/DistanceLegend.jsx} + * @returns {number|boolean} Returns the distance on the globe, in meters, + * that is equivalent to 1 pixel on the screen at the center bottom point + * of the current scene. Returns false if there was a problem getting the + * measurement. + */ + pixelToMeters: function () { + try { const view = this; - const camera = view.camera; - const ellipsoid = view.scene.globe.ellipsoid; + const scene = view.scene; + const globe = scene.globe; + const camera = scene.camera; - if (!startCoordinates || !endCoordinates) { - return null + // For measuring geodesic distances (shortest route between two points + // on the Earth's surface) + if (!view.geodesic) { + view.geodesic = new Cesium.EllipsoidGeodesic(); } - let coordinate = camera.pickEllipsoid(startCoordinates, ellipsoid); + // Find two points that are 1 pixel apart at the bottom center of the + // cesium canvas. + const width = scene.canvas.clientWidth; + const height = scene.canvas.clientHeight; - // Translate coordinates - let x1 = startCoordinates.x; - let y1 = startCoordinates.y; - const x2 = endCoordinates.x; - const y2 = endCoordinates.y; - // Define differences and error check - const dx = Math.abs(x2 - x1); - const dy = Math.abs(y2 - y1); - const sx = (x1 < x2) ? 1 : -1; - const sy = (y1 < y2) ? 1 : -1; - let err = dx - dy; - - coordinate = camera.pickEllipsoid({ x: x1, y: y1 }, ellipsoid); - if (coordinate) { - return coordinate - } + const left = camera.getPickRay( + new Cesium.Cartesian2((width / 2) | 0, height - 1) + ); + const right = camera.getPickRay( + new Cesium.Cartesian2((1 + width / 2) | 0, height - 1) + ); - // Main loop - while (!((x1 == x2) && (y1 == y2))) { - const e2 = err << 1; - if (e2 > -dy) { - err -= dy; - x1 += sx; - } - if (e2 < dx) { - err += dx; - y1 += sy; - } + const leftPosition = globe.pick(left, scene); + const rightPosition = globe.pick(right, scene); - coordinate = camera.pickEllipsoid({ x: x1, y: y1 }, ellipsoid); - if (coordinate) { - return coordinate - } + // A point must exist at both positions to get the distance + if (!Cesium.defined(leftPosition) || !Cesium.defined(rightPosition)) { + return false; } - return null; - }, - - /** - * Set a Cesium event handler for when the mouse moves. If the scale bar is - * enabled, then a updates the Map model's current position attribute whenever the - * mouse moves. If showFeatureInfo is enabled, then changes the cursor to a - * pointer when it hovers over a feature. - */ - setMouseMoveListeners: function () { - try { - - const view = this; - - // Change the cursor to a pointer when it hovers over a clickable feature - // (e.g. a 3D tile) if picking is enabled. - const updateCursor = function (mousePosition) { - var pickedFeature = view.scene.pick(mousePosition); - if (Cesium.defined(pickedFeature)) { - view.el.style.cursor = 'pointer'; - } else { - view.el.style.cursor = 'default'; - } - } + // Find the geodesic distance, in meters, between the two points that + // are 1 pixel apart + const leftCartographic = + globe.ellipsoid.cartesianToCartographic(leftPosition); + const rightCartographic = + globe.ellipsoid.cartesianToCartographic(rightPosition); - // Slow this function down a little. Picking is quite slow. - const updateCursorThrottled = _.throttle(updateCursor, 150) + view.geodesic.setEndPoints(leftCartographic, rightCartographic); - // Update the model with long and lat when the mouse moves, if the map model - // is set to show the scale bar - const setCurrentPosition = function (mousePosition) { - var pickRay = view.camera.getPickRay(mousePosition); - var cartesian = view.scene.globe.pick(pickRay, view.scene); - if (cartesian) { - view.model.set('currentPosition', view.getDegreesFromCartesian(cartesian)) - } - } + const onePixelInMeters = view.geodesic.surfaceDistance; - // Handle mouse move - this.inputHandler.setInputAction(function (movement) { - const mousePosition = movement.endPosition; - if (view.model.get('showScaleBar')) { - setCurrentPosition(mousePosition) - } - if (view.model.get('showFeatureInfo')) { - updateCursorThrottled(mousePosition) - } - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - - } - catch (error) { - console.log( - 'There was an error setting the mouse listeners in a CesiumWidgetView' + - '. Error details: ' + error - ); - } - }, - - /** - * Update the map model's currentScale attribute, which is used for the scale bar. - * Finds the distance between two pixels at the *bottom center* of the screen. - */ - updateCurrentScale: function () { - try { - const view = this; - let currentScale = { - pixels: null, - meters: null - } - const onePixelInMeters = view.pixelToMeters() - if (onePixelInMeters || onePixelInMeters === 0) { - currentScale = { - pixels: 1, - meters: onePixelInMeters - } - } - view.model.set('currentScale', currentScale); + return onePixelInMeters; + } catch (error) { + console.log( + "Failed to get a pixel to meters measurement in a CesiumWidgetView" + + ". Error details: " + + error + ); + return false; + } + }, + + /** + * Finds the function that is configured for the given asset model type in + * the {@link CesiumWidgetView#mapAssetRenderFunctions} array, then + * renders the asset in the map. If there is a problem rendering the asset + * (e.g. it is an unsupported type that is not configured in the + * mapAssetRenderFunctions), then sets the AssetModel's status to error. + * @param {MapAsset} mapAsset A MapAsset layer to render in the map, such + * as a Cesium3DTileset or a CesiumImagery model. + */ + addAsset: function (mapAsset) { + try { + if (!mapAsset) { + return; } - catch (error) { - console.log( - 'There was an error updating the scale from a CesiumWidgetView' + - '. Error details: ' + error + var view = this; + var type = mapAsset.get("type"); + // Find the render option from the options configured in the view, + // given the asset model type + const renderOption = + _.find(view.mapAssetRenderFunctions, function (option) { + return option.types.includes(type); + }) || {}; + // Get the function for this type + const renderFunction = view[renderOption.renderFunction]; + + // If the cesium widget does not have a way to display this error, + // update the error status in the model (this will be reflected in the + // LayerListView) + if (!renderFunction || typeof renderFunction !== "function") { + mapAsset.set( + "statusDetails", + "This type of resource is not supported in the map widget." ); + mapAsset.set("status", "error"); + return; } - }, - - /** - * Finds the geodesic distance (in meters) between two points that are 1 pixel - * apart at the bottom, center of the Cesium canvas. Adapted from TerriaJS. See - * {@link https://github.com/TerriaJS/terriajs/blob/main/lib/ReactViews/Map/Legend/DistanceLegend.jsx} - * @returns {number|boolean} Returns the distance on the globe, in meters, that is - * equivalent to 1 pixel on the screen at the center bottom point of the current - * scene. Returns false if there was a problem getting the measurement. - */ - pixelToMeters: function () { - try { - - const view = this - const scene = view.scene - const globe = scene.globe - const camera = scene.camera - - // For measuring geodesic distances (shortest route between two points on the - // Earth's surface) - if (!view.geodesic) { - view.geodesic = new Cesium.EllipsoidGeodesic(); - } - - // Find two points that are 1 pixel apart at the bottom center of the cesium - // canvas. - const width = scene.canvas.clientWidth; - const height = scene.canvas.clientHeight; - - const left = camera.getPickRay( - new Cesium.Cartesian2((width / 2) | 0, height - 1) - ); - const right = camera.getPickRay( - new Cesium.Cartesian2((1 + width / 2) | 0, height - 1) - ); - - const leftPosition = globe.pick(left, scene); - const rightPosition = globe.pick(right, scene); - // A point must exist at both positions to get the distance - if (!Cesium.defined(leftPosition) || !Cesium.defined(rightPosition)) { - return false + // The asset should be visible and the cesium model should be ready + // before starting to render the asset + const checkAndRenderAsset = function () { + let shouldRender = + mapAsset.get("visible") && mapAsset.get("status") === "ready"; + if (shouldRender) { + renderFunction.call(view, mapAsset.get("cesiumModel")); + view.stopListening(mapAsset); } + }; - // Find the geodesic distance, in meters, between the two points that are 1 - // pixel apart - const leftCartographic = globe.ellipsoid.cartesianToCartographic( - leftPosition - ); - const rightCartographic = globe.ellipsoid.cartesianToCartographic( - rightPosition - ); + checkAndRenderAsset(); - view.geodesic.setEndPoints(leftCartographic, rightCartographic); - - const onePixelInMeters = view.geodesic.surfaceDistance; - - return onePixelInMeters - - } - catch (error) { - console.log( - 'Failed to get a pixel to meters measurement in a CesiumWidgetView' + - '. Error details: ' + error - ); - return false + if (!mapAsset.get("visible")) { + view.listenToOnce(mapAsset, "change:visible", checkAndRenderAsset); } - }, - - /** - * Finds the function that is configured for the given asset model type in the - * {@link CesiumWidgetView#mapAssetRenderFunctions} array, then renders the asset - * in the map. If there is a problem rendering the asset (e.g. it is an - * unsupported type that is not configured in the mapAssetRenderFunctions), then - * sets the AssetModel's status to error. - * @param {MapAsset} mapAsset A MapAsset layer to render in the map, such as a - * Cesium3DTileset or a CesiumImagery model. - */ - addAsset: function (mapAsset) { - try { - if (!mapAsset) { - return - } - var view = this - var type = mapAsset.get('type') - // Find the render option from the options configured in the view, given the - // asset model type - const renderOption = _.find(view.mapAssetRenderFunctions, function (option) { - return option.types.includes(type) - }) || {}; - // Get the function for this type - const renderFunction = view[renderOption.renderFunction] - - // If the cesium widget does not have a way to display this error, update the - // error status in the model (this will be reflected in the LayerListView) - if (!renderFunction || typeof renderFunction !== 'function') { - mapAsset.set('statusDetails', 'This type of resource is not supported in the map widget.') - mapAsset.set('status', 'error') - return - } - - // The asset should be visible and the cesium model should be ready before - // starting to render the asset - const checkAndRenderAsset = function () { - let shouldRender = mapAsset.get('visible') && mapAsset.get('status') === 'ready' - if (shouldRender) { - renderFunction.call(view, mapAsset.get('cesiumModel')) - view.stopListening(mapAsset) - } - } - - checkAndRenderAsset() - if (!mapAsset.get('visible')) { - view.listenToOnce(mapAsset, 'change:visible', checkAndRenderAsset) - } - - if (mapAsset.get('status') !== 'ready') { - view.listenTo(mapAsset, 'change:status', checkAndRenderAsset) - } - - } - catch (error) { - console.error( - 'There was an error rendering an asset in a CesiumWidgetView' + - '. Error details: ' + error - ); - mapAsset.set('statusDetails', 'There was a problem rendering this resource in the map widget.') - mapAsset.set('status', 'error') + if (mapAsset.get("status") !== "ready") { + view.listenTo(mapAsset, "change:status", checkAndRenderAsset); } - }, - - /** - * When an asset is removed from the map model, remove it from the map. - * @param {MapAsset} mapAsset - The MapAsset model removed from the map - * @since x.x.x - */ - removeAsset: function (mapAsset, b, c) { - if (!mapAsset) return - // Get the cesium model from the asset - const cesiumModel = mapAsset.get('cesiumModel') - if (!cesiumModel) return - // Find the remove function for this type of asset - const removeFunctionName = this.mapAssetRenderFunctions.find(function (option) { - return option.types.includes(mapAsset.get('type')) - })?.removeFunction - const removeFunction = this[removeFunctionName] - // If there is a function for this type of asset, call it - if (removeFunction && typeof removeFunction === 'function') { - removeFunction.call(this, cesiumModel) - } else { - console.log('No remove function found for this type of asset', mapAsset); + } catch (e) { + console.error("Error rendering an asset", e, mapAsset); + mapAsset.set( + "statusDetails", + "There was a problem rendering this resource in the map widget." + ); + mapAsset.set("status", "error"); + } + }, + + /** + * When an asset is removed from the map model, remove it from the map. + * @param {MapAsset} mapAsset - The MapAsset model removed from the map + * @since x.x.x + */ + removeAsset: function (mapAsset) { + if (!mapAsset) return; + // Get the cesium model from the asset + const cesiumModel = mapAsset.get("cesiumModel"); + if (!cesiumModel) return; + // Find the remove function for this type of asset + const removeFunctionName = this.mapAssetRenderFunctions.find(function ( + option + ) { + return option.types.includes(mapAsset.get("type")); + })?.removeFunction; + const removeFunction = this[removeFunctionName]; + // If there is a function for this type of asset, call it + if (removeFunction && typeof removeFunction === "function") { + removeFunction.call(this, cesiumModel); + } else { + console.log( + "No remove function found for this type of asset", + mapAsset + ); + } + }, + + /** + * Renders peaks and valleys in the 3D version of the map, given a terrain + * model. If a terrain model has already been set on the map, this will + * replace it. + * @param {Cesium.TerrainProvider} cesiumModel a Cesium Terrain Provider + * model to use for the map + */ + updateTerrain: function (cesiumModel) { + // TODO: Add listener to the map model for when the terrain changes + this.scene.terrainProvider = cesiumModel; + this.requestRender(); + }, + + /** + * Renders a 3D tileset in the map. + * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model + * that contains the information about the 3D tiles to render in the map + */ + add3DTileset: function (cesiumModel) { + this.scene.primitives.add(cesiumModel); + }, + + /** + * Remove a 3D tileset from the map. + * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model + * to remove from the map + * @since x.x.x + */ + remove3DTileset: function (cesiumModel) { + this.scene.primitives.remove(cesiumModel); + }, + + /** + * Renders vector data (excluding 3D tilesets) in the Map. + * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source + * model to render on the map + */ + addVectorData: function (cesiumModel) { + this.dataSourceCollection.add(cesiumModel); + }, + + /** + * Remove vector data (excluding 3D tilesets) from the Map. + * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source + * model to remove from the map + * @since x.x.x + */ + removeVectorData: function (cesiumModel) { + this.dataSourceCollection.remove(cesiumModel); + }, + + /** + * 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(); + }, + + /** + * Remove imagery from the Map. + * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to + * remove from the map + * @since x.x.x + */ + removeImagery: function (cesiumModel) { + console.log("Removing imagery from map", cesiumModel); + console.log("Imagery layers", this.scene.imageryLayers); + this.scene.imageryLayers.remove(cesiumModel); + }, + + /** + * Arranges the imagery that is rendered the Map according to the order + * that the imagery is arranged in the layers collection. + * @since 2.21.0 + */ + sortImagery: function () { + try { + const imageryInMap = this.scene.imageryLayers; + const imageryModels = this.model + .get("layers") + .getAll("CesiumImagery"); + + // If there are no imagery layers, or just one, return + if ( + !imageryInMap || + !imageryModels || + imageryInMap.length <= 1 || + imageryModels.length <= 1 + ) { + return; } - }, - - /** - * Renders peaks and valleys in the 3D version of the map, given a terrain model. - * If a terrain model has already been set on the map, this will replace it. - * @param {Cesium.TerrainProvider} cesiumModel a Cesium Terrain Provider model to - * use for the map - */ - updateTerrain: function (cesiumModel) { - // TODO: Add listener to the map model for when the terrain changes - this.scene.terrainProvider = cesiumModel - this.requestRender(); - }, - /** - * Renders a 3D tileset in the map. - * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model that - * contains the information about the 3D tiles to render in the map - */ - add3DTileset: function (cesiumModel) { - this.scene.primitives.add(cesiumModel) - }, - - /** - * Remove a 3D tileset from the map. - * @param {Cesium.Cesium3DTileset} cesiumModel The Cesium 3D tileset model to - * remove from the map - * @since x.x.x - */ - remove3DTileset: function (cesiumModel) { - this.scene.primitives.remove(cesiumModel) - }, - - /** - * Renders vector data (excluding 3D tilesets) in the Map. - * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source - * model to render on the map - */ - addVectorData: function (cesiumModel) { - this.dataSourceCollection.add(cesiumModel) - }, - - /** - * Remove vector data (excluding 3D tilesets) from the Map. - * @param {Cesium.GeoJsonDataSource} cesiumModel - The Cesium data source - * model to remove from the map - * @since x.x.x - */ - removeVectorData: function (cesiumModel) { - this.dataSourceCollection.remove(cesiumModel) - }, - - /** - * 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() - }, - - /** - * Remove imagery from the Map. - * @param {Cesium.ImageryLayer} cesiumModel The Cesium imagery model to remove - * from the map - * @since x.x.x - */ - removeImagery: function (cesiumModel) { - console.log('Removing imagery from map', cesiumModel); - console.log('Imagery layers', this.scene.imageryLayers); - this.scene.imageryLayers.remove(cesiumModel) - }, - - /** - * Arranges the imagery that is rendered the Map according to the order - * that the imagery is arranged in the layers collection. - * @since 2.21.0 - */ - sortImagery: function() { - try { - const imageryInMap = this.scene.imageryLayers - const imageryModels = this.model.get('layers').getAll('CesiumImagery') - - // If there are no imagery layers, or just one, return - if ( - !imageryInMap || !imageryModels || - imageryInMap.length <= 1 || imageryModels.length <= 1 - ) { - return - } - - // If there are more than one imagery layer, arrange them in the order that - // they were added to the map - for (let i = 0; i < imageryModels.length; i++) { - const cesiumModel = imageryModels[i].get('cesiumModel') - if (cesiumModel) { - if (imageryInMap.contains(cesiumModel)) { - imageryInMap.lowerToBottom(cesiumModel) - } + // If there are more than one imagery layer, arrange them in the order + // that they were added to the map + for (let i = 0; i < imageryModels.length; i++) { + const cesiumModel = imageryModels[i].get("cesiumModel"); + if (cesiumModel) { + if (imageryInMap.contains(cesiumModel)) { + imageryInMap.lowerToBottom(cesiumModel); } } } - catch (error) { + } catch (error) { + console.log( + "There was an error sorting displayed imagery in a CesiumWidgetView" + + ". Error details: " + + error + ); + } + }, + + /** + * Display a box around every rendered tile in the tiling scheme, and draw + * a label inside it indicating the X, Y, Level indices of the tile. This + * is mostly useful for debugging terrain and imagery rendering problems. + * This function should be called after the other imagery layers have been + * added to the map, e.g. at the end of the render function. + * @param {string} [color='#ffffff'] The color of the grid outline and + * labels. Must be a CSS color string, beginning with a #. + * @param {'GeographicTilingScheme'|'WebMercatorTilingScheme'} + * [tilingScheme='GeographicTilingScheme'] The tiling scheme to use. + * Defaults to GeographicTilingScheme. + */ + showImageryGrid: function ( + color = "#ffffff", + tilingScheme = "GeographicTilingScheme" + ) { + try { + const view = this; + // Check the color is valid + if (!color || typeof color !== "string" || !color.startsWith("#")) { console.log( - 'There was an error sorting displayed imagery in a CesiumWidgetView' + - '. Error details: ' + error + `${color} is an invalid color for imagery grid. ` + + `Must be a hex color starting with '#'. ` + + `Setting color to white: '#ffffff'` ); + color = "#ffffff"; } - }, - /** - * Display a box around every rendered tile in the tiling scheme, and - * draw a label inside it indicating the X, Y, Level indices of the - * tile. This is mostly useful for debugging terrain and imagery - * rendering problems. This function should be called after the other - * imagery layers have been added to the map, e.g. at the end of the - * render function. - * @param {string} [color='#ffffff'] The color of the grid outline and - * labels. Must be a CSS color string, beginning with a #. - * @param {'GeographicTilingScheme'|'WebMercatorTilingScheme'} - * [tilingScheme='GeographicTilingScheme'] The tiling scheme to use. - * Defaults to GeographicTilingScheme. - */ - showImageryGrid: function ( - color = '#ffffff', - tilingScheme = 'GeographicTilingScheme' - ) { - try { - const view = this - // Check the color is valid - if (!color || typeof color !== 'string' || !color.startsWith('#')) { - console.log(`${color} is an invalid color for imagery grid. ` + - `Must be a hex color starting with '#'. ` + - `Setting color to white: '#ffffff'`) - color = '#ffffff' - } - - // Check the tiling scheme is valid - const availableTS = ['GeographicTilingScheme', 'WebMercatorTilingScheme'] - if (availableTS.indexOf(tilingScheme) == -1) { - console.log(`${tilingScheme} is not a valid tiling scheme ` + - `for the imagery grid. Using WebMercatorTilingScheme`) - tilingScheme = 'WebMercatorTilingScheme' - } - - // Create the imagery grid - const gridOpts = { - tilingScheme: new Cesium[tilingScheme](), - color: Cesium.Color.fromCssColorString(color) - } - - const gridOutlines = new Cesium.GridImageryProvider(gridOpts) - const gridCoords = new Cesium.TileCoordinatesImageryProvider(gridOpts) - view.scene.imageryLayers.addImageryProvider(gridOutlines) - view.scene.imageryLayers.addImageryProvider(gridCoords) - } - catch (error) { + // Check the tiling scheme is valid + const availableTS = [ + "GeographicTilingScheme", + "WebMercatorTilingScheme", + ]; + if (availableTS.indexOf(tilingScheme) == -1) { console.log( - 'There was an error showing the imagery grid in a CesiumWidgetView' + - '. Error details: ' + error + `${tilingScheme} is not a valid tiling scheme ` + + `for the imagery grid. Using WebMercatorTilingScheme` ); + tilingScheme = "WebMercatorTilingScheme"; } - } - } - ); + // Create the imagery grid + const gridOpts = { + tilingScheme: new Cesium[tilingScheme](), + color: Cesium.Color.fromCssColorString(color), + }; - return CesiumWidgetView; + const gridOutlines = new Cesium.GridImageryProvider(gridOpts); + const gridCoords = new Cesium.TileCoordinatesImageryProvider( + gridOpts + ); + view.scene.imageryLayers.addImageryProvider(gridOutlines); + view.scene.imageryLayers.addImageryProvider(gridCoords); + } catch (error) { + console.log( + "There was an error showing the imagery grid in a CesiumWidgetView" + + ". Error details: " + + error + ); + } + }, + } + ); - } -); + return CesiumWidgetView; +}); diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index dfc4ec029..9c829c11c 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -192,8 +192,6 @@ define(["backbone"], function (Backbone) { coordinates.push(coords) } layer.set("cesiumOptions", { data: geoJSON }) - // TODO: In all MapAsset models, listen for changes to the cesiumOptions - // object and re-create the cesiumModel when it changes. }, /** diff --git a/src/js/views/maps/FeatureInfoView.js b/src/js/views/maps/FeatureInfoView.js index 2e64ee8f6..ada1774e6 100644 --- a/src/js/views/maps/FeatureInfoView.js +++ b/src/js/views/maps/FeatureInfoView.js @@ -460,7 +460,7 @@ define( }, /** - * Trigger an event from the parent Map Asset model that tells the Map Widget to + * Trigger an event from the parent Map model that tells the Map Widget to * zoom to the full extent of this feature in the map. Also make sure that the Map * Asset layer is visible in the map. */ @@ -469,7 +469,7 @@ define( const model = this.model; const mapAsset = model ? model.get('mapAsset') : false; if (mapAsset) { - mapAsset.trigger('flyToExtent', model) + mapAsset.zoomTo(model) } } catch (error) { diff --git a/src/js/views/maps/LayerNavigationView.js b/src/js/views/maps/LayerNavigationView.js index 73aeaf2ec..4fca1cae6 100644 --- a/src/js/views/maps/LayerNavigationView.js +++ b/src/js/views/maps/LayerNavigationView.js @@ -134,13 +134,10 @@ define( flyToExtent : function(){ try { this.model.show() - this.model.trigger('flyToExtent', this.model) + this.model.zoomTo(this.model) } - catch (error) { - console.log( - 'There was an error triggering a "flyToExtent" event in a LayerNavigationView' + - '. Error details: ' + error - ); + catch (e) { + console.log("Error flying to extent of a layer", e); } }, diff --git a/src/js/views/maps/MapView.js b/src/js/views/maps/MapView.js index d26fa5085..2ee79a5a7 100644 --- a/src/js/views/maps/MapView.js +++ b/src/js/views/maps/MapView.js @@ -234,35 +234,31 @@ define( */ renderFeatureInfo: function () { try { - this.featureInfo = new FeatureInfoView({ - el: this.subElements.featureInfoContainer, - model: this.model.get('selectedFeatures').at(0) + const view = this; + const interactions = view.model.get('interactions') + const features = view.model.getSelectedFeatures(); + + view.featureInfo = new FeatureInfoView({ + el: view.subElements.featureInfoContainer, + model: features.at(0) + }).render() + + // When the selectedFeatures collection changes, update the feature + // info view + view.stopListening(features, 'update') + view.listenTo(features, 'update', function () { + view.featureInfo.changeModel(features.at(-1)) }) - this.featureInfo.render() - - // When the selectedFeatures collection changes, update the feature info view - function setSelectFeaturesListeners() { - this.stopListening(this.model.get('selectedFeatures'), 'update') - this.listenTo(this.model.get('selectedFeatures'), 'update', function () { - this.featureInfo.changeModel(this.model.get('selectedFeatures').at(-1)) - }) - } - setSelectFeaturesListeners.call(this) - - // If the Feature model is ever completely replaced for any reason, make the - // the Feature Info view gets updated. - this.stopListening(this.model, 'change:selectedFeatures') - this.listenTo(this.model, 'change:selectedFeatures', function (mapModel, featuresCollection) { - this.featureInfo.changeModel(featuresCollection.at(-1)) - setSelectFeaturesListeners.call(this) - }) - return this.featureInfo + + // If the Feature model is ever completely replaced for any reason, + // make the the Feature Info view gets updated. + const event = 'change:selectedFeatures' + view.stopListening(interactions, event) + view.listenTo(interactions, event, view.renderFeatureInfo); + return view.featureInfo } - catch (error) { - console.log( - 'There was an error rendering a FeatureInfoView in a MapView' + - '. Error details: ' + error - ); + catch (e) { + console.log('Error rendering a FeatureInfoView in a MapView', e); } }, @@ -311,23 +307,24 @@ define( */ renderScaleBar: function () { try { + const interactions = this.model.get('interactions') + if (!interactions) { + this.listenToOnce(this.model, 'change:interactions', this.renderScaleBar); + return + } this.scaleBar = new ScaleBarView({ - el: this.subElements.scaleBarContainer - }) - this.scaleBar.render() - - this.stopListening(this.model, 'change:currentPosition') - this.listenTo(this.model, 'change:currentPosition', function (model, position) { - this.scaleBar.updateCoordinates(position.latitude, position.longitude) - }) - - this.stopListening(this.model, 'change:currentScale') - this.listenTo(this.model, 'change:currentScale', function (model, scale) { - this.scaleBar.updateScale(scale.pixels, scale.meters) + el: this.subElements.scaleBarContainer, + scaleModel: interactions.get('scale'), + pointModel: interactions.get('mousePosition') }) + this.scaleBar.render(); + // If the interaction model or relevant sub-models are ever completely + // replaced for any reason, re-render the scale bar. + this.listenToOnce(interactions, 'change:scale change:mousePosition', this.renderScaleBar); + this.listenToOnce(this.model, 'change:interactions', this.renderScaleBar); - return this.scaleBar + return this.scaleBar; } catch (error) { console.log( @@ -337,6 +334,35 @@ define( } }, + /** + * Get a list of the views that this view contains. + * @returns {Backbone.View[]} Returns an array of all of the sub-views. + * Some may be undefined if they have not been rendered yet. + * @since x.x.x + */ + getSubViews: function () { + return [ + this.mapWidget, + this.toolbar, + this.featureInfo, + this.layerDetails, + this.scaleBar + ] + }, + + /** + * Executed when the view is closed. This will close all of the sub-views. + * @since x.x.x + */ + onClose: function () { + const subViews = this.getSubViews() + subViews.forEach(subView => { + if (subView && typeof subView.onClose === 'function') { + subView.onClose() + } + }) + } + } ); diff --git a/src/js/views/maps/ScaleBarView.js b/src/js/views/maps/ScaleBarView.js index 76a0abaa6..ddb05fba1 100644 --- a/src/js/views/maps/ScaleBarView.js +++ b/src/js/views/maps/ScaleBarView.js @@ -42,6 +42,20 @@ define( */ className: 'scale-bar', + /** + * The model that holds the current scale of the map in pixels:meters + * @type {GeoScale} + * @since x.x.x + */ + scaleModel: null, + + /** + * The model that holds the current position of the mouse on the map + * @type {GeoPoint} + * @since x.x.x + */ + pointModel: null, + /** * The primary HTML template for this view * @type {Underscore.template} @@ -145,7 +159,7 @@ define( } } } catch (e) { - console.log('A ScaleBarView failed to initialize. Error message: ' + e); + console.log('A ScaleBarView failed to initialize.', e); } }, @@ -177,6 +191,10 @@ define( this.updateCoordinates() this.updateScale() + // Listen for changes to the models + this.listenToScaleModel() + this.listenToPointModel() + return this } @@ -188,6 +206,49 @@ define( } }, + /** + * Update the scale bar when the pixel:meters ratio changes + * @since x.x.x + */ + listenToScaleModel: function () { + const view = this; + this.listenTo(this.scaleModel, 'change', function () { + view.updateScale( + view.scaleModel.get('pixels'), + view.scaleModel.get('meters') + ); + }); + }, + + /** + * Stop listening to the scale model + * @since x.x.x + */ + stopListeningToScaleModel: function () { + this.stopListening(this.scaleModel, 'change'); + }, + + /** + * Update the scale bar view when the lat and long change + * @since x.x.x + */ + listenToPointModel: function () { + const view = this; + this.listenTo(this.pointModel, 'change:latitude change:longitude', function () { + view.updateCoordinates( + view.pointModel.get('latitude'), + view.pointModel.get('longitude') + ); + }); + }, + + /** + * Stop listening to the point model + */ + stopListeningToPointModel: function () { + this.stopListening(this.pointModel, 'change:latitude change:longitude'); + }, + /** * Updates the displayed coordinates on the scale bar view. Numbers are rounded so * that long and lat have 5 digits after the decimal point. @@ -340,6 +401,15 @@ define( } }, + /** + * Function to execute when this view is removed from the DOM + * @since x.x.x + */ + onClose: function () { + this.stopListeningToScaleModel() + this.stopListeningToPointModel() + } + } ); diff --git a/src/js/views/maps/ToolbarView.js b/src/js/views/maps/ToolbarView.js index e17974027..b6a6de0fa 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -172,7 +172,7 @@ define( label: 'Home', icon: 'home', action: function (view, model) { - model.trigger('flyHome') + model.flyHome(); } }, { diff --git a/src/js/views/search/CatalogSearchView.js b/src/js/views/search/CatalogSearchView.js index 08075dbd9..e16ec678e 100644 --- a/src/js/views/search/CatalogSearchView.js +++ b/src/js/views/search/CatalogSearchView.js @@ -382,7 +382,7 @@ define([ if (this.limitSearchToMapOnInteraction && !this.limitSearchToMapArea) { this.listenToOnce( - this.model.get("map"), + this.model.get("map").get("interactions"), "change:firstInteraction", function () { this.toggleMapFilter(true); From 0668c2422ade38ad11817e3e9ecfe8d39d004790 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 6 Sep 2023 14:48:42 -0400 Subject: [PATCH 05/24] Add unit tests for new map models Issue #2189 --- .../specs/unit/models/maps/GeoPoint.spec.js | 61 +++ .../specs/unit/models/maps/GeoScale.spec.js | 47 +++ .../unit/models/maps/MapInteraction.spec.js | 386 ++++++++++++++++++ test/scripts/generate-tests.py | 11 +- 4 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 test/js/specs/unit/models/maps/GeoPoint.spec.js create mode 100644 test/js/specs/unit/models/maps/GeoScale.spec.js create mode 100644 test/js/specs/unit/models/maps/MapInteraction.spec.js diff --git a/test/js/specs/unit/models/maps/GeoPoint.spec.js b/test/js/specs/unit/models/maps/GeoPoint.spec.js new file mode 100644 index 000000000..96319bf73 --- /dev/null +++ b/test/js/specs/unit/models/maps/GeoPoint.spec.js @@ -0,0 +1,61 @@ +define([ + "../../../../../../../../src/js/models/maps/GeoPoint", +], function (GeoPoint) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoPoint Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoPoint instance", function () { + new GeoPoint().should.be.instanceof(GeoPoint); + }); + }); + + describe("Validation", function () { + it("should validate a valid GeoPoint", function () { + var point = new GeoPoint({ + latitude: 0, + longitude: 0, + height: 0 + }); + point.isValid().should.be.true; + }); + + it("should invalidate a GeoPoint with an invalid latitude", function () { + var point = new GeoPoint({ + latitude: 100, + longitude: 0, + height: 0 + }); + point.isValid().should.be.false; + }); + + it("should invalidate a GeoPoint with an invalid longitude", function () { + var point = new GeoPoint({ + latitude: 0, + longitude: 200, + height: 0 + }); + point.isValid().should.be.false; + }); + + it("should invalidate a GeoPoint with an invalid height", function () { + var point = new GeoPoint({ + latitude: 0, + longitude: 0, + height: "foo" + }); + point.isValid().should.be.false; + }); + }); + + + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/GeoScale.spec.js b/test/js/specs/unit/models/maps/GeoScale.spec.js new file mode 100644 index 000000000..5375fda25 --- /dev/null +++ b/test/js/specs/unit/models/maps/GeoScale.spec.js @@ -0,0 +1,47 @@ +define([ + "../../../../../../../../src/js/models/maps/GeoScale", +], function (GeoScale) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoScale Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoScale instance", function () { + new GeoScale().should.be.instanceof(GeoScale); + }); + }); + + describe("Validation", function () { + it("should validate a valid GeoScale", function () { + var scale = new GeoScale({ + pixel: 1, + meters: 1 + }); + scale.isValid().should.be.true; + }); + + it("should invalidate a GeoScale with an invalid pixel scale", function () { + var scale = new GeoScale({ + pixel: -1, + meters: 1 + }); + scale.isValid().should.be.false; + }); + + it("should invalidate a GeoScale with an invalid meters scale", function () { + var scale = new GeoScale({ + pixel: 1, + meters: -1 + }); + scale.isValid().should.be.false; + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/MapInteraction.spec.js b/test/js/specs/unit/models/maps/MapInteraction.spec.js new file mode 100644 index 000000000..58fe67ffb --- /dev/null +++ b/test/js/specs/unit/models/maps/MapInteraction.spec.js @@ -0,0 +1,386 @@ +// "use strict"; + +// define([ +// "backbone", +// "collections/maps/Features", +// "models/maps/Feature", +// "models/maps/GeoBoundingBox", +// "models/maps/GeoPoint", +// "models/maps/GeoScale", +// ], function (Backbone, Features, Feature, GeoBoundingBox, GeoPoint, GeoScale) { +// /** +// * @class MapInteraction +// * @classdesc The Map Interaction stores information about user interaction +// * with a map, including the current position of the mouse, the feature that +// * the mouse is currently hovering over, and the position on the map that the +// * user has clicked, as well as the current view extent of the map. +// * @classcategory Models/Maps +// * @name MapInteraction +// * @since x.x.x +// * @extends Backbone.Model +// */ +// var MapInteraction = Backbone.Model.extend( +// /** @lends MapInteraction.prototype */ { +// /** +// * The type of model this is. +// * @type {String} +// */ +// type: "MapInteraction", + +// /** +// * Overrides the default Backbone.Model.defaults() function to specify +// * default attributes for the Map. +// * @returns {Object} The default attributes for the Map. +// * @property {GeoPoint} mousePosition - The current position of the mouse +// * on the map. +// * @property {GeoPoint} clickedPosition - The position on the map that the +// * user last clicked. +// * @property {GeoScale} scale - The current scale of the map in +// * pixels:meters. +// * @property {GeoBoundingBox} viewExtent - The current extent of the map +// * view. +// * @property {Features} hoveredFeatures - The feature that the mouse is +// * currently hovering over. +// * @property {Features} clickedFeatures - The feature that the user last +// * clicked. +// * @property {Features} selectedFeatures - The feature that is currently +// * selected. +// * @property {Boolean} firstInteraction - Whether or not the user has +// * interacted with the map yet. This is set to true when the user has +// * clicked, hovered, panned, or zoomed the map. The only action that is +// * ignored is mouse movement over the map. +// * @property {String} previousAction - The previous action that was +// * performed on the map. This may be any of the labels in the Cesium +// * ScreenSpaceEventType enumeration: +// * {@link https://cesium.com/learn/cesiumjs/ref-doc/global.html#ScreenSpaceEventType} +// * @property {Feature|MapAsset|GeoBoundingBox} zoomTarget - The feature or +// * map asset that the map should zoom to. The map widget should listen to +// * this property and zoom to the specified feature or map asset when this +// * property is set. The property should be cleared after the map widget +// * has zoomed to the specified feature or map asset. +// * +// * TODO +// * * @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. +// * +// * * @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) +// */ +// defaults: function () { +// return { +// mousePosition: new GeoPoint(), +// clickedPosition: new GeoPoint(), +// scale: new GeoScale(), +// viewExtent: new GeoBoundingBox(), +// hoveredFeatures: new Features(), +// clickedFeatures: new Features(), +// selectedFeatures: new Features(), +// firstInteraction: false, // <- "hasInteracted"? +// previousAction: null, +// zoomTarget: null, +// }; +// }, + +// /** +// * Run when a new Map is created. +// * @param {MapConfig} attrs - An object specifying configuration options +// * for the map. If any config option is not specified, the default will be +// * used instead (see {@link MapInteraction#defaults}). +// */ +// initialize: function (attrs, options) { +// try { +// this.connectEvents(); +// } catch (e) { +// console.log("Error initializing a Map Interaction model", e); +// } +// }, + +// /** +// * Connects the MapInteraction model to events from the map widget. +// */ +// connectEvents: function () { +// this.listenForFirstInteraction(); +// this.listenTo(this, "change:previousAction", this.handleClick); +// }, + +// /** +// * Listens for the first interaction with the map (click, hover, pan, or +// * zoom) and sets the 'firstInteraction' attribute to true when it occurs. +// */ +// listenForFirstInteraction: function () { +// if (model.get("firstInteraction")) return; +// const listener = new Backbone.Model(); +// const model = this; +// listener.listenTo( +// this, +// "change:previousAction", +// function (m, eventType) { +// if (eventType != "MOUSE_MOVE") { +// model.set("firstInteraction", true); +// listener.stopListening(); +// listener.destroy(); +// } +// } +// ); +// }, + +// /** +// * Handles a mouse click on the map. If the user has clicked on a feature, +// * the feature is set as the 'clickedFeatures' attribute. If the map is +// * configured to show details when a feature is clicked, the feature is +// * also set as the 'selectedFeatures' attribute. +// * @param {MapInteraction} m - The MapInteraction model. +// * @param {String} action - The type of mouse click event that occurred. +// * All except LEFT_CLICK are ignored. +// */ +// handleClick: function (m, action) { +// if (action !== "LEFT_CLICK") return; +// // Clone the models in hovered features and set them as clicked features +// const hoveredFeatures = this.get("hoveredFeatures").models; +// this.setClickedFeatures(hoveredFeatures); +// if (this.get("mapModel")?.get("clickFeatureAction") === "showDetails") { +// this.selectFeatures(hoveredFeatures); +// } +// }, + +// /** +// * Sets the position of the mouse on the map. Creates a new GeoPoint model +// * if one doesn't already exist on the mousePosition attribute. +// * @param {Object} position - An object with 'longitude' and 'latitude' +// * properties. +// * @returns {GeoPoint} The mouse position as a GeoPoint model. +// */ +// setMousePosition: function (position) { +// let mousePosition = this.get("mousePosition"); +// if (!mousePosition) { +// mousePosition = new GeoPoint(); +// this.set("mousePosition", mousePosition); +// } +// mousePosition.set(position); +// return mousePosition; +// }, + +// /** +// * Set the pixel:meter scale of the map. Creates a new GeoScale model if +// * one doesn't already exist on the scale attribute. +// * @param {Object} scale - An object with 'meters' and 'pixels' +// * properties. +// * @returns {GeoScale} The scale as a GeoScale model. +// */ +// setScale: function (scale) { +// let scaleModel = this.get("scale"); +// if (!scaleModel) { +// scaleModel = new GeoScale(); +// this.set("scale", scaleModel); +// } +// scaleModel.set(scale); +// return scaleModel; +// }, + +// /** +// * Set the extent of the map view. Creates a new GeoBoundingBox model if +// * one doesn't already exist on the viewExtent attribute. +// * @param {Object} extent - An object with 'north', 'east', 'south', and +// * 'west' properties. +// * @returns {GeoBoundingBox} The view extent as a GeoBoundingBox model. +// */ +// setViewExtent: function (extent) { +// let viewExtent = this.get("viewExtent"); +// if (!viewExtent) { +// viewExtent = new GeoBoundingBox(); +// this.set("viewExtent", viewExtent); +// } +// viewExtent.set(extent); +// return viewExtent; +// }, + +// /** +// * Set the feature that the mouse is currently hovering over. +// * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]} features - +// * An array of feature objects selected directly from the map view. +// */ +// setHoveredFeatures: function (features) { +// this.setFeatures(features, "hoveredFeatures", true); +// }, + +// /** +// * Set the feature that the user last clicked. +// * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]|Object[]} +// * features - An array of feature objects selected directly from the map +// * view. +// */ +// setClickedFeatures: function (features) { +// this.setFeatures(features, "clickedFeatures", true); +// }, + +// /** +// * Set the feature that is currently selected. +// * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[|Object[]]} +// * features - An array of feature objects selected directly from the map +// * view. +// */ +// selectFeatures: function (features) { +// this.setFeatures(features, "selectedFeatures", true); +// }, + +// /** +// * Set features on either the hoveredFeatures, clickedFeatures, or +// * selectedFeatures attribute. If the replace parameter is true, then the +// * features will replace the current features on the attribute. +// * @param {Cesium.Entity|Cesium.Cesium3DTileFeature|Feature[]|Object[]} +// * features - An array of feature objects selected directly from the map +// * view. +// * @param {'hoveredFeatures'|'clickedFeatures'|'selectedFeatures'} type - +// * The type of feature to set. +// * @param {Boolean} [replace=true] - Whether or not to replace the current +// * features on the attribute with the new features. +// */ +// setFeatures: function (features, type, replace = true) { +// try { +// const model = this; + +// // Create a features collection if one doesn't already exist +// if (!model.get(type)) model.set(type, new Features()); + +// // Remove any null or undefined features +// if (Array.isArray(features)) features = features.filter((f) => f); +// // Remove any default features (which are empty models) +// if (features instanceof Features) { +// features = features.filter((f) => !f.isDefault()); +// } +// // If no feature is passed to this function (and replace is true), +// if (!features || features.length === 0) { +// if (replace) model.get(type).set([], { remove: true }); +// return; +// } + +// // Ignore if new features are identical to the current features +// const currentFeatures = model.get(type); +// if ( +// features && +// currentFeatures && +// currentFeatures.length === features.length && +// currentFeatures.containsFeatures(features) +// ) { +// return; +// } + +// // Convert the feature objects, which may be types specific to the map +// // widget (Cesium), to a generic Feature model +// features = model.convertFeatures(features); + +// // Update the Feature model with the new selected feature information. +// const newAttrs = features.map(function (feature) { +// return Object.assign( +// {}, +// new Feature().defaults(), +// feature.attributes +// ); +// }); +// model.get(type).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|Feature[]} features - +// * An array of feature objects selected directly from the map view, or +// * @returns {Feature[]} An array of Feature models. +// * @since 2.25.0 +// */ +// convertFeatures: function (features) { +// if (!features) return []; +// if (!features.map) features = [features]; +// const mapModel = this.get("mapModel"); +// 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 mapModel.get("layers").getFeatureAttributes(features)?.[0]; +// }); +// return attrs.map((attr) => new Feature(attr)); +// }, +// } +// ); + +// return MapInteraction; +// }); + + +define([ + "../../../../../../../../src/js/models/maps/MapInteraction", +], function (MapInteraction) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("MapInteraction Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a MapInteraction instance", function () { + new MapInteraction().should.be.instanceof(MapInteraction); + }); + }); + + + describe("setting user interactions", function () { + + it("should set the mouse position", function () { + const model = new MapInteraction(); + const position = { longitude: 1, latitude: 2 }; + model.setMousePosition(position); + model.get("mousePosition").get("latitude").should.equal(2); + model.get("mousePosition").get("longitude").should.equal(1); + }); + + it("should set the scale", function () { + const model = new MapInteraction(); + const scale = { meters: 1, pixels: 2 }; + model.setScale(scale); + model.get("scale").get("meters").should.equal(1); + model.get("scale").get("pixels").should.equal(2); + }); + + it("should set the view extent", function () { + const model = new MapInteraction(); + const extent = { north: 1, east: 2, south: 3, west: 4 }; + model.setViewExtent(extent); + model.get("viewExtent").get("north").should.equal(1); + model.get("viewExtent").get("east").should.equal(2); + model.get("viewExtent").get("south").should.equal(3); + model.get("viewExtent").get("west").should.equal(4); + }); + }); + + }); +}); \ No newline at end of file diff --git a/test/scripts/generate-tests.py b/test/scripts/generate-tests.py index 3612949c5..55b5609ad 100644 --- a/test/scripts/generate-tests.py +++ b/test/scripts/generate-tests.py @@ -3,14 +3,9 @@ # 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", + "models/maps/GeoPoint.js", + "models/maps/GeoScale.js", + "models/maps/MapInteraction.js", ] test_template = """ From e2d5bdec934c9c2c2b2e5b31453b85992c22eeed Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 20 Sep 2023 16:21:30 -0400 Subject: [PATCH 06/24] Fix issues with selected features in Cesium Map Issues #2189, 2180 --- src/js/collections/maps/Features.js | 227 ++- src/js/collections/maps/MapAssets.js | 9 +- src/js/models/maps/Feature.js | 334 ++-- src/js/models/maps/GeoBoundingBox.js | 5 +- src/js/models/maps/Map.js | 6 +- src/js/models/maps/MapInteraction.js | 60 +- src/js/models/maps/assets/Cesium3DTileset.js | 16 +- src/js/models/maps/assets/CesiumVectorData.js | 27 +- src/js/models/maps/assets/MapAsset.js | 1664 +++++++++-------- 9 files changed, 1151 insertions(+), 1197 deletions(-) diff --git a/src/js/collections/maps/Features.js b/src/js/collections/maps/Features.js index 768c17197..c49eb33b3 100644 --- a/src/js/collections/maps/Features.js +++ b/src/js/collections/maps/Features.js @@ -1,125 +1,118 @@ -'use strict'; +"use strict"; -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'models/maps/Feature' - ], - function ( - $, - _, - Backbone, - Feature - ) { +define(["jquery", "underscore", "backbone", "models/maps/Feature"], function ( + $, + _, + Backbone, + Feature +) { + /** + * @class Features + * @classdesc A Features collection contains the relevant properties of a group of + * selected geo-spatial features from a map. + * @class Features + * @classcategory Collections/Maps + * @extends Backbone.Collection + * @since 2.18.0 + * @constructor + */ + var Features = Backbone.Collection.extend( + /** @lends Features.prototype */ { + /** + * The class/model that this collection contains. + * @type {Backbone.Model} + */ + model: Feature, - /** - * @class Features - * @classdesc A Features collection contains the relevant properties of a group of - * selected geo-spatial features from a map. - * @class Features - * @classcategory Collections/Maps - * @extends Backbone.Collection - * @since 2.18.0 - * @constructor - */ - var Features = Backbone.Collection.extend( - /** @lends Features.prototype */ { + /** + * Get an array of all of the unique Map Assets that are associated with this + * collection. (When a feature model is part of a layer, it will have the layer + * model (Map Asset) set as a property) + * @returns {MapAsset[]} Returns an a array of all the unique Map Assets (imagery, + * tile sets, etc.) in this collection. + */ + getMapAssets: function () { + return this.getUniqueAttrs("mapAsset"); + }, - /** - * The class/model that this collection contains. - * @type {Backbone.Model} - */ - model: Feature, + /** + * Get an array of all the unique feature objects associated with this collection. + * @param {string} [type] Optionally set a type of feature to return. If set, then + * only features that have this constructor name will be returned. + * @returns {Array} Returns an array of all of the unique feature objects in the + * collection. Feature objects are the objects used by the map widget to represent + * a feature in the map. For example, in Cesium this could be a + * Cesium3DTileFeature or an Entity. + */ + getFeatureObjects: function (type) { + let featureObjects = this.getUniqueAttrs("featureObject"); + if (type) { + featureObjects = featureObjects.filter(function (featureObject) { + return featureObject.constructor.name === type; + }); + } + return featureObjects; + }, - /** - * Get an array of all of the unique Map Assets that are associated with this - * collection. (When a feature model is part of a layer, it will have the layer - * model (Map Asset) set as a property) - * @returns {MapAsset[]} Returns an a array of all the unique Map Assets (imagery, - * tile sets, etc.) in this collection. - */ - getMapAssets: function () { - return this.getUniqueAttrs('mapAsset') - }, + /** + * Get an array of unique values for some attribute that may be set on the models + * in this collection + * @param {string} attrName The name of the attr to get unique values for + * @returns {Array} Returns an array of unique values of the given attribute + */ + getUniqueAttrs: function (attrName) { + try { + let uniqueAttrs = []; + this.each(function (featureModel) { + const attr = featureModel.get(attrName); + if (attr && !uniqueAttrs.includes(attr)) { + uniqueAttrs.push(attr); + } + }); + return uniqueAttrs; + } catch (error) { + console.log( + `Failed to get unique attributes for "${attrName}".`, + error + ); + } + }, - /** - * Get an array of all the unique feature objects associated with this collection. - * @param {string} [type] Optionally set a type of feature to return. If set, then - * only features that have this constructor name will be returned. - * @returns {Array} Returns an array of all of the unique feature objects in the - * collection. Feature objects are the objects used by the map widget to represent - * a feature in the map. For example, in Cesium this could be a - * Cesium3DTileFeature or an Entity. - */ - getFeatureObjects: function (type) { - let featureObjects = this.getUniqueAttrs('featureObject') - if (type) { - featureObjects = featureObjects.filter(function (featureObject) { - return featureObject.constructor.name === type - }) - } - return featureObjects - }, + /** + * Checks if a given feature object is an attribute in one of the Feature models + * in this collection. + * @param {Feature|Cesium.Cesium3DTilesetFeature|Cesium.Entity} featureObject + * @returns {boolean} Returns true if the given feature object is in this + * collection, false otherwise. + * @since 2.25.0 + */ + containsFeature: function (featureObject) { + if (this.models.length === 0) return false; + if (!featureObject) return false; + featureObject = + featureObject instanceof Feature + ? featureObject.get("featureObject") + : featureObject; + return this.findWhere({ 'featureObject': featureObject }) ? true : false; + }, - /** - * Get an array of unique values for some attribute that may be set on the models - * in this collection - * @param {string} attrName The name of the attr to get unique values for - * @returns {Array} Returns an array of unique values of the given attribute - */ - getUniqueAttrs: function (attrName) { - try { - let uniqueAttrs = [] - this.each(function (featureModel) { - const attr = featureModel.get(attrName) - if (attr && !uniqueAttrs.includes(attr)) { - uniqueAttrs.push(attr) - } - }) - return uniqueAttrs - } - catch (error) { - console.log( - 'Failed to get unique values for an attribute in a Features collection' + - '. Error details: ' + error - ); - } - }, + /** + * 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) + ); + }, - /** - * Checks if a given feature object is an attribute in one of the Feature models - * in this collection. - * @param {Feature|Cesium.Cesium3DTilesetFeature|Cesium.Entity} featureObject - * @returns {boolean} Returns true if the given feature object is in this - * collection, false otherwise. - * @since 2.25.0 - */ - containsFeature: function (featureObject) { - 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)); - }, - - } - ); - - return Features; - - } -); \ No newline at end of file + return Features; +}); diff --git a/src/js/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index 262ff3c2a..1dc3a6d7b 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -142,12 +142,8 @@ define([ 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 - ); + } catch (e) { + console.log("Failed to set the map model on MapAssets collection", e); } }, @@ -236,6 +232,7 @@ define([ * @since 2.25.0 */ getFeatureAttributes: function (features) { + if (!Array.isArray(features)) features = [features]; return features.map((feature) => { const asset = this.findAssetWithFeature(feature); return asset?.getFeatureAttributes(feature); diff --git a/src/js/models/maps/Feature.js b/src/js/models/maps/Feature.js index f68c61e4a..66b2b0fc6 100644 --- a/src/js/models/maps/Feature.js +++ b/src/js/models/maps/Feature.js @@ -1,190 +1,156 @@ -'use strict'; - -define( - [ - 'jquery', - 'underscore', - 'backbone' - ], - function ( - $, - _, - Backbone - ) { - /** - * @classdesc A Feature Model organizes information about a single feature of a vector - * layer in a map. - * @classcategory Models/Maps - * @class Feature - * @name Feature - * @extends Backbone.Model - * @since 2.18.0 - * @constructor - */ - var Feature = Backbone.Model.extend( - /** @lends Feature.prototype */ { - - /** - * The name of this type of model - * @type {string} - */ - type: 'Feature', - - /** - * Default attributes for Feature models - * @name Feature#defaults - * @type {Object} - * @property {Object} properties Property names (keys) and property values - * (values) for properties set on this feature. For example, the properties that - * would be in an attributes table for a shapefile. - * @property {MapAsset} mapAsset If the feature is part of a Map Asset, then the - * model for that asset. For example, if this is a feature if a 3D tileset, then - * the Cesium3DTileset map asset model. - * @property {string|number} featureID An ID that's used to identify this feature - * in the map. It should be unique among all map features. (In Cesium, this is the - * Pick ID key.) - * @property {*} featureObject The object that a Map widget uses to represent this - * feature in the map. For example, in Cesium this could be a - * Cesium.Cesium3DTileFeature or a Cesium.Entity. - * @property {string} label An optional friendly label or name for this feature. - */ - defaults: function () { - return { - properties: {}, - mapAsset: null, - featureID: null, - featureObject: null, - label: null +"use strict"; + +define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { + /** + * @classdesc A Feature Model organizes information about a single feature of + * a vector layer in a map. + * @classcategory Models/Maps + * @class Feature + * @name Feature + * @extends Backbone.Model + * @since 2.18.0 + * @constructor + */ + var Feature = Backbone.Model.extend( + /** @lends Feature.prototype */ { + /** + * The name of this type of model + * @type {string} + */ + type: "Feature", + + /** + * Default attributes for Feature models + * @name Feature#defaults + * @type {Object} + * @property {Object} properties Property names (keys) and property values + * (values) for properties set on this feature. For example, the + * properties that would be in an attributes table for a shapefile. + * @property {MapAsset} mapAsset If the feature is part of a Map Asset, + * then the model for that asset. For example, if this is a feature if a + * 3D tileset, then the Cesium3DTileset map asset model. + * @property {string|number} featureID An ID that's used to identify this + * feature in the map. It should be unique among all map features. (In + * Cesium, this is the Pick ID key.) + * @property {*} featureObject The object that a Map widget uses to + * represent this feature in the map. For example, in Cesium this could be + * a Cesium.Cesium3DTileFeature or a Cesium.Entity. + * @property {string} label An optional friendly label or name for this + * feature. + */ + defaults: function () { + return { + properties: {}, + mapAsset: null, + featureID: null, + featureObject: null, + label: null, + }; + }, + + /** + * Checks if the attributes for this model are only the default + * attributes. + * @returns {boolean} Returns true if all of the attributes are equal to + * the default attributes, false otherwise. + */ + isDefault: function () { + try { + var defaults = this.defaults(); + var current = this.attributes; + return _.isEqual(defaults, current); + } catch (error) { + console.log( + "Failed to check if a Feature model is the default.", + error + ); + } + }, + + /** + * Clears all the model attributes and sets them to the default values. + * This will trigger a change event. No event is triggered if all of the + * value are already set to default. + */ + setToDefault: function () { + try { + // Don't make changes if model is already the default + if (!this.isDefault()) { + this.clear({ silent: true }); + this.set(this.defaults()); } - }, - - // /** - // * Executed when a new Feature 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) { - // try { - - // } - // catch (error) { - // console.log( - // 'There was an error initializing a Feature model' + - // '. Error details: ' + error - // ); - // } - // }, - - /** - * Checks if the attributes for this model are only the default attributes. - * @returns {boolean} Returns true if all of the attributes are equal to the - * default attributes, false otherwise. - */ - isDefault: function () { - try { - var defaults = this.defaults() - var current = this.attributes - return _.isEqual(defaults, current) + } catch (error) { + console.log( + "There was an error reset a Feature model to default" + + ". Error details: " + + error + ); + } + }, + + /** + * Given an map-widget-specific-object representing a feature, and a + * MapAssets collection, this function will attempt to find the + * corresponding MapAsset model in the collection, and extract the + * feature attributes from that model. + * @param {*} feature - An object representing a feature in the map. For + * example, in Cesium this could be a Cesium.Cesium3DTileFeature or a + * Cesium.Entity. + * @param {MapAssets} assets - A MapAssets collection to use to extract + * feature attributes from a feature object. + * @returns {object} - The JSON object of all the Feature attributes + * @since x.x.x + */ + attrsFromFeatureObject: function (feature, assets) { + if (feature instanceof Feature) { + return feature.clone().attributes; + } + let attrs = null; + // if this is already an object with feature attributes, return it + if (typeof feature == "object") { + if ( + feature.hasOwnProperty("mapAsset") && + feature.hasOwnProperty("properties") + ) { + attrs = feature; + } else if (assets) { + attrs = assets.getFeatureAttributes([feature])[0]; } - catch (error) { - console.log( - 'There was an error checking if a Feature model has only default attributes in a Feature' + - '. Error details: ' + error + } + return attrs; + }, + + /** + * Parses the given input into a JSON object to be set on the model. If + * passed a MapAssets collection as an option, and the input includes an + * assets property, then the parse function will attempt to find the + * feature object's corresponding MapAsset model in the collection, and + * extract the feature attributes from that model. + * + * @param {object} input - The raw response object + * @param {object} [options] - Options for the parse function + * @property {MapAssets} [options.assets] - A MapAssets collection to use + * to extract feature attributes from a feature object. + * @return {object} - The JSON object of all the Feature attributes + */ + parse: function (input, options) { + try { + if (!input) return null; + if (input.featureObject && options.assets) { + const attrs = this.attrsFromFeatureObject( + input.featureObject, + options.assets ); + input = Object.assign({}, input, attrs); } - }, - - /** - * Clears all the model attributes and sets them to the default values. This will - * trigger a change event. No event is triggered if all of the value are already - * set to default. - */ - setToDefault: function () { - try { - // Don't make changes if model is already the default - if (!this.isDefault()) { - this.clear({ silent: true }) - this.set(this.defaults()) - } - } - catch (error) { - console.log( - 'There was an error reset a Feature model to default' + - '. 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 Feature attributes - // */ - // parse: function (input) { - - // try { - - // var modelJSON = {}; - - // return modelJSON - - // } - // catch (error) { - // console.log( - // 'There was an error parsing a Feature 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 Feature model' + - // '. Error details: ' + error - // ); - // } - // }, - - // /** - // * Creates a string using the values set on this model's attributes. - // * @return {string} The Feature string - // */ - // serialize: function () { - // try { - // var serializedFeature = ''; - - // return serializedFeature; - // } - // catch (error) { - // console.log( - // 'There was an error serializing a Feature model' + - // '. Error details: ' + error - // ); - // } - // }, - - }); - return Feature; + return input; + } catch (error) { + console.log("Failed to parse a Feature model", error); + } + }, + } + ); - } -); + return Feature; +}); diff --git a/src/js/models/maps/GeoBoundingBox.js b/src/js/models/maps/GeoBoundingBox.js index 93dc4959c..87e4b7cb2 100644 --- a/src/js/models/maps/GeoBoundingBox.js +++ b/src/js/models/maps/GeoBoundingBox.js @@ -84,10 +84,7 @@ define(["backbone"], function (Backbone) { */ getArea: function () { if (!this.isValid()) { - console.warn( - `Bounds are invalid: ${JSON.stringify(bounds)}. ` + - `Returning the globe's area for the given bounding box.` - ); + console.warn("Invalid bounding box, returning globe area"); return 360 * 180; } const { north, south, east, west } = this.attributes; diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 341c73962..f15cfe4a5 100644 --- a/src/js/models/maps/Map.js +++ b/src/js/models/maps/Map.js @@ -210,11 +210,7 @@ define([ } this.setUpInteractions(); } catch (error) { - console.log( - "There was an error initializing a Map model" + - ". Error details: " + - error - ); + console.log('Failed to initialize a Map model.', error); } }, diff --git a/src/js/models/maps/MapInteraction.js b/src/js/models/maps/MapInteraction.js index b7dc2efd4..efa0e6ce6 100644 --- a/src/js/models/maps/MapInteraction.js +++ b/src/js/models/maps/MapInteraction.js @@ -123,9 +123,9 @@ define([ * zoom) and sets the 'firstInteraction' attribute to true when it occurs. */ listenForFirstInteraction: function () { + const model = this; if (model.get("firstInteraction")) return; const listener = new Backbone.Model(); - const model = this; listener.listenTo( this, "change:previousAction", @@ -153,8 +153,11 @@ define([ // Clone the models in hovered features and set them as clicked features const hoveredFeatures = this.get("hoveredFeatures").models; this.setClickedFeatures(hoveredFeatures); - if (this.get("mapModel")?.get("clickFeatureAction") === "showDetails") { + const clickAction = this.get("mapModel")?.get("clickFeatureAction"); + if (clickAction === "showDetails") { this.selectFeatures(hoveredFeatures); + } else if (clickAction === "zoom") { + this.set("zoomTarget", hoveredFeatures[0]); } }, @@ -235,6 +238,7 @@ define([ * view. */ selectFeatures: function (features) { + const model = this; this.setFeatures(features, "selectedFeatures", true); }, @@ -257,13 +261,13 @@ define([ // Create a features collection if one doesn't already exist if (!model.get(type)) model.set(type, new Features()); - // Remove any null or undefined features + // Remove any null, undefined, or empty features if (Array.isArray(features)) features = features.filter((f) => f); - // Remove any default features (which are empty models) if (features instanceof Features) { features = features.filter((f) => !f.isDefault()); } - // If no feature is passed to this function (and replace is true), + + // Empty collection if features array is empty (and replace is true) if (!features || features.length === 0) { if (replace) model.get(type).set([], { remove: true }); return; @@ -280,51 +284,17 @@ define([ return; } - // Convert the feature objects, which may be types specific to the map - // widget (Cesium), to a generic Feature model - features = model.convertFeatures(features); + const assets = this.get("mapModel")?.get("layers"); - // Update the Feature model with the new selected feature information. - const newAttrs = features.map(function (feature) { - return Object.assign( - {}, - new Feature().defaults(), - feature.attributes - ); - }); - model.get(type).set(newAttrs, { remove: replace }); + const newAttrs = features.map((f) => ({ featureObject: f })); + + model + .get(type) + .set(newAttrs, { remove: replace, parse: true, assets: assets }); } 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|Feature[]} features - - * An array of feature objects selected directly from the map view, or - * @returns {Feature[]} An array of Feature models. - * @since 2.25.0 - */ - convertFeatures: function (features) { - if (!features) return []; - if (!features.map) features = [features]; - const mapModel = this.get("mapModel"); - 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 mapModel.get("layers").getFeatureAttributes(features)?.[0]; - }); - return attrs.map((attr) => new Feature(attr)); - }, } ); diff --git a/src/js/models/maps/assets/Cesium3DTileset.js b/src/js/models/maps/assets/Cesium3DTileset.js index 0968dda8e..df560048a 100644 --- a/src/js/models/maps/assets/Cesium3DTileset.js +++ b/src/js/models/maps/assets/Cesium3DTileset.js @@ -2,22 +2,14 @@ define( [ - 'jquery', - 'underscore', - 'backbone', 'cesium', 'models/maps/assets/MapAsset', - 'models/maps/AssetColor', 'models/maps/AssetColorPalette', 'collections/maps/VectorFilters' ], function ( - $, - _, - Backbone, Cesium, MapAsset, - AssetColor, AssetColorPalette, VectorFilters ) { @@ -74,7 +66,8 @@ define( * specific to each type of asset. */ defaults: function () { - return _.extend( + return Object.assign( + {}, this.constructor.__super__.defaults(), { type: 'Cesium3DTileset', @@ -215,6 +208,9 @@ define( setListeners: function () { try { + // call the super method + this.constructor.__super__.setListeners.call(this); + // When opacity, color, or visibility changes (will also update the filters) this.stopListening(this, 'change:opacity change:color change:visible') this.listenTo( @@ -245,6 +241,8 @@ define( // changes on a Cesium map. const cesiumModel = model.get('cesiumModel') + if(!cesiumModel) return + // If the layer isn't visible at all, don't bother setting up colors or // filters. Just set every feature to hidden. if (!model.isVisible()) { diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index fee95fc5b..060c0b6d4 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -106,7 +106,7 @@ define( initialize: function (assetConfig) { try { - if(!assetConfig) assetConfig = {}; + if (!assetConfig) assetConfig = {}; MapAsset.prototype.initialize.call(this, assetConfig); @@ -240,19 +240,18 @@ define( */ setListeners: function () { try { + this.constructor.__super__.setListeners.call(this); const appearEvents = 'change:visible change:opacity change:color change:outlineColor' + ' change:temporarilyHidden' this.stopListening(this, appearEvents) this.listenTo(this, appearEvents, this.updateAppearance) - this.stopListening(this.get('filters'), 'update') - this.listenTo(this.get('filters'), 'update', this.updateFeatureVisibility) + const filters = this.get('filters'); + this.stopListening(filters, 'update') + this.listenTo(filters, 'update', this.updateFeatureVisibility) } catch (error) { - console.log( - 'There was an error setting listeners in a CesiumVectorData model' + - '. Error details: ' + error - ); + console.log('Failed to set CesiumVectorData listeners.', error); } }, @@ -289,7 +288,7 @@ define( * @returns {Cesium.Entity} - The Entity object if found, otherwise null. * @since 2.25.0 */ - getEntityFromMapObject(mapObject) { + getEntityFromMapObject: function(mapObject) { const entityType = this.get("featureType") if (mapObject instanceof entityType) return mapObject if (mapObject.id instanceof entityType) return mapObject.id @@ -328,7 +327,7 @@ define( * @returns {Object} An object containing key-value mapping of property names to * properties. */ - getPropertiesFromFeature(feature) { + getPropertiesFromFeature: function(feature) { feature = this.getEntityFromMapObject(feature) if (!feature) return null const featureProps = feature.properties @@ -382,7 +381,10 @@ define( try { const model = this; - const cesiumModel = this.get('cesiumModel') + const cesiumModel = this.get('cesiumModel'); + + if (!cesiumModel) return + const entities = cesiumModel.entities.values // Suspending events while updating a large number of entities helps @@ -564,10 +566,11 @@ define( * @since 2.25.0 */ getStyles: function (entity) { - if(!entity) return null + if (!entity) return null + entity = this.getEntityFromMapObject(entity) if (this.featureIsSelected(entity)) { return this.getSelectedStyles(entity) - } + } const color = this.colorForEntity(entity); if (!color) { return null } const outlineColor = this.colorToCesiumColor( diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index 0441f4b46..b47b0e42e 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -1,296 +1,285 @@ -'use strict'; - -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'models/portals/PortalImage', - 'models/maps/AssetColorPalette', - MetacatUI.root + '/components/dayjs.min.js' - ], - function ( - $, - _, - Backbone, - PortalImage, - AssetColorPalette, - dayjs - ) { - /** - * @classdesc A MapAsset Model comprises information required to fetch source data for - * some asset or resource that is displayed in a map, such as imagery (raster) tiles, - * vector data, a 3D tileset, or a terrain model. This model also contains metadata - * about the source data, like an attribution and a description. It represents the - * most generic type of asset, and can be extended to support specific assets that are - * compatible with a given map widget. - * @classcategory Models/Maps/Assets - * @class MapAsset - * @name MapAsset - * @extends Backbone.Model - * @since 2.18.0 - * @constructor - */ - var MapAsset = Backbone.Model.extend( - /** @lends MapAsset.prototype */ { - - /** - * The name of this type of model - * @type {string} - */ - type: 'MapAsset', - - /** - * Default attributes for MapAsset models - * @name MapAsset#defaults - * @type {Object} - * @property {('Cesium3DTileset'|'BingMapsImageryProvider'|'IonImageryProvider'|'WebMapTileServiceImageryProvider'|'TileMapServiceImageryProvider'|'CesiumTerrainProvider')} type - * The format of the data. Must be one of the supported types. - * @property {string} label A user friendly name for this asset, to be displayed - * in a map. - * @property {string} [icon = ''] - * A PID for an SVG saved as a dataObject, or an SVG string. The SVG will be used - * as an icon that will be displayed next to the label in the layers list. It - * should be an SVG file that has no fills, borders, or styles set on it (since - * the icon will be shaded dynamically by the maps CSS using a fill attribute). It - * must use a viewbox property rather than a height and width. - * @property {string} [description = ''] A brief description about the asset, e.g. - * which area it covers, the resolution, etc. - * @property {string} [attribution = ''] A credit or attribution to display along - * with this map resource. - * @property {string} [moreInfoLink = ''] A link to show in a map where a user can - * find more information about this resource. - * @property {string} [downloadLink = ''] A link to show in a map where a user can - * go to download the source data. - * @property {string} [id = ''] If this asset's data is archived in a DataONE - * repository, the ID of the data package. - * @property {Boolean} [selected = false] Set to true when this asset has been - * selected by the user in the layer list. - * @property {Number} [opacity = 1] A number between 0 and 1 indicating the - * opacity of the layer on the map, with 0 representing fully transparent and 1 - * representing fully opaque. This applies to raster (imagery) and vector assets, - * not to terrain assets. - * @property {Boolean} [visible = true] Set to true if the layer is visible on the - * map, false if it is hidden. This applies to raster (imagery) and vector assets, - * not to terrain assets. - * @property {AssetColorPalette} [colorPalette] The color or colors mapped to - * attributes of this asset. This applies to raster/imagery and vector assets. For - * imagery, the colorPalette will be used to create a legend. For vector assets - * (e.g. 3Dtilesets), it will also be used to style the features. - * @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 - * of an existing date property. - * @property {MapConfig#Notification} [notification] A custom badge and message to - * display about the layer in the Layer list. For example, this could highlight - * the layer if it is new, give a warning if they layer is under development, etc. - * @property {'ready'|'error'|null} [status = null] Set to 'ready' when the - * resource is loaded and ready to be rendered in a map view. Set to 'error' when - * the asset is not supported, or there was a problem requesting the resource. - * @property {string} [statusDetails = null] Any further details about the status, - * especially when there was an error. - */ - defaults: function () { - return { - type: '', - label: '', - icon: '', - description: '', - attribution: '', - moreInfoLink: '', - downloadLink: '', - id: '', - selected: false, - opacity: 1, - visible: true, - colorPalette: null, - customProperties: {}, - featureTemplate: {}, - featureType: null, - notification: {}, - status: null, - statusDetails: null - } - }, - - /** - * The source of a specific asset (i.e. layer or terrain data) to show on the map, - * as well as metadata and display properties of the asset. Some properties listed - * here do not apply to all asset types, but this is specified in the property - * description. - * @typedef {Object} MapAssetConfig - * @name MapConfig#MapAssetConfig - * @property {('Cesium3DTileset'|'BingMapsImageryProvider'|'IonImageryProvider'|'WebMapTileServiceImageryProvider'|'WebMapServiceImageryProvider'|'TileMapServiceImageryProvider'|'NaturalEarthII'|'CesiumTerrainProvider'|'GeoJsonDataSource'|'USGSImageryTopo'|'OpenStreetMapImageryProvider')} type - - * A string indicating the format of the data. Some of these types correspond - * directly to Cesium classes. The NaturalEarthII type is a special imagery layer - * that automatically sets the cesiumOptions to load the Natural Earth II imagery - * that is shipped with Cesium/MetacatUI. If this type is set, then no other - * cesiumOptions are required. The same is true for USGSImageryTopo, which pulls - * imagery directly from USGS. - * @property {(Cesium3DTileset#cesiumOptions|CesiumImagery#cesiumOptions|CesiumTerrain#cesiumOptions|CesiumVectorData#cesiumOptions)} [cesiumOptions] - - * For MapAssets that are configured for Cesium, like - * Cesium3DTilesets, an object with options to pass to the Cesium constructor - * function that creates the Cesium model. Options are specific to each type of - * asset. For details, see documentation for each of the types. - * @property {string} label - A user friendly name for this asset, to be displayed - * in a map. - * @property {string} [icon] - A PID for an SVG saved as a dataObject, or an SVG - * string. The SVG will be used as an icon that will be displayed next to the - * label in the layers list. It should be an SVG file that has no fills, borders, - * or styles set on it (since the icon will be shaded dynamically by the maps CSS - * using a fill attribute). It must use a viewbox property rather than a height - * and width. - * @property {Number} [opacity=1] - A number between 0 and 1 indicating the - * opacity of the layer on the map, with 0 representing fully transparent and 1 - * representing fully opaque. This applies to raster (imagery) and vector assets, - * not to terrain assets. - * @property {Boolean} [visible=true] - Set to true if the layer is visible on the - * map, false if it is hidden. This applies to raster (imagery) and vector assets, - * not to terrain assets. - * @property {string} [description] - A brief description about the asset, e.g. - * which area it covers, the resolution, etc. - * @property {string} [attribution] A credit or attribution to display along with - * this asset. - * @property {string} [moreInfoLink] A complete URL used to create a link to show - * in a map where a user can find more information about this resource. - * @property {string} [downloadLink] A complete URL used to show a link in a map - * where a user can go to download the source data. - * @property {string} [id] If this asset's data is archived in a DataONE - * repository, the ID of the data package. - * @property {MapConfig#ColorPaletteConfig} [colorPalette] The color or colors - * mapped to attributes of this asset. This applies to raster/imagery and vector - * assets. For imagery, the colorPalette will be used to create a legend. For - * vector assets (e.g. 3Dtilesets), it will also be used to style the features. - * @property {MapConfig#FeatureTemplate} [featureTemplate] Configuration for the - * content and layout of the Feature Info panel ({@link FeatureInfoView}) - the - * panel that shows information about a selected feature from a vector asset. If - * no feature template is set, then the default table layout is used. - * @property {MapConfig#CustomProperties} [customProperties] Definitions of custom - * properties of features, potentially based on existing properties. For example, - * a custom property could be a formatted version of another date property. These - * custom properties can be used in the filters, colorPalette, or featureTemplate. - * So far, custom strings and formatted dates are supported. Eventually, the - * custom properties may be expanded to support formatted numbers and booleans. - * @property {MapConfig#VectorFilterConfig} [filters] - A set of conditions used - * to show or hide specific features of this tileset. - * @property {MapConfig#Notification} [notification] A custom badge and message to - * display about the layer in the Layer list. For example, this could highlight - * the layer if it is new, give a warning if they layer is under development, etc. - */ - - /** - * A feature template configures the format and content of information displayed - * in the Feature Info panel ({@link FeatureInfoView}). The Feature Info panel is - * displayed in a map when a user clicks on a vector feature in a map. - * @typedef {Object} FeatureTemplate - * @name MapConfig#FeatureTemplate - * @since 2.19.0 - * @property {'story'|'table'} [template='table'] The name/ID of the template to - * use. This must match the name of one of the templates available in - * {@link FeatureInfoView#contentTemplates}. - * @property {string} [label] Sets which of the feature properties to use as the - * title for the FeatureInfoView. The string must exactly match the key for a - * property that exists in the feature. - * @property {MapConfig#StoryTemplateOptions} [options] A list of key-value pairs - * that map the template variable to a property/attribute of the the feature. Keys - * are the template variable names and values are the names of properties in the - * feature. Template variable names are specific to each template. Currently only - * the 'story' template allows variables. These are specified in the - * {@link FeatureInfoView#contentTemplates}. - * @example - * // Use the "story" template, which shows a secondary title, image, description, - * // and link. - * { - * "template": "story", - * "label": "title", - * "options": { - * "subtitle": "formattedDate", - * "description": "summary", - * "thumbnail": "imageSrc", - * "url": "newsLink", - * "urlText": "newsTitle", - * } - * } - * @example - * // Use the default template (a table), but use the "forestName" attribute for - * // the FeatureInfo panel label - * { - * "label": "forestName" - * } - */ - - /** - * An object that maps template variable to feature properties for the "story" - * template. - * @typedef {Object} - * @name MapConfig#StoryTemplateOptions - * @since 2.19.0 - * @property {string} subtitle The name of a feature property to use for a - * secondary title in the template - * @property {string} description The name of a feature property that contains a - * brief summary or description of the feature; displayed as a paragraph. - * @property {string} thumbnail The name of a feature property that contains a URL - * for an image. Displayed as a thumbnail next to the description. - * @property {string} url The name of a feature property with a URL to use to - * create a link (e.g. to learn more information about the given feature) - * @property {string} urlText The name of a feature property that has text to - * display for the url. Defaults to 'Read More' if none is set. - */ - - /** - * An object where the keys indicate the name/ID of the new custom property to - * create, and the values are an object that defines the new property. - * @typedef {Object.} CustomProperties - * @name MapConfig#CustomProperties - * @since 2.19.0 - * @example - * { - * "year": { - * "type": "date", - * "property": "dateTime", - * "format": "YYYY", - * }, - * "urlText": { - * "type": "string", - * "value": "Click here to learn more about this feature" - * } - * } - */ - - /** - * An object that defines a formatted date to use as a property in a feature. Used - * in the {@link MapConfig#CustomProperties} object. - * @typedef {Object} CustomDateProperty - * @name MapConfig#CustomDateProperty - * @since 2.19.0 - * @property {'date'} type Must be set to 'date' to indicate that this is a custom - * date property - * @property {string} property The name/ID of the existing date property to format - * @property {string} format A string that indicates the new format to use. - * Follows the syntax used by Day.JS, see - * {@link https://day.js.org/docs/en/display/format} - */ - - /** - * An object that defines a custom string to use as a property in a feature. Used - * in the {@link MapConfig#CustomProperties} object. - * @typedef {Object} CustomStringProperty - * @name MapConfig#CustomStringProperty - * @since 2.19.0 - * @property {'string'} type Must be set to 'string' to indicate that this is a - * custom string property - * @property {string} value The new string to use. So far only static strings are - * available. In the future, templates that include other properties may be - * supported. - */ - - /** +"use strict"; + +define([ + "underscore", + "backbone", + "models/portals/PortalImage", + "models/maps/AssetColorPalette", + MetacatUI.root + "/components/dayjs.min.js", +], function (_, Backbone, PortalImage, AssetColorPalette, dayjs) { + /** + * @classdesc A MapAsset Model comprises information required to fetch source data for + * some asset or resource that is displayed in a map, such as imagery (raster) tiles, + * vector data, a 3D tileset, or a terrain model. This model also contains metadata + * about the source data, like an attribution and a description. It represents the + * most generic type of asset, and can be extended to support specific assets that are + * compatible with a given map widget. + * @classcategory Models/Maps/Assets + * @class MapAsset + * @name MapAsset + * @extends Backbone.Model + * @since 2.18.0 + * @constructor + */ + var MapAsset = Backbone.Model.extend( + /** @lends MapAsset.prototype */ { + /** + * The name of this type of model + * @type {string} + */ + type: "MapAsset", + + /** + * Default attributes for MapAsset models + * @name MapAsset#defaults + * @type {Object} + * @property {('Cesium3DTileset'|'BingMapsImageryProvider'|'IonImageryProvider'|'WebMapTileServiceImageryProvider'|'TileMapServiceImageryProvider'|'CesiumTerrainProvider')} type + * The format of the data. Must be one of the supported types. + * @property {string} label A user friendly name for this asset, to be displayed + * in a map. + * @property {string} [icon = ''] + * A PID for an SVG saved as a dataObject, or an SVG string. The SVG will be used + * as an icon that will be displayed next to the label in the layers list. It + * should be an SVG file that has no fills, borders, or styles set on it (since + * the icon will be shaded dynamically by the maps CSS using a fill attribute). It + * must use a viewbox property rather than a height and width. + * @property {string} [description = ''] A brief description about the asset, e.g. + * which area it covers, the resolution, etc. + * @property {string} [attribution = ''] A credit or attribution to display along + * with this map resource. + * @property {string} [moreInfoLink = ''] A link to show in a map where a user can + * find more information about this resource. + * @property {string} [downloadLink = ''] A link to show in a map where a user can + * go to download the source data. + * @property {string} [id = ''] If this asset's data is archived in a DataONE + * repository, the ID of the data package. + * @property {Boolean} [selected = false] Set to true when this asset has been + * selected by the user in the layer list. + * @property {Number} [opacity = 1] A number between 0 and 1 indicating the + * opacity of the layer on the map, with 0 representing fully transparent and 1 + * representing fully opaque. This applies to raster (imagery) and vector assets, + * not to terrain assets. + * @property {Boolean} [visible = true] Set to true if the layer is visible on the + * map, false if it is hidden. This applies to raster (imagery) and vector assets, + * not to terrain assets. + * @property {AssetColorPalette} [colorPalette] The color or colors mapped to + * attributes of this asset. This applies to raster/imagery and vector assets. For + * imagery, the colorPalette will be used to create a legend. For vector assets + * (e.g. 3Dtilesets), it will also be used to style the features. + * @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 + * of an existing date property. + * @property {MapConfig#Notification} [notification] A custom badge and message to + * display about the layer in the Layer list. For example, this could highlight + * the layer if it is new, give a warning if they layer is under development, etc. + * @property {'ready'|'error'|null} [status = null] Set to 'ready' when the + * resource is loaded and ready to be rendered in a map view. Set to 'error' when + * the asset is not supported, or there was a problem requesting the resource. + * @property {string} [statusDetails = null] Any further details about the status, + * especially when there was an error. + */ + defaults: function () { + return { + type: "", + label: "", + icon: '', + description: "", + attribution: "", + moreInfoLink: "", + downloadLink: "", + id: "", + selected: false, + opacity: 1, + visible: true, + colorPalette: null, + customProperties: {}, + featureTemplate: {}, + featureType: null, + notification: {}, + status: null, + statusDetails: null, + }; + }, + + /** + * The source of a specific asset (i.e. layer or terrain data) to show on the map, + * as well as metadata and display properties of the asset. Some properties listed + * here do not apply to all asset types, but this is specified in the property + * description. + * @typedef {Object} MapAssetConfig + * @name MapConfig#MapAssetConfig + * @property {('Cesium3DTileset'|'BingMapsImageryProvider'|'IonImageryProvider'|'WebMapTileServiceImageryProvider'|'WebMapServiceImageryProvider'|'TileMapServiceImageryProvider'|'NaturalEarthII'|'CesiumTerrainProvider'|'GeoJsonDataSource'|'USGSImageryTopo'|'OpenStreetMapImageryProvider')} type - + * A string indicating the format of the data. Some of these types correspond + * directly to Cesium classes. The NaturalEarthII type is a special imagery layer + * that automatically sets the cesiumOptions to load the Natural Earth II imagery + * that is shipped with Cesium/MetacatUI. If this type is set, then no other + * cesiumOptions are required. The same is true for USGSImageryTopo, which pulls + * imagery directly from USGS. + * @property {(Cesium3DTileset#cesiumOptions|CesiumImagery#cesiumOptions|CesiumTerrain#cesiumOptions|CesiumVectorData#cesiumOptions)} [cesiumOptions] - + * For MapAssets that are configured for Cesium, like + * Cesium3DTilesets, an object with options to pass to the Cesium constructor + * function that creates the Cesium model. Options are specific to each type of + * asset. For details, see documentation for each of the types. + * @property {string} label - A user friendly name for this asset, to be displayed + * in a map. + * @property {string} [icon] - A PID for an SVG saved as a dataObject, or an SVG + * string. The SVG will be used as an icon that will be displayed next to the + * label in the layers list. It should be an SVG file that has no fills, borders, + * or styles set on it (since the icon will be shaded dynamically by the maps CSS + * using a fill attribute). It must use a viewbox property rather than a height + * and width. + * @property {Number} [opacity=1] - A number between 0 and 1 indicating the + * opacity of the layer on the map, with 0 representing fully transparent and 1 + * representing fully opaque. This applies to raster (imagery) and vector assets, + * not to terrain assets. + * @property {Boolean} [visible=true] - Set to true if the layer is visible on the + * map, false if it is hidden. This applies to raster (imagery) and vector assets, + * not to terrain assets. + * @property {string} [description] - A brief description about the asset, e.g. + * which area it covers, the resolution, etc. + * @property {string} [attribution] A credit or attribution to display along with + * this asset. + * @property {string} [moreInfoLink] A complete URL used to create a link to show + * in a map where a user can find more information about this resource. + * @property {string} [downloadLink] A complete URL used to show a link in a map + * where a user can go to download the source data. + * @property {string} [id] If this asset's data is archived in a DataONE + * repository, the ID of the data package. + * @property {MapConfig#ColorPaletteConfig} [colorPalette] The color or colors + * mapped to attributes of this asset. This applies to raster/imagery and vector + * assets. For imagery, the colorPalette will be used to create a legend. For + * vector assets (e.g. 3Dtilesets), it will also be used to style the features. + * @property {MapConfig#FeatureTemplate} [featureTemplate] Configuration for the + * content and layout of the Feature Info panel ({@link FeatureInfoView}) - the + * panel that shows information about a selected feature from a vector asset. If + * no feature template is set, then the default table layout is used. + * @property {MapConfig#CustomProperties} [customProperties] Definitions of custom + * properties of features, potentially based on existing properties. For example, + * a custom property could be a formatted version of another date property. These + * custom properties can be used in the filters, colorPalette, or featureTemplate. + * So far, custom strings and formatted dates are supported. Eventually, the + * custom properties may be expanded to support formatted numbers and booleans. + * @property {MapConfig#VectorFilterConfig} [filters] - A set of conditions used + * to show or hide specific features of this tileset. + * @property {MapConfig#Notification} [notification] A custom badge and message to + * display about the layer in the Layer list. For example, this could highlight + * the layer if it is new, give a warning if they layer is under development, etc. + */ + + /** + * A feature template configures the format and content of information displayed + * in the Feature Info panel ({@link FeatureInfoView}). The Feature Info panel is + * displayed in a map when a user clicks on a vector feature in a map. + * @typedef {Object} FeatureTemplate + * @name MapConfig#FeatureTemplate + * @since 2.19.0 + * @property {'story'|'table'} [template='table'] The name/ID of the template to + * use. This must match the name of one of the templates available in + * {@link FeatureInfoView#contentTemplates}. + * @property {string} [label] Sets which of the feature properties to use as the + * title for the FeatureInfoView. The string must exactly match the key for a + * property that exists in the feature. + * @property {MapConfig#StoryTemplateOptions} [options] A list of key-value pairs + * that map the template variable to a property/attribute of the the feature. Keys + * are the template variable names and values are the names of properties in the + * feature. Template variable names are specific to each template. Currently only + * the 'story' template allows variables. These are specified in the + * {@link FeatureInfoView#contentTemplates}. + * @example + * // Use the "story" template, which shows a secondary title, image, description, + * // and link. + * { + * "template": "story", + * "label": "title", + * "options": { + * "subtitle": "formattedDate", + * "description": "summary", + * "thumbnail": "imageSrc", + * "url": "newsLink", + * "urlText": "newsTitle", + * } + * } + * @example + * // Use the default template (a table), but use the "forestName" attribute for + * // the FeatureInfo panel label + * { + * "label": "forestName" + * } + */ + + /** + * An object that maps template variable to feature properties for the "story" + * template. + * @typedef {Object} + * @name MapConfig#StoryTemplateOptions + * @since 2.19.0 + * @property {string} subtitle The name of a feature property to use for a + * secondary title in the template + * @property {string} description The name of a feature property that contains a + * brief summary or description of the feature; displayed as a paragraph. + * @property {string} thumbnail The name of a feature property that contains a URL + * for an image. Displayed as a thumbnail next to the description. + * @property {string} url The name of a feature property with a URL to use to + * create a link (e.g. to learn more information about the given feature) + * @property {string} urlText The name of a feature property that has text to + * display for the url. Defaults to 'Read More' if none is set. + */ + + /** + * An object where the keys indicate the name/ID of the new custom property to + * create, and the values are an object that defines the new property. + * @typedef {Object.} CustomProperties + * @name MapConfig#CustomProperties + * @since 2.19.0 + * @example + * { + * "year": { + * "type": "date", + * "property": "dateTime", + * "format": "YYYY", + * }, + * "urlText": { + * "type": "string", + * "value": "Click here to learn more about this feature" + * } + * } + */ + + /** + * An object that defines a formatted date to use as a property in a feature. Used + * in the {@link MapConfig#CustomProperties} object. + * @typedef {Object} CustomDateProperty + * @name MapConfig#CustomDateProperty + * @since 2.19.0 + * @property {'date'} type Must be set to 'date' to indicate that this is a custom + * date property + * @property {string} property The name/ID of the existing date property to format + * @property {string} format A string that indicates the new format to use. + * Follows the syntax used by Day.JS, see + * {@link https://day.js.org/docs/en/display/format} + */ + + /** + * An object that defines a custom string to use as a property in a feature. Used + * in the {@link MapConfig#CustomProperties} object. + * @typedef {Object} CustomStringProperty + * @name MapConfig#CustomStringProperty + * @since 2.19.0 + * @property {'string'} type Must be set to 'string' to indicate that this is a + * custom string property + * @property {string} value The new string to use. So far only static strings are + * available. In the future, templates that include other properties may be + * supported. + */ + + /** * A notification displays a badge in the {@link LayerListView} and a message in * the {@link LayerDetailsView}. This is useful for indicating some special status * of the layer: "new", "under development", etc. @@ -309,551 +298,596 @@ define( * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the * attributes, which will be set on the model. */ - initialize: function (assetConfig) { - try { - - const model = this; - - if (!assetConfig || typeof assetConfig !== 'object') { - assetConfig = {} - } else { - assetConfig = JSON.parse(JSON.stringify(assetConfig)) - } - - // Set the color palette - if (assetConfig.colorPalette) { - this.set('colorPalette', new AssetColorPalette(assetConfig.colorPalette)) - } - - // Fetch the icon, if there is one - if (assetConfig.icon) { - if (model.isSVG(assetConfig.icon)) { - model.updateIcon(assetConfig.icon) - } else { - // If the string is not an SVG then assume it is a PID and try to fetch - // the SVG file. fetchIcon will update the icon when complete. - model.fetchIcon(assetConfig.icon) - } - } + initialize: function (assetConfig) { + try { + const model = this; - this.setListeners(); - } - catch (e) { - console.log('Error initializing a MapAsset model', e); + if (!assetConfig || typeof assetConfig !== "object") { + assetConfig = {}; + } else { + assetConfig = JSON.parse(JSON.stringify(assetConfig)); } - }, - - /** - * Set all of the listeners for this model - * @since x.x.x - */ - setListeners: function () { - try { - - // The map asset cannot be visible on the map if there was an error loading - // the asset - this.stopListening(this, 'change:status') - this.listenTo(this, 'change:status', function (model, status) { - if (status === 'error') { - this.set('visible', false) - } - }) - this.stopListening(this, 'change:visible') - this.listenTo(this, 'change:visible', function (model, visible) { - if (this.get('status') === 'error') { - this.set('visible', false) - } - }) - - // Update the style of the asset to highlight the selected features when - // features from this asset are selected in the map. - if (typeof this.updateAppearance === 'function') { - const setSelectFeaturesListeners = function () { - const mapModel = this.get('mapModel'); - if (!mapModel) { return } - const interactions = mapModel.get('interactions'); - const selectedFeatures = mapModel.getSelectedFeatures(); - - this.stopListening(selectedFeatures, 'update'); - this.listenTo(selectedFeatures, 'update', this.updateAppearance) - - this.stopListening(interactions, 'change:selectedFeatures') - this.listenTo(interactions, 'change:selectedFeatures', function () { - this.updateAppearance() - setSelectFeaturesListeners() - }) + // Set the color palette + if (assetConfig.colorPalette) { + this.set( + "colorPalette", + new AssetColorPalette(assetConfig.colorPalette) + ); + } - } - setSelectFeaturesListeners.call(this) - this.stopListening(this, 'change:mapModel', setSelectFeaturesListeners) - this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) + // Fetch the icon, if there is one + if (assetConfig.icon) { + if (model.isSVG(assetConfig.icon)) { + model.updateIcon(assetConfig.icon); + } else { + // If the string is not an SVG then assume it is a PID and try to fetch + // the SVG file. fetchIcon will update the icon when complete. + model.fetchIcon(assetConfig.icon); } - - // Listen for changes to the cesiumOptions object - this.stopListening(this, 'change:cesiumOptions'); - this.listenTo(this, 'change:cesiumOptions', function () { - this.createCesiumModel(true) - }) - } catch (e) { - console.log("Error setting MapAsset Listeners.", e); } - }, - - /** - * Get the asset config's cesiumOptions, if it has any. This will return - * a copy of the cesiumOptions object, so that changes made to the - * returned object will not affect the original cesiumOptions object. - * @returns {Object} A copy of the cesiumOptions object, or null if there - * are no cesiumOptions. - * @since 2.26.0 - */ - getCesiumOptions: function () { - const cesiumOptions = this.get('cesiumOptions') - if (!cesiumOptions) { return null } - return JSON.parse(JSON.stringify(cesiumOptions)) - }, - - /** - * 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 - */ - featureIsSelected: function (feature) { - const map = this.get('mapModel') - if (!map) { return false } - return map.getSelectedFeatures(); - }, - - /** - * 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. - * @since 2.25.0 - */ - 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 - * @since 2.25.0 - */ - 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.get('cesiumModel') == 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), + this.setListeners(); + } catch (e) { + console.log("Error initializing a MapAsset model", e); + } + }, + + /** + * When the asset can't be loaded, hide it from the map and show an error. + * @since x.x.x + */ + handleError: function () { + this.set("visible", false); + this.stopListening(this, "change:visible"); + }, + + /** + * Set all of the listeners for this model + * @since x.x.x + */ + setListeners: function () { + try { + const status = this.get("status"); + if (status === "error") { + this.handleError(); + return; } - }, - - /** - * Given a set of properties from a Feature from this Map Asset model, add any - * custom properties to the properties object and return it. - * @since 2.19.0 - * @param {Object} properties A set of key-value pairs representing the existing - * properties of a feature from this asset. - * @returns {Object} The properties object with any custom properties added. - */ - addCustomProperties: function (properties) { - try { - - const model = this; - const customProperties = model.get('customProperties'); - const formattedProperties = {}; - - if (!customProperties || !Object.keys(customProperties).length) { - return properties + // The map asset cannot be visible on the map if there was an error + // loading the asset + this.stopListening(this, "change:status"); + this.listenTo(this, "change:status", function (model, status) { + if (status === "error") { + this.handleError(); + } else { + this.setListeners(); } + }); - if (!properties || typeof properties !== 'object') { - properties = {} - } + // Listen for changes to the cesiumOptions object + this.stopListening(this, "change:cesiumOptions"); + this.listenTo(this, "change:cesiumOptions", function () { + this.createCesiumModel(true); + }); - if (customProperties) { - _.each(customProperties, function (config, key) { - let formattedValue = ''; - if (config.type === 'date') { - formattedValue = model.formatDateProperty(config, properties) - // TODO: support formatted numbers and booleans... - // } else if (config.type === 'number') { - // formattedValue = model.formatNumberProperty(config, properties) - // } else if (config.type === 'boolean') { - // formattedValue = model.formatBooleanProperty(config, properties) - } else { - formattedValue = model.formatStringProperty(config, properties) - } - formattedProperties[key] = formattedValue; - }); - } - // merge the properties with the formatted properties - return Object.assign(properties, formattedProperties); - } catch (error) { - console.log( - 'There was an error adding custom properties. Returning properties ' + - 'unchanged. Error details: ' + - error - ); - return properties - } - }, + this.listenToSelectedFeatures(); + } catch (e) { + console.log("Error setting MapAsset Listeners.", e); + } + }, + + /** + * Update the appearance of features from this asset when they are + * selected or deselected in the map widget. + * @since x.x.x + */ + listenToSelectedFeatures: function () { + if (typeof this.updateAppearance !== "function") { + return; + } + + const mapModel = this.get("mapModel"); + if (!mapModel) { + this.listenToOnce( + this, + "change:mapModel", + this.listenToSelectedFeatures + ); + return; + } + + const interactions = mapModel.get("interactions"); + + if (!interactions) { + this.listenToOnce( + mapModel, + "change:interactions", + this.listenToSelectedFeatures + ); + return; + } + + const selectedFeatures = mapModel.getSelectedFeatures(); + + if(selectedFeatures){ + this.stopListening(selectedFeatures, "update"); + this.listenTo(selectedFeatures, "update", this.updateAppearance); + } + + // Reset the listeners if the selectedFeatures collection or the + // interactions model is replaced + this.listenToOnce(interactions, "change:selectedFeatures", function () { + this.updateAppearance(); + this.listenToSelectedFeatures(); + }); + + this.listenToOnce(mapModel, "change:interactions", function () { + this.updateAppearance(); + this.listenToSelectedFeatures(); + }); + }, + + /** + * Get the asset config's cesiumOptions, if it has any. This will return + * a copy of the cesiumOptions object, so that changes made to the + * returned object will not affect the original cesiumOptions object. + * @returns {Object} A copy of the cesiumOptions object, or null if there + * are no cesiumOptions. + * @since 2.26.0 + */ + getCesiumOptions: function () { + const cesiumOptions = this.get("cesiumOptions"); + if (!cesiumOptions) { + return null; + } + return JSON.parse(JSON.stringify(cesiumOptions)); + }, + + /** + * 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 + */ + featureIsSelected: function (feature) { + const map = this.get("mapModel"); + if (!map) { + return false; + } + const selectedFeatures = map.getSelectedFeatures(); + if (!selectedFeatures) { + return false; + } + const isSelected = selectedFeatures.containsFeature(feature); + return isSelected; + }, + + /** + * 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. + * @since 2.25.0 + */ + 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 + * @since 2.25.0 + */ + 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.get("cesiumModel") == 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. + * @since 2.19.0 + * @param {Object} properties A set of key-value pairs representing the existing + * properties of a feature from this asset. + * @returns {Object} The properties object with any custom properties added. + */ + addCustomProperties: function (properties) { + try { + const model = this; + const customProperties = model.get("customProperties"); + const formattedProperties = {}; - /** - * Given a definition for a new date property, and the properties that already - * exist on a specific feature, returns a new string with the formatted date. - * @since 2.19.0 - * @param {MapConfig#CustomDateProperty} config - An object that defines the new - * date property to create - * @param {Object} properties key-value pairs representing existing properties in - * a Feature - * @returns {string} The value for the new date property, formatted as defined by - * config, for the given feature - */ - formatDateProperty: function (config, properties) { - try { - if (!properties) { - properties = {} - } - let formattedDate = '' - if (!config || !config.format) { - return formattedDate; - } - const value = properties[config.property]; - if (value) { - formattedDate = dayjs(value).format(config.format); - } - return formattedDate; - } - catch (error) { - console.log( - 'There was an error formatting a date for a Feature model' + - '. Error details: ' + error - ); - return ''; + if (!customProperties || !Object.keys(customProperties).length) { + return properties; } - }, - /** - * For a given set of Feature properties and a definition for a new sting - * property, returns the value of the custom property. Note that since only static - * strings are supported so far, this function essentially just returns the value - * of config.value. This function exists to allow support of dynamic strings in - * the future (e.g. combining strings from existing properties) - * @since 2.19.0 - * @param {MapConfig#CustomStringProperty} config The object the defines the new - * custom property - * @param {Object} properties key-value pairs representing existing properties in - * a Feature - * @returns {string} The new string for the given Feature property - */ - formatStringProperty: function (config, properties) { - try { - if (!properties) { - properties = {} - } - let formattedString = '' - if (!config || !config.value) { - return formattedString; - } - formattedString = config.value; - return formattedString; + if (!properties || typeof properties !== "object") { + properties = {}; } - catch (error) { - console.log( - 'There was an error formatting a string for a Feature model' + - '. Error details: ' + error - ); - return ''; - } - }, - - // formatNumberProperty: function (config, properties) { - // try { - // if (!properties) { - // properties = {} - // } - // let formattedNumber = '' - // // TODO... - // } - // catch (error) { - // console.log( - // 'There was an error formatting a number for a Feature model' + - // '. Error details: ' + error - // ); - // return ''; - // } - // }, - - // formatBooleanProperty: function (config, properties) { - // try { - // if (!properties) { - // properties = {} - // } - // let formattedBoolean = '' - // // TODO... - // } - // catch (error) { - // console.log( - // 'There was an error formatting a boolean for a Feature model' + - // '. Error details: ' + error - // ); - // return ''; - // } - // }, - /** - * Sanitizes an SVG string and updates the model's 'icon' attribute the sanitized - * string. Also sets the 'iconStatus' attribute to 'success'. - * @param {string} icon An SVG string to use for the MapAsset icon - */ - updateIcon: function (icon) { - const model = this - try { - model.sanitizeIcon(icon, function (sanitizedIcon) { - model.set('icon', sanitizedIcon) - model.set('iconStatus', 'success') - }) + if (customProperties) { + _.each(customProperties, function (config, key) { + let formattedValue = ""; + if (config.type === "date") { + formattedValue = model.formatDateProperty(config, properties); + // TODO: support formatted numbers and booleans... + // } else if (config.type === 'number') { + // formattedValue = model.formatNumberProperty(config, properties) + // } else if (config.type === 'boolean') { + // formattedValue = model.formatBooleanProperty(config, properties) + } else { + formattedValue = model.formatStringProperty(config, properties); + } + formattedProperties[key] = formattedValue; + }); } - catch (error) { - console.log( - 'There was an error updating an icon in a MapAsset model' + - '. Error details: ' + error - ); + // merge the properties with the formatted properties + return Object.assign(properties, formattedProperties); + } catch (error) { + console.log( + "There was an error adding custom properties. Returning properties " + + "unchanged. Error details: " + + error + ); + return properties; + } + }, + + /** + * Given a definition for a new date property, and the properties that already + * exist on a specific feature, returns a new string with the formatted date. + * @since 2.19.0 + * @param {MapConfig#CustomDateProperty} config - An object that defines the new + * date property to create + * @param {Object} properties key-value pairs representing existing properties in + * a Feature + * @returns {string} The value for the new date property, formatted as defined by + * config, for the given feature + */ + formatDateProperty: function (config, properties) { + try { + if (!properties) { + properties = {}; } - }, - - /** - * Simple test to see if a string is an SVG - * @param {string} str The string to check - * @returns {Boolean} Returns true if the string starts with ``, regardless of case - */ - isSVG: function (str) { - const strLower = str.toLowerCase() - return strLower.startsWith('') - }, - - /** - * Fetches an SVG given a pid, sanitizes it, then updates the model's icon - * attribute with the new and SVG string (after sanitizing it) - * @param {string} pid - */ - fetchIcon: function (pid) { - const model = this - try { - model.set('iconStatus', 'fetching') - - // Use the portal image model to get the correct baseURL for an image - const imageURL = new PortalImage({ - identifier: pid - }).get('imageURL') - - fetch(imageURL) - .then(function (response) { - return response.text(); - }) - .then(function (data) { - if (model.isSVG(data)) { - model.updateIcon(data) - } - }) - .catch(function (response) { - model.set('iconStatus', 'error') - }); + let formattedDate = ""; + if (!config || !config.format) { + return formattedDate; } - catch (error) { - console.log( - 'Failed to fetch an icon for a MapAsset' + - '. Error details: ' + error - ); - model.set('iconStatus', 'error') + const value = properties[config.property]; + if (value) { + formattedDate = dayjs(value).format(config.format); } - }, - - /** - * Takes an SVG string and returns it with only the allowed tags and attributes - * @param {string} icon The SVG icon string to sanitize - * @param {function} callback Function to call once the icon has been sanitized. - * Will pass the sanitized icon string. - */ - sanitizeIcon: function (icon, callback) { - try { - // Use the showdown xss filter to sanitize the SVG string - require(['showdown', 'showdownXssFilter'], function (showdown, showdownXss) { - var converter = new showdown.Converter({ - extensions: ['xssfilter'] - }); - let sanitizedIcon = converter.makeHtml(icon); - // Remove the

tags that showdown wraps the string in - sanitizedIcon = sanitizedIcon.replace(/^(

)/, '') - sanitizedIcon = sanitizedIcon.replace(/(<\/p>)$/, '') - // Call the callback - if (callback && typeof callback === 'function') { - callback(sanitizedIcon) - } - }) + return formattedDate; + } catch (error) { + console.log( + "There was an error formatting a date for a Feature model" + + ". Error details: " + + error + ); + return ""; + } + }, + + /** + * For a given set of Feature properties and a definition for a new sting + * property, returns the value of the custom property. Note that since only static + * strings are supported so far, this function essentially just returns the value + * of config.value. This function exists to allow support of dynamic strings in + * the future (e.g. combining strings from existing properties) + * @since 2.19.0 + * @param {MapConfig#CustomStringProperty} config The object the defines the new + * custom property + * @param {Object} properties key-value pairs representing existing properties in + * a Feature + * @returns {string} The new string for the given Feature property + */ + formatStringProperty: function (config, properties) { + try { + if (!properties) { + properties = {}; } - catch (error) { - console.log( - 'There was an error sanitizing an SVG icon in a MapAsset model' + - '. Error details: ' + error - ); + let formattedString = ""; + if (!config || !config.value) { + return formattedString; } - }, - - /** - * Resets the Map Asset's status and statusDetails attributes to their default - * values. - * @since 2.21.0 - */ - resetStatus: function () { - const defaults = this.defaults() - this.set('status', defaults.status) - this.set('statusDetails', defaults.statusDetails) - }, - - /** - * Checks if the asset information has been fetched and is ready to use. - * @returns {Promise} Returns a promise that resolves to this model when ready. - */ - whenReady: function () { - const model = this; - return new Promise(function (resolve, reject) { - if (model.get('status') === 'ready') { - resolve(model) - return - } - model.stopListening(model, 'change:status') - model.listenTo(model, 'change:status', function () { - if (model.get('status') === 'ready') { - model.stopListening(model, 'change:status') - resolve(model) + formattedString = config.value; + return formattedString; + } catch (error) { + console.log( + "There was an error formatting a string for a Feature model" + + ". Error details: " + + error + ); + return ""; + } + }, + + // formatNumberProperty: function (config, properties) { + // try { + // if (!properties) { + // properties = {} + // } + // let formattedNumber = '' + // // TODO... + // } + // catch (error) { + // console.log( + // 'There was an error formatting a number for a Feature model' + + // '. Error details: ' + error + // ); + // return ''; + // } + // }, + + // formatBooleanProperty: function (config, properties) { + // try { + // if (!properties) { + // properties = {} + // } + // let formattedBoolean = '' + // // TODO... + // } + // catch (error) { + // console.log( + // 'There was an error formatting a boolean for a Feature model' + + // '. Error details: ' + error + // ); + // return ''; + // } + // }, + + /** + * Sanitizes an SVG string and updates the model's 'icon' attribute the sanitized + * string. Also sets the 'iconStatus' attribute to 'success'. + * @param {string} icon An SVG string to use for the MapAsset icon + */ + updateIcon: function (icon) { + const model = this; + try { + model.sanitizeIcon(icon, function (sanitizedIcon) { + model.set("icon", sanitizedIcon); + model.set("iconStatus", "success"); + }); + } catch (error) { + console.log( + "There was an error updating an icon in a MapAsset model" + + ". Error details: " + + error + ); + } + }, + + /** + * Simple test to see if a string is an SVG + * @param {string} str The string to check + * @returns {Boolean} Returns true if the string starts with ``, regardless of case + */ + isSVG: function (str) { + const strLower = str.toLowerCase(); + return strLower.startsWith(""); + }, + + /** + * Fetches an SVG given a pid, sanitizes it, then updates the model's icon + * attribute with the new and SVG string (after sanitizing it) + * @param {string} pid + */ + fetchIcon: function (pid) { + const model = this; + try { + model.set("iconStatus", "fetching"); + + // Use the portal image model to get the correct baseURL for an image + const imageURL = new PortalImage({ + identifier: pid, + }).get("imageURL"); + + fetch(imageURL) + .then(function (response) { + return response.text(); + }) + .then(function (data) { + if (model.isSVG(data)) { + model.updateIcon(data); } }) + .catch(function (response) { + model.set("iconStatus", "error"); + }); + } catch (error) { + console.log( + "Failed to fetch an icon for a MapAsset" + + ". Error details: " + + error + ); + model.set("iconStatus", "error"); + } + }, + + /** + * Takes an SVG string and returns it with only the allowed tags and attributes + * @param {string} icon The SVG icon string to sanitize + * @param {function} callback Function to call once the icon has been sanitized. + * Will pass the sanitized icon string. + */ + sanitizeIcon: function (icon, callback) { + try { + // Use the showdown xss filter to sanitize the SVG string + require(["showdown", "showdownXssFilter"], function ( + showdown, + showdownXss + ) { + var converter = new showdown.Converter({ + extensions: ["xssfilter"], + }); + let sanitizedIcon = converter.makeHtml(icon); + // Remove the

tags that showdown wraps the string in + sanitizedIcon = sanitizedIcon.replace(/^(

)/, ""); + sanitizedIcon = sanitizedIcon.replace(/(<\/p>)$/, ""); + // Call the callback + if (callback && typeof callback === "function") { + callback(sanitizedIcon); + } }); - }, - - /** - * Given properties of a Feature model from this MapAsset, returns the color - * associated with that feature. - * @param {Object} properties The properties of the feature to get the color for; - * An object containing key-value mapping of property names to properties. (See - * the 'properties' attribute of {@link Feature#defaults}.) - * @returns {AssetColor#Color} The color associated with the given set of - * properties. - */ - getColor: function (properties) { - try { - const model = this - const colorPalette = model.get('colorPalette') - return ( - colorPalette?.getColor(properties) || - new AssetColorPalette().getDefaultColor() - ) - } - catch (e) { - console.log('Failed to a color in a MapAsset model', e); - } - }, - - /** - * This function checks whether a feature from the MapAsset is visible on the map - * based on the properties of the feature and the MapAsset's filter settings. - * @param {Object} properties The properties of the feature to be filtered. (See - * the 'properties' attribute of {@link Feature#defaults}.) - * @returns {boolean} Returns true if the feature passes all the filters, or if - * there are no filters set for this MapAsset. Returns false if the feature fails - * any of the filters. - */ - featureIsVisible: function (properties) { - const model = this - const filters = model.get('filters') - if (filters && filters.length) { - return filters.featureIsVisible(properties) - } else { - return true - } - }, - - /** - * Indicate that the map widget should navigate to a given feature from - * this MapAsset. - * @param {Feature} feature The feature to navigate to. - * @since x.x.x - */ - zoomTo: function (target) { - this.get('mapModel')?.zoomTo(target) - }, - - /** - * Checks that the visible attribute is set to true and that the opacity attribute - * is greater than zero. If both conditions are met, returns true. - * @returns {boolean} Returns true if the MapAsset has opacity > 0 and is visible. - */ - isVisible: function () { - if(this.get('temporarilyHidden') === true) return false - return this.get('visible') && this.get('opacity') > 0 - }, - - /** - * Make sure the layer is visible. Sets visibility to true if false, and sets - * opacity to 0.5 if it's less than 0.05. - */ - show: function () { - // If the opacity is very low, set it to 50% - if (this.get('opacity') < 0.05) { - this.set('opacity', 0.5) - } - // Make sure the layer is visible - if (this.get('visible') === false) { - this.set('visible', true) + } catch (error) { + console.log( + "There was an error sanitizing an SVG icon in a MapAsset model" + + ". Error details: " + + error + ); + } + }, + + /** + * Resets the Map Asset's status and statusDetails attributes to their default + * values. + * @since 2.21.0 + */ + resetStatus: function () { + const defaults = this.defaults(); + this.set("status", defaults.status); + this.set("statusDetails", defaults.statusDetails); + }, + + /** + * Checks if the asset information has been fetched and is ready to use. + * @returns {Promise} Returns a promise that resolves to this model when ready. + */ + whenReady: function () { + const model = this; + return new Promise(function (resolve, reject) { + if (model.get("status") === "ready") { + resolve(model); + return; } - }, - - }); - - return MapAsset; - - } -); + model.stopListening(model, "change:status"); + model.listenTo(model, "change:status", function () { + if (model.get("status") === "ready") { + model.stopListening(model, "change:status"); + resolve(model); + } + }); + }); + }, + + /** + * Given properties of a Feature model from this MapAsset, returns the color + * associated with that feature. + * @param {Object} properties The properties of the feature to get the color for; + * An object containing key-value mapping of property names to properties. (See + * the 'properties' attribute of {@link Feature#defaults}.) + * @returns {AssetColor#Color} The color associated with the given set of + * properties. + */ + getColor: function (properties) { + try { + const model = this; + const colorPalette = model.get("colorPalette"); + return ( + colorPalette?.getColor(properties) || + new AssetColorPalette().getDefaultColor() + ); + } catch (e) { + console.log("Failed to a color in a MapAsset model", e); + } + }, + + /** + * This function checks whether a feature from the MapAsset is visible on the map + * based on the properties of the feature and the MapAsset's filter settings. + * @param {Object} properties The properties of the feature to be filtered. (See + * the 'properties' attribute of {@link Feature#defaults}.) + * @returns {boolean} Returns true if the feature passes all the filters, or if + * there are no filters set for this MapAsset. Returns false if the feature fails + * any of the filters. + */ + featureIsVisible: function (properties) { + const model = this; + const filters = model.get("filters"); + if (filters && filters.length) { + return filters.featureIsVisible(properties); + } else { + return true; + } + }, + + /** + * Indicate that the map widget should navigate to a given feature from + * this MapAsset. + * @param {Feature} feature The feature to navigate to. + * @since x.x.x + */ + zoomTo: function (target) { + this.get("mapModel")?.zoomTo(target); + }, + + /** + * Checks that the visible attribute is set to true and that the opacity attribute + * is greater than zero. If both conditions are met, returns true. + * @returns {boolean} Returns true if the MapAsset has opacity > 0 and is visible. + */ + isVisible: function () { + if (this.get("temporarilyHidden") === true) return false; + return this.get("visible") && this.get("opacity") > 0; + }, + + /** + * Make sure the layer is visible. Sets visibility to true if false, and sets + * opacity to 0.5 if it's less than 0.05. + */ + show: function () { + // If the opacity is very low, set it to 50% + if (this.get("opacity") < 0.05) { + this.set("opacity", 0.5); + } + // Make sure the layer is visible + if (this.get("visible") === false) { + this.set("visible", true); + } + }, + } + ); + + return MapAsset; +}); From 90fdc5cd9e7b1bf3283c35180679c22d08fa4da5 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 20 Sep 2023 18:01:36 -0400 Subject: [PATCH 07/24] Ensure layer is visible after load error is fixed Issues #2189, 2180 --- src/js/models/maps/assets/CesiumVectorData.js | 13 +++++---- src/js/models/maps/assets/MapAsset.js | 14 +++++----- src/js/views/maps/CesiumWidgetView.js | 28 ++++++++++--------- 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 060c0b6d4..7972cd173 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -197,6 +197,12 @@ define( const data = cesiumOptions.data; delete cesiumOptions.data + if(!dataSource){ + model.set('status', 'error') + model.set('statusDetails', 'Failed to create a Cesium DataSource model.') + return + } + dataSource.load(data, cesiumOptions) .then(function (loadedData) { model.set('cesiumModel', loadedData) @@ -228,10 +234,7 @@ define( } } catch (error) { - console.log( - 'Failed to create a Cesium Model for a CesiumVectorData model' + - '. Error details: ' + error - ); + console.log('Failed to create a VectorData Cesium Model.', error); } }, @@ -240,7 +243,7 @@ define( */ setListeners: function () { try { - this.constructor.__super__.setListeners.call(this); + MapAsset.prototype.setListeners.call(this) const appearEvents = 'change:visible change:opacity change:color change:outlineColor' + ' change:temporarilyHidden' diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index b47b0e42e..f50d34bc9 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -338,6 +338,7 @@ define([ * @since x.x.x */ handleError: function () { + this.set("originalVisibility", this.get("visible")); this.set("visible", false); this.stopListening(this, "change:visible"); }, @@ -352,17 +353,16 @@ define([ if (status === "error") { this.handleError(); return; + } else { + const vis = this.get("originalVisibility") + if(typeof vis === "boolean"){ + this.set("visible", vis); + } } // The map asset cannot be visible on the map if there was an error // loading the asset this.stopListening(this, "change:status"); - this.listenTo(this, "change:status", function (model, status) { - if (status === "error") { - this.handleError(); - } else { - this.setListeners(); - } - }); + this.listenTo(this, "change:status", this.setListeners); // Listen for changes to the cesiumOptions object this.stopListening(this, "change:cesiumOptions"); diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index fd8ea913c..641592e87 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -407,16 +407,15 @@ define([ // Listen for addition or removal of layers TODO: Add similar listeners // for terrain - view.stopListening(layers, "add"); - view.listenTo(layers, "add", view.addAsset); - view.stopListening(layers, "remove"); - view.listenTo(layers, "remove", view.removeAsset); - - // Each layer fires 'appearanceChanged' whenever the color, opacity, - // etc. has been updated. Re-render the scene when this happens. - view.stopListening(layers, "appearanceChanged"); - view.listenTo(layers, "appearanceChanged", view.requestRender); - + if(layers){ + view.stopListening(layers); + view.listenTo(layers, "add", view.addAsset); + view.listenTo(layers, "remove", view.removeAsset); + + // Each layer fires 'appearanceChanged' whenever the color, opacity, + // etc. has been updated. Re-render the scene when this happens. + view.listenTo(layers, "appearanceChanged", view.requestRender); + } // Reset asset listeners if the layers collection is replaced view.stopListening(model, "change:layers"); view.listenTo(model, "change:layers", view.setAssetListeners); @@ -620,9 +619,12 @@ define([ // property. Add in reverse order for layers to appear in the correct // order on the map. const layers = view.model.get("layers"); - _.each(layers.last(layers.length).reverse(), function (mapAsset) { - view.addAsset(mapAsset); - }); + if (layers && layers.length) { + const layersReverse = layers.last(layers.length).reverse(); + layersReverse.forEach(function (layer) { + view.addAsset(layer); + }) + } // The Cesium Widget will support just one terrain option to start. // Later, we'll allow users to switch between terrains if there is more From ef72df50a5db6bec051146f5ff7897d412a3d61f Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 20 Sep 2023 18:11:06 -0400 Subject: [PATCH 08/24] Enable basic drawing on Cesium map - Still needs methods to clear the polygon and stop drawing; proper UI; testing Issue #2180 --- src/js/views/maps/DrawToolView.js | 105 ++++++++++++++++++++++-------- src/js/views/maps/ToolbarView.js | 12 +--- 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index 9c829c11c..abda39abe 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -43,7 +43,7 @@ define(["backbone"], function (Backbone) { /** * The CesiumVectorData model that we will use to store the drawn * polygon(s) - * @type { + * @type {CesiumVectorData} */ drawLayer: undefined, @@ -52,13 +52,13 @@ define(["backbone"], function (Backbone) { * @param {Object} options */ initialize: function (options) { - this.model = this.model; + this.model = options.model; if (!this.model) { this.handleNoMapModel(); - return + return; } - this.activated = options.activated || false; this.makeDrawLayer(); + this.activated = options.activated || false; if (this.activated) { this.activate(); } @@ -69,7 +69,7 @@ define(["backbone"], function (Backbone) { * map. Saves it to the polygon property. */ makeDrawLayer: function () { - if (!this.model) return + if (!this.model) return; this.drawLayer = this.model.addAsset({ type: "GeoJsonDataSource", hideInLayerList: true, // <- TODO: Look for this property in the @@ -82,10 +82,10 @@ define(["backbone"], function (Backbone) { { type: "Feature", properties: {}, - "geometry": { - "coordinates": [], - "type": "Polygon" - } + geometry: { + coordinates: [], + type: "Polygon", + }, }, ], }, @@ -97,7 +97,7 @@ define(["backbone"], function (Backbone) { * Removes the polygon object from the map */ removeDrawLayer: function () { - if (!this.model) return + if (!this.model) return; this.model.removeAsset(this.model); }, @@ -112,6 +112,7 @@ define(["backbone"], function (Backbone) { } this.renderToolbar(); this.startListeners(); + return this; }, /** @@ -133,65 +134,117 @@ define(["backbone"], function (Backbone) { drawButton.innerHTML = "Draw"; drawButton.addEventListener("click", function () { view.activate(); + // make the button green for testing + drawButton.style.backgroundColor = "green"; }); el.appendChild(drawButton); const clearButton = document.createElement("button"); clearButton.innerHTML = "Clear"; clearButton.addEventListener("click", function () { view.removeDrawLayer(); + // make the button red for testing + drawButton.style.backgroundColor = "red"; }); el.appendChild(clearButton); - }, /** * Starts the listeners for the draw tool */ startListeners: function () { - this.stopListening(); - // TODO: Make a general method in the map widget that gives the - // coordinates of the mouse click - this.listenTo(this.model, "change:clickedCoordinates", this.handleClick); + this.stopListeners(); + + const mapModel = this.model; + this.interactions = mapModel?.get("interactions"); + this.clickedPosition = this.interactions?.get("clickedPosition"); + + this.listenToOnce(mapModel, "change:interactions", this.startListeners); + this.listenToOnce( + this.interactions, + "change:clickedPosition", + this.startListeners + ); + + if (!this.originalClickAction) { + this.originalClickAction = this.model.get("clickFeatureAction"); + } + this.model.set("clickFeatureAction", null); + + this.listenTo( + this.clickedPosition, + "change:latitude change:longitude", + this.handleClick + ); }, /** * Stops the listeners for the draw tool */ stopListeners: function () { - this.stopListening(this.model); + const targets = [this.model, this.interactions, this.clickedPosition]; + targets.forEach((target) => { + if (target) this.stopListening(target); + }, this); + if (this.originalClickAction) { + this.model.set("clickFeatureAction", this.originalClickAction); + this.originalClickAction = null; + } }, /** * Handles a click on the map. If the draw tool is active, it will add the * coordinates of the click to the polygon being drawn. - * @param {Number[]} coordinates - The most recently clicked coordinates */ - handleClick: function (coordinates) { + handleClick: function () { if (!this.activated) { return; } + const coordinates = [ + this.clickedPosition.get("longitude"), + this.clickedPosition.get("latitude"), + ]; this.addCoordinate(coordinates); }, /** * Adds a coordinate to the polygon being drawn - * @param {Array} coords - The coordinates to add + * @param {Array} coords - The coordinates to add, in the form [longitude, + * latitude] */ addCoordinate: function (coords) { + // TODO: Something like this... We may also want to add a general method // to the VectorData model that allows us to add a coordinate, but this // will be specific to the GeoJsonDataSource const layer = this.drawLayer; - const geoJSON = layer.get("cesiumOptions")?.data - const coordinates = geoJSON?.features[0]?.geometry?.coordinates - if (!coordinates) { + const geoJSON = layer.get("cesiumOptions")?.data; + const coordinates = geoJSON?.features[0]?.geometry?.coordinates?.[0]; + + if (!coordinates || !coordinates.length) { // Create new coordinates array - geoJSON.features[0].geometry.coordinates = [coords] + geoJSON.features[0].geometry.coordinates = [[]]; + // Add the coordinate to the new array + geoJSON.features[0].geometry.coordinates[0].push(coords); } else { - // Add to existing coordinates array - coordinates.push(coords) + // Check if the last coordinate is the same as the first coordinate. If + // so, we want to add the new coordinate as the second to last. Otherwise + // we want to add it to the end. + const lastCoord = coordinates[coordinates.length - 1]; + const firstCoord = coordinates[0]; + if (lastCoord[0] == firstCoord[0] && lastCoord[1] == firstCoord[1]) { + // Add the coordinate as the second to last + coordinates.splice(coordinates.length - 1, 0, coords); + } else { + // Add the coordinate to the end + coordinates.push(coords); + // Make the coordinates valid for a GeoJSON polygon by adding the first + // coordinate to the end + coordinates.push(coordinates[0]); + } } - layer.set("cesiumOptions", { data: geoJSON }) + + layer.set("cesiumOptions", { data: geoJSON }); + layer.createCesiumModel(true); }, /** diff --git a/src/js/views/maps/ToolbarView.js b/src/js/views/maps/ToolbarView.js index b6a6de0fa..e8eac5e69 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -1,4 +1,3 @@ - 'use strict'; define( @@ -460,10 +459,7 @@ define( return contentContainer } catch (error) { - console.log( - 'There was an error rendering section content in a ToolbarView' + - '. Error details: ' + error - ); + console.log('Error rendering ToolbarView section', error); } }, @@ -506,6 +502,7 @@ define( * @param {SectionElement} sectionEl The section to activate */ activateSection: function (sectionEl) { + if(!sectionEl) return; try { if (sectionEl.action && typeof sectionEl.action === 'function') { const view = this; @@ -518,10 +515,7 @@ define( } } catch (error) { - console.log( - 'There was an error showing a toolbar section in a ToolbarView' + - '. Error details: ' + error - ); + console.log('Failed to show a section in a ToolbarView', error); } }, From 0fc87fb9adbf9c90998f6f07378835f22fa4903a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 21 Sep 2023 17:31:25 -0400 Subject: [PATCH 09/24] Add connector & collection for drawing on map - Add the GeoPoints collection, with methods for serializing to GeoJson - Add a model that listens to a GeoPoints collection and updates a CesiumVectorData model with new geometry - Use these in the DrawToolView - Enable clearing a polygon - Show on first click, line on second click, polygon on subsequent clicks Issue #2180 --- src/js/collections/maps/GeoPoints.js | 215 +++++++++++ .../models/connectors/GeoPoints-VectorData.js | 169 +++++++++ src/js/models/maps/GeoPoint.js | 45 ++- src/js/models/maps/MapInteraction.js | 58 ++- src/js/models/maps/assets/CesiumVectorData.js | 5 +- src/js/views/maps/DrawToolView.js | 348 ++++++++++-------- 6 files changed, 656 insertions(+), 184 deletions(-) create mode 100644 src/js/collections/maps/GeoPoints.js create mode 100644 src/js/models/connectors/GeoPoints-VectorData.js diff --git a/src/js/collections/maps/GeoPoints.js b/src/js/collections/maps/GeoPoints.js new file mode 100644 index 000000000..25cf9569b --- /dev/null +++ b/src/js/collections/maps/GeoPoints.js @@ -0,0 +1,215 @@ +"use strict"; + +define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { + /** + * @class GeoPoints + * @classdesc A group of ordered geographic points. + * @class GeoPoints + * @classcategory Collections/Maps + * @extends Backbone.Collection + * @since x.x.x + * @constructor + */ + var GeoPoints = Backbone.Collection.extend( + /** @lends GeoPoints.prototype */ { + /** + * The class/model that this collection contains. + * @type {Backbone.Model} + */ + model: GeoPoint, + + /** + * Given a point in various formats, format it such that it can be used to + * add to this collection. + * @param {Array|Object|GeoPoint} point - Accepted formats are: + * - An array of the form [longitude, latitude], with an optional third + * element for height + * - An object with a "longitude" and "latitude" property, and + * optionally a "height" property + * - A GeoPoint model + * @returns {Object|GeoPoint} Returns an object with "longitude" and + * "latitude" properties, and optionally a "height" property, or a + * GeoPoint model. + */ + formatPoint: function (point) { + let attributes = {}; + if (Array.isArray(point) && point.length > 1) { + attributes.longitude = point[0]; + attributes.latitude = point[1]; + if (point[2]) { + attributes.height = point[2]; + } + } else if ( + point instanceof GeoPoint || + (point.latitude && point.longitude) + ) { + attributes = point; + } + return attributes; + }, + + /** + * Add a point to the collection. Use this rather than the Backbone add + * method to allow for different formats of points to be added. + * @param {Array|Object|GeoPoint} point - See {@link formatPoint} for + * accepted formats. + * @returns {GeoPoint} Returns the GeoPoint model that was added. + */ + addPoint: function (point) { + point = this.formatPoint(point); + return this.add(point); + }, + + /** + * Remove a specific point from the collection. Use this rather than the + * Backbone remove method to allow for different formats of points to be + * removed. + * @param {Array|Object|GeoPoint|Number} indexOrPoint - The index of the + * point to remove, or the point itself. See {@link formatPoint} for + * accepted formats. + * @returns {GeoPoint} Returns the GeoPoint model that was removed. + */ + removePoint(indexOrPoint) { + if (typeof indexOrPoint === "number") { + this.removePointByIndex(indexOrPoint); + } else if (Array.isArray(indexOrPoint)) { + this.removePointByAttr(indexOrPoint); + } + }, + + /** + * Remove a point from the collection based on its attributes. + * @param {Array|Object|GeoPoint} point - Any format supported by + * {@link formatPoint} is accepted. + * @returns {GeoPoint} Returns the GeoPoint model that was removed. + */ + removePointByAttr: function (point) { + point = this.formatPoint(point); + const model = this.findWhere(point); + return this.remove(model); + }, + + /** + * Remove a point from the collection based on its index. + * @param {Number} index - The index of the point to remove. + * @returns {GeoPoint} Returns the GeoPoint model that was removed. + */ + removePointByIndex: function (index) { + if (index < 0 || index >= this.length) { + console.warn("Index out of bounds, GeoPoint not removed."); + return; + } + const model = this.at(index); + return this.remove(model); + }, + + /** + * Convert the collection to a GeoJSON object. The output can be the + * series of points as Point features, the points connected as a + * LineString feature, or the points connected and closed as a Polygon. + * + * Note: For a "Polygon" geometry type, when there's only one point in the + * collection, the output will be a "Point". If there are only two points, + * the output will be a "LineString", unless `forceAsPolygon` is set to + * true. + * + * @param {String} geometryType - The type of geometry to create. Can be + * "Point", "LineString", or "Polygon". + * @param {Boolean} [forceAsPolygon=false] - Set to true to enforce the + * output as a polygon for the "Polygon" geometry type, regardless of the + * number of points in the collection. + * @returns {Object} Returns a GeoJSON object of type "Point", + * "LineString", or "Polygon". + */ + toGeoJson: function (geometryType, forceAsPolygon = false) { + if (!forceAsPolygon && geometryType === "Polygon" && this.length < 3) { + geometryType = this.length === 1 ? "Point" : "LineString"; + } + return { + type: "FeatureCollection", + features: this.toGeoJsonFeatures(geometryType), + }; + }, + + /** + * Convert the collection to a GeoJSON object. The output can be the + * series of points as Point features, the points connected as a + * LineString feature, or the points connected and closed as a Polygon. + * @param {"Point"|"LineString"|"Polygon"} geometryType - The type of + * geometry to create. + * @returns {Object[]} Returns an array of GeoJSON features. + */ + toGeoJsonFeatures: function (geometryType) { + switch (geometryType) { + case "Point": + return this.toGeoJsonPointFeatures(); + case "LineString": + return [this.toGeoJsonLineStringFeature()]; + case "Polygon": + return [this.toGeoJsonPolygonFeature()]; + default: + return []; + } + }, + + /** + * Convert the collection to an array of GeoJSON point features. + * @returns {Object[]} Returns an array of GeoJSON point features. + */ + toGeoJsonPointFeatures: function () { + return this.models.map((model) => { + return model.toGeoJsonFeature(); + }); + }, + + /** + * Convert the collection to a GeoJSON LineString feature. + * @returns {Object} Returns a GeoJSON LineString feature. + */ + toGeoJsonLineStringFeature: function () { + return { + type: "Feature", + geometry: { + type: "LineString", + coordinates: this.to2DArray(), + }, + properties: {}, + }; + }, + + /** + * Convert the collection to a GeoJSON Polygon feature. The polygon will + * be closed if it isn't already. + * @returns {Object} Returns a GeoJSON Polygon feature. + */ + toGeoJsonPolygonFeature: function () { + const coordinates = this.to2DArray(); + // Make sure the polygon is closed + if (coordinates[0] != coordinates[coordinates.length - 1]) { + coordinates.push(coordinates[0]); + } + return { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [coordinates], + }, + properties: {}, + }; + }, + + /** + * Convert the collection to an array of arrays, where each sub-array + * contains the longitude and latitude of a point. + * @returns {Array[]} Returns an array of arrays. + */ + to2DArray: function () { + return this.models.map((model) => { + return model.to2DArray(); + }); + }, + } + ); + + return GeoPoints; +}); diff --git a/src/js/models/connectors/GeoPoints-VectorData.js b/src/js/models/connectors/GeoPoints-VectorData.js new file mode 100644 index 000000000..02bc732b4 --- /dev/null +++ b/src/js/models/connectors/GeoPoints-VectorData.js @@ -0,0 +1,169 @@ +/*global define */ +define([ + "backbone", + "collections/maps/GeoPoints", + "models/maps/assets/CesiumVectorData", +], function (Backbone, GeoPoints, CesiumVectorData) { + "use strict"; + + /** + * @class PointsVectorDataConnector + * @classdesc This connector keeps a CesiumVectorData model in sync with the + * points in a GeoPoints collection. This connector will listen for changes to + * the GeoPoints collection and update the cesiumModel with the features + * created from the points in the collection. + * @name PointsVectorDataConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + * @since x.x.x + * + * TODO: Extend to allow for a collection of GeoPoints collections, where each + * GeoPoints collection can be represented as a different polygon in the + * CesiumVectorData model. + */ + return Backbone.Model.extend( + /** @lends PointsVectorDataConnector.prototype */ { + /** + * The type of Backbone.Model this is. + * @type {string} + * @default "PointsVectorDataConnector" + */ + type: "PointsVectorDataConnector", + + /** + * Extends the default Backbone.Model.defaults() function to specify + * default attributes for the PointsVectorDataConnector model. + */ + defaults: function () { + return { + points: null, + vectorLayer: null, + isConnected: false, + }; + }, + + /** + * Initialize the model. + * @param {Object} attrs - The attributes for this model. + * @param {GeoPoints | Object} [attributes.points] - The GeoPoints + * collection to use for this connector or a JSON object with options to + * create a new GeoPoints collection. If not provided, a new GeoPoints + * collection will be created. + * @param {CesiumVectorData | Object} [attributes.vectorLayer] - The + * CesiumVectorData model to use for this connector or a JSON object with + * options to create a new CesiumVectorData model. If not provided, a new + * CesiumVectorData model will be created. + */ + initialize: function (attrs) { + try { + attrs = attrs || {}; + this.setPoints(attrs.points); + this.setVectorLayer(attrs.vectorLayer); + if (attrs.isConnected) { + this.connect(); + } + } catch (e) { + console.log("Error initializing a PointsVectorDataConnector", e); + } + }, + + /** + * Set or create and set the GeoPoints collection for this connector. + * @param {GeoPoints | Object} [points] - The GeoPoints collection to use + * for this connector or a JSON object with options to create a new + * GeoPoints collection. If not provided, a new GeoPoints collection will + * be created. + * @returns {GeoPoints} The GeoPoints collection for this connector. + */ + setPoints: function (points) { + if (points instanceof GeoPoints) { + this.set("points", points); + } else { + this.set("points", new GeoPoints(points)); + } + return this.get("points"); + }, + + /** + * Set or create and set the CesiumVectorData model for this connector. + * @param {CesiumVectorData | Object} [vectorLayer] - The CesiumVectorData + * model to use for this connector or a JSON object with options to create + * a new CesiumVectorData model. If not provided, a new CesiumVectorData + * model will be created. + * @returns {CesiumVectorData} The CesiumVectorData model for this + * connector. + */ + setVectorLayer: function (vectorLayer) { + if (vectorLayer instanceof CesiumVectorData) { + this.set("vectorLayer", vectorLayer); + } else { + this.set("vectorLayer", new CesiumVectorData(vectorLayer)); + } + return this.get("vectorLayer"); + }, + + /** + * Listen for changes to the Points collection and update the + * CesiumVectorData model with the features created from the points in + * the collection. + */ + connect: function () { + try { + const connector = this; + this.disconnect(); + + const handler = (this.eventHandler = new Backbone.Model()); + const points = this.get("points") || this.setPoints(); + + // Update the vectorLayer when the points collection is updated. + handler.listenTo(points, "update reset", () => { + connector.updateVectorLayer(); + }); + + // Restart listeners the points collection or the vectorLayer is + // replaced with a new collection or model. + handler.listenToOnce(this, "change:points change:vectorLayer", () => { + if (this.get("isConnected")) { + connector.connect(); + } + }); + + this.set("isConnected", true); + } catch (e) { + console.warn( + "Error connecting a PointsVectorDataConnector, disconnecting.", + e + ); + connector.disconnect(); + } + }, + + /** + * Stop listening for changes to the Points collection. + */ + disconnect: function () { + const handler = this.eventHandler; + if (handler) { + handler.stopListening(); + handler.clear(); + handler = null; + } + this.set("isConnected", false); + }, + + /** + * Update the CesiumVectorData model with the features created from the + * points in the collection. + */ + updateVectorLayer: function () { + const points = this.get("points") || this.setPoints(); + const layer = this.get("vectorLayer") || this.setVectorLayer(); + const geoJson = points.toGeoJson("Polygon"); + const opts = layer.getCesiumOptions() || {}; + opts.data = geoJson; + layer.set("cesiumOptions", opts); + }, + } + ); +}); diff --git a/src/js/models/maps/GeoPoint.js b/src/js/models/maps/GeoPoint.js index adb826709..7626864da 100644 --- a/src/js/models/maps/GeoPoint.js +++ b/src/js/models/maps/GeoPoint.js @@ -35,19 +35,38 @@ define(["backbone"], function (Backbone) { }; }, - // /** - // * Run when a new GeoPoint is created. - // * @param {Object} attrs - An object specifying configuration options for - // * the GeoPoint. If any config option is not specified, the default will - // * be used instead (see {@link GeoPoint#defaults}). - // */ - // initialize: function (attrs, options) { - // try { - // // ... - // } catch (e) { - // console.log("Error initializing a GeoPoint model", e); - // } - // }, + /** + * Get the long and lat of the point as an array + * @returns {Array} An array in the form [longitude, latitude] + */ + to2DArray: function () { + return [this.get("longitude"), this.get("latitude")]; + }, + + /** + * Convert the point to a GeoJSON geometry object + * @returns {Object} A GeoJSON geometry object with the type (Point) and + * coordinates of the point + */ + toGeoJsonGeometry: function () { + return { + type: "Point", + coordinates: this.to2DArray() + }; + }, + + /** + * Convert the point to a GeoJSON feature object + * @returns {Object} A GeoJSON feature object with the type (Feature) and + * geometry of the point + */ + toGeoJsonFeature: function () { + return { + type: "Feature", + geometry: this.toGeoJsonGeometry(), + properties: {} + }; + }, /** * Validate the model attributes diff --git a/src/js/models/maps/MapInteraction.js b/src/js/models/maps/MapInteraction.js index efa0e6ce6..fdc15e83a 100644 --- a/src/js/models/maps/MapInteraction.js +++ b/src/js/models/maps/MapInteraction.js @@ -159,23 +159,61 @@ define([ } else if (clickAction === "zoom") { this.set("zoomTarget", hoveredFeatures[0]); } + // TODO: throttle this? + this.setClickedPositionFromMousePosition(); }, /** - * Sets the position of the mouse on the map. Creates a new GeoPoint model - * if one doesn't already exist on the mousePosition attribute. + * Sets the clicked position to the current mouse position. + */ + setClickedPositionFromMousePosition: function () { + const mousePosition = this.get("mousePosition"); + // get just the longitude and latitude + const coords = { + longitude: mousePosition.get("longitude"), + latitude: mousePosition.get("latitude") + }; + this.setClickedPosition(coords); + }, + + /** + * Set the position for either the mousePosition or clickedPosition + * attribute. Creates a new GeoPoint model if one doesn't already exist + * on the attribute. + * @param {'mousePosition'|'clickedPosition'} attributeName - The name of + * the attribute to set. * @param {Object} position - An object with 'longitude' and 'latitude' * properties. - * @returns {GeoPoint} The mouse position as a GeoPoint model. + * @returns {GeoPoint} The corresponding position as a GeoPoint model. */ - setMousePosition: function (position) { - let mousePosition = this.get("mousePosition"); - if (!mousePosition) { - mousePosition = new GeoPoint(); - this.set("mousePosition", mousePosition); + setPosition: function(attributeName, position) { + let point = this.get(attributeName); + if (!point) { + point = new GeoPoint(); + this.set(attributeName, point); } - mousePosition.set(position); - return mousePosition; + point.set(position); + return point; + }, + + /** + * Sets the position on the map that the user last clicked. + * @param {Object} position - An object with 'longitude' and 'latitude' + * properties. + * @returns {GeoPoint} The clicked position as a GeoPoint model. + */ + setClickedPosition: function(position) { + return this.setPosition("clickedPosition", position); + }, + + /** + * Sets the position of the mouse on the map. + * @param {Object} position - An object with 'longitude' and 'latitude' + * properties. + * @returns {GeoPoint} The mouse position as a GeoPoint model. + */ + setMousePosition: function(position) { + return this.setPosition("mousePosition", position); }, /** diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 7972cd173..b58b2e62a 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -190,14 +190,14 @@ define( if (dataSourceFunction && typeof dataSourceFunction === 'function') { - if (!recreate) { + if (!recreate || !dataSource) { dataSource = new dataSourceFunction(label) } const data = cesiumOptions.data; delete cesiumOptions.data - if(!dataSource){ + if (!dataSource) { model.set('status', 'error') model.set('statusDetails', 'Failed to create a Cesium DataSource model.') return @@ -212,6 +212,7 @@ define( model.updateFeatureVisibility() model.updateAppearance() model.set('status', 'ready') + }) .otherwise(function (error) { // See https://cesium.com/learn/cesiumjs/ref-doc/RequestErrorEvent.html diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index abda39abe..3700780b4 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -1,10 +1,14 @@ "use strict"; -define(["backbone"], function (Backbone) { +define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( + Backbone, + GeoPointsVectorData +) { /** * @class DrawTool - * @classdesc Functionality for drawing an arbitrary polygon on a Cesium map - * using the mouse. + * @classdesc The DrawTool view allows a user to draw an arbitrary polygon on + * the map. The polygon is stored in a GeoPoints collection and displayed on + * the map using a connected CesiumVectorData model. * @classcategory Views/Maps * @name DrawTool * @extends Backbone.View @@ -27,78 +31,128 @@ define(["backbone"], function (Backbone) { className: "draw-tool", /** - * Whether or not the draw tool is currently active. If not active, it - * will not listen for mouse clicks. - * @type {boolean} + * The current mode of the draw tool. This could be "draw", "edit", + * "delete", or false to indicate that the draw tool is not active. + * Currently only "draw" and false are supported. */ - activated: false, + mode: false, /** * The Cesium map model to draw on. This must be the same model that the * mapWidget is using. * @type {Map} */ - model: undefined, + mapModel: undefined, /** - * The CesiumVectorData model that we will use to store the drawn - * polygon(s) + * A reference to the MapInteraction model on the MapModel that is used to + * listen for clicks on the map. + * @type {MapInteraction} + */ + interactions: undefined, + + /** + * The CesiumVectorData model that will display the polygon that is being + * drawn. * @type {CesiumVectorData} */ - drawLayer: undefined, + layer: undefined, + + /** + * The GeoPoints collection that stores the points of the polygon that is + * being drawn. + * @type {GeoPoints} + */ + points: undefined, /** * Initializes the DrawTool - * @param {Object} options + * @param {Object} options - A literal object with options to pass to the + * view + * @param {Map} options.model - The Cesium map model to draw on. This must + * be the same model that the mapWidget is using. + * @param {string} [options.mode=false] - The initial mode of the draw + * tool. */ initialize: function (options) { - this.model = options.model; - if (!this.model) { + this.mapModel = options.model; + if (!this.mapModel) { this.handleNoMapModel(); return; } - this.makeDrawLayer(); - this.activated = options.activated || false; - if (this.activated) { - this.activate(); - } + // Add models & collections and add interactions, layer, connector, + // points, and originalAction properties to this view + this.setUpMapModel(); + this.setUpLayer(); + this.setUpConnector(); }, /** - * Creates the polygon object that will be modified as a user draws on the - * map. Saves it to the polygon property. + * Sets up the map model and adds the interactions and originalAction + * properties to this view. */ - makeDrawLayer: function () { - if (!this.model) return; - this.drawLayer = this.model.addAsset({ + setUpMapModel: function () { + this.originalAction = this.mapModel.get("clickFeatureAction"); + this.interactions = + this.mapModel.get("interactions") || + this.mapModel.setUpInteractions(); + }, + + /** + * Sets up the layer to show the polygon on the map that is being drawn. + * Adds the layer property to this view. + * @returns {CesiumVectorData} The CesiumVectorData model that will + * display the polygon that is being drawn. + */ + setUpLayer: function () { + this.layer = this.mapModel.addAsset({ type: "GeoJsonDataSource", - hideInLayerList: true, // <- TODO: Look for this property in the - // layer list view. If it's true, don't show it. Document it in the - // map config docs. - cesiumOptions: { - data: { - type: "FeatureCollection", - features: [ - { - type: "Feature", - properties: {}, - geometry: { - coordinates: [], - type: "Polygon", - }, - }, - ], - }, - }, + hideInLayerList: true, // <- TODO: Hide in LayerList, doc in mapConfig + }); + return this.layer; + }, + + /** + * Sets up the connector to connect the GeoPoints collection to the + * CesiumVectorData model. Adds the connector and points properties to + * this view. + * @returns {GeoPointsVectorData} The connector + */ + setUpConnector: function () { + this.connector = new GeoPointsVectorData({ + vectorLayer: this.layer, }); + this.points = this.connector.get("points"); + this.connector.connect(); + return this.connector; + }, + + /** + * Adds a point to the polygon that is being drawn. + * @param {Object} point - The point to add to the polygon. This should + * have a latitude and longitude property. + * @returns {GeoPoint} The GeoPoint model that was added to the polygon. + */ + addPoint: function (point) { + return this.points.addPoint(point); + }, + + /** + * Clears the polygon that is being drawn. + */ + clearPoints: function () { + this.points.reset(null); }, /** * Removes the polygon object from the map + * TODO: Test this */ - removeDrawLayer: function () { - if (!this.model) return; - this.model.removeAsset(this.model); + removeLayer: function () { + if (!this.mapModel || !this.layer) return; + this.connector.disconnect(); + this.connector.set("vectorLayer", null); + this.mapModel.removeAsset(this.layer); }, /** @@ -106,12 +160,7 @@ define(["backbone"], function (Backbone) { * @returns {DrawTool} Returns the view */ render: function () { - if (!this.model) { - this.handleNoMapModel(); - return; - } this.renderToolbar(); - this.startListeners(); return this; }, @@ -120,157 +169,138 @@ define(["backbone"], function (Backbone) { */ handleNoMapModel: function () { console.warn("No map model provided to DrawTool"); + // TODO: Add a message to the view to let the user know that the draw + // tool is not available }, /** - * Create and insert the buttons for drawing and clearing the polygon + * Create and insert the buttons for drawing and clearing the polygon. + * TODO: Add all buttons and style them. This is just a WIP for now. */ renderToolbar: function () { - // TODO: At a minimum we need buttons to: Start drawing, Clear drawing. - // Just some place holder buttons for now: const view = this; const el = this.el; const drawButton = document.createElement("button"); drawButton.innerHTML = "Draw"; drawButton.addEventListener("click", function () { - view.activate(); - // make the button green for testing - drawButton.style.backgroundColor = "green"; + if (view.mode === "draw") { + view.setMode(false); + } else { + view.setMode("draw"); + } }); + this.drawButton = drawButton; el.appendChild(drawButton); const clearButton = document.createElement("button"); clearButton.innerHTML = "Clear"; clearButton.addEventListener("click", function () { - view.removeDrawLayer(); - // make the button red for testing - drawButton.style.backgroundColor = "red"; + view.clearPoints(); + view.setMode(false); }); el.appendChild(clearButton); }, /** - * Starts the listeners for the draw tool + * Sets the mode of the draw tool. Currently only "draw" and false are + * supported. + * @param {string|boolean} mode - The mode to set. This can be "draw" or + * false to indicate that the draw tool should not be active. */ - startListeners: function () { - this.stopListeners(); - - const mapModel = this.model; - this.interactions = mapModel?.get("interactions"); - this.clickedPosition = this.interactions?.get("clickedPosition"); - - this.listenToOnce(mapModel, "change:interactions", this.startListeners); - this.listenToOnce( - this.interactions, - "change:clickedPosition", - this.startListeners - ); - - if (!this.originalClickAction) { - this.originalClickAction = this.model.get("clickFeatureAction"); - } - this.model.set("clickFeatureAction", null); - - this.listenTo( - this.clickedPosition, - "change:latitude change:longitude", - this.handleClick - ); - }, - - /** - * Stops the listeners for the draw tool - */ - stopListeners: function () { - const targets = [this.model, this.interactions, this.clickedPosition]; - targets.forEach((target) => { - if (target) this.stopListening(target); - }, this); - if (this.originalClickAction) { - this.model.set("clickFeatureAction", this.originalClickAction); - this.originalClickAction = null; + setMode: function (mode) { + if (this.mode === mode) return; + this.mode = mode; + if (mode === "draw") { + this.setClickListeners(); + this.drawButton.style.backgroundColor = "green"; + } else if (mode === false) { + this.removeClickListeners(); + this.drawButton.style.backgroundColor = "grey"; } }, /** - * Handles a click on the map. If the draw tool is active, it will add the - * coordinates of the click to the polygon being drawn. + * Removes the click listeners from the map model and sets the + * clickFeatureAction back to its original value. */ - handleClick: function () { - if (!this.activated) { - return; + removeClickListeners: function () { + const handler = this.clickHandler; + if (handler) { + handler.stopListening(); + handler.clear(); + this.clickHandler = null; } - const coordinates = [ - this.clickedPosition.get("longitude"), - this.clickedPosition.get("latitude"), - ]; - this.addCoordinate(coordinates); + this.mapModel.set("clickFeatureAction", this.originalClickAction); + this.listeningForClicks = false; }, /** - * Adds a coordinate to the polygon being drawn - * @param {Array} coords - The coordinates to add, in the form [longitude, - * latitude] + * Set listeners to call the handleClick method when the user clicks on + * the map. */ - addCoordinate: function (coords) { - - // TODO: Something like this... We may also want to add a general method - // to the VectorData model that allows us to add a coordinate, but this - // will be specific to the GeoJsonDataSource - const layer = this.drawLayer; - const geoJSON = layer.get("cesiumOptions")?.data; - const coordinates = geoJSON?.features[0]?.geometry?.coordinates?.[0]; - - if (!coordinates || !coordinates.length) { - // Create new coordinates array - geoJSON.features[0].geometry.coordinates = [[]]; - // Add the coordinate to the new array - geoJSON.features[0].geometry.coordinates[0].push(coords); - } else { - // Check if the last coordinate is the same as the first coordinate. If - // so, we want to add the new coordinate as the second to last. Otherwise - // we want to add it to the end. - const lastCoord = coordinates[coordinates.length - 1]; - const firstCoord = coordinates[0]; - if (lastCoord[0] == firstCoord[0] && lastCoord[1] == firstCoord[1]) { - // Add the coordinate as the second to last - coordinates.splice(coordinates.length - 1, 0, coords); - } else { - // Add the coordinate to the end - coordinates.push(coords); - // Make the coordinates valid for a GeoJSON polygon by adding the first - // coordinate to the end - coordinates.push(coordinates[0]); + setClickListeners: function () { + const view = this; + const handler = (this.clickHandler = new Backbone.Model()); + const interactions = this.interactions; + const clickedPosition = interactions.get("clickedPosition"); + this.mapModel.set("clickFeatureAction", null); + handler.listenTo( + clickedPosition, + "change:latitude change:longitude", + () => { + view.handleClick(); } - } - - layer.set("cesiumOptions", { data: geoJSON }); - layer.createCesiumModel(true); - }, - - /** - * Activates the draw tool. This means that it will listen for mouse - * clicks on the map and draw a polygon based on those clicks. - */ - activate: function () { - this.activated = true; - this.startListeners(); + ); + this.listeningForClicks = true; + // When the clickedPosition GeoPoint model or the MapInteractions model + // is replaced, restart the listeners on the new model. + handler.listenToOnce( + interactions, + "change:clickedPosition", + function () { + if (view.listeningForClicks) { + view.handleClick(); + view.setClickListeners(); + } + } + ); + handler.listenToOnce(this.mapModel, "change:interactions", function () { + if (view.listeningForClicks) { + view.handleClick(); + view.setClickListeners(); + } + }); }, /** - * Deactivates the draw tool. This means that it will no longer listen for - * mouse clicks on the map. + * Handles a click on the map. If the draw tool is active, it will add the + * coordinates of the click to the polygon being drawn. + * @param {Number} [throttle=50] - The number of milliseconds to block + * clicks for after a click is handled. This prevents double clicks. */ - deactivate: function () { - this.activated = false; - this.stopListeners(); + handleClick: function (throttle = 50) { + // Prevent double clicks + if (this.blockClick) return; + this.blockClick = true; + setTimeout(() => { + this.blockClick = false; + }, throttle); + // Add the point to the polygon + if (this.mode === "draw") { + const point = this.interactions.get("clickedPosition"); + console.log("Adding point", point); + this.addPoint({ + latitude: point.get("latitude"), + longitude: point.get("longitude"), + }); + } }, /** * Clears the polygon that is being drawn */ onClose: function () { - this.removeAsset(); - this.deactivate(); + this.removeLayer(); + this.removeClickListeners(); }, } ); From 92e29eb607160fe5ac2df5e38405aaa7229e91aa Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 28 Sep 2023 17:48:04 -0400 Subject: [PATCH 10/24] Add buttons & actions to DrawToolView; use CZML - Switch from GeoJson to CZML (improves ability to draw around poles) - Set up the DrawTool for actions like deleting & moving points, running a callback with user-created points as argument Issue #2180 --- src/js/collections/maps/GeoPoints.js | 101 +++++++++ .../models/connectors/GeoPoints-VectorData.js | 6 +- src/js/models/maps/GeoPoint.js | 39 +++- src/js/models/maps/GeoUtilities.js | 58 ++++++ src/js/models/maps/Geohash.js | 21 +- src/js/views/maps/DrawToolView.js | 194 +++++++++++++++--- test/config/tests.json | 3 + 7 files changed, 370 insertions(+), 52 deletions(-) create mode 100644 src/js/models/maps/GeoUtilities.js diff --git a/src/js/collections/maps/GeoPoints.js b/src/js/collections/maps/GeoPoints.js index 25cf9569b..48ee17555 100644 --- a/src/js/collections/maps/GeoPoints.js +++ b/src/js/collections/maps/GeoPoints.js @@ -131,6 +131,95 @@ define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { }; }, + // TODO: Move this to a CZML model, use in GeoHash/es + + /** + * Get the header object for a CZML document. + * @returns {Object} Returns a CZML header object. + */ + getCZMLHeader: function () { + return { + id: "document", + version: "1.0", + name: "GeoPoints", + }; + }, + + /** + * Convert the collection to a CZML document. + * @param {String} geometryType - The type of geometry to create. + * @param {Boolean} [forceAsPolygon=false] - Set to true to enforce the + * output as a polygon for the "Polygon" geometry type, regardless of the + * number of points in the collection. + * @returns {Object[]} Returns an array of CZML objects. + */ + toCzml: function (geometryType, forceAsPolygon = false) { + if (!forceAsPolygon && geometryType === "Polygon" && this.length < 3) { + geometryType = this.length === 1 ? "Point" : "LineString"; + } + const czml = [this.getCZMLHeader()]; + switch (geometryType) { + case "Point": + czml.concat(this.toCZMLPoints()); + break; + case "LineString": + czml.push(this.getCZMLLineString()); + break; + case "Polygon": + czml.push(this.getCZMLPolygon()); + break; + default: + break; + } + return czml; + }, + + /** + * Convert the collection to an array of CZML point objects. + * @returns {Object[]} Returns an array of CZML point objects. + */ + toCZMLPoints: function () { + return this.models.map((model) => { + return model.toCZML(); + }) + }, + + /** + * Convert the collection to a CZML polygon object. + * @returns {Object} Returns a CZML polygon object. + */ + getCZMLPolygon: function () { + const coords = this.toECEFArray(); + // make a random ID: + const id = "polygon_" + Math.floor(Math.random() * 1000000); + return { + id: id, + name: "Polygon", + polygon: { + positions: { + cartesian: coords, + }, + }, + }; + }, + + /** + * Convert the collection to a CZML line string object. + * @returns {Object} Returns a CZML line string object. + */ + getCZMLLineString: function () { + const coords = this.toECEFArray(); + return { + id: this.cid, + name: "LineString", + polyline: { + positions: { + cartesian: coords, + }, + }, + }; + }, + /** * Convert the collection to a GeoJSON object. The output can be the * series of points as Point features, the points connected as a @@ -208,6 +297,18 @@ define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { return model.to2DArray(); }); }, + + /** + * Convert the collection to a cartesian array, where each every three + * elements represents the x, y, and z coordinates of a vertex, e.g. + * [x1, y1, z1, x2, y2, z2, ...]. + * @returns {Array} Returns an array of numbers. + */ + toECEFArray: function () { + return this.models.flatMap((model) => { + return model.toECEFArray(); + }); + }, } ); diff --git a/src/js/models/connectors/GeoPoints-VectorData.js b/src/js/models/connectors/GeoPoints-VectorData.js index 02bc732b4..95a7a9b3a 100644 --- a/src/js/models/connectors/GeoPoints-VectorData.js +++ b/src/js/models/connectors/GeoPoints-VectorData.js @@ -159,9 +159,11 @@ define([ updateVectorLayer: function () { const points = this.get("points") || this.setPoints(); const layer = this.get("vectorLayer") || this.setVectorLayer(); - const geoJson = points.toGeoJson("Polygon"); + const type = model.get("type"); + const geom = "Polygon"; + const data = type === "geojson" ? points.toGeoJson(geom) : this.toCzml(geom); const opts = layer.getCesiumOptions() || {}; - opts.data = geoJson; + opts.data = data; layer.set("cesiumOptions", opts); }, } diff --git a/src/js/models/maps/GeoPoint.js b/src/js/models/maps/GeoPoint.js index 7626864da..2ff0d7415 100644 --- a/src/js/models/maps/GeoPoint.js +++ b/src/js/models/maps/GeoPoint.js @@ -1,6 +1,6 @@ "use strict"; -define(["backbone"], function (Backbone) { +define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilities) { /** * @class GeoPoint * @classdesc The GeoPoint model stores geographical coordinates including @@ -68,6 +68,43 @@ define(["backbone"], function (Backbone) { }; }, + /** + * Convert the point to a feature in a CZML document + * @returns {Object} A CZML feature object with the type (Feature) and + * geometry of the point. + */ + toCZML: function () { + const ecefCoord = this.toECEFArray(); + return { + id: this.cid, + point: { + pixelSize: 10, + show: true, + heightReference: "CLAMP_TO_GROUND", + }, + position: { + cartesian: ecefCoord + } + }; + }, + + /** + * Convert the point to an array of ECEF coordinates + * @returns {Array} An array in the form [x, y, z] + */ + toECEFArray: function () { + return this.geodeticToECEF(this.to2DArray()); + }, + + /** + * Convert a given point to an array of ECEF coordinates + * @param {Array} coord - An array in the form [longitude, latitude] + * @returns {Array} An array in the form [x, y, z] + */ + geodeticToECEF: function (coord) { + return GeoUtilities.prototype.geodeticToECEF(coord); + }, + /** * Validate the model attributes * @param {Object} attrs - The model's attributes diff --git a/src/js/models/maps/GeoUtilities.js b/src/js/models/maps/GeoUtilities.js new file mode 100644 index 000000000..4ea00f806 --- /dev/null +++ b/src/js/models/maps/GeoUtilities.js @@ -0,0 +1,58 @@ +"use strict"; + +define(["backbone", "models/maps/GeoUtilities"], function ( + Backbone, + GeoUtilities +) { + /** + * @class GeoUtilities + * @classdesc The GeoUtilities model has methods foe handling spatial data + * that are used across multiple models/collections/views, and that don't + * belong in any one of them. + * @classcategory Models/Maps + * @name GeoUtilities + * @since x.x.x + * @extends Backbone.Model + */ + var GeoUtilities = Backbone.Model.extend( + /** @lends GeoUtilities.prototype */ { + /** + * The type of model this is. + * @type {String} + */ + type: "GeoUtilities", + + /** + * Convert geodetic coordinates to Earth-Centered, Earth-Fixed (ECEF) + * coordinates. Currently this function assumes the WGS-84 ellipsoid, + * and does not account for altitude/height (it's assumed the coordinate + * is at sea level) + * @param {Array} coord The geodetic coordinates in the form [longitude, + * 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]; + }, + } + ); + + return GeoUtilities; +}); diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 429e3c436..24372d065 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -287,7 +287,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( /** * Get the geohash as a CZML Feature. - * @param {*} label The key for the property to display as a label. + * @param {string} 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. @@ -346,24 +346,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( * @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]; + return GeoUtilities.geodeticToECEF(coord); }, } ); diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index 3700780b4..75fa7dc17 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -31,9 +31,51 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( className: "draw-tool", /** - * The current mode of the draw tool. This could be "draw", "edit", - * "delete", or false to indicate that the draw tool is not active. - * Currently only "draw" and false are supported. + * Class to use for the buttons + * @type {string} + */ + buttonClass: "map-view__button", + + /** + * The buttons to display in the toolbar and their corresponding actions. + * TODO: Finish documenting this when more finalized. + */ + buttons: [ + { + name: "draw", // === mode + label: "Draw Polygon", + icon: "pencil", + }, + { + name: "move", + label: "Move Point", + icon: "move", + }, + { + name: "remove", + label: "Remove Point", + icon: "eraser", + }, + { + name: "clear", + label: "Clear Polygon", + icon: "trash", + method: "clearPoints", + }, + { + name: "save", + label: "Save", + icon: "save", + method: "save", + }, + ], + + buttonEls: {}, + + /** + * The current mode of the draw tool. This can be "draw", "move", + * "remove", or "add" - any of the "name" properties of the buttons array, + * excluding buttons like "clear" and "save" that have a method property. */ mode: false, @@ -106,8 +148,19 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( */ setUpLayer: function () { this.layer = this.mapModel.addAsset({ - type: "GeoJsonDataSource", - hideInLayerList: true, // <- TODO: Hide in LayerList, doc in mapConfig + type: "CzmlDataSource", + label: "Your Polygon", + description: "The polygon that you are drawing on the map", + hideInLayerList: true, // TODO: Hide in LayerList, doc in mapConfig + outlineColor: "#FF3E41", // TODO + opacity: 0.55, // TODO + colorPalette: { + colors: [ + { + color: "#FF3E41", // TODO + }, + ], + }, }); return this.layer; }, @@ -134,14 +187,14 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( * @returns {GeoPoint} The GeoPoint model that was added to the polygon. */ addPoint: function (point) { - return this.points.addPoint(point); + return this.points?.addPoint(point); }, /** * Clears the polygon that is being drawn. */ clearPoints: function () { - this.points.reset(null); + this.points?.reset(null); }, /** @@ -180,24 +233,45 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( renderToolbar: function () { const view = this; const el = this.el; - const drawButton = document.createElement("button"); - drawButton.innerHTML = "Draw"; - drawButton.addEventListener("click", function () { - if (view.mode === "draw") { - view.setMode(false); - } else { - view.setMode("draw"); - } - }); - this.drawButton = drawButton; - el.appendChild(drawButton); - const clearButton = document.createElement("button"); - clearButton.innerHTML = "Clear"; - clearButton.addEventListener("click", function () { - view.clearPoints(); - view.setMode(false); + + // Create the buttons + view.buttons.forEach(options => { + const button = document.createElement("button"); + button.className = this.buttonClass; + button.innerHTML = ` ${options.label}`; + button.addEventListener("click", function () { + const method = options.method; + if(method) view[method](); + else view.toggleMode(options.name); + }); + if(!view.buttonEls) view.buttonEls = {}; + view.buttonEls[options.name + "Button"] = button; + el.appendChild(button); }); - el.appendChild(clearButton); + }, + + /** + * Sends the polygon coordinates to a callback function to do something + * with them. + * TODO: This is a WIP. + */ + save: function () { + this.setMode(false); + this.removeClickListeners(); + console.log(this.points.toJSON()); + // TODO: Call a callback function to save the polygon + }, + + /** + * Toggles the mode of the draw tool. + * @param {string} mode - The mode to toggle to. + */ + toggleMode: function (mode) { + if (this.mode === mode) { + this.setMode(false); + } else { + this.setMode(mode); + } }, /** @@ -209,12 +283,34 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( setMode: function (mode) { if (this.mode === mode) return; this.mode = mode; - if (mode === "draw") { - this.setClickListeners(); - this.drawButton.style.backgroundColor = "green"; - } else if (mode === false) { + if (mode) { + if (!this.listeningForClicks) this.setClickListeners(); + this.activateButton(mode); + } else { + this.resetButtonStyles(); this.removeClickListeners(); - this.drawButton.style.backgroundColor = "grey"; + } + }, + + /** + * Sets the style of the button with the given name to indicate that it is + * active. + */ + activateButton: function (buttonName) { + const buttonEl = this.buttonEls[buttonName + "Button"]; + if(!buttonEl) return; + this.resetButtonStyles(); + buttonEl.style.backgroundColor = "blue"; // TODO - create active style + }, + + /** + * Resets the styles of all of the buttons to indicate that they are not + * active. + */ + resetButtonStyles: function () { + // Iterate through the buttonEls object and reset the styles + for (const button in this.buttonEls) { + this.buttonEls[button].style.backgroundColor = "grey"; // TODO - create default style } }, @@ -287,7 +383,6 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( // Add the point to the polygon if (this.mode === "draw") { const point = this.interactions.get("clickedPosition"); - console.log("Adding point", point); this.addPoint({ latitude: point.get("latitude"), longitude: point.get("longitude"), @@ -295,6 +390,45 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( } }, + /** + * The action to perform when the mode is "draw" and the user clicks on + * the map. + */ + handleDrawClick: function () { + if (!this.mode === "draw") return + const point = this.interactions.get("clickedPosition"); + if(!point) return + this.addPoint({ + latitude: point.get("latitude"), + longitude: point.get("longitude"), + }); + }, + + /** + * The action to perform when the mode is "move" and the user clicks on + * the map. + */ + handleMoveClick: function () { + if (!this.mode === "move") return + const feature = this.interactions.get("clickedFeature"); + if (!feature) return + // TODO: Set a listener to update the point feature and coords + // when it is clicked and dragged + }, + + /** + * The action to perform when the mode is "remove" and the user clicks on + * the map. + */ + handleRemoveClick: function () { + if (!this.mode === "remove") return + const feature = this.interactions.get("clickedFeature"); + if (!feature) return + // TODO: Get the coords of the clicked feature and remove the point + // from the polygon + console.log("remove feature", feature); + }, + /** * Clears the polygon that is being drawn */ diff --git a/test/config/tests.json b/test/config/tests.json index a558e0dec..a668b5fd9 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -23,6 +23,9 @@ "./js/specs/unit/models/metadata/eml211/EMLDistribution.spec.js", "./js/specs/unit/models/maps/assets/CesiumImagery.spec.js", "./js/specs/unit/collections/maps/Geohashes.spec.js", + "./js/specs/unit/models/maps/GeoPoint.spec.js", + "./js/specs/unit/models/maps/GeoScale.spec.js", + "./js/specs/unit/models/maps/MapInteraction.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", From c89ff998c94dcb66a022e7c17ed79aa869640df2 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 5 Oct 2023 20:01:35 -0400 Subject: [PATCH 11/24] Speed up vector layer rendering, fix drawing - Fix issues with drawing polygons, including drawing polygons over poles - Allow drawn polygons to have properties set like other layers (color, opacity, etc) - When drawing, draw both points and polygons - Add CustomDataSource support - Greatly reduce the number of re-renders Cesium must do (improve map performance) - Add connectors between GeoPoints collection and polygons & points Entities Issues #2180 and #2189 --- src/js/collections/maps/GeoPoints.js | 17 +- src/js/collections/maps/MapAssets.js | 2 +- src/js/models/connectors/GeoPoints-Cesium.js | 163 ++ .../connectors/GeoPoints-CesiumPoints.js | 169 ++ .../connectors/GeoPoints-CesiumPolygon.js | 73 + .../models/connectors/GeoPoints-VectorData.js | 171 --- src/js/models/maps/GeoPoint.js | 27 +- src/js/models/maps/GeoUtilities.js | 5 +- src/js/models/maps/Geohash.js | 15 +- src/js/models/maps/MapInteraction.js | 7 +- src/js/models/maps/assets/CesiumGeohash.js | 4 +- src/js/models/maps/assets/CesiumVectorData.js | 1360 +++++++++-------- src/js/models/maps/assets/MapAsset.js | 31 + src/js/views/maps/CesiumWidgetView.js | 96 +- src/js/views/maps/DrawToolView.js | 90 +- 15 files changed, 1270 insertions(+), 960 deletions(-) create mode 100644 src/js/models/connectors/GeoPoints-Cesium.js create mode 100644 src/js/models/connectors/GeoPoints-CesiumPoints.js create mode 100644 src/js/models/connectors/GeoPoints-CesiumPolygon.js delete mode 100644 src/js/models/connectors/GeoPoints-VectorData.js diff --git a/src/js/collections/maps/GeoPoints.js b/src/js/collections/maps/GeoPoints.js index 48ee17555..d225ee0be 100644 --- a/src/js/collections/maps/GeoPoints.js +++ b/src/js/collections/maps/GeoPoints.js @@ -190,10 +190,8 @@ define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { */ getCZMLPolygon: function () { const coords = this.toECEFArray(); - // make a random ID: - const id = "polygon_" + Math.floor(Math.random() * 1000000); return { - id: id, + id: this.cid, name: "Polygon", polygon: { positions: { @@ -309,6 +307,19 @@ define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { return model.toECEFArray(); }); }, + + /** + * Convert the collection to an array of coordinates in the format + * native to the map widget. For Cesium, this is an array of + * Cartesian3 objects in ECEF coordinates. + * @returns {Array} An array of coordinates that can be used by the map + * widget. + */ + asMapWidgetCoords: function () { + return this.models.map((model) => { + return model.get("mapWidgetCoords"); + }); + }, } ); diff --git a/src/js/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index 1dc3a6d7b..bfea0be14 100644 --- a/src/js/collections/maps/MapAssets.js +++ b/src/js/collections/maps/MapAssets.js @@ -55,7 +55,7 @@ define([ model: Cesium3DTileset, }, { - types: ["GeoJsonDataSource", "CzmlDataSource"], + types: ["GeoJsonDataSource", "CzmlDataSource", "CustomDataSource"], model: CesiumVectorData, }, { diff --git a/src/js/models/connectors/GeoPoints-Cesium.js b/src/js/models/connectors/GeoPoints-Cesium.js new file mode 100644 index 000000000..3fc55b160 --- /dev/null +++ b/src/js/models/connectors/GeoPoints-Cesium.js @@ -0,0 +1,163 @@ +"use strict"; + +/*global define */ +define([ + "backbone", + "cesium", + "collections/maps/GeoPoints", + "models/maps/assets/CesiumVectorData", +], function (Backbone, Cesium, GeoPoints, CesiumVectorData) { + /** + * @class GeoPointsCesiumConnector + * @classdesc This is the base model for other connectors that create geometry + * in Cesium based on points in a GeoPoints collection. + * @name GeoPointsCesiumConnector + * @extends Backbone.Model + * @constructor + * @classcategory Models/Connectors + * @since x.x.x + */ + return Backbone.Model.extend( + /** @lends GeoPointsCesiumConnector.prototype */ { + /** + * The type of Backbone.Model this is. + * @type {string} + * @default "GeoPointsCesiumConnector" + */ + type: "GeoPointsCesiumConnector", + + /** + * Extends the default Backbone.Model.defaults() function to specify + * default attributes for the GeoPointsCesiumConnector model. + * @returns {Object} The default attributes + * @property {GeoPoints} geoPoints - The points collection to visualize + * @property {CesiumVectorData} layer - The CesiumVectorData model to use + * to visualize the points. This must be a CesiumVectorData model. + * @property {Boolean} isConnected - Whether the layer is currently being + * updated with changes to the points collection. + */ + defaults: function () { + return { + geoPoints: null, + layer: null, + isConnected: false, + }; + }, + + /** + * Initialize the model. + * @param {Object} attrs - The attributes for this model. + * @param {GeoPoints | Array} [attributes.geoPoints] - The GeoPoints + * collection to use for this connector or an array of JSON attributes to + * create a new GeoPoints collection. If not provided, a new empty + * GeoPoints collection will be created. + * @param {CesiumVectorData | Object} [attributes.layer] - The + * CesiumVectorData CesiumVectorData model to use for this connector or a + * JSON object with options to create a model. If not provided, a new + * layer will be created. + */ + initialize: function (attrs) { + try { + attrs = attrs || {}; + this.setGeoPoints(attrs.geoPoints); + this.setLayer(attrs.layer); + if (attrs.isConnected) { + this.connect(); + } + } catch (e) { + console.log("Error initializing a GeoPointsCesiumConnector", e); + } + }, + + /** + * Set or create and set the GeoPoints collection for this connector. + * @param {GeoPoints | Object} [points] - The GeoPoints collection to use + * for this connector or an array of JSON attributes to create points. + * @returns {GeoPoints} The GeoPoints collection for this connector. + */ + setGeoPoints: function (geoPoints) { + if (geoPoints instanceof GeoPoints) { + this.set("geoPoints", geoPoints); + } else { + this.set("geoPoints", new GeoPoints(geoPoints)); + } + return this.get("geoPoints"); + }, + + /** + * Set or create and set the CesiumVectorData model for this connector. + * @param {CesiumVectorData | Object} [layer] - The CesiumVectorData model + * to use for this connector or a JSON object with options to create a new + * CesiumVectorData model. If not provided, a new CesiumVectorData model + * will be created. + * @returns {CesiumVectorData} The CesiumVectorData model for this + * connector. + */ + setLayer: function (layer) { + if (layer instanceof CesiumVectorData) { + this.set("layer", layer); + } else { + this.set("layer", new CesiumVectorData(layer)); + } + return this.get("layer"); + }, + + /** + * Listen for changes to the Points collection and update the + * CesiumVectorData model with point entities. + */ + connect: function () { + try { + // Listen for changes to the points collection and update the layer + let geoPoints = this.get("geoPoints"); + const events = ["update", "reset"]; + events.forEach((eventName) => { + this.listenTo(geoPoints, eventName, function (...args) { + this.handleCollectionChange(eventName, ...args); + }); + }); + + // Restart listeners when points or the layer is replaced + this.listenToOnce(this, "change:geoPoints change:layer", () => { + if (this.get("isConnected")) { + this.connect(); + } + }); + // Restart listeners when points or the layer is replaced + this.listenToOnce(this, "change:geoPoints change:layer", () => { + if (this.get("isConnected")) { + this.connect(); + } + }); + + this.set("isConnected", true); + } catch (e) { + console.warn("Error connecting Points to Cesium. Disconnecting.", e); + this.disconnect(); + } + }, + + /** + * Stop listening for changes to the Points collection. + */ + disconnect: function () { + this.stopListening(this.get("geoPoints")); + this.set("isConnected", false); + }, + + /** + * Handle add, remove, merge, and reset events from the points collection + * @param {"update"|"reset"} eventName - The name of the event + * @param {GeoPoints} collection - The points collection + * @param {Object} options - Options for the event, as passed by Backbone + */ + handleCollectionChange(eventName, collection, options) { + try { + // What to do when the collection changes + } catch (e) { + console.warn('Error handling a "' + eventName + '" event.', e); + } + }, + } + ); +}); diff --git a/src/js/models/connectors/GeoPoints-CesiumPoints.js b/src/js/models/connectors/GeoPoints-CesiumPoints.js new file mode 100644 index 000000000..c6354cbc3 --- /dev/null +++ b/src/js/models/connectors/GeoPoints-CesiumPoints.js @@ -0,0 +1,169 @@ +"use strict"; + +/*global define */ +define(["cesium", "models/connectors/GeoPoints-Cesium"], function ( + Cesium, + GeoPointsCesiumConnector +) { + /** + * @class GeoPointsCesiumPointsConnector + * @classdesc This connector keeps a CesiumVectorData model in sync with the + * points in a GeoPoints collection. This connector will listen for changes to + * the GeoPoints collection and update the cesiumModel with point entities + * created from the points in the collection. + * @name GeoPointsCesiumPointsConnector + * @extends GeoPointsCesiumConnector + * @constructor + * @classcategory Models/Connectors + * @since x.x.x + */ + return GeoPointsCesiumConnector.extend( + /** @lends GeoPointsCesiumPointsConnector.prototype */ { + /** + * The type of Backbone.Model this is. + * @type {string} + * @default "GeoPointsCesiumPointsConnector" + */ + type: "GeoPointsCesiumPointsConnector", + + /** + * Extends the default Backbone.Model.defaults() function to specify + * default attributes for the GeoPointsCesiumPointsConnector model. + * @extends GeoPointsCesiumConnector.defaults + * @returns {Object} The default attributes + * @property {Array} layerPoints - The list of point entities that have + * been added to the layer. + */ + defaults: function () { + return { + // extend the defaults from the parent class + ...GeoPointsCesiumConnector.prototype.defaults(), + layerPoints: [], + }; + }, + + /** + * Handle add, remove, merge, and reset events from the points collection + * @param {"update"|"reset"} eventName - The name of the event + * @param {GeoPoints} collection - The points collection + * @param {Object} options - Options for the event, as passed by Backbone + */ + handleCollectionChange(eventName, collection, options) { + try { + // For merges and resets, just remove all points and re-add them + if (!options?.add && !options?.remove) { + this.resetLayerPoints(); + return; + } + // For adds and removes, just add or remove the points that changed + if (eventName === "update") { + if (options.add) { + const newModels = options.changes.added; + newModels.forEach((model) => { + this.addLayerPoint(model); + }); + } + if (options.remove) { + const removedModels = options.changes.removed; + removedModels.forEach((model) => { + this.removeLayerPoint(model); + }); + } + } + } catch (e) { + console.warn('Error handling a "' + eventName + '" event.', e); + } + }, + + /** + * Resync the layer points with the points from the points collection. + * This removes all point entities previously added to the layer and adds + * new ones for each point in the points collection. + */ + resetLayerPoints: function () { + const layer = this.get("layer"); + layer.suspendEvents(); + this.removeAllLayerPoints(); + this.addAllLayerPoints(); + layer.resumeEvents(); + }, + + /** + * Remove all layer points previously added to the layer. + * @returns {Boolean} Whether the layer points were removed + */ + removeAllLayerPoints: function () { + const layer = this.get("layer"); + if (!layer) return false; + const layerPoints = this.get("layerPoints"); + layerPoints.forEach((entity) => { + layer.removeEntity(entity); + }); + return true; + }, + + /** + * Add all points from the points collection to the layer. + * @returns {Boolean} Whether the layer points were added + */ + addAllLayerPoints: function () { + const layer = this.get("layer"); + if (!layer) return false; + const geoPoints = this.get("geoPoints"); + geoPoints.each((model) => { + this.addLayerPoint(model); + }); + return true; + }, + + /** + * Add a point from the points collection to the layer. Adds the point + * entity to the layerPoints array for tracking. + * @param {GeoPoint} model - The point model to add to the layer + * @returns {Cesium.Entity} The layer point that was created + */ + addLayerPoint: function (model) { + try { + const layer = this.get("layer") || this.setLayer(); + const layerPoint = layer.addEntity({ + id: model.cid, + position: model.get("mapWidgetCoords"), + point: { + pixelSize: 2, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + }, + }); + // Track the layer point so we can remove it later + const layerPoints = this.get("layerPoints"); + layerPoints.push(layerPoint); + return layerPoint; + } catch (e) { + console.log("Failed to add a point to a CesiumVectorData.", e); + } + }, + + /** + * Remove a point from the points collection from the layer. Removes the + * point entity from the layerPoints array. + * @param {GeoPoint} model - The point model to remove from the layer + * @returns {Cesium.Entity} The layer point that was removed + */ + removeLayerPoint: function (model) { + try { + const layer = this.get("layer"); + if (!layer) return false; + const removedPoint = layer.removeEntity(model.cid); + // Remove the layer point from the list of layer points + const layerPoints = this.get("layerPoints"); + const index = layerPoints.indexOf(removedPoint); + if (index > -1) { + layerPoints.splice(index, 1); + } + return removedPoint; + } catch (e) { + console.log("Failed to remove a point from a CesiumVectorData.", e); + } + }, + } + ); +}); diff --git a/src/js/models/connectors/GeoPoints-CesiumPolygon.js b/src/js/models/connectors/GeoPoints-CesiumPolygon.js new file mode 100644 index 000000000..5d726d745 --- /dev/null +++ b/src/js/models/connectors/GeoPoints-CesiumPolygon.js @@ -0,0 +1,73 @@ +"use strict"; + +/*global define */ +define(["cesium", "models/connectors/GeoPoints-Cesium"], function ( + Cesium, + GeoPointsCesiumConnector +) { + /** + * @class GeoPointsCesiumPolygonConnector + * @classdesc This connector keeps a CesiumVectorData model in sync with the + * points in a GeoPoints collection. This connector will listen for changes to + * the GeoPoints collection and update the cesiumModel a polygon with vertices + * created from the points in the collection. + * @name GeoPointsCesiumPolygonConnector + * @extends GeoPointsCesiumConnector + * @constructor + * @classcategory Models/Connectors + * @since x.x.x + */ + return GeoPointsCesiumConnector.extend( + /** @lends GeoPointsCesiumPolygonConnector.prototype */ { + /** + * The type of Backbone.Model this is. + * @type {string} + * @default "GeoPointsCesiumPolygonConnector" + */ + type: "GeoPointsCesiumPolygonConnector", + + /** + * Extends the default Backbone.Model.defaults() function to specify + * default attributes for the GeoPointsCesiumPolygonConnector model. + * @extends GeoPointsCesiumConnector.defaults + * @returns {Object} The default attributes + * @property {Cesium.Entity} polygon - The polygon entity that has + * vertices created from the points in the collection. + */ + defaults: function () { + return { + // extend the defaults from the parent class + ...GeoPointsCesiumConnector.prototype.defaults(), + polygon: null, + }; + }, + + /** + * Create a Cesium.Polygon entity and add it to the layer. + * @returns {Cesium.Entity} The Cesium.Polygon entity that was added to + * the CesiumVectorData model. + */ + addPolygon: function () { + const layer = this.get("layer") || this.setVectorLayer(); + const geoPoints = this.get("geoPoints") || this.setPoints(); + return layer.addEntity({ + polygon: { + height: null, // <- clamp to ground + hierarchy: new Cesium.CallbackProperty(() => { + return new Cesium.PolygonHierarchy(geoPoints.asMapWidgetCoords()); + }, false), + }, + }); + }, + + /** + * Reset the positions of the polygon vertices to the current points in + * the GeoPoints collection. + */ + handleCollectionChange: function () { + this.get("polygon") || this.addPolygon(); + this.get("layer").updateAppearance(); + }, + } + ); +}); diff --git a/src/js/models/connectors/GeoPoints-VectorData.js b/src/js/models/connectors/GeoPoints-VectorData.js deleted file mode 100644 index 95a7a9b3a..000000000 --- a/src/js/models/connectors/GeoPoints-VectorData.js +++ /dev/null @@ -1,171 +0,0 @@ -/*global define */ -define([ - "backbone", - "collections/maps/GeoPoints", - "models/maps/assets/CesiumVectorData", -], function (Backbone, GeoPoints, CesiumVectorData) { - "use strict"; - - /** - * @class PointsVectorDataConnector - * @classdesc This connector keeps a CesiumVectorData model in sync with the - * points in a GeoPoints collection. This connector will listen for changes to - * the GeoPoints collection and update the cesiumModel with the features - * created from the points in the collection. - * @name PointsVectorDataConnector - * @extends Backbone.Model - * @constructor - * @classcategory Models/Connectors - * @since x.x.x - * - * TODO: Extend to allow for a collection of GeoPoints collections, where each - * GeoPoints collection can be represented as a different polygon in the - * CesiumVectorData model. - */ - return Backbone.Model.extend( - /** @lends PointsVectorDataConnector.prototype */ { - /** - * The type of Backbone.Model this is. - * @type {string} - * @default "PointsVectorDataConnector" - */ - type: "PointsVectorDataConnector", - - /** - * Extends the default Backbone.Model.defaults() function to specify - * default attributes for the PointsVectorDataConnector model. - */ - defaults: function () { - return { - points: null, - vectorLayer: null, - isConnected: false, - }; - }, - - /** - * Initialize the model. - * @param {Object} attrs - The attributes for this model. - * @param {GeoPoints | Object} [attributes.points] - The GeoPoints - * collection to use for this connector or a JSON object with options to - * create a new GeoPoints collection. If not provided, a new GeoPoints - * collection will be created. - * @param {CesiumVectorData | Object} [attributes.vectorLayer] - The - * CesiumVectorData model to use for this connector or a JSON object with - * options to create a new CesiumVectorData model. If not provided, a new - * CesiumVectorData model will be created. - */ - initialize: function (attrs) { - try { - attrs = attrs || {}; - this.setPoints(attrs.points); - this.setVectorLayer(attrs.vectorLayer); - if (attrs.isConnected) { - this.connect(); - } - } catch (e) { - console.log("Error initializing a PointsVectorDataConnector", e); - } - }, - - /** - * Set or create and set the GeoPoints collection for this connector. - * @param {GeoPoints | Object} [points] - The GeoPoints collection to use - * for this connector or a JSON object with options to create a new - * GeoPoints collection. If not provided, a new GeoPoints collection will - * be created. - * @returns {GeoPoints} The GeoPoints collection for this connector. - */ - setPoints: function (points) { - if (points instanceof GeoPoints) { - this.set("points", points); - } else { - this.set("points", new GeoPoints(points)); - } - return this.get("points"); - }, - - /** - * Set or create and set the CesiumVectorData model for this connector. - * @param {CesiumVectorData | Object} [vectorLayer] - The CesiumVectorData - * model to use for this connector or a JSON object with options to create - * a new CesiumVectorData model. If not provided, a new CesiumVectorData - * model will be created. - * @returns {CesiumVectorData} The CesiumVectorData model for this - * connector. - */ - setVectorLayer: function (vectorLayer) { - if (vectorLayer instanceof CesiumVectorData) { - this.set("vectorLayer", vectorLayer); - } else { - this.set("vectorLayer", new CesiumVectorData(vectorLayer)); - } - return this.get("vectorLayer"); - }, - - /** - * Listen for changes to the Points collection and update the - * CesiumVectorData model with the features created from the points in - * the collection. - */ - connect: function () { - try { - const connector = this; - this.disconnect(); - - const handler = (this.eventHandler = new Backbone.Model()); - const points = this.get("points") || this.setPoints(); - - // Update the vectorLayer when the points collection is updated. - handler.listenTo(points, "update reset", () => { - connector.updateVectorLayer(); - }); - - // Restart listeners the points collection or the vectorLayer is - // replaced with a new collection or model. - handler.listenToOnce(this, "change:points change:vectorLayer", () => { - if (this.get("isConnected")) { - connector.connect(); - } - }); - - this.set("isConnected", true); - } catch (e) { - console.warn( - "Error connecting a PointsVectorDataConnector, disconnecting.", - e - ); - connector.disconnect(); - } - }, - - /** - * Stop listening for changes to the Points collection. - */ - disconnect: function () { - const handler = this.eventHandler; - if (handler) { - handler.stopListening(); - handler.clear(); - handler = null; - } - this.set("isConnected", false); - }, - - /** - * Update the CesiumVectorData model with the features created from the - * points in the collection. - */ - updateVectorLayer: function () { - const points = this.get("points") || this.setPoints(); - const layer = this.get("vectorLayer") || this.setVectorLayer(); - const type = model.get("type"); - const geom = "Polygon"; - const data = type === "geojson" ? points.toGeoJson(geom) : this.toCzml(geom); - const opts = layer.getCesiumOptions() || {}; - opts.data = data; - layer.set("cesiumOptions", opts); - }, - } - ); -}); diff --git a/src/js/models/maps/GeoPoint.js b/src/js/models/maps/GeoPoint.js index 2ff0d7415..ea73452a1 100644 --- a/src/js/models/maps/GeoPoint.js +++ b/src/js/models/maps/GeoPoint.js @@ -1,6 +1,9 @@ "use strict"; -define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilities) { +define(["backbone", "models/maps/GeoUtilities"], function ( + Backbone, + GeoUtilities +) { /** * @class GeoPoint * @classdesc The GeoPoint model stores geographical coordinates including @@ -26,12 +29,16 @@ define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilitie * @property {number} longitude - The longitude of the point in degrees * @property {number} height - The height of the point in meters above sea * level + * @property {*} mapWidgetCoords - Optionally, Coordinates in the format + * provided by the map widget. For example, for Cesium, this is the Cesium + * Cartesian3 ECEF coordinates. */ defaults: function () { return { latitude: null, longitude: null, - height: null + height: null, + mapWidgetCoords: null, }; }, @@ -51,7 +58,7 @@ define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilitie toGeoJsonGeometry: function () { return { type: "Point", - coordinates: this.to2DArray() + coordinates: this.to2DArray(), }; }, @@ -64,7 +71,7 @@ define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilitie return { type: "Feature", geometry: this.toGeoJsonGeometry(), - properties: {} + properties: {}, }; }, @@ -83,8 +90,8 @@ define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilitie heightReference: "CLAMP_TO_GROUND", }, position: { - cartesian: ecefCoord - } + cartesian: ecefCoord, + }, }; }, @@ -109,11 +116,11 @@ define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilitie * Validate the model attributes * @param {Object} attrs - The model's attributes */ - validate: function(attrs) { + validate: function (attrs) { if (attrs.latitude < -90 || attrs.latitude > 90) { return "Invalid latitude. Must be between -90 and 90."; } - + if (attrs.longitude < -180 || attrs.longitude > 180) { return "Invalid longitude. Must be between -180 and 180."; } @@ -121,10 +128,10 @@ define(["backbone", "models/maps/GeoUtilities"], function (Backbone, GeoUtilitie // Assuming height is in meters and can theoretically be below sea // level. Adjust the height constraints as needed for your specific // application. - if (typeof attrs.height !== 'number') { + if (typeof attrs.height !== "number") { return "Invalid height. Must be a number."; } - } + }, } ); diff --git a/src/js/models/maps/GeoUtilities.js b/src/js/models/maps/GeoUtilities.js index 4ea00f806..62e51cddc 100644 --- a/src/js/models/maps/GeoUtilities.js +++ b/src/js/models/maps/GeoUtilities.js @@ -1,9 +1,6 @@ "use strict"; -define(["backbone", "models/maps/GeoUtilities"], function ( - Backbone, - GeoUtilities -) { +define(["backbone"], function (Backbone) { /** * @class GeoUtilities * @classdesc The GeoUtilities model has methods foe handling spatial data diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 24372d065..4f9e865a2 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -1,11 +1,12 @@ "use strict"; -define(["jquery", "underscore", "backbone", "nGeohash"], function ( - $, - _, - Backbone, - nGeohash -) { +define([ + "jquery", + "underscore", + "backbone", + "nGeohash", + "models/maps/GeoUtilities", +], function ($, _, Backbone, nGeohash, GeoUtilities) { /** * @classdesc A Geohash Model represents a single geohash. * @classcategory Models/Geohashes @@ -346,7 +347,7 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( * @returns {Array} The ECEF coordinates. */ geodeticToECEF: function (coord) { - return GeoUtilities.geodeticToECEF(coord); + return GeoUtilities.prototype.geodeticToECEF(coord); }, } ); diff --git a/src/js/models/maps/MapInteraction.js b/src/js/models/maps/MapInteraction.js index fdc15e83a..bf731a408 100644 --- a/src/js/models/maps/MapInteraction.js +++ b/src/js/models/maps/MapInteraction.js @@ -168,10 +168,11 @@ define([ */ setClickedPositionFromMousePosition: function () { const mousePosition = this.get("mousePosition"); - // get just the longitude and latitude const coords = { longitude: mousePosition.get("longitude"), - latitude: mousePosition.get("latitude") + latitude: mousePosition.get("latitude"), + height: mousePosition.get("height"), + mapWidgetCoords: mousePosition.get("mapWidgetCoords"), }; this.setClickedPosition(coords); }, @@ -191,7 +192,7 @@ define([ if (!point) { point = new GeoPoint(); this.set(attributeName, point); - } + } point.set(position); return point; }, diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index b4f23328f..499a716f5 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -277,11 +277,11 @@ define([ // Set the GeoJSON representing geohashes on the model const cesiumOptions = this.getCesiumOptions(); const type = model.get("type"); - const data = type === "geojson" ? this.getGeoJSON() : this.getCZML(); + const data = type === "GeoJsonDataSource" ? this.getGeoJSON() : this.getCZML(); cesiumOptions["data"] = data; cesiumOptions["height"] = 0; model.set("cesiumOptions", cesiumOptions); - // Create the model like a regular GeoJSON data source + // Create the model like a regular vector data source CesiumVectorData.prototype.createCesiumModel.call(this, recreate); } catch (e) { console.log("Error creating a CesiumGeohash model. ", e); diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index b58b2e62a..e95ec7ab6 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -1,674 +1,746 @@ -'use strict'; - -define( - [ - 'jquery', - 'underscore', - 'backbone', - 'cesium', - 'models/maps/assets/MapAsset', - 'models/maps/AssetColor', - 'models/maps/AssetColorPalette', - 'collections/maps/VectorFilters' - ], - function ( - $, - _, - Backbone, - Cesium, - MapAsset, - AssetColor, - AssetColorPalette, - 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: 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 - * @extends MapAsset - * @since 2.19.0 - * @constructor - */ - var CesiumVectorData = MapAsset.extend( - /** @lends CesiumVectorData.prototype */ { - - /** - * The name of this type of model - * @type {string} - */ - type: 'CesiumVectorData', - - /** - * Options that are supported for creating Cesium DataSources. The object will be - * passed to the cesium DataSource's load method as options, so the properties - * listed in the Cesium documentation are also supported. Each type of Cesium Data - * Source has a specific set of load method options. See for example, the - * GeoJsonDataSource options: - * {@link https://cesium.com/learn/cesiumjs/ref-doc/GeoJsonDataSource.html} - * @typedef {Object} CesiumVectorData#cesiumOptions - * @property {string|Object} data - The url, GeoJSON object, or TopoJSON object to - * be loaded. - */ - - /** - * Default attributes for CesiumVectorData models - * @name CesiumVectorData#defaults - * @extends MapAsset#defaults - * @type {Object} - * @property {'GeoJsonDataSource'} type The format of the data. Must be - * '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 - * or colors mapped to attributes of this asset. Used to style the features and to - * make a legend. - * @property {Cesium.GeoJsonDataSource} cesiumModel A Cesium DataSource model - * created and used by Cesium that organizes the data to display in the Cesium - * Widget. See - * {@link https://cesium.com/learn/cesiumjs/ref-doc/DataSource.html?classFilter=DataSource} - * @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( - this.constructor.__super__.defaults(), - { - type: 'GeoJsonDataSource', - filters: new VectorFilters(), - cesiumModel: null, - cesiumOptions: {}, - colorPalette: new AssetColorPalette(), - icon: '', - outlineColor: null, - featureType: Cesium.Entity - } - ); - }, - - /** - * Executed when a new CesiumVectorData model is created. - * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the - * attributes, which will be set on the model. - */ - initialize: function (assetConfig) { - try { +"use strict"; + +define([ + "underscore", + "cesium", + "models/maps/assets/MapAsset", + "models/maps/AssetColor", + "models/maps/AssetColorPalette", + "collections/maps/VectorFilters", +], function ( + _, + Cesium, + MapAsset, + AssetColor, + AssetColorPalette, + 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, CzmlDataSource, and CustomDataSource are + * supported. Eventually this model could support the KmlDataSource. + * @classcategory Models/Maps/Assets + * @class CesiumVectorData + * @name CesiumVectorData + * @extends MapAsset + * @since 2.19.0 + * @constructor + */ + var CesiumVectorData = MapAsset.extend( + /** @lends CesiumVectorData.prototype */ { + /** + * The name of this type of model + * @type {string} + */ + type: "CesiumVectorData", + + /** + * Options that are supported for creating Cesium DataSources. The object + * will be passed to the cesium DataSource's load method as options, so + * the properties listed in the Cesium documentation are also supported. + * Each type of Cesium Data Source has a specific set of load method + * options. See for example, the GeoJsonDataSource options: + * {@link https://cesium.com/learn/cesiumjs/ref-doc/GeoJsonDataSource.html} + * @typedef {Object} CesiumVectorData#cesiumOptions + * @property {string|Object} data - The url, GeoJSON object, or TopoJSON + * object to be loaded. + */ + + /** + * Default attributes for CesiumVectorData models + * @name CesiumVectorData#defaults + * @extends MapAsset#defaults + * @type {Object} + * @property {'GeoJsonDataSource'} type The format of the data. Must be + * '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 or colors mapped to attributes of this asset. Used to style + * the features and to make a legend. + * @property {Cesium.GeoJsonDataSource} cesiumModel A Cesium DataSource + * model created and used by Cesium that organizes the data to display in + * the Cesium Widget. See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/DataSource.html?classFilter=DataSource} + * @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 {string|AssetColor} [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(this.constructor.__super__.defaults(), { + type: "GeoJsonDataSource", + filters: new VectorFilters(), + cesiumModel: null, + cesiumOptions: {}, + colorPalette: new AssetColorPalette(), + icon: '', + outlineColor: null, + featureType: Cesium.Entity, + }); + }, + + /** + * Executed when a new CesiumVectorData 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 (!assetConfig) assetConfig = {}; + + MapAsset.prototype.initialize.call(this, assetConfig); + + if (assetConfig.filters) { + this.set("filters", new VectorFilters(assetConfig.filters)); + } - if (!assetConfig) assetConfig = {}; + // displayReady will be updated by the Cesium map within which the + // asset is rendered. The map will set it to true when the data is + // ready to be 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({ color: assetConfig.outlineColor }) + ); + } - MapAsset.prototype.initialize.call(this, assetConfig); + if ( + assetConfig.highlightColor && + !(assetConfig.highlightColor instanceof AssetColor) + ) { + this.set( + "highlightColor", + new AssetColor({ color: assetConfig.highlightColor }) + ); + } - if (assetConfig.filters) { - this.set('filters', new VectorFilters(assetConfig.filters)) + this.createCesiumModel(); + } catch (error) { + console.log("Error initializing a CesiumVectorData model.", 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; + const cesiumOptions = this.getCesiumOptions(); + const type = model.get("type"); + 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) { + 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(); } + } - // displayReady will be updated by the Cesium map within which the asset is - // rendered. The map will set it to true when the data is ready to be - // 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({ color: assetConfig.outlineColor }) - ); - } - - if ( - assetConfig.highlightColor && - !(assetConfig.highlightColor instanceof AssetColor) - ) { - this.set( - "highlightColor", - new AssetColor({ color: assetConfig.highlightColor }) - ); - } + model.set("displayReady", false); + model.resetStatus(); - this.createCesiumModel(); + if (typeof dataSourceFunction !== "function") { + model.setError(`${type} is not a supported data type.`); + return; + } + if (!dataSource) { + dataSource = new dataSourceFunction(label); + } + if (!dataSource) { + model.setError("Failed to create a Cesium DataSource model."); + return; + } + // There is no data to load for a CustomDataSource + if (type === "CustomDataSource") { + model.set("cesiumModel", dataSource); + model.setListeners(); + model.setReady(); + model.runVisualizers(); + return; } - catch (error) { - console.log('Error initializing a CesiumVectorData model.', error); + + // For GeoJSON and CZML data sources + if (!cesiumOptions || !cesiumOptions.data) { + model.setError( + "No data was provided to create a Cesium DataSource model." + ); + return; } - }, - - /** - * 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; - const cesiumOptions = this.getCesiumOptions(); - const type = model.get('type') - 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) { + const data = JSON.parse(JSON.stringify(cesiumOptions.data)); + delete cesiumOptions.data; + + dataSource + .load(data, cesiumOptions) + .then(function (loadedData) { + model.set("cesiumModel", loadedData); 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.setListeners(); } + model.updateFeatureVisibility(); + model.updateAppearance(); + model.setReady(); + }) + .otherwise(model.setError.bind(model)); + } catch (error) { + console.log("Failed to create a VectorData Cesium Model.", error); + } + }, + + /** + * Set listeners that update the cesium model when the backbone model is + * updated. + */ + setListeners: function () { + try { + MapAsset.prototype.setListeners.call(this); + const appearEvents = + "change:visible change:opacity change:color change:outlineColor" + + " change:temporarilyHidden"; + this.stopListening(this, appearEvents); + this.listenTo(this, appearEvents, this.updateAppearance); + const filters = this.get("filters"); + this.stopListening(filters, "update"); + this.listenTo(filters, "update", this.updateFeatureVisibility); + } catch (error) { + console.log("Failed to set CesiumVectorData listeners.", error); + } + }, + + /** + * Checks that the map is ready to display this asset. The displayReady + * attribute is updated by the Cesium map when the dataSourceDisplay is + * updated. + * @returns {Promise} Returns a promise that resolves to this model when + * ready to be displayed. + */ + whenDisplayReady: function () { + return this.whenReady().then(function (model) { + return new Promise(function (resolve, reject) { + if (model.get("displayReady")) { + resolve(model); + return; } - - model.resetStatus(); - - if (!cesiumOptions || !cesiumOptions.data) { - model.set('status', 'error'); - model.set('statusDetails', 'Vector data source is missing: A URL or data object is required') - return - } - - if (dataSourceFunction && typeof dataSourceFunction === 'function') { - - if (!recreate || !dataSource) { - dataSource = new dataSourceFunction(label) + model.stopListening(model, "change:displayReady"); + model.listenTo(model, "change:displayReady", function () { + if (model.get("displayReady")) { + model.stopListening(model, "change:displayReady"); + resolve(model); } + }); + }); + }); + }, + + /** + * 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. + * @since 2.25.0 + */ + getEntityFromMapObject: function (mapObject) { + const entityType = this.get("featureType"); + if (mapObject instanceof entityType) return mapObject; + if (mapObject.id instanceof entityType) return mapObject.id; + return null; + }, + + /** + * @inheritdoc + * @since 2.25.0 + */ + getFeatureAttributes: function (feature) { + feature = this.getEntityFromMapObject(feature); + return MapAsset.prototype.getFeatureAttributes.call(this, feature); + }, + + /** + * @inheritdoc + * @since 2.25.0 + */ + usesFeatureType: function (feature) { + // 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; + }, + + /** + * Given a feature from a Cesium Vector Data source, returns any + * properties that are set on the feature, similar to an attributes table. + * @param {Cesium.Entity} feature A Cesium Entity + * @returns {Object} An object containing key-value mapping of property + * names to properties. + */ + getPropertiesFromFeature: function (feature) { + 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; + 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; + }, + + /** + * Updates the styles set on the cesiumModel object based on the + * colorPalette and filters attributes. + */ + updateAppearance: function () { + try { + const model = this; + const cesiumModel = this.get("cesiumModel"); + this.set("displayReady", false); + + if (!cesiumModel) { + return; + } - const data = cesiumOptions.data; - delete cesiumOptions.data + const entities = cesiumModel.entities.values; - if (!dataSource) { - model.set('status', 'error') - model.set('statusDetails', 'Failed to create a Cesium DataSource model.') - return - } + // Suspending events while updating a large number of entities helps + // performance. + cesiumModel.entities.suspendEvents(); - dataSource.load(data, cesiumOptions) - .then(function (loadedData) { - model.set('cesiumModel', loadedData) - if (!recreate) { - model.setListeners() - } - model.updateFeatureVisibility() - model.updateAppearance() - model.set('status', 'ready') - - }) - .otherwise(function (error) { - // See https://cesium.com/learn/cesiumjs/ref-doc/RequestErrorEvent.html - let details = error; - // Write a helpful error message - switch (error.statusCode) { - case 404: - details = 'The resource was not found (error code 404).' - break; - case 500: - details = 'There was a server error (error code 500).' - break; - } - model.set('status', 'error'); - model.set('statusDetails', details) - }) - } else { - model.set('status', 'error') - model.set('statusDetails', type + ' is not a supported imagery type.') - } + // 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; + if (model.get("opacity") === 0) model.set("visible", false); + } else { + cesiumModel.entities.show = true; + this.styleEntities(entities); } - catch (error) { - console.log('Failed to create a VectorData Cesium Model.', error); + + cesiumModel.entities.resumeEvents(); + this.runVisualizers(); + } catch (e) { + console.log("Failed to update CesiumVectorData model styles.", e); + } + }, + + runVisualizers: function () { + const dataSource = this.get("cesiumModel"); + const visualizers = dataSource._visualizers; + if (!visualizers || !visualizers.length) { + this.whenVisualizersReady(this.runVisualizers.bind(this)); + return; + } + const time = Cesium.JulianDate.now(); + let displayReadyNow = dataSource.update(time); + for (let x = 0; x < visualizers.length; x++) { + displayReadyNow = visualizers[x].update(time) && displayReadyNow; + } + this.set("displayReady", displayReadyNow); + this.trigger("appearanceChanged"); + }, + + /** + * Check for the existence of visualizers and run the callback when they + * are ready. This is useful for waiting to run code that depends on the + * visualizers being ready. It will attempt to run the callback every + * pingRate ms until the visualizers are ready, or until the maxPings is + * reached. + * @param {*} callBack + * @param {*} maxPings + */ + whenVisualizersReady: function (callBack, pingRate = 100, maxPings = 30) { + const model = this; + let pings = 0; + const interval = setInterval(function () { + pings++; + if (pings > maxPings) { + clearInterval(interval); + return; } - }, - - /** - * Set listeners that update the cesium model when the backbone model is updated. - */ - setListeners: function () { - try { - MapAsset.prototype.setListeners.call(this) - const appearEvents = - 'change:visible change:opacity change:color change:outlineColor' + - ' change:temporarilyHidden' - this.stopListening(this, appearEvents) - this.listenTo(this, appearEvents, this.updateAppearance) - const filters = this.get('filters'); - this.stopListening(filters, 'update') - this.listenTo(filters, 'update', this.updateFeatureVisibility) + const visualizers = model.get("cesiumModel")._visualizers; + if (visualizers && visualizers.length) { + clearInterval(interval); + callBack(); } - catch (error) { - console.log('Failed to set CesiumVectorData listeners.', error); + }, pingRate); + }, + + getEntityCollection: function () { + const model = this; + const dataSource = model.get("cesiumModel"); + return dataSource?.entities; + }, + + getEntities: function () { + return this.getEntityCollection()?.values || []; + }, + + suspendEvents: function () { + const entities = this.getEntityCollection(); + if (entities) entities.suspendEvents(); + }, + + resumeEvents: function () { + const entities = this.getEntityCollection(); + if (entities) entities.resumeEvents(); + }, + + addEntity: function (entity) { + try { + const entities = this.getEntityCollection(); + if (!entities) return false; + const newEntity = entities.add(entity); + this.styleEntities([newEntity]); + this.runVisualizers(); + return newEntity; + } catch (e) { + console.log("Failed to add an entity.", e); + } + }, + + removeEntity: function (entity) { + try { + const entities = this.getEntities(); + if (!entities) return false; + let removed = false; + // if entity is a string, remove by ID + if (typeof entity === "string") { + removed = entities.removeById(entity); + } else { + // Otherwise, assume it's an entity object + removed = entities.remove(entity); } - }, - - /** - * Checks that the map is ready to display this asset. The displayReady attribute - * is updated by the Cesium map when the dataSourceDisplay is updated. - * @returns {Promise} Returns a promise that resolves to this model when ready to - * be displayed. - */ - whenDisplayReady: function () { - return this.whenReady() - .then(function (model) { - return new Promise(function (resolve, reject) { - if (model.get('displayReady')) { - resolve(model) - return - } - model.stopListening(model, 'change:displayReady') - model.listenTo(model, 'change:displayReady', function () { - if (model.get('displayReady')) { - model.stopListening(model, 'change:displayReady') - resolve(model) - } - }) - }); - }) - }, - - /** - * 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. - * @since 2.25.0 - */ - getEntityFromMapObject: function(mapObject) { - const entityType = this.get("featureType") - if (mapObject instanceof entityType) return mapObject - if (mapObject.id instanceof entityType) return mapObject.id - return null - }, - - /** - * @inheritdoc - * @since 2.25.0 - */ - getFeatureAttributes: function (feature) { - feature = this.getEntityFromMapObject(feature) - return MapAsset.prototype.getFeatureAttributes.call(this, feature) - }, - - /** - * @inheritdoc - * @since 2.25.0 - */ - usesFeatureType: function (feature) { - // 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 - }, - - /** - * Given a feature from a Cesium Vector Data source, returns any properties that are set - * on the feature, similar to an attributes table. - * @param {Cesium.Entity} feature A Cesium Entity - * @returns {Object} An object containing key-value mapping of property names to - * properties. - */ - getPropertiesFromFeature: function(feature) { - feature = this.getEntityFromMapObject(feature) - if (!feature) return null - const featureProps = feature.properties - let properties = {} - if (featureProps) { - properties = feature.properties.getValue(new Date()) + this.runVisualizers(); + return removed; + } catch (e) { + console.log("Failed to remove an entity.", e); + } + }, + + /** + * Update the styles for a set of entities + * @param {Array} entities - The entities to update + * @since 2.25.0 + */ + 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; } - 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 - 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 - }, - - /** - * Updates the styles set on the cesiumModel object based on the colorPalette and - * filters attributes. - */ - updateAppearance: function () { - try { - - const model = this; - const cesiumModel = this.get('cesiumModel'); - - if (!cesiumModel) return - - 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, just hide all entities and update the - // visibility property to indicate that layer is hidden - if (!model.isVisible()) { - cesiumModel.entities.show = false - if (model.get('opacity') === 0) model.set('visible', false); - } else { - cesiumModel.entities.show = true - this.styleEntities(entities) + entity.show = true; + for (const [type, styleFunction] of Object.entries(entityStyleMap)) { + if (entity[type]) { + styleFunction.call(this, entity, styles); } - - 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') - - } - 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 2.25.0 - */ - 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; + }, this); + }, + + /** + * 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 2.25.0 + */ + 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; + }, + + /** + * 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 2.25.0 + */ + 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 2.25.0 + */ + 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 2.25.0 + */ + styleBillboard: function (entity, styles) { + if (!this.pinBuilder) { + this.pinBuilder = new Cesium.PinBuilder(); + } + 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 2.25.0 + */ + 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 2.25.0 + */ + 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 2.25.0 + */ + 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 2.25.0 + */ + 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 2.25.0 + */ + getStyles: function (entity) { + if (!entity) return null; + entity = this.getEntityFromMapObject(entity); + 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, + }; + }, + + /** + * Shows or hides each feature from this Map Asset based on the filters. + */ + updateFeatureVisibility: function () { + try { + const model = this; + const entities = this.getEntities(); + const filters = model.get("filters"); + + if (!entities || !filters) return; + + // Suspending events while updating a large number of entities helps + // performance. + this.suspendEvents(); + + for (var i = 0; i < entities.length; i++) { + let visible = true; + const entity = entities[i]; + if (filters && filters.length) { + const properties = model.getPropertiesFromFeature(entity); + visible = model.featureIsVisible(properties); } - entity.show = true; - for (const [type, styleFunction] of Object.entries(entityStyleMap)) { - if (entity[type]) { - styleFunction.call(this, entity, styles); - } - } - }, this); - }, - - /** - * 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 2.25.0 - */ - 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 - }, - - /** - * 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 2.25.0 - */ - 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 2.25.0 - */ - 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 2.25.0 - */ - styleBillboard: function (entity, styles) { - if (!this.pinBuilder) { - this.pinBuilder = new Cesium.PinBuilder() - } - 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 2.25.0 - */ - 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 2.25.0 - */ - 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 2.25.0 - */ - 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 2.25.0 - */ - 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 2.25.0 - */ - getStyles: function (entity) { - if (!entity) return null - entity = this.getEntityFromMapObject(entity) - if (this.featureIsSelected(entity)) { - return this.getSelectedStyles(entity) + entity.show = visible; } - 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, - } - }, - - /** - * Shows or hides each feature from this Map Asset based on the filters. - */ - updateFeatureVisibility: function () { - try { - const model = this; - const cesiumModel = model.get('cesiumModel') - const entities = cesiumModel.entities.values - const filters = model.get('filters') - - // Suspending events while updating a large number of entities helps - // performance. - cesiumModel.entities.suspendEvents() - - for (var i = 0; i < entities.length; i++) { - let visible = true - const entity = entities[i] - if (filters && filters.length) { - const properties = model.getPropertiesFromFeature(entity) - visible = model.featureIsVisible(properties) + + this.resumeEvents(); + model.runVisualizers(); + } catch (e) { + console.log("Failed to update CesiumVectorData model styles.", e); + } + }, + + /** + * Waits for the model to be ready to display, then gets a Cesium Bounding + * Sphere that can be used to navigate to view the full extent of the + * vector data. See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}. + * @param {Cesium.DataSourceDisplay} dataSourceDisplay The data source + * display attached to the CesiumWidget scene that this bounding sphere is + * for. Required. + * @returns {Promise} Returns a promise that resolves to a Cesium Bounding + * Sphere when ready + */ + getBoundingSphere: function (dataSourceDisplay) { + return this.whenDisplayReady() + .then(function (model) { + const entities = model.getEntities(); // .slice(0)? + const boundingSpheres = []; + const boundingSphereScratch = new Cesium.BoundingSphere(); + for (let i = 0, len = entities.length; i < len; i++) { + let state = Cesium.BoundingSphereState.PENDING; + state = dataSourceDisplay.getBoundingSphere( + entities[i], + false, + boundingSphereScratch + ); + if (state === Cesium.BoundingSphereState.PENDING) { + return false; + } else if (state !== Cesium.BoundingSphereState.FAILED) { + boundingSpheres.push( + Cesium.BoundingSphere.clone(boundingSphereScratch) + ); } - entity.show = visible } - - 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') - } - catch (error) { + if (boundingSpheres.length) { + return Cesium.BoundingSphere.fromBoundingSpheres(boundingSpheres); + } + return false; + }) + .catch(function (error) { console.log( - 'There was an error updating CesiumVectorData feature visibility' + - '. Error details: ' + error + "Failed to get the bounding sphere for a CesiumVectorData model" + + ". Error details: " + + error ); - } - }, - - /** - * Waits for the model to be ready to display, then gets a Cesium Bounding Sphere - * that can be used to navigate to view the full extent of the vector data. See - * {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}. - * @param {Cesium.DataSourceDisplay} dataSourceDisplay The data source display - * attached to the CesiumWidget scene that this bounding sphere is for. Required. - * @returns {Promise} Returns a promise that resolves to a Cesium Bounding Sphere - * when ready - */ - getBoundingSphere: function (dataSourceDisplay) { - return this.whenDisplayReady() - .then(function (model) { - const entities = model.get('cesiumModel').entities.values.slice(0) - const boundingSpheres = []; - const boundingSphereScratch = new Cesium.BoundingSphere(); - for (let i = 0, len = entities.length; i < len; i++) { - let state = Cesium.BoundingSphereState.PENDING; - state = dataSourceDisplay.getBoundingSphere( - entities[i], false, boundingSphereScratch - ) - if (state === Cesium.BoundingSphereState.PENDING) { - return false; - } else if (state !== Cesium.BoundingSphereState.FAILED) { - boundingSpheres.push(Cesium.BoundingSphere.clone(boundingSphereScratch)); - } - } - if (boundingSpheres.length) { - return Cesium.BoundingSphere.fromBoundingSpheres(boundingSpheres); - } - return false - }).catch(function (error) { - console.log( - 'Failed to get the bounding sphere for a CesiumVectorData model' + - '. Error details: ' + error - ); - }) - }, - - }); - - return CesiumVectorData; + }); + }, + } + ); - } -); + return CesiumVectorData; +}); diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index f50d34bc9..e84fd5320 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -333,6 +333,37 @@ define([ } }, + /** + * Set an error status and message for this asset. + * @param {Object|String} error - An error object with a status code + * attribute or or string with details about the error. + * @since x.x.x + */ + setError: function (error) { + // See https://cesium.com/learn/cesiumjs/ref-doc/RequestErrorEvent.html + let details = error; + // Write a helpful error message + switch (error.statusCode) { + case 404: + details = 'The resource was not found (error code 404).' + break; + case 500: + details = 'There was a server error (error code 500).' + break; + } + this.set('status', 'error'); + this.set('statusDetails', details) + }, + + /** + * Set a ready status for this asset. + * @since x.x.x + */ + setReady: function () { + this.set('status', 'ready') + this.set('statusDetails', null) + }, + /** * When the asset can't be loaded, hide it from the map and show an error. * @since x.x.x diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 641592e87..51b32a6c9 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -86,7 +86,7 @@ define([ removeFunction: "remove3DTileset", }, { - types: ["GeoJsonDataSource", "CzmlDataSource"], + types: ["GeoJsonDataSource", "CzmlDataSource", "CustomDataSource"], renderFunction: "addVectorData", removeFunction: "removeVectorData", }, @@ -240,8 +240,8 @@ define([ /** * Create a DataSourceDisplay and DataSourceCollection for the Cesium - * widget, and listen to the clock tick to update the display. This is - * required to display vector data (e.g. GeoJSON) on the map. + * widget. This is required to display vector data (e.g. GeoJSON) on the + * map. * @since x.x.x * @returns {Cesium.DataSourceDisplay} The Cesium DataSourceDisplay */ @@ -252,11 +252,6 @@ define([ scene: view.scene, dataSourceCollection: view.dataSourceCollection, }); - view.clock.onTick.removeEventListener( - view.updateDataSourceDisplay, - view - ); - view.clock.onTick.addEventListener(view.updateDataSourceDisplay, view); return view.dataSourceDisplay; }, @@ -285,64 +280,32 @@ define([ if (view.zoomTarget) { view.completeFlight(view.zoomTarget, view.zoomOptions); } + // The dataSourceDisplay must be set to 'ready' to get bounding + // spheres for dataSources + view.dataSourceDisplay._ready = true; } catch (e) { console.log("Error calling post render functions:", e); } }, /** - * Runs on every Cesium clock tick. Updates the display of the - * CesiumVectorData models in the scene. Similar to - * Cesium.DataSourceDisplay.update function, in that it runs update() on - * each DataSource and each DataSource's visualizer, except that it also - * updates each CesiumVectorData model's 'displayReady' attribute. (Sets - * to true when the asset is ready to be rendered in the map, false - * otherwise). Also re-renders the scene when the displayReady attribute - * changes. + * Run the update method and all visualizers for each data source. + * @since x.x.x */ - updateDataSourceDisplay: function () { - try { - const view = this; - const layers = view.model.get("layers"); - - var dataSources = view.dataSourceDisplay.dataSources; - if (!dataSources || !dataSources.length) { - return; - } - - let allReady = true; - const allReadyBefore = view.dataSourceDisplay._ready; - - for (let i = 0, len = dataSources.length; i < len; i++) { - const time = view.clock.currentTime; - const dataSource = dataSources.get(i); - const visualizers = dataSource._visualizers; - - const assetModel = layers.findWhere({ - cesiumModel: dataSource, - }); - let displayReadyNow = dataSource.update(time); - - for (let x = 0; x < visualizers.length; x++) { - displayReadyNow = visualizers[x].update(time) && displayReadyNow; - } - - assetModel.set("displayReady", displayReadyNow); - - allReady = displayReadyNow && allReady; - } - - // If any dataSource has switched display states, then re-render the - // scene. - if (allReady !== allReadyBefore) { - view.scene.requestRender(); - } - // The dataSourceDisplay must be set to 'ready' to get bounding - // spheres for dataSources - view.dataSourceDisplay._ready = allReady; - } catch (e) { - console.log("Error updating the data source display.", e); + updateAllDataSources: function () { + const view = this; + const dataSources = view.dataSourceDisplay.dataSources; + if (!dataSources || !dataSources.length) { + return; } + const time = view.clock.currentTime; + dataSources.forEach(function (dataSource) { + dataSource.update(view.clock.currentTime); + // for each visualizer, update it + dataSource._visualizers.forEach(function (visualizer) { + visualizer.update(time); + }); + }); }, /** @@ -554,10 +517,18 @@ define([ let newPosition = null; if (cartesian) { newPosition = view.getDegreesFromCartesian(cartesian); + newPosition.mapWidgetCoords = cartesian; } view.interactions.setMousePosition(newPosition); }, + /** + * Record the feature hovered over by the mouse based on position. + * @param {Object} position - The position of the mouse on the map + * @param {number} [delay=200] - The minimum number of milliseconds that + * must pass between calls to this function. + * @since x.x.x + */ setHoveredFeatures: function (position, delay = 200) { const view = this; const lastCall = this.setHoveredFeaturesLastCall || 0; @@ -568,14 +539,15 @@ define([ view.interactions.setHoveredFeatures([pickedFeature]); }, + /** + * React when the user interacts with the map. + * @since x.x.x + */ setInteractionListeners: function () { - // TODO: unset listeners too const interactions = this.interactions; const hoveredFeatures = interactions.get("hoveredFeatures"); + this.stopListening(hoveredFeatures, "change update"); this.listenTo(hoveredFeatures, "change update", this.updateCursor); - // this.listenTo( interactions, "change update", - // this.handleClickedFeatures - // ); }, /** diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index 75fa7dc17..768f4499c 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -1,8 +1,10 @@ "use strict"; -define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( +define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connectors/GeoPoints-CesiumPoints", "collections/maps/GeoPoints"], function ( Backbone, - GeoPointsVectorData + GeoPointsVectorData, + GeoPointsCesiumPoints, + GeoPoints ) { /** * @class DrawTool @@ -107,6 +109,19 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( */ points: undefined, + /** + * The color of the polygon that is being drawn as a hex string. + * @type {string} + */ + color: "#a31840", + + /** + * The initial opacity of the polygon that is being drawn. A number + * between 0 and 1. + * @type {number} + */ + opacity: 0.8, + /** * Initializes the DrawTool * @param {Object} options - A literal object with options to pass to the @@ -126,7 +141,7 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( // points, and originalAction properties to this view this.setUpMapModel(); this.setUpLayer(); - this.setUpConnector(); + this.setUpConnectors(); }, /** @@ -148,21 +163,20 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( */ setUpLayer: function () { this.layer = this.mapModel.addAsset({ - type: "CzmlDataSource", + type: "CustomDataSource", label: "Your Polygon", description: "The polygon that you are drawing on the map", hideInLayerList: true, // TODO: Hide in LayerList, doc in mapConfig - outlineColor: "#FF3E41", // TODO - opacity: 0.55, // TODO + outlineColor: this.color, + opacity: this.opacity, colorPalette: { colors: [ { - color: "#FF3E41", // TODO + color: this.color, }, ], }, - }); - return this.layer; + }) }, /** @@ -171,12 +185,18 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( * this view. * @returns {GeoPointsVectorData} The connector */ - setUpConnector: function () { - this.connector = new GeoPointsVectorData({ - vectorLayer: this.layer, + setUpConnectors: function () { + const points = this.points = new GeoPoints(); + this.polygonConnector = new GeoPointsVectorData({ + layer: this.layer, + geoPoints: points, + }); + this.pointsConnector = new GeoPointsCesiumPoints({ + layer: this.layer, + geoPoints: points, }); - this.points = this.connector.get("points"); - this.connector.connect(); + this.polygonConnector.connect(); + this.pointsConnector.connect(); return this.connector; }, @@ -203,6 +223,7 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( */ removeLayer: function () { if (!this.mapModel || !this.layer) return; + // TODO this.connector.disconnect(); this.connector.set("vectorLayer", null); this.mapModel.removeAsset(this.layer); @@ -386,49 +407,12 @@ define(["backbone", "models/connectors/GeoPoints-VectorData"], function ( this.addPoint({ latitude: point.get("latitude"), longitude: point.get("longitude"), + height: point.get("height"), + mapWidgetCoords: point.get("mapWidgetCoords"), }); } }, - /** - * The action to perform when the mode is "draw" and the user clicks on - * the map. - */ - handleDrawClick: function () { - if (!this.mode === "draw") return - const point = this.interactions.get("clickedPosition"); - if(!point) return - this.addPoint({ - latitude: point.get("latitude"), - longitude: point.get("longitude"), - }); - }, - - /** - * The action to perform when the mode is "move" and the user clicks on - * the map. - */ - handleMoveClick: function () { - if (!this.mode === "move") return - const feature = this.interactions.get("clickedFeature"); - if (!feature) return - // TODO: Set a listener to update the point feature and coords - // when it is clicked and dragged - }, - - /** - * The action to perform when the mode is "remove" and the user clicks on - * the map. - */ - handleRemoveClick: function () { - if (!this.mode === "remove") return - const feature = this.interactions.get("clickedFeature"); - if (!feature) return - // TODO: Get the coords of the clicked feature and remove the point - // from the polygon - console.log("remove feature", feature); - }, - /** * Clears the polygon that is being drawn */ From 6ff2d852793891af9bd66f8e72854b06bdce1c03 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 13 Oct 2023 16:35:41 -0400 Subject: [PATCH 12/24] Rerun visualizers until async processes complete - In CesiumVectorData, in the new runVisualizers method, re-run in cases where visualizers are waiting for an async process to complete. - Fixes issue with Cesium Geohashes not showing up - Add some missing JSDocs to CesiumVectorData Issue #2180 --- src/js/models/maps/assets/CesiumVectorData.js | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index e95ec7ab6..2efc337db 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -371,7 +371,7 @@ define([ // Suspending events while updating a large number of entities helps // performance. - cesiumModel.entities.suspendEvents(); + model.suspendEvents(); // If the asset isn't visible, just hide all entities and update the // visibility property to indicate that layer is hidden @@ -383,13 +383,20 @@ define([ this.styleEntities(entities); } - cesiumModel.entities.resumeEvents(); + model.resumeEvents(); this.runVisualizers(); } catch (e) { console.log("Failed to update CesiumVectorData model styles.", e); } }, + /** + * Run the Cesium visualizers for this asset. Visualizers render data + * associated with DataSource instances. Visualizers must be run after + * changes are made to the data or the appearance of the data. + * @since x.x.x + * @see {@link https://cesium.com/learn/cesiumjs/ref-doc/Visualizer.html} + */ runVisualizers: function () { const dataSource = this.get("cesiumModel"); const visualizers = dataSource._visualizers; @@ -403,6 +410,12 @@ define([ displayReadyNow = visualizers[x].update(time) && displayReadyNow; } this.set("displayReady", displayReadyNow); + if (!displayReadyNow) { + // If the display is not ready, try again. It means the visualizers + // are waiting for an asynchronous process to complete. + setTimeout(this.runVisualizers.bind(this), 10); + return + } this.trigger("appearanceChanged"); }, @@ -412,8 +425,12 @@ define([ * visualizers being ready. It will attempt to run the callback every * pingRate ms until the visualizers are ready, or until the maxPings is * reached. - * @param {*} callBack - * @param {*} maxPings + * @param {Function} callBack - The function to run when the visualizers + * are ready + * @param {Number} [pingRate=100] - The number of milliseconds to wait + * between pings - pings are used to check if the visualizers are ready + * @param {Number} [maxPings=30] - The maximum number of pings to wait + * before giving up */ whenVisualizersReady: function (callBack, pingRate = 100, maxPings = 30) { const model = this; @@ -432,26 +449,54 @@ define([ }, pingRate); }, + /** + * Get the Cesium EntityCollection for this asset + * @returns {Cesium.EntityCollection} The Cesium EntityCollection + * @since x.x.x + */ getEntityCollection: function () { const model = this; const dataSource = model.get("cesiumModel"); return dataSource?.entities; }, + /** + * Get the Cesium Entities for this asset + * @returns {Cesium.Entity[]} The Cesium Entities + * @since x.x.x + */ getEntities: function () { return this.getEntityCollection()?.values || []; }, + /** + * Suspend events on the Cesium EntityCollection. This will prevent + * visualizers from running until resumeEvents is called. + * @since x.x.x + */ suspendEvents: function () { const entities = this.getEntityCollection(); if (entities) entities.suspendEvents(); }, + /** + * Resume events on the Cesium EntityCollection. This will allow + * visualizers to run again. + * @since x.x.x + */ resumeEvents: function () { const entities = this.getEntityCollection(); if (entities) entities.resumeEvents(); }, + /** + * Manually an entity to the Cesium EntityCollection. + * @param {Object} entity - The ConstructorOptions with properties to pass + * to Cesium.EntityCollection.add. See + * {@link https://cesium.com/learn/cesiumjs/ref-doc/EntityCollection.html?classFilter=EntityCollection#add} + * @returns {Cesium.Entity} The Cesium Entity that was added + * @since x.x.x + */ addEntity: function (entity) { try { const entities = this.getEntityCollection(); @@ -465,6 +510,13 @@ define([ } }, + /** + * Manually remove an entity from the Cesium EntityCollection. + * @param {Cesium.Entity|string} entity - The entity or ID of the entity + * to remove + * @returns {Boolean} True if the entity was removed, false otherwise + * @since x.x.x + */ removeEntity: function (entity) { try { const entities = this.getEntities(); @@ -472,7 +524,7 @@ define([ let removed = false; // if entity is a string, remove by ID if (typeof entity === "string") { - removed = entities.removeById(entity); + removed = entities.removeById(entity); } else { // Otherwise, assume it's an entity object removed = entities.remove(entity); From 2926dec7bf9a6f5bc4d873a26b22cdeb5072e412 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 13 Oct 2023 18:52:20 -0400 Subject: [PATCH 13/24] Minor changes to draw tool UI - Style draw tool buttons - Hide edit & delete point buttons for now (not implemented) - Enable configuring hiding a layer in the layer list - Fix issue with removing entities in CesiumVectorData Issue #2180 --- src/css/map-view.css | 20 +++- src/js/models/maps/assets/CesiumVectorData.js | 2 +- src/js/models/maps/assets/MapAsset.js | 42 ++++--- src/js/views/maps/DrawToolView.js | 112 ++++++++++++------ src/js/views/maps/LayerListView.js | 7 +- 5 files changed, 127 insertions(+), 56 deletions(-) diff --git a/src/css/map-view.css b/src/css/map-view.css index ce9bddef6..d3c9439e8 100644 --- a/src/css/map-view.css +++ b/src/css/map-view.css @@ -120,6 +120,10 @@ font-size: 1rem; } +.map-view__button--active { + background-color: var(--map-col-highlight); +} + /* ---- BADGE ---- */ .map-view__badge{ @@ -953,4 +957,18 @@ other class: .ui-slider-range */ box-shadow: var(--map-shadow-md); /* imagery appears lighter on the map */ filter: brightness(1.75); -} \ No newline at end of file +} + +/***************************************************************************************** + * + * Draw Tool + * + * Panel for drawing polygons in the map + * + */ + +.draw-tool { + display: grid; + grid-auto-rows: min-content; + grid-gap: 1rem; +} diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 2efc337db..69591079a 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -519,7 +519,7 @@ define([ */ removeEntity: function (entity) { try { - const entities = this.getEntities(); + const entities = this.getEntityCollection(); if (!entities) return false; let removed = false; // if entity is a string, remove by ID diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index e84fd5320..e02f5391c 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -84,6 +84,8 @@ define([ * the asset is not supported, or there was a problem requesting the resource. * @property {string} [statusDetails = null] Any further details about the status, * especially when there was an error. + * @property {Boolean} [hideInLayerList = false] Set to true to hide this asset + * from the layer list. */ defaults: function () { return { @@ -105,6 +107,7 @@ define([ notification: {}, status: null, statusDetails: null, + hideInLayerList: false, }; }, @@ -171,6 +174,8 @@ define([ * @property {MapConfig#Notification} [notification] A custom badge and message to * display about the layer in the Layer list. For example, this could highlight * the layer if it is new, give a warning if they layer is under development, etc. + * @property {boolean} [hideInLayerList] - Set to true to hide this asset from the + * layer list. */ /** @@ -280,24 +285,25 @@ define([ */ /** - * A notification displays a badge in the {@link LayerListView} and a message in - * the {@link LayerDetailsView}. This is useful for indicating some special status - * of the layer: "new", "under development", etc. - * @typedef {Object} Notification - * @name MapConfig#Notification - * @since 2.22.0 - * @property {'yellow'|'green'|'blue'|'contrast'} [style] - The badge and message - * color. If none is set, then notification elements will be similar to the - * background colour (subtle). - * @property {string} badge - The text to display in the badge element next to the - * layer label in the list. This badge should be as few characters as possible. - * @property {string} message - A longer message to display explaining the status. - - /** - * Executed when a new MapAsset model is created. - * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the - * attributes, which will be set on the model. - */ + * A notification displays a badge in the {@link LayerListView} and a message in + * the {@link LayerDetailsView}. This is useful for indicating some special status + * of the layer: "new", "under development", etc. + * @typedef {Object} Notification + * @name MapConfig#Notification + * @since 2.22.0 + * @property {'yellow'|'green'|'blue'|'contrast'} [style] - The badge and message + * color. If none is set, then notification elements will be similar to the + * background colour (subtle). + * @property {string} badge - The text to display in the badge element next to the + * layer label in the list. This badge should be as few characters as possible. + * @property {string} message - A longer message to display explaining the status. + */ + + /** + * Executed when a new MapAsset model is created. + * @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the + * attributes, which will be set on the model. + */ initialize: function (assetConfig) { try { const model = this; diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index 768f4499c..9a93bbda8 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -38,9 +38,28 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect */ buttonClass: "map-view__button", + /** + * Class to use for the active button + * @type {string} + */ + buttonClassActive: "map-view__button--active", + + /** + * @typedef {Object} DrawToolButtonOptions + * @property {string} name - The name of the button. This should be the + * same as the mode that the button will activate (if the button is + * supposed to activate a mode). + * @property {string} label - The label to display on the button. + * @property {string} icon - The name of the icon to display on the + * button. + * @property {string} [method] - The name of the method to call when the + * button is clicked. If this is not provided, the button will toggle the + * mode of the draw tool. + */ + /** * The buttons to display in the toolbar and their corresponding actions. - * TODO: Finish documenting this when more finalized. + * @type {DrawToolButtonOptions[]} */ buttons: [ { @@ -48,21 +67,21 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect label: "Draw Polygon", icon: "pencil", }, - { - name: "move", - label: "Move Point", - icon: "move", - }, - { - name: "remove", - label: "Remove Point", - icon: "eraser", - }, + // { + // name: "move", + // label: "Move Point", + // icon: "move", + // }, + // { + // name: "remove", + // label: "Remove Point", + // icon: "eraser", + // }, { name: "clear", label: "Clear Polygon", icon: "trash", - method: "clearPoints", + method: "reset", }, { name: "save", @@ -72,6 +91,12 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect }, ], + /** + * The buttons that have been rendered in the toolbar. Formatted as an + * object with the button name as the key and the button element as the + * value. + * @type {Object} + */ buttonEls: {}, /** @@ -120,7 +145,7 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect * between 0 and 1. * @type {number} */ - opacity: 0.8, + opacity: 0.5, /** * Initializes the DrawTool @@ -134,7 +159,7 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect initialize: function (options) { this.mapModel = options.model; if (!this.mapModel) { - this.handleNoMapModel(); + console.warn("No map model was provided."); return; } // Add models & collections and add interactions, layer, connector, @@ -166,7 +191,7 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect type: "CustomDataSource", label: "Your Polygon", description: "The polygon that you are drawing on the map", - hideInLayerList: true, // TODO: Hide in LayerList, doc in mapConfig + hideInLayerList: true, outlineColor: this.color, opacity: this.opacity, colorPalette: { @@ -217,15 +242,24 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect this.points?.reset(null); }, + /** + * Resets the draw tool to its initial state. + */ + reset: function () { + this.setMode(false); + this.clearPoints(); + this.removeClickListeners(); + }, + /** * Removes the polygon object from the map - * TODO: Test this */ removeLayer: function () { if (!this.mapModel || !this.layer) return; - // TODO - this.connector.disconnect(); - this.connector.set("vectorLayer", null); + this.polygonConnector.disconnect(); + this.polygonConnector.set("vectorLayer", null); + this.pointsConnector.disconnect(); + this.pointsConnector.set("vectorLayer", null); this.mapModel.removeAsset(this.layer); }, @@ -234,17 +268,23 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect * @returns {DrawTool} Returns the view */ render: function () { + if(!this.mapModel) { + this.showError("No map model was provided."); + return this; + } this.renderToolbar(); return this; }, /** - * What to do when this view doesn't have a map view to draw on + * Show an error message to the user if the map model is not available + * or any other error occurs. + * @param {string} [message] - The error message to show to the user. */ - handleNoMapModel: function () { - console.warn("No map model provided to DrawTool"); - // TODO: Add a message to the view to let the user know that the draw - // tool is not available + showError: function (message) { + const str = `` + + ` The draw tool is not available. ${message}`; + this.el.innerHTML = str; }, /** @@ -274,13 +314,14 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect /** * Sends the polygon coordinates to a callback function to do something * with them. - * TODO: This is a WIP. + * @param {Function} callback - The callback function to send the polygon + * coordinates to. */ - save: function () { + save: function (callback) { this.setMode(false); - this.removeClickListeners(); - console.log(this.points.toJSON()); - // TODO: Call a callback function to save the polygon + if(callback && typeof callback === "function") { + callback(this.points.toJSON()); + } }, /** @@ -321,7 +362,7 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect const buttonEl = this.buttonEls[buttonName + "Button"]; if(!buttonEl) return; this.resetButtonStyles(); - buttonEl.style.backgroundColor = "blue"; // TODO - create active style + buttonEl.classList.add(this.buttonClassActive); }, /** @@ -331,7 +372,10 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect resetButtonStyles: function () { // Iterate through the buttonEls object and reset the styles for (const button in this.buttonEls) { - this.buttonEls[button].style.backgroundColor = "grey"; // TODO - create default style + if (this.buttonEls.hasOwnProperty(button)) { + const buttonEl = this.buttonEls[button]; + buttonEl.classList.remove(this.buttonClassActive); + } } }, @@ -396,10 +440,10 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect */ handleClick: function (throttle = 50) { // Prevent double clicks - if (this.blockClick) return; - this.blockClick = true; + if (this.clickActionBlocked) return; + this.clickActionBlocked = true; setTimeout(() => { - this.blockClick = false; + this.clickActionBlocked = false; }, throttle); // Add the point to the polygon if (this.mode === "draw") { diff --git a/src/js/views/maps/LayerListView.js b/src/js/views/maps/LayerListView.js index f53e300c4..3cca479b7 100644 --- a/src/js/views/maps/LayerListView.js +++ b/src/js/views/maps/LayerListView.js @@ -107,8 +107,7 @@ define( setListeners: function () { try { if (this.collection) { - this.listenTo(this.collection, 'add', this.render); - this.listenTo(this.collection, 'remove', this.render); + this.listenTo(this.collection, 'add remove reset', this.render); } } catch (e) { console.log('Failed to set listeners:', e); @@ -138,6 +137,10 @@ define( // Render a layer item for each layer in the collection this.collection.forEach(function (layerModel) { + if(layerModel.get('hideInLayerList') === true){ + // skip this layer + return + } var layerItem = new LayerItemView({ model: layerModel }) From 516cd604854c23964a45cd873d062fa0107bec4d Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Fri, 13 Oct 2023 19:08:55 -0400 Subject: [PATCH 14/24] Fix opacity of draw tool polygon Issue #2180 --- src/js/views/maps/DrawToolView.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index 9a93bbda8..8567538e7 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -145,7 +145,7 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect * between 0 and 1. * @type {number} */ - opacity: 0.5, + opacity: 0.3, /** * Initializes the DrawTool @@ -193,6 +193,7 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect description: "The polygon that you are drawing on the map", hideInLayerList: true, outlineColor: this.color, + highlightColor: this.color, opacity: this.opacity, colorPalette: { colors: [ From 7f4103b39ed4f84b27e4e4440d109c8f01baf811 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Mon, 16 Oct 2023 12:23:39 -0400 Subject: [PATCH 15/24] Update polygon on draw rather than add new one Add an ID to the polygon created when drawing on map so that it is updated when new points are added rather than creating a new one Issue #2180 --- src/js/models/connectors/GeoPoints-Cesium.js | 8 ++++++-- src/js/models/connectors/GeoPoints-CesiumPolygon.js | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/js/models/connectors/GeoPoints-Cesium.js b/src/js/models/connectors/GeoPoints-Cesium.js index 3fc55b160..f224c6d31 100644 --- a/src/js/models/connectors/GeoPoints-Cesium.js +++ b/src/js/models/connectors/GeoPoints-Cesium.js @@ -108,9 +108,12 @@ define([ */ connect: function () { try { + this.disconnect(); + // Listen for changes to the points collection and update the layer - let geoPoints = this.get("geoPoints"); + const geoPoints = this.get("geoPoints"); const events = ["update", "reset"]; + events.forEach((eventName) => { this.listenTo(geoPoints, eventName, function (...args) { this.handleCollectionChange(eventName, ...args); @@ -141,7 +144,8 @@ define([ * Stop listening for changes to the Points collection. */ disconnect: function () { - this.stopListening(this.get("geoPoints")); + const geoPoints = this.get("geoPoints"); + if (geoPoints) this.stopListening(geoPoints); this.set("isConnected", false); }, diff --git a/src/js/models/connectors/GeoPoints-CesiumPolygon.js b/src/js/models/connectors/GeoPoints-CesiumPolygon.js index 5d726d745..7a27854bd 100644 --- a/src/js/models/connectors/GeoPoints-CesiumPolygon.js +++ b/src/js/models/connectors/GeoPoints-CesiumPolygon.js @@ -48,9 +48,11 @@ define(["cesium", "models/connectors/GeoPoints-Cesium"], function ( * the CesiumVectorData model. */ addPolygon: function () { + const id = this.cid; const layer = this.get("layer") || this.setVectorLayer(); const geoPoints = this.get("geoPoints") || this.setPoints(); return layer.addEntity({ + id: id, // If entity with this ID already exists, it will be updated polygon: { height: null, // <- clamp to ground hierarchy: new Cesium.CallbackProperty(() => { From b245cca1ba6b17f5e60627a9e8098fd2a8d6f06a Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Tue, 17 Oct 2023 15:53:59 -0400 Subject: [PATCH 16/24] Fix geo tests & minor bugs found while testing - Set up new geo tests Issues #2180 and #2189 --- src/js/models/filters/SpatialFilter.js | 23 ++++++++- src/js/models/maps/Geohash.js | 4 +- src/js/models/maps/assets/CesiumGeohash.js | 2 +- test/config/tests.json | 6 +++ .../unit/collections/maps/GeoPoints.spec.js | 21 +++++++++ .../unit/collections/maps/Geohashes.spec.js | 47 ------------------- .../models/connectors/Filters-Map.spec.js | 4 +- .../connectors/GeoPoints-Cesium.spec.js | 21 +++++++++ .../connectors/GeoPoints-CesiumPoints.spec.js | 21 +++++++++ .../GeoPoints-CesiumPolygon.spec.js | 21 +++++++++ .../unit/models/maps/GeoBoundingBox.spec.js | 21 +++++++++ .../unit/models/maps/GeoUtilities.spec.js | 21 +++++++++ .../models/maps/assets/CesiumGeohash.spec.js | 9 ++-- .../models/maps/assets/CesiumImagery.spec.js | 6 +-- 14 files changed, 165 insertions(+), 62 deletions(-) create mode 100644 test/js/specs/unit/collections/maps/GeoPoints.spec.js create mode 100644 test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js create mode 100644 test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js create mode 100644 test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js create mode 100644 test/js/specs/unit/models/maps/GeoBoundingBox.spec.js create mode 100644 test/js/specs/unit/models/maps/GeoUtilities.spec.js diff --git a/src/js/models/filters/SpatialFilter.js b/src/js/models/filters/SpatialFilter.js index 6d2e9719e..c49573c52 100644 --- a/src/js/models/filters/SpatialFilter.js +++ b/src/js/models/filters/SpatialFilter.js @@ -101,15 +101,25 @@ define([ } }, + /** + * Remove the listeners. + * @since x.x.x + */ + removeListeners: function () { + const extentEvents = + "change:height change:north change:south change:east change:west"; + this.stopListening(this, extentEvents); + }, + /** * Set a listener that updates the filter when the coordinates & height * change * @since 2.25.0 */ setListeners: function () { + this.removeListeners(); const extentEvents = "change:height change:north change:south change:east change:west"; - this.stopListening(this, extentEvents); this.listenTo(this, extentEvents, this.updateFilterFromExtent); }, @@ -318,7 +328,13 @@ define([ * @inheritdoc */ resetValue: function () { - const df = this.defaults(); + // Need to stop listeners because otherwise changing the coords will + // update the filter values. This sometimes updates the values *after* + // the values are reset, preventing the reset from working. + this.removeListeners(); + + let df = this.defaults(); + this.set({ values: df.values, east: df.east, @@ -327,6 +343,9 @@ define([ south: df.south, height: df.height, }); + + // Reset the listeners + this.setListeners(); }, } ); diff --git a/src/js/models/maps/Geohash.js b/src/js/models/maps/Geohash.js index 4f9e865a2..cb213dc65 100644 --- a/src/js/models/maps/Geohash.js +++ b/src/js/models/maps/Geohash.js @@ -316,9 +316,9 @@ define([ }, properties: properties, }; - if (label) { + if (label && (properties[label] || properties[label] === 0)) { (feature["label"] = { - text: properties[label].toString(), + text: properties[label]?.toString(), show: true, fillColor: { rgba: [255, 255, 255, 255], diff --git a/src/js/models/maps/assets/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index 499a716f5..ed4c0a4b6 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -154,7 +154,7 @@ define([ const limit = this.get("maxGeoHashes"); const geohashes = this.get("geohashes") const area = this.getViewExtent().getArea(); - return this.get("geohashes").getMaxPrecision(area, limit); + return geohashes.getMaxPrecision(area, limit); }, /** diff --git a/test/config/tests.json b/test/config/tests.json index a668b5fd9..e45233660 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -1,5 +1,11 @@ { "unit": [ + "./js/specs/unit/collections/maps/GeoPoints.spec.js", + "./js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js", + "./js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js", + "./js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js", + "./js/specs/unit/models/maps/GeoBoundingBox.spec.js", + "./js/specs/unit/models/maps/GeoUtilities.spec.js", "./js/specs/unit/collections/SolrResults.spec.js", "./js/specs/unit/models/Search.spec.js", "./js/specs/unit/models/filters/Filter.spec.js", diff --git a/test/js/specs/unit/collections/maps/GeoPoints.spec.js b/test/js/specs/unit/collections/maps/GeoPoints.spec.js new file mode 100644 index 000000000..17f5feba5 --- /dev/null +++ b/test/js/specs/unit/collections/maps/GeoPoints.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/collections/maps/GeoPoints", +], function (GeoPoints) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoPoints Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoPoints instance", function () { + new GeoPoints().should.be.instanceof(GeoPoints); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/collections/maps/Geohashes.spec.js b/test/js/specs/unit/collections/maps/Geohashes.spec.js index e711ed40b..63d1d82f6 100644 --- a/test/js/specs/unit/collections/maps/Geohashes.spec.js +++ b/test/js/specs/unit/collections/maps/Geohashes.spec.js @@ -58,44 +58,9 @@ define(["../../../../../../../../src/js/collections/maps/Geohashes"], function ( .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; @@ -117,18 +82,6 @@ define(["../../../../../../../../src/js/collections/maps/Geohashes"], function ( .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 () { 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 bd14a86dc..9c97a3475 100644 --- a/test/js/specs/unit/models/connectors/Filters-Map.spec.js +++ b/test/js/specs/unit/models/connectors/Filters-Map.spec.js @@ -52,7 +52,7 @@ define([ 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); + map.get("interactions").setViewExtent(extent); this.filtersMap.updateSpatialFilters(); spatialFilters[0].get("north").should.equal(1); spatialFilters[0].get("south").should.equal(2); @@ -80,6 +80,6 @@ define([ const spatialFilters = this.filtersMap.get("spatialFilters"); spatialFilters[0].get("values").should.deep.equal([]); }); - }); + }); }); }); diff --git a/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js b/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js new file mode 100644 index 000000000..5901a1c82 --- /dev/null +++ b/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/GeoPoints-Cesium", +], function (GeoPointsCesium) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoPointsCesium Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoPointsCesium instance", function () { + new GeoPointsCesium().should.be.instanceof(GeoPointsCesium); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js new file mode 100644 index 000000000..b948dd58b --- /dev/null +++ b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/GeoPoints-CesiumPoints", +], function (GeoPointsCesiumPoints) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoPointsCesiumPoints Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoPointsCesiumPoints instance", function () { + new GeoPointsCesiumPoints().should.be.instanceof(GeoPointsCesiumPoints); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js new file mode 100644 index 000000000..f5ae3e006 --- /dev/null +++ b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/connectors/GeoPoints-CesiumPolygon", +], function (GeoPointsCesiumPolygon) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoPointsCesiumPolygon Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoPointsCesiumPolygon instance", function () { + new GeoPointsCesiumPolygon().should.be.instanceof(GeoPointsCesiumPolygon); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js b/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js new file mode 100644 index 000000000..d9553d8d2 --- /dev/null +++ b/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/maps/GeoBoundingBox", +], function (GeoBoundingBox) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoBoundingBox Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoBoundingBox instance", function () { + new GeoBoundingBox().should.be.instanceof(GeoBoundingBox); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/GeoUtilities.spec.js b/test/js/specs/unit/models/maps/GeoUtilities.spec.js new file mode 100644 index 000000000..a643471f4 --- /dev/null +++ b/test/js/specs/unit/models/maps/GeoUtilities.spec.js @@ -0,0 +1,21 @@ +define([ + "../../../../../../../../src/js/models/maps/GeoUtilities", +], function (GeoUtilities) { + // Configure the Chai assertion library + var should = chai.should(); + var expect = chai.expect; + + describe("GeoUtilities Test Suite", function () { + /* Set up */ + beforeEach(function () {}); + + /* Tear down */ + afterEach(function () {}); + + describe("Initialization", function () { + it("should create a GeoUtilities instance", function () { + new GeoUtilities().should.be.instanceof(GeoUtilities); + }); + }); + }); +}); \ 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 9eab3db57..5cf58e9dd 100644 --- a/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js +++ b/test/js/specs/unit/models/maps/assets/CesiumGeohash.spec.js @@ -1,8 +1,7 @@ define([ "../../../../../../../../src/js/models/maps/assets/CesiumGeohash", - "../../../../../../../../src/js/collections/maps/Geohashes", "../../../../../../../../src/js/models/maps/Map", -], function (CesiumGeohash, Geohashes) { +], function (CesiumGeohash, MapModel) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; @@ -10,7 +9,7 @@ define([ describe("CesiumGeohash Test Suite", function () { /* Set up */ beforeEach(function () { - this.map = new Map(); + this.map = new MapModel(); this.model = new CesiumGeohash(); this.model.set("mapModel", this.map); }); @@ -83,7 +82,9 @@ define([ it("should get the precision", function () { this.model.replaceGeohashes(); this.model.set("maxGeoHashes", 32); - this.map.set("currentViewExtent", { + console.log(this.map.attributes); + console.log(this.map.get("interactions")); + this.map.get("interactions").setViewExtent({ north: 90, south: -90, east: 180, diff --git a/test/js/specs/unit/models/maps/assets/CesiumImagery.spec.js b/test/js/specs/unit/models/maps/assets/CesiumImagery.spec.js index 2ddc8931e..8b6bf00b6 100644 --- a/test/js/specs/unit/models/maps/assets/CesiumImagery.spec.js +++ b/test/js/specs/unit/models/maps/assets/CesiumImagery.spec.js @@ -38,11 +38,9 @@ define([ describe("Creating the Cesium Model", function () { it("should convert list of degrees to a Cesium rectangle", function (done) { - const expectedRect = Cesium.Rectangle.fromDegrees(...boundingBox) imagery.whenReady().then(function (model) { - const rect = model.get("cesiumOptions").rectangle - const rectsEqual = Cesium.Rectangle.equals(rect, expectedRect) - rectsEqual.should.be.true + const rect = model.get("cesiumModel").rectangle + expect(rect.constructor.name).to.equal("Rectangle") done() }, function (error) { done(error) From f7d6ddfc87e234cf8a4cb6ca2ac91e32496b5267 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 18 Oct 2023 16:27:38 -0400 Subject: [PATCH 17/24] Fix Cesium datasource issues & minor draw tool bug - Properly switch the maps "click action" back to original setting after draw tool turned off - Fixed delayed rendering of datasources after update (esp. Geohashes) - Fix zooming to features on click (issue with getting bounding sphere of a feature) Issues #2180 and #2189 --- src/js/models/maps/assets/CesiumVectorData.js | 18 +-- src/js/views/maps/CesiumWidgetView.js | 121 ++++++++++++++---- src/js/views/maps/DrawToolView.js | 3 +- 3 files changed, 101 insertions(+), 41 deletions(-) diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 69591079a..9eaf66bf2 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -405,16 +405,14 @@ define([ return; } const time = Cesium.JulianDate.now(); - let displayReadyNow = dataSource.update(time); + let displayReadyNow = true for (let x = 0; x < visualizers.length; x++) { displayReadyNow = visualizers[x].update(time) && displayReadyNow; } - this.set("displayReady", displayReadyNow); if (!displayReadyNow) { - // If the display is not ready, try again. It means the visualizers - // are waiting for an asynchronous process to complete. - setTimeout(this.runVisualizers.bind(this), 10); - return + setTimeout(this.runVisualizers.bind(this), 300); + } else { + this.set("displayReady", true); } this.trigger("appearanceChanged"); }, @@ -783,12 +781,8 @@ define([ } return false; }) - .catch(function (error) { - console.log( - "Failed to get the bounding sphere for a CesiumVectorData model" + - ". Error details: " + - error - ); + .catch(function (e) { + console.log("Error getting bounding sphere.", e); }); }, } diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index 51b32a6c9..f168dda1c 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -280,9 +280,6 @@ define([ if (view.zoomTarget) { view.completeFlight(view.zoomTarget, view.zoomOptions); } - // The dataSourceDisplay must be set to 'ready' to get bounding - // spheres for dataSources - view.dataSourceDisplay._ready = true; } catch (e) { console.log("Error calling post render functions:", e); } @@ -290,6 +287,8 @@ define([ /** * Run the update method and all visualizers for each data source. + * @return {boolean} Returns true if all data sources are ready to be + * displayed. * @since x.x.x */ updateAllDataSources: function () { @@ -299,13 +298,17 @@ define([ return; } const time = view.clock.currentTime; - dataSources.forEach(function (dataSource) { + let displayReady = true; + for (let i = 0; i < dataSources.length; i++) { + const dataSource = dataSources.get(i); dataSource.update(view.clock.currentTime); // for each visualizer, update it dataSource._visualizers.forEach(function (visualizer) { - visualizer.update(time); + displayReady = displayReady && visualizer.update(time); }); - }); + } + view.dataSourceDisplay._ready = displayReady; + return displayReady; }, /** @@ -370,11 +373,11 @@ define([ // Listen for addition or removal of layers TODO: Add similar listeners // for terrain - if(layers){ + if (layers) { view.stopListening(layers); view.listenTo(layers, "add", view.addAsset); view.listenTo(layers, "remove", view.removeAsset); - + // Each layer fires 'appearanceChanged' whenever the color, opacity, // etc. has been updated. Re-render the scene when this happens. view.listenTo(layers, "appearanceChanged", view.requestRender); @@ -595,7 +598,7 @@ define([ const layersReverse = layers.last(layers.length).reverse(); layersReverse.forEach(function (layer) { view.addAsset(layer); - }) + }); } // The Cesium Widget will support just one terrain option to start. @@ -662,19 +665,18 @@ define([ */ completeFlight: function (target, options) { try { - const view = this; - if (typeof options !== "object") options = {}; // A target is required - if (!target) { - return; - } + if (!target) return; + + const view = this; + if (typeof options !== "object") options = {}; + view.resetZoomTarget(); // If the target is a Bounding Sphere, use the camera's built-in // function if (target instanceof Cesium.BoundingSphere) { view.camera.flyToBoundingSphere(target, options); - view.resetZoomTarget(); return; } @@ -713,27 +715,31 @@ 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. - setTimeout(() => { - // TODO check if needed - view.flyTo(target.get("featureObject"), options); - }, 0); + // check if the layer is displayReady + const layer = target.get("mapAsset"); + const displayReady = layer.get("displayReady"); + if (!displayReady) { + // Must wait for layer to be rendered in via the dataSourceDisplay + // before we can get the bounding sphere for the feature. + view.listenToOnce(layer, "change:displayReady", function () { + view.flyTo(target, options); + }); + return + } + 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( - entity, - false, - entityBoundingSphere - ); - setTimeout(() => { - // TODO check if needed + + view.dataSourceDisplay._ready = true + view.getBoundingSphereFromEntity(entity).then(function (entityBoundingSphere) { view.flyTo(entityBoundingSphere, options); - }, 0); + }); return; } @@ -770,6 +776,65 @@ define([ } }, + getBoundingSphereFromEntity: function (entity) { + const view = this + const entityBoundingSphere = new Cesium.BoundingSphere(); + const readyState = Cesium.BoundingSphereState.DONE; + function getBS() { + return view.dataSourceDisplay.getBoundingSphere( + entity, + false, + entityBoundingSphere + ); + } + // Return a promise that resolves to bounding box when it's ready. + // Keep running getBS at intervals until it's ready. + return new Promise(function (resolve, reject) { + let attempts = 0; + const maxAttempts = 100; + const interval = setInterval(function () { + attempts++; + const state = getBS(); + if (state !== readyState) { + // Search for the entity again in case it was removed and + // re-added to the data source display. + entity = view.getEntityById(entity.id, entity.entityCollection); + if(!entity) { + clearInterval(interval); + reject("Failed to get bounding sphere for entity, entity not found."); + } + + } else { + clearInterval(interval); + resolve(entityBoundingSphere); + } + if (attempts >= maxAttempts) { + clearInterval(interval); + reject("Failed to get bounding sphere for entity."); + } + }, 100); + }) + }, + + /** + * Search an entity collection for an entity with a given id. + * @param {string} id - The id of the entity to find. + * @param {Cesium.EntityCollection} collection - The collection to search. + * @returns {Cesium.Entity} The entity with the given id, or null if no + * entity with that id exists in the collection. + * @since x.x.x + */ + getEntityById: function (id, collection) { + const entities = collection.values; + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (entity.id === id) { + return entity; + } + } + return null; + }, + resetZoomTarget: function () { const view = this; view.zoomTarget = null; diff --git a/src/js/views/maps/DrawToolView.js b/src/js/views/maps/DrawToolView.js index 8567538e7..368117680 100644 --- a/src/js/views/maps/DrawToolView.js +++ b/src/js/views/maps/DrawToolView.js @@ -386,12 +386,13 @@ define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connect */ removeClickListeners: function () { const handler = this.clickHandler; + const originalAction = this.originalAction; if (handler) { handler.stopListening(); handler.clear(); this.clickHandler = null; } - this.mapModel.set("clickFeatureAction", this.originalClickAction); + this.mapModel.set("clickFeatureAction", originalAction); this.listeningForClicks = false; }, From e24b8c67341eacfeac3de656baf70e8143a8f2c7 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 18 Oct 2023 17:49:14 -0400 Subject: [PATCH 18/24] Complete unit tests for new geo models & coll'ns Issues #2180 and #2189 --- src/js/collections/maps/GeoPoints.js | 11 +- test/config/tests.json | 12 +- .../unit/collections/maps/GeoPoints.spec.js | 387 +++++++++++++++++- .../connectors/GeoPoints-Cesium.spec.js | 29 +- .../connectors/GeoPoints-CesiumPoints.spec.js | 28 +- .../GeoPoints-CesiumPolygon.spec.js | 9 +- .../unit/models/maps/GeoBoundingBox.spec.js | 92 ++++- .../unit/models/maps/GeoUtilities.spec.js | 12 + 8 files changed, 556 insertions(+), 24 deletions(-) diff --git a/src/js/collections/maps/GeoPoints.js b/src/js/collections/maps/GeoPoints.js index d225ee0be..566a33ef2 100644 --- a/src/js/collections/maps/GeoPoints.js +++ b/src/js/collections/maps/GeoPoints.js @@ -71,9 +71,12 @@ define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { */ removePoint(indexOrPoint) { if (typeof indexOrPoint === "number") { - this.removePointByIndex(indexOrPoint); + return this.removePointByIndex(indexOrPoint); } else if (Array.isArray(indexOrPoint)) { - this.removePointByAttr(indexOrPoint); + return this.removePointByAttr(indexOrPoint); + } else { + // try just removing the point + return this.remove(indexOrPoint); } }, @@ -157,10 +160,10 @@ define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { if (!forceAsPolygon && geometryType === "Polygon" && this.length < 3) { geometryType = this.length === 1 ? "Point" : "LineString"; } - const czml = [this.getCZMLHeader()]; + let czml = [this.getCZMLHeader()]; switch (geometryType) { case "Point": - czml.concat(this.toCZMLPoints()); + czml = czml.concat(this.toCZMLPoints()); break; case "LineString": czml.push(this.getCZMLLineString()); diff --git a/test/config/tests.json b/test/config/tests.json index e45233660..712ea5fa6 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -1,11 +1,5 @@ { "unit": [ - "./js/specs/unit/collections/maps/GeoPoints.spec.js", - "./js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js", - "./js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js", - "./js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js", - "./js/specs/unit/models/maps/GeoBoundingBox.spec.js", - "./js/specs/unit/models/maps/GeoUtilities.spec.js", "./js/specs/unit/collections/SolrResults.spec.js", "./js/specs/unit/models/Search.spec.js", "./js/specs/unit/models/filters/Filter.spec.js", @@ -31,6 +25,12 @@ "./js/specs/unit/collections/maps/Geohashes.spec.js", "./js/specs/unit/models/maps/GeoPoint.spec.js", "./js/specs/unit/models/maps/GeoScale.spec.js", + "./js/specs/unit/collections/maps/GeoPoints.spec.js", + "./js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js", + "./js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js", + "./js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js", + "./js/specs/unit/models/maps/GeoBoundingBox.spec.js", + "./js/specs/unit/models/maps/GeoUtilities.spec.js", "./js/specs/unit/models/maps/MapInteraction.spec.js", "./js/specs/unit/models/connectors/Filters-Map.spec.js", "./js/specs/unit/models/connectors/Filters-Search.spec.js", diff --git a/test/js/specs/unit/collections/maps/GeoPoints.spec.js b/test/js/specs/unit/collections/maps/GeoPoints.spec.js index 17f5feba5..794c88501 100644 --- a/test/js/specs/unit/collections/maps/GeoPoints.spec.js +++ b/test/js/specs/unit/collections/maps/GeoPoints.spec.js @@ -1,21 +1,396 @@ -define([ - "../../../../../../../../src/js/collections/maps/GeoPoints", -], function (GeoPoints) { +// "use strict"; + +// define(["backbone", "models/maps/GeoPoint"], function (Backbone, GeoPoint) { +// /** +// * @class GeoPoints +// * @classdesc A group of ordered geographic points. +// * @class GeoPoints +// * @classcategory Collections/Maps +// * @extends Backbone.Collection +// * @since x.x.x +// * @constructor +// */ +// var GeoPoints = Backbone.Collection.extend( +// /** @lends GeoPoints.prototype */ { +// /** +// * The class/model that this collection contains. +// * @type {Backbone.Model} +// */ +// model: GeoPoint, + +// /** +// * Given a point in various formats, format it such that it can be used to +// * add to this collection. +// * @param {Array|Object|GeoPoint} point - Accepted formats are: +// * - An array of the form [longitude, latitude], with an optional third +// * element for height +// * - An object with a "longitude" and "latitude" property, and +// * optionally a "height" property +// * - A GeoPoint model +// * @returns {Object|GeoPoint} Returns an object with "longitude" and +// * "latitude" properties, and optionally a "height" property, or a +// * GeoPoint model. +// */ +// formatPoint: function (point) { +// let attributes = {}; +// if (Array.isArray(point) && point.length > 1) { +// attributes.longitude = point[0]; +// attributes.latitude = point[1]; +// if (point[2]) { +// attributes.height = point[2]; +// } +// } else if ( +// point instanceof GeoPoint || +// (point.latitude && point.longitude) +// ) { +// attributes = point; +// } +// return attributes; +// }, + +// /** +// * Add a point to the collection. Use this rather than the Backbone add +// * method to allow for different formats of points to be added. +// * @param {Array|Object|GeoPoint} point - See {@link formatPoint} for +// * accepted formats. +// * @returns {GeoPoint} Returns the GeoPoint model that was added. +// */ +// addPoint: function (point) { +// point = this.formatPoint(point); +// return this.add(point); +// }, + +// /** +// * Remove a specific point from the collection. Use this rather than the +// * Backbone remove method to allow for different formats of points to be +// * removed. +// * @param {Array|Object|GeoPoint|Number} indexOrPoint - The index of the +// * point to remove, or the point itself. See {@link formatPoint} for +// * accepted formats. +// * @returns {GeoPoint} Returns the GeoPoint model that was removed. +// */ +// removePoint(indexOrPoint) { +// if (typeof indexOrPoint === "number") { +// this.removePointByIndex(indexOrPoint); +// } else if (Array.isArray(indexOrPoint)) { +// this.removePointByAttr(indexOrPoint); +// } +// }, + +// /** +// * Remove a point from the collection based on its attributes. +// * @param {Array|Object|GeoPoint} point - Any format supported by +// * {@link formatPoint} is accepted. +// * @returns {GeoPoint} Returns the GeoPoint model that was removed. +// */ +// removePointByAttr: function (point) { +// point = this.formatPoint(point); +// const model = this.findWhere(point); +// return this.remove(model); +// }, + +// /** +// * Remove a point from the collection based on its index. +// * @param {Number} index - The index of the point to remove. +// * @returns {GeoPoint} Returns the GeoPoint model that was removed. +// */ +// removePointByIndex: function (index) { +// if (index < 0 || index >= this.length) { +// console.warn("Index out of bounds, GeoPoint not removed."); +// return; +// } +// const model = this.at(index); +// return this.remove(model); +// }, + +// /** +// * Convert the collection to a GeoJSON object. The output can be the +// * series of points as Point features, the points connected as a +// * LineString feature, or the points connected and closed as a Polygon. +// * +// * Note: For a "Polygon" geometry type, when there's only one point in the +// * collection, the output will be a "Point". If there are only two points, +// * the output will be a "LineString", unless `forceAsPolygon` is set to +// * true. +// * +// * @param {String} geometryType - The type of geometry to create. Can be +// * "Point", "LineString", or "Polygon". +// * @param {Boolean} [forceAsPolygon=false] - Set to true to enforce the +// * output as a polygon for the "Polygon" geometry type, regardless of the +// * number of points in the collection. +// * @returns {Object} Returns a GeoJSON object of type "Point", +// * "LineString", or "Polygon". +// */ +// toGeoJson: function (geometryType, forceAsPolygon = false) { +// if (!forceAsPolygon && geometryType === "Polygon" && this.length < 3) { +// geometryType = this.length === 1 ? "Point" : "LineString"; +// } +// return { +// type: "FeatureCollection", +// features: this.toGeoJsonFeatures(geometryType), +// }; +// }, + +// // TODO: Move this to a CZML model, use in GeoHash/es + +// /** +// * Get the header object for a CZML document. +// * @returns {Object} Returns a CZML header object. +// */ +// getCZMLHeader: function () { +// return { +// id: "document", +// version: "1.0", +// name: "GeoPoints", +// }; +// }, + +// /** +// * Convert the collection to a CZML document. +// * @param {String} geometryType - The type of geometry to create. +// * @param {Boolean} [forceAsPolygon=false] - Set to true to enforce the +// * output as a polygon for the "Polygon" geometry type, regardless of the +// * number of points in the collection. +// * @returns {Object[]} Returns an array of CZML objects. +// */ +// toCzml: function (geometryType, forceAsPolygon = false) { +// if (!forceAsPolygon && geometryType === "Polygon" && this.length < 3) { +// geometryType = this.length === 1 ? "Point" : "LineString"; +// } +// const czml = [this.getCZMLHeader()]; +// switch (geometryType) { +// case "Point": +// czml.concat(this.toCZMLPoints()); +// break; +// case "LineString": +// czml.push(this.getCZMLLineString()); +// break; +// case "Polygon": +// czml.push(this.getCZMLPolygon()); +// break; +// default: +// break; +// } +// return czml; +// }, + +// /** +// * Convert the collection to an array of CZML point objects. +// * @returns {Object[]} Returns an array of CZML point objects. +// */ +// toCZMLPoints: function () { +// return this.models.map((model) => { +// return model.toCZML(); +// }) +// }, + +// /** +// * Convert the collection to a CZML polygon object. +// * @returns {Object} Returns a CZML polygon object. +// */ +// getCZMLPolygon: function () { +// const coords = this.toECEFArray(); +// return { +// id: this.cid, +// name: "Polygon", +// polygon: { +// positions: { +// cartesian: coords, +// }, +// }, +// }; +// }, + +// /** +// * Convert the collection to a CZML line string object. +// * @returns {Object} Returns a CZML line string object. +// */ +// getCZMLLineString: function () { +// const coords = this.toECEFArray(); +// return { +// id: this.cid, +// name: "LineString", +// polyline: { +// positions: { +// cartesian: coords, +// }, +// }, +// }; +// }, + +// /** +// * Convert the collection to a GeoJSON object. The output can be the +// * series of points as Point features, the points connected as a +// * LineString feature, or the points connected and closed as a Polygon. +// * @param {"Point"|"LineString"|"Polygon"} geometryType - The type of +// * geometry to create. +// * @returns {Object[]} Returns an array of GeoJSON features. +// */ +// toGeoJsonFeatures: function (geometryType) { +// switch (geometryType) { +// case "Point": +// return this.toGeoJsonPointFeatures(); +// case "LineString": +// return [this.toGeoJsonLineStringFeature()]; +// case "Polygon": +// return [this.toGeoJsonPolygonFeature()]; +// default: +// return []; +// } +// }, + +// /** +// * Convert the collection to an array of GeoJSON point features. +// * @returns {Object[]} Returns an array of GeoJSON point features. +// */ +// toGeoJsonPointFeatures: function () { +// return this.models.map((model) => { +// return model.toGeoJsonFeature(); +// }); +// }, + +// /** +// * Convert the collection to a GeoJSON LineString feature. +// * @returns {Object} Returns a GeoJSON LineString feature. +// */ +// toGeoJsonLineStringFeature: function () { +// return { +// type: "Feature", +// geometry: { +// type: "LineString", +// coordinates: this.to2DArray(), +// }, +// properties: {}, +// }; +// }, + +// /** +// * Convert the collection to a GeoJSON Polygon feature. The polygon will +// * be closed if it isn't already. +// * @returns {Object} Returns a GeoJSON Polygon feature. +// */ +// toGeoJsonPolygonFeature: function () { +// const coordinates = this.to2DArray(); +// // Make sure the polygon is closed +// if (coordinates[0] != coordinates[coordinates.length - 1]) { +// coordinates.push(coordinates[0]); +// } +// return { +// type: "Feature", +// geometry: { +// type: "Polygon", +// coordinates: [coordinates], +// }, +// properties: {}, +// }; +// }, + +// /** +// * Convert the collection to an array of arrays, where each sub-array +// * contains the longitude and latitude of a point. +// * @returns {Array[]} Returns an array of arrays. +// */ +// to2DArray: function () { +// return this.models.map((model) => { +// return model.to2DArray(); +// }); +// }, + +// /** +// * Convert the collection to a cartesian array, where each every three +// * elements represents the x, y, and z coordinates of a vertex, e.g. +// * [x1, y1, z1, x2, y2, z2, ...]. +// * @returns {Array} Returns an array of numbers. +// */ +// toECEFArray: function () { +// return this.models.flatMap((model) => { +// return model.toECEFArray(); +// }); +// }, + +// /** +// * Convert the collection to an array of coordinates in the format +// * native to the map widget. For Cesium, this is an array of +// * Cartesian3 objects in ECEF coordinates. +// * @returns {Array} An array of coordinates that can be used by the map +// * widget. +// */ +// asMapWidgetCoords: function () { +// return this.models.map((model) => { +// return model.get("mapWidgetCoords"); +// }); +// }, +// } +// ); + +// return GeoPoints; +// }); + +define(["../../../../../../../../src/js/collections/maps/GeoPoints"], function ( + GeoPoints +) { // Configure the Chai assertion library var should = chai.should(); var expect = chai.expect; describe("GeoPoints Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.geoPoints = new GeoPoints(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.geoPoints = null; + }); describe("Initialization", function () { it("should create a GeoPoints instance", function () { new GeoPoints().should.be.instanceof(GeoPoints); }); }); + + describe("Manipulating points", function () { + it("should add a point", function () { + this.geoPoints.addPoint([0, 0]); + this.geoPoints.length.should.equal(1); + }); + + it("should remove a point by index", function () { + this.geoPoints.addPoint([0, 0]); + this.geoPoints.removePointByIndex(0); + this.geoPoints.length.should.equal(0); + }); + + it("should remove a point by attribute", function () { + this.geoPoints.addPoint([0, 0]); + this.geoPoints.removePointByAttr(0, 0); + this.geoPoints.length.should.equal(0); + }); + + it("should remove a point by model", function () { + const that = this; + const model = this.geoPoints.addPoint([0, 0]); + this.geoPoints.removePoint(model); + this.geoPoints.length.should.equal(0); + }); + }); + + describe("Serialization", function () { + it("should convert to GeoJSON", function () { + this.geoPoints.addPoint([0, 0]); + const geoJson = this.geoPoints.toGeoJson("Point"); + geoJson.features.length.should.equal(1); + geoJson.features[0].geometry.type.should.equal("Point"); + }); + + it("should convert to CZML", function () { + this.geoPoints.addPoint([5, 5]); + const czml = this.geoPoints.toCzml("Point"); + czml.length.should.equal(2); + czml[1].position.cartesian.length.should.equal(3); + czml[1].point.should.be.instanceof(Object); + }); + }); }); -}); \ No newline at end of file +}); diff --git a/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js b/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js index 5901a1c82..9c62ee4f5 100644 --- a/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js +++ b/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js @@ -7,15 +7,40 @@ define([ describe("GeoPointsCesium Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.geoPointsCesium = new GeoPointsCesium(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.geoPointsCesium = null; + }); describe("Initialization", function () { it("should create a GeoPointsCesium instance", function () { new GeoPointsCesium().should.be.instanceof(GeoPointsCesium); }); + + it("should set the GeoPoints collection", function () { + this.geoPointsCesium.get("geoPoints").models.should.be.empty; + }); + + it("should set the CesiumVectorData model", function () { + this.geoPointsCesium.get("layer").should.be.instanceof(Object) + }); + }); + + describe("Connect", function () { + it("should connect to the GeoPoints collection", function () { + this.geoPointsCesium.connect(); + this.geoPointsCesium.get("isConnected").should.equal(true); + }); + + it("should disconnect from the GeoPoints collection", function () { + this.geoPointsCesium.connect(); + this.geoPointsCesium.disconnect(); + this.geoPointsCesium.get("isConnected").should.equal(false); + }); }); }); }); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js index b948dd58b..f9bd6996c 100644 --- a/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js +++ b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js @@ -7,15 +7,39 @@ define([ describe("GeoPointsCesiumPoints Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + // Create a new GeoPointsCesiumPoints instance + this.geoPointsCesiumPoints = new GeoPointsCesiumPoints(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + // Destroy the GeoPointsCesiumPoints instance + this.geoPointsCesiumPoints.destroy(); + }); describe("Initialization", function () { it("should create a GeoPointsCesiumPoints instance", function () { new GeoPointsCesiumPoints().should.be.instanceof(GeoPointsCesiumPoints); }); }); + + describe("Defaults", function () { + + it("should have a layerPoints array", function () { + this.geoPointsCesiumPoints.get("layerPoints").should.be.an("array"); + }); + }); + + describe("handleCollectionChange", function () { + it("should be a function", function () { + this.geoPointsCesiumPoints + .handleCollectionChange.should.be.a("function"); + }); + + }); + + + }); }); \ No newline at end of file diff --git a/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js index f5ae3e006..2ae22d56e 100644 --- a/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js +++ b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js @@ -7,15 +7,20 @@ define([ describe("GeoPointsCesiumPolygon Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.geoPointsCesiumPolygon = new GeoPointsCesiumPolygon(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.geoPointsCesiumPolygon.destroy(); + }); describe("Initialization", function () { it("should create a GeoPointsCesiumPolygon instance", function () { new GeoPointsCesiumPolygon().should.be.instanceof(GeoPointsCesiumPolygon); }); }); + }); }); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js b/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js index d9553d8d2..15d70c087 100644 --- a/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js +++ b/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js @@ -1,3 +1,5 @@ + + define([ "../../../../../../../../src/js/models/maps/GeoBoundingBox", ], function (GeoBoundingBox) { @@ -7,15 +9,101 @@ define([ describe("GeoBoundingBox Test Suite", function () { /* Set up */ - beforeEach(function () {}); + beforeEach(function () { + this.geoBoundingBox = new GeoBoundingBox(); + }); /* Tear down */ - afterEach(function () {}); + afterEach(function () { + this.geoBoundingBox.destroy(); + }); describe("Initialization", function () { it("should create a GeoBoundingBox instance", function () { new GeoBoundingBox().should.be.instanceof(GeoBoundingBox); }); }); + + describe("Defaults", function () { + it("should have a north attribute", function () { + expect(this.geoBoundingBox.get("north")).to.equal(null); + }); + + it("should have a south attribute", function () { + expect(this.geoBoundingBox.get("south")).to.equal(null); + }); + + it("should have an east attribute", function () { + expect(this.geoBoundingBox.get("east")).to.equal(null); + }); + + it("should have a west attribute", function () { + expect(this.geoBoundingBox.get("west")).to.equal(null); + }); + + it("should have a height attribute", function () { + expect(this.geoBoundingBox.get("height")).to.equal(null); + }); + }); + + describe("Validation", function () { + it("should be valid with valid attributes", function () { + const valid = new GeoBoundingBox({ + north: 90, + south: -90, + east: 180, + west: -180, + }); + expect(valid.isValid()).to.equal(true); + }); + + it("should be invalid with invalid attributes", function () { + const invalid = new GeoBoundingBox({ + north: 91, + south: -91, + east: 181, + west: -181, + }); + expect(invalid.isValid()).to.equal(false); + }); + }); + + describe("methods", function () { + it("should split a bounding box that crosses the prime meridian", function () { + const bbox = new GeoBoundingBox({ + north: 90, + south: -90, + east: -180, + west: 180 + }); + const split = bbox.split(); + expect(split.length).to.equal(2); + expect(split[0].get("east")).to.equal(180); + expect(split[1].get("west")).to.equal(-180); + }); + + it("should not split a bounding box that does not cross the prime meridian", function () { + const bbox = new GeoBoundingBox({ + north: 90, + south: -90, + east: 10, + west: 0, + }); + const split = bbox.split(); + expect(split.length).to.equal(1); + expect(split[0].get("east")).to.equal(10); + expect(split[0].get("west")).to.equal(0); + }); + + it("should calculate area", function () { + const bbox = new GeoBoundingBox({ + north: 90, + south: -90, + east: 180, + west: -180, + }); + expect(bbox.getArea()).to.equal(360 * 180); + }); + }); }); }); \ No newline at end of file diff --git a/test/js/specs/unit/models/maps/GeoUtilities.spec.js b/test/js/specs/unit/models/maps/GeoUtilities.spec.js index a643471f4..367197a66 100644 --- a/test/js/specs/unit/models/maps/GeoUtilities.spec.js +++ b/test/js/specs/unit/models/maps/GeoUtilities.spec.js @@ -1,3 +1,4 @@ + define([ "../../../../../../../../src/js/models/maps/GeoUtilities", ], function (GeoUtilities) { @@ -17,5 +18,16 @@ define([ new GeoUtilities().should.be.instanceof(GeoUtilities); }); }); + + describe("geodeticToECEF", function () { + it("should convert geodetic coordinates to ECEF coordinates", function () { + const coord = [30, 40]; + const ecef = new GeoUtilities().geodeticToECEF(coord); + console.log(ecef); + ecef[0].should.be.closeTo(4243843, 1.0); + ecef[1].should.be.closeTo(2450184, 1.0); + ecef[2].should.be.closeTo(4084413, 1.0); + }); + }); }); }); \ No newline at end of file From b8e7ba917457684bb70fb706cc0034c9d0aacb10 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 1 Nov 2023 14:03:36 -0400 Subject: [PATCH 19/24] Add moveStartAndCameraChanged event to map - Wait to trigger catalog search until camera is moving has moved enough to be considered 'changed' - Remove listeners in Map-Search-Filters connector - Update some documentation - Clean up some of the CesiumVectorData code Issues #2180 and #2189 --- src/js/models/connectors/Filters-Map.js | 4 +- .../models/connectors/Map-Search-Filters.js | 1 + src/js/models/connectors/Map-Search.js | 4 +- src/js/models/maps/MapInteraction.js | 75 +++++++++++-------- src/js/models/maps/assets/CesiumVectorData.js | 38 +++++----- src/js/views/maps/CesiumWidgetView.js | 4 +- 6 files changed, 69 insertions(+), 57 deletions(-) diff --git a/src/js/models/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index b3afd7772..266b05073 100644 --- a/src/js/models/connectors/Filters-Map.js +++ b/src/js/models/connectors/Filters-Map.js @@ -185,7 +185,7 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( } const interactions = this.get("map")?.get("interactions"); this.stopListening(this.get("filters"), "add remove"); - this.stopListening(interactions, "moveEnd moveStart"); + this.stopListening(interactions, "moveEnd moveStartAndChanged"); this.set("isConnected", false); } catch (e) { console.log("Error stopping Filter-Map listeners: ", e); @@ -207,7 +207,7 @@ define(["backbone", "collections/Filters", "models/maps/Map"], function ( this.updateSpatialFilters(); // Trigger a 'changing' event on the filters collection to // indicate that the spatial filter is being updated - this.listenTo(interactions, "moveStart", function () { + this.listenTo(interactions, "moveStartAndChanged", function () { this.get("filters").trigger("changing"); }); this.listenTo(interactions, "moveEnd", function () { diff --git a/src/js/models/connectors/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index d37bed28d..7c411f55d 100644 --- a/src/js/models/connectors/Map-Search-Filters.js +++ b/src/js/models/connectors/Map-Search-Filters.js @@ -213,6 +213,7 @@ define([ * so that they work together. */ connect: function () { + this.disconnect(); this.coordinateMoveEndSearch(); this.getConnectors().forEach((connector) => connector.connect()); }, diff --git a/src/js/models/connectors/Map-Search.js b/src/js/models/connectors/Map-Search.js index ae7b5f266..7b9fd4483 100644 --- a/src/js/models/connectors/Map-Search.js +++ b/src/js/models/connectors/Map-Search.js @@ -179,7 +179,7 @@ define([ // 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(interactions, "moveStart", this.hideGeoHashLayer); + this.listenTo(interactions, "moveStartAndChanged", 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 @@ -240,7 +240,7 @@ define([ const searchResults = this.get("searchResults"); this.stopListening(searchResults, "update reset"); this.stopListening(searchResults, "change:showOnMap"); - this.stopListening(interactions, "moveStart moveEnd"); + this.stopListening(interactions, "moveStartAndChanged moveEnd"); this.stopListening(searchResults, "request"); this.set("isConnected", false); }, diff --git a/src/js/models/maps/MapInteraction.js b/src/js/models/maps/MapInteraction.js index bf731a408..96c37726e 100644 --- a/src/js/models/maps/MapInteraction.js +++ b/src/js/models/maps/MapInteraction.js @@ -32,19 +32,25 @@ define([ * default attributes for the Map. * @returns {Object} The default attributes for the Map. * @property {GeoPoint} mousePosition - The current position of the mouse - * on the map. + * on the map. Updated by the map widget to show the longitude, latitude, + * and height (elevation) at the position of the mouse on the map. * @property {GeoPoint} clickedPosition - The position on the map that the * user last clicked. * @property {GeoScale} scale - The current scale of the map in - * pixels:meters. - * @property {GeoBoundingBox} viewExtent - The current extent of the map - * view. + * pixels:meters, i.e. The number of pixels on the screen that equal the + * number of meters on the map/globe. Updated by the map widget. + * @property {GeoBoundingBox} viewExtent - The extent of the currently + * visible area in the map widget. Updated by the map widget. * @property {Features} hoveredFeatures - The feature that the mouse is - * currently hovering over. + * currently hovering over. Updated by the map widget with a Feature model + * when a user hovers over a geographical feature on the map. * @property {Features} clickedFeatures - The feature that the user last - * clicked. - * @property {Features} selectedFeatures - The feature that is currently - * selected. + * clicked. Updated by the map widget with a Feature model when a user + * clicks on a geographical feature on the map. + * @property {Features} selectedFeatures - Features from one or more + * layers that are highlighted or selected on the map. Updated by the map + * widget with a Feature model when a user selects a geographical feature + * on the map (e.g. by clicking) * @property {Boolean} firstInteraction - Whether or not the user has * interacted with the map yet. This is set to true when the user has * clicked, hovered, panned, or zoomed the map. The only action that is @@ -58,28 +64,6 @@ define([ * this property and zoom to the specified feature or map asset when this * property is set. The property should be cleared after the map widget * has zoomed to the specified feature or map asset. - * - * TODO - * * @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. - * - * * @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) */ defaults: function () { return { @@ -90,7 +74,7 @@ define([ hoveredFeatures: new Features(), clickedFeatures: new Features(), selectedFeatures: new Features(), - firstInteraction: false, // <- "hasInteracted"? + firstInteraction: false, previousAction: null, zoomTarget: null, }; @@ -115,6 +99,7 @@ define([ */ connectEvents: function () { this.listenForFirstInteraction(); + this.listenForMoveStartAndChange(); this.listenTo(this, "change:previousAction", this.handleClick); }, @@ -139,6 +124,34 @@ define([ ); }, + /** + * Expands the camera events that are passed to the MapInteraction model + * from the map widget by creating a 'moveStartAndChanged' event. This + * event is triggered after the camera starts moving if and only if the + * camera position changes enough to trigger a 'cameraChanged' event. This + * event is useful for triggering actions that should only occur after the + * camera has moved and the camera position has changed. + * @since x.x.x + */ + listenForMoveStartAndChange: function () { + if (this.moveStartChangeListener) { + this.moveStartChangeListener.destroy(); + } + const listener = new Backbone.Model(); + const model = this; + this.moveStartChangeListener = listener; + listener.stopListening(model, "moveStart"); + listener.listenTo(model, "moveStart", function () { + listener.listenToOnce(model, "cameraChanged", function () { + listener.stopListening(model, "moveEnd"); + model.trigger("moveStartAndChanged"); + }) + listener.listenToOnce(model, "moveEnd", function () { + listener.stopListening(model, "cameraChanged"); + }) + }) + }, + /** * Handles a mouse click on the map. If the user has clicked on a feature, * the feature is set as the 'clickedFeatures' attribute. If the map is diff --git a/src/js/models/maps/assets/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index 9eaf66bf2..e596f1fe9 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -360,30 +360,26 @@ define([ updateAppearance: function () { try { const model = this; - const cesiumModel = this.get("cesiumModel"); + const entities = this.getEntities(); + const entityCollection = this.getEntityCollection(); this.set("displayReady", false); - if (!cesiumModel) { - return; - } - - const entities = cesiumModel.entities.values; - - // Suspending events while updating a large number of entities helps - // performance. - model.suspendEvents(); - - // 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; - if (model.get("opacity") === 0) model.set("visible", false); - } else { - cesiumModel.entities.show = true; - this.styleEntities(entities); + if (entities && entities.length) { + if (model.isVisible()) { + // Suspending events while updating a large number of entities helps + // performance. + model.suspendEvents(); + entityCollection.show = true; + this.styleEntities(entities); + model.resumeEvents(); + } else { + // If the asset isn't visible, just hide all entities and update the + // visibility property to indicate that layer is hidden + entityCollection.show = false; + if (model.get("opacity") === 0) model.set("visible", false); + } } - model.resumeEvents(); this.runVisualizers(); } catch (e) { console.log("Failed to update CesiumVectorData model styles.", e); @@ -399,7 +395,7 @@ define([ */ runVisualizers: function () { const dataSource = this.get("cesiumModel"); - const visualizers = dataSource._visualizers; + const visualizers = dataSource?._visualizers; if (!visualizers || !visualizers.length) { this.whenVisualizersReady(this.runVisualizers.bind(this)); return; diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index f168dda1c..b62983725 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -455,7 +455,9 @@ define([ // model, and runs any functions configured above. Object.entries(cameraEvents).forEach(function ([label, functions]) { const callback = function () { - interactions.trigger(label); + // Rename because 'changed' is too similar to the Backbone event + const eventName = label === "changed" ? "cameraChanged" : label; + interactions.trigger(eventName); functions.forEach(function (func) { view[func].call(view); }); From 67be6beb6be1729d0a5170f8bbc818fa3b644974 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 25 Oct 2023 15:09:28 -0400 Subject: [PATCH 20/24] Fix taxa persisting between editor sessions bug Fixes #2196 --- src/js/views/metadata/EML211View.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/views/metadata/EML211View.js b/src/js/views/metadata/EML211View.js index 7daf5571b..700595a1b 100644 --- a/src/js/views/metadata/EML211View.js +++ b/src/js/views/metadata/EML211View.js @@ -2347,8 +2347,8 @@ define(['underscore', 'jquery', 'backbone', if (taxonCoverages && taxonCoverages.length >= 1){ const taxonCoverage = taxonCoverages[0]; const classifications = taxonCoverage.get("taxonomicClassification"); - classifications.push(...newClassifications); - taxonCoverage.set("taxonomicClassification", classifications); + const allClass = classifications.concat(newClassifications); + taxonCoverage.set("taxonomicClassification", allClass); } else { // If there is no element for some reason, // create one and add the new taxon to its From dc471e41a878af4157579c3c2955488e898fedd0 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Wed, 25 Oct 2023 16:11:26 -0400 Subject: [PATCH 21/24] Improve error handling of view service response Fixes #2144 --- src/js/views/MetadataIndexView.js | 147 ++++++++++++++++-------------- src/js/views/MetadataView.js | 62 +++++++------ 2 files changed, 111 insertions(+), 98 deletions(-) diff --git a/src/js/views/MetadataIndexView.js b/src/js/views/MetadataIndexView.js index 3d1bfc197..99910ff88 100644 --- a/src/js/views/MetadataIndexView.js +++ b/src/js/views/MetadataIndexView.js @@ -65,85 +65,92 @@ define(['jquery', encodeURIComponent(this.pid)+'")&rows=1&start=0&fl=*&wt=json'; var requestSettings = { url: MetacatUI.appModel.get('queryServiceUrl') + query, - success: function(data, textStatus, xhr){ + success: function (data, textStatus, xhr) { - if(data.response.numFound == 0){ + try { - if( view.parentView && view.parentView.model ){ + if (!data?.response?.numFound) { - //Show a "not indexed" message if there is system metadata but nothing in - // the index - if(view.parentView.model.get("systemMetadata")){ - view.showNotIndexed(); - } - //Show a "not found" message if there is no system metadata and no results in the index - else{ - view.parentView.model.set("notFound", true); - view.parentView.showNotFound(); - } - } + if (view.parentView && view.parentView.model) { - view.flagComplete(); - } - else{ - view.docs = data.response.docs; + //Show a "not indexed" message if there is system metadata but nothing in + // the index + if (view.parentView.model.get("systemMetadata")) { + view.showNotIndexed(); + } + //Show a "not found" message if there is no system metadata and no results in the index + else { + view.parentView.model.set("notFound", true); + view.parentView.showNotFound(); + } + } - _.each(data.response.docs, function(doc, i, list){ + view.flagComplete(); + } + else { + view.docs = data.response.docs; - //If this is a data object and there is a science metadata doc that describes it, then navigate to that Metadata View. - if((doc.formatType == "DATA") && (doc.isDocumentedBy && doc.isDocumentedBy.length)){ - view.onClose(); - MetacatUI.uiRouter.navigate("view/" + doc.isDocumentedBy[0], true); - return; - } + _.each(data.response.docs, function (doc, i, list) { - var metadataEl = $(document.createElement("section")).attr("id", "metadata-index-details"), - id = doc.id, - creator = doc.origin, - title = doc.title, - pubDate = doc.pubDate, - dateUploaded = doc.dateUploaded, - keys = Object.keys(doc), - docModel = new SolrResult(doc); - - //Extract General Info details that we want to list first - var generalInfoKeys = ["title", "id", "abstract", "pubDate", "keywords"]; - keys = _.difference(keys, generalInfoKeys); - $(metadataEl).append(view.formatAttributeSection(docModel, generalInfoKeys, "General")); - - //Extract Spatial details - var spatialKeys = ["site", "southBoundCoord", "northBoundCoord", "westBoundCoord", "eastBoundCoord"]; - keys = _.difference(keys, spatialKeys); - $(metadataEl).append(view.formatAttributeSection(docModel, spatialKeys, "Geographic Region")); - - //Extract Temporal Coverage details - var temporalKeys = ["beginDate", "endDate"]; - keys = _.difference(keys, temporalKeys); - $(metadataEl).append(view.formatAttributeSection(docModel, temporalKeys, "Temporal Coverage")); - - //Extract Taxonomic Coverage details - var taxonKeys = ["order", "phylum", "family", "genus", "species", "scientificName"]; - keys = _.difference(keys, taxonKeys); - $(metadataEl).append(view.formatAttributeSection(docModel, taxonKeys, "Taxonomic Coverage")); - - //Extract People details - var peopleKeys = ["origin", "investigator", "contactOrganization", "project"]; - keys = _.difference(keys, peopleKeys); - $(metadataEl).append(view.formatAttributeSection(docModel, peopleKeys, "People and Associated Parties")); - - //Extract Access Control details - var accessKeys = ["isPublic", "submitter", "rightsHolder", "writePermission", "readPermission", "changePermission", "authoritativeMN"]; - keys = _.difference(keys, accessKeys); - $(metadataEl).append(view.formatAttributeSection(docModel, accessKeys, "Access Control")); - - //Add the rest of the metadata - $(metadataEl).append(view.formatAttributeSection(docModel, keys, "Other")); - - view.$el.html(metadataEl); + //If this is a data object and there is a science metadata doc that describes it, then navigate to that Metadata View. + if ((doc.formatType == "DATA") && (doc.isDocumentedBy && doc.isDocumentedBy.length)) { + view.onClose(); + MetacatUI.uiRouter.navigate("view/" + doc.isDocumentedBy[0], true); + return; + } - view.flagComplete(); - }); + var metadataEl = $(document.createElement("section")).attr("id", "metadata-index-details"), + id = doc.id, + creator = doc.origin, + title = doc.title, + pubDate = doc.pubDate, + dateUploaded = doc.dateUploaded, + keys = Object.keys(doc), + docModel = new SolrResult(doc); + + //Extract General Info details that we want to list first + var generalInfoKeys = ["title", "id", "abstract", "pubDate", "keywords"]; + keys = _.difference(keys, generalInfoKeys); + $(metadataEl).append(view.formatAttributeSection(docModel, generalInfoKeys, "General")); + + //Extract Spatial details + var spatialKeys = ["site", "southBoundCoord", "northBoundCoord", "westBoundCoord", "eastBoundCoord"]; + keys = _.difference(keys, spatialKeys); + $(metadataEl).append(view.formatAttributeSection(docModel, spatialKeys, "Geographic Region")); + + //Extract Temporal Coverage details + var temporalKeys = ["beginDate", "endDate"]; + keys = _.difference(keys, temporalKeys); + $(metadataEl).append(view.formatAttributeSection(docModel, temporalKeys, "Temporal Coverage")); + + //Extract Taxonomic Coverage details + var taxonKeys = ["order", "phylum", "family", "genus", "species", "scientificName"]; + keys = _.difference(keys, taxonKeys); + $(metadataEl).append(view.formatAttributeSection(docModel, taxonKeys, "Taxonomic Coverage")); + + //Extract People details + var peopleKeys = ["origin", "investigator", "contactOrganization", "project"]; + keys = _.difference(keys, peopleKeys); + $(metadataEl).append(view.formatAttributeSection(docModel, peopleKeys, "People and Associated Parties")); + + //Extract Access Control details + var accessKeys = ["isPublic", "submitter", "rightsHolder", "writePermission", "readPermission", "changePermission", "authoritativeMN"]; + keys = _.difference(keys, accessKeys); + $(metadataEl).append(view.formatAttributeSection(docModel, accessKeys, "Access Control")); + + //Add the rest of the metadata + $(metadataEl).append(view.formatAttributeSection(docModel, keys, "Other")); + + view.$el.html(metadataEl); + + view.flagComplete(); + }); + } + } catch (e) { + console.log("Error parsing Solr response: " + e); + console.log("Solr response: " + data); + view.parentView.showNotFound(); } }, error: function(){ diff --git a/src/js/views/MetadataView.js b/src/js/views/MetadataView.js index 23a878855..2564a90c9 100644 --- a/src/js/views/MetadataView.js +++ b/src/js/views/MetadataView.js @@ -386,47 +386,53 @@ define(['jquery', var loadSettings = { url: endpoint, success: function (response, status, xhr) { + try { - //If the user has navigated away from the MetadataView, then don't render anything further - if (MetacatUI.appView.currentView != viewRef) - return; - - //Our fallback is to show the metadata details from the Solr index - if (status == "error") - viewRef.renderMetadataFromIndex(); - else { - //Check for a response that is a 200 OK status, but is an error msg - if ((response.length < 250) && (response.indexOf("Error transforming document") > -1) && viewRef.model.get("indexed")) { - viewRef.renderMetadataFromIndex(); + //If the user has navigated away from the MetadataView, then don't render anything further + if (MetacatUI.appView.currentView != viewRef) return; - } - //Mark this as a metadata doc with no stylesheet, or one that is at least different than usual EML and FGDC - else if ((response.indexOf('id="Metadata"') == -1)) { - viewRef.$el.addClass("container no-stylesheet"); - if (viewRef.model.get("indexed")) { + //Our fallback is to show the metadata details from the Solr index + if (status == "error" || !response || typeof response !== "string") + viewRef.renderMetadataFromIndex(); + else { + //Check for a response that is a 200 OK status, but is an error msg + if ((response.length < 250) && (response.indexOf("Error transforming document") > -1) && viewRef.model.get("indexed")) { viewRef.renderMetadataFromIndex(); return; } - } + //Mark this as a metadata doc with no stylesheet, or one that is at least different than usual EML and FGDC + else if ((response.indexOf('id="Metadata"') == -1)) { + viewRef.$el.addClass("container no-stylesheet"); + + if (viewRef.model.get("indexed")) { + viewRef.renderMetadataFromIndex(); + return; + } + } - //Now show the response from the view service - viewRef.$(viewRef.metadataContainer).html(response); + //Now show the response from the view service + viewRef.$(viewRef.metadataContainer).html(response); - //If there is no info from the index and there is no metadata doc rendered either, then display a message - if (viewRef.$el.is(".no-stylesheet") && viewRef.model.get("archived") && !viewRef.model.get("indexed")) - viewRef.$(viewRef.metadataContainer).prepend(viewRef.alertTemplate({ msg: "There is limited metadata about this dataset since it has been archived." })); + //If there is no info from the index and there is no metadata doc rendered either, then display a message + if (viewRef.$el.is(".no-stylesheet") && viewRef.model.get("archived") && !viewRef.model.get("indexed")) + viewRef.$(viewRef.metadataContainer).prepend(viewRef.alertTemplate({ msg: "There is limited metadata about this dataset since it has been archived." })); - viewRef.alterMarkup(); + viewRef.alterMarkup(); - viewRef.trigger("metadataLoaded"); + viewRef.trigger("metadataLoaded"); - //Add a map of the spatial coverage - if (gmaps) viewRef.insertSpatialCoverageMap(); + //Add a map of the spatial coverage + if (gmaps) viewRef.insertSpatialCoverageMap(); - // Injects Clipboard objects into DOM elements returned from the View Service - viewRef.insertCopiables(); + // Injects Clipboard objects into DOM elements returned from the View Service + viewRef.insertCopiables(); + } + } catch (e) { + console.log("Error rendering metadata from the view service", e); + console.log("Response from the view service: ", response); + viewRef.renderMetadataFromIndex(); } }, error: function (xhr, textStatus, errorThrown) { From 703883ee83cd6907c15a3235c8be2830c4be5287 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 26 Oct 2023 16:44:17 -0400 Subject: [PATCH 22/24] Prevent weird TOC placement in portals - Add min height to markdown sections with portals - Also rename methods from postRender to what they do, because backbone calls these methods automatically even though this isn't documented! fixes #2195 --- src/js/themes/dataone/css/metacatui.css | 4 ++++ src/js/views/MarkdownView.js | 22 ++++------------------ src/js/views/TOCView.js | 20 +++++++++++++------- src/js/views/portals/PortalSectionView.js | 4 +--- 4 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/js/themes/dataone/css/metacatui.css b/src/js/themes/dataone/css/metacatui.css index 625095593..90614feb4 100644 --- a/src/js/themes/dataone/css/metacatui.css +++ b/src/js/themes/dataone/css/metacatui.css @@ -479,6 +479,10 @@ width: 100%; color: var(--c-neutral-8); } +.toc-view + .markdown { + min-height: 90vh; +} + /* FORM/INPUT CUSTOMIZATIONS -------------------------------------------------- */ .depth { diff --git a/src/js/views/MarkdownView.js b/src/js/views/MarkdownView.js index ada6ecd33..41be210b3 100644 --- a/src/js/views/MarkdownView.js +++ b/src/js/views/MarkdownView.js @@ -88,7 +88,7 @@ define([ "jquery", "underscore", "backbone", * render - Renders the MarkdownView; converts markdown to HTML and * displays it. */ - render: function() { + render: function () { // Show a loading message while we render the markdown to HTML this.$el.html(this.loadingTemplate({ @@ -143,15 +143,10 @@ define([ "jquery", "underscore", "backbone", this.$el.html(this.template({ markdown: htmlFromMD })); if( this.showTOC ){ - this.listenToOnce(this, "TOCRendered", function(){ - this.trigger("mdRendered"); - this.postRender(); - }); this.renderTOC(); - } else { - this.trigger("mdRendered"); - this.postRender(); } + + this.trigger("mdRendered"); }); @@ -162,15 +157,6 @@ define([ "jquery", "underscore", "backbone", }, - postRender: function(){ - if(this.tocView){ - this.tocView.postRender(); - } else { - this.listenToOnce(this, "TOCRendered", function(){ - this.tocView.postRender(); - }); - } - }, /** * listRequiredExtensions - test which extensions are needed, then load @@ -378,7 +364,7 @@ define([ "jquery", "underscore", "backbone", view.$el.addClass("span9"); } - view.trigger("TOCRendered"); + view.tocView.setAffix(); }); diff --git a/src/js/views/TOCView.js b/src/js/views/TOCView.js index bcfeb4dca..1e68b66db 100644 --- a/src/js/views/TOCView.js +++ b/src/js/views/TOCView.js @@ -121,7 +121,6 @@ define(["jquery", } - var view = this; return this; }, @@ -278,11 +277,11 @@ define(["jquery", // Add scroll spy $("body").off("activate"); - $("body").on("activate", function(e){ + $("body").on("activate", function (e) { view.scrollSpyExtras(e); }); $(window).off("resize"); - $(window).on("resize", function(){ + $(window).on("resize", function () { $spy.scrollspy("refresh"); }); @@ -294,19 +293,26 @@ define(["jquery", /** - * affixTOC - description + * Adds and refreshes bootstrap's affix functionality. This function + * should be called after the DOM has been rendered or updated. Renamed + * from postRender to avoid it being called automatically by Backbone. + * @since x.x.x */ - postRender: function(){ + setAffix: function(){ try { var isVisible = this.$el.find(":visible").length > 0; - if(this.affix === true && isVisible){ + if(!isVisible || !this.$el.offset()){ + return; + } + + if (this.affix === true) { this.$el.affix({ offset: this.$el.offset().top }); } - if(this.addScrollspy && isVisible){ + if(this.addScrollspy){ this.renderScrollspy(); } diff --git a/src/js/views/portals/PortalSectionView.js b/src/js/views/portals/PortalSectionView.js index 38da5f54e..afb164607 100644 --- a/src/js/views/portals/PortalSectionView.js +++ b/src/js/views/portals/PortalSectionView.js @@ -144,9 +144,7 @@ define(["jquery", } }); - if(this.markdownView){ - this.markdownView.postRender(); - } + this.markdownView?.tocView?.setAffix(); }, /** From 713f5288cf6340ff40473b8182bce108b60b1bf1 Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 26 Oct 2023 18:17:15 -0400 Subject: [PATCH 23/24] Adjust height of feature info panel in Cesium map again after content has been loaded. Fixes #2192 --- src/js/views/maps/FeatureInfoView.js | 38 +++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/js/views/maps/FeatureInfoView.js b/src/js/views/maps/FeatureInfoView.js index ada1774e6..7323aec67 100644 --- a/src/js/views/maps/FeatureInfoView.js +++ b/src/js/views/maps/FeatureInfoView.js @@ -256,6 +256,8 @@ define( try { + const view = this; + // Elements to update const title = this.getFeatureTitle() const iFrame = this.elements.iFrame @@ -275,12 +277,17 @@ define( this.elements.title.innerHTML = title // Update the iFrame content - iFrame.height = 0; this.getContent().then(function (html) { iFrameDiv.innerHTML = html; - const maxHeight = window.innerHeight - 275; - const scrollHeight = iFrame.contentWindow.document.body.scrollHeight + 5; - iFrame.height = scrollHeight > maxHeight ? maxHeight : scrollHeight; + view.updateIFrameHeight(); + // Not the ideal solution, but check the height of the iFrame + // again after some time to allow external content to load. This + // is necessary for content that loads asynchronously, like + // images. Difficult to set listeners for this, since the content + // may be from a different domain. + setTimeout(function () { + view.updateIFrameHeight(); + }, 850); }) // Show or hide the layer details button, update the text @@ -299,6 +306,29 @@ define( } }, + /** + * Update the height of the iFrame to match the height of the content + * within it. + * @param {number} [height] The height to set the iFrame to. If no + * height is provided, then the height of the content within the iFrame + * will be used. + * @param {boolean} [limit=true] Whether or not to limit the height of + * the iFrame to the height of the window, minus 275px. + * @since x.x.x + */ + updateIFrameHeight: function (height, limit = true) { + const iFrame = this.elements?.iFrame; + if (!iFrame) return; + if ((!height && height !== 0) || height < 0) { + height = iFrame.contentWindow.document.body.scrollHeight + 5; + } + if (limit) { + const maxHeight = window.innerHeight - 275; + height = height > maxHeight ? maxHeight : height; + } + iFrame.style.height = height + "px"; + }, + /** * Get the inner HTML content to insert into the iFrame. The content will vary * based on the feature and if there is a template set on the parent Map Asset From 187351c027f4b679f1e04b7f9eb414ed06adab5c Mon Sep 17 00:00:00 2001 From: Robyn Thiessen-Bock Date: Thu, 26 Oct 2023 18:22:57 -0400 Subject: [PATCH 24/24] Allow all users to set datasets to private on KNB Fixes #2215 --- src/js/themes/knb/config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/js/themes/knb/config.js b/src/js/themes/knb/config.js index 02a702cfc..9f81191fe 100644 --- a/src/js/themes/knb/config.js +++ b/src/js/themes/knb/config.js @@ -45,7 +45,6 @@ MetacatUI.AppConfig = Object.assign({ read: true }], hiddenSubjectsInAccessPolicy: ["CN=knb-data-admins,DC=dataone,DC=org"], - showDatasetPublicToggleForSubjects: ["CN=knb-data-admins,DC=dataone,DC=org"], allowChangeRightsHolder: false, enableMeasurementTypeView: true,