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/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/GeoPoints.js b/src/js/collections/maps/GeoPoints.js new file mode 100644 index 000000000..566a33ef2 --- /dev/null +++ b/src/js/collections/maps/GeoPoints.js @@ -0,0 +1,330 @@ +"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") { + return this.removePointByIndex(indexOrPoint); + } else if (Array.isArray(indexOrPoint)) { + return this.removePointByAttr(indexOrPoint); + } else { + // try just removing the point + return this.remove(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"; + } + let czml = [this.getCZMLHeader()]; + switch (geometryType) { + case "Point": + czml = 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; +}); 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/collections/maps/MapAssets.js b/src/js/collections/maps/MapAssets.js index 262ff3c2a..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, }, { @@ -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/connectors/Filters-Map.js b/src/js/models/connectors/Filters-Map.js index 505d89a62..266b05073 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 moveStartAndChanged"); 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, "moveStartAndChanged", 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/GeoPoints-Cesium.js b/src/js/models/connectors/GeoPoints-Cesium.js new file mode 100644 index 000000000..f224c6d31 --- /dev/null +++ b/src/js/models/connectors/GeoPoints-Cesium.js @@ -0,0 +1,167 @@ +"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 { + this.disconnect(); + + // Listen for changes to the points collection and update the layer + const 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 () { + const geoPoints = this.get("geoPoints"); + if (geoPoints) this.stopListening(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..7a27854bd --- /dev/null +++ b/src/js/models/connectors/GeoPoints-CesiumPolygon.js @@ -0,0 +1,75 @@ +"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 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(() => { + 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/Map-Search-Filters.js b/src/js/models/connectors/Map-Search-Filters.js index cc7557aca..7c411f55d 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"); @@ -213,6 +213,7 @@ define([ * so that they work together. */ connect: function () { + this.disconnect(); this.coordinateMoveEndSearch(); this.getConnectors().forEach((connector) => connector.connect()); }, @@ -242,6 +243,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 +255,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 +272,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 827593203..7b9fd4483 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, "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 // 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); @@ -206,8 +207,6 @@ define([ * See {@link MapSearchFiltersConnector#onMoveEnd} */ onMoveEnd: function () { - const searchResults = this.get("searchResults"); - const map = this.get("map"); this.showGeoHashLayer(); this.updateFacet(); }, @@ -237,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, "moveStartAndChanged 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..c49573c52 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. @@ -103,30 +101,50 @@ 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); }, /** * 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 +153,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 +175,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({ @@ -316,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, @@ -325,6 +343,9 @@ define([ south: df.south, height: df.height, }); + + // Reset the listeners + this.setListeners(); }, } ); 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 new file mode 100644 index 000000000..87e4b7cb2 --- /dev/null +++ b/src/js/models/maps/GeoBoundingBox.js @@ -0,0 +1,182 @@ +"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("Invalid bounding box, returning globe area"); + 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..ea73452a1 --- /dev/null +++ b/src/js/models/maps/GeoPoint.js @@ -0,0 +1,139 @@ +"use strict"; + +define(["backbone", "models/maps/GeoUtilities"], function ( + Backbone, + GeoUtilities +) { + /** + * @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 + * @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, + mapWidgetCoords: null, + }; + }, + + /** + * 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: {}, + }; + }, + + /** + * 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 + */ + 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/GeoUtilities.js b/src/js/models/maps/GeoUtilities.js new file mode 100644 index 000000000..62e51cddc --- /dev/null +++ b/src/js/models/maps/GeoUtilities.js @@ -0,0 +1,55 @@ +"use strict"; + +define(["backbone"], function (Backbone) { + /** + * @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..cb213dc65 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 @@ -287,7 +288,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. @@ -315,9 +316,9 @@ define(["jquery", "underscore", "backbone", "nGeohash"], function ( }, 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], @@ -346,24 +347,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.prototype.geodeticToECEF(coord); }, } ); diff --git a/src/js/models/maps/Map.js b/src/js/models/maps/Map.js index 6b7390d08..f15cfe4a5 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,94 +208,62 @@ define([ this.set("terrains", new MapAssets(config.terrains)); } } + 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); } }, /** - * 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")); }, /** @@ -351,16 +282,30 @@ 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/models/maps/MapInteraction.js b/src/js/models/maps/MapInteraction.js new file mode 100644 index 000000000..96c37726e --- /dev/null +++ b/src/js/models/maps/MapInteraction.js @@ -0,0 +1,354 @@ +"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. 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, 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. 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. 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 + * 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. + */ + 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, + 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.listenForMoveStartAndChange(); + 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 () { + const model = this; + if (model.get("firstInteraction")) return; + const listener = new Backbone.Model(); + listener.listenTo( + this, + "change:previousAction", + function (m, eventType) { + if (eventType != "MOUSE_MOVE") { + model.set("firstInteraction", true); + listener.stopListening(); + listener.destroy(); + } + } + ); + }, + + /** + * 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 + * 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); + const clickAction = this.get("mapModel")?.get("clickFeatureAction"); + if (clickAction === "showDetails") { + this.selectFeatures(hoveredFeatures); + } else if (clickAction === "zoom") { + this.set("zoomTarget", hoveredFeatures[0]); + } + // TODO: throttle this? + this.setClickedPositionFromMousePosition(); + }, + + /** + * Sets the clicked position to the current mouse position. + */ + setClickedPositionFromMousePosition: function () { + const mousePosition = this.get("mousePosition"); + const coords = { + longitude: mousePosition.get("longitude"), + latitude: mousePosition.get("latitude"), + height: mousePosition.get("height"), + mapWidgetCoords: mousePosition.get("mapWidgetCoords"), + }; + 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 corresponding position as a GeoPoint model. + */ + setPosition: function(attributeName, position) { + let point = this.get(attributeName); + if (!point) { + point = new GeoPoint(); + this.set(attributeName, point); + } + 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); + }, + + /** + * 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) { + const model = this; + 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, undefined, or empty features + if (Array.isArray(features)) features = features.filter((f) => f); + if (features instanceof Features) { + features = features.filter((f) => !f.isDefault()); + } + + // 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; + } + + // 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; + } + + const assets = this.get("mapModel")?.get("layers"); + + 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); + } + }, + } + ); + + return MapInteraction; +}); 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/CesiumGeohash.js b/src/js/models/maps/assets/CesiumGeohash.js index c07e8e72c..ed4c0a4b6 100644 --- a/src/js/models/maps/assets/CesiumGeohash.js +++ b/src/js/models/maps/assets/CesiumGeohash.js @@ -153,9 +153,8 @@ 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); - return this.get("geohashes").getMaxPrecision(area, limit); + const area = this.getViewExtent().getArea(); + return 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 @@ -271,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); @@ -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/CesiumVectorData.js b/src/js/models/maps/assets/CesiumVectorData.js index fee95fc5b..cecfe759a 100644 --- a/src/js/models/maps/assets/CesiumVectorData.js +++ b/src/js/models/maps/assets/CesiumVectorData.js @@ -1,667 +1,788 @@ -'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 { - - if(!assetConfig) assetConfig = {}; +"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)); + } - MapAsset.prototype.initialize.call(this, 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 }) + ); + } - if (assetConfig.filters) { - this.set('filters', new VectorFilters(assetConfig.filters)) - } + if ( + assetConfig.highlightColor && + !(assetConfig.highlightColor instanceof AssetColor) + ) { + this.set( + "highlightColor", + new AssetColor({ color: assetConfig.highlightColor }) + ); + } - // 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 }) - ); + 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(); } + } - this.createCesiumModel(); + model.set("displayReady", false); + model.resetStatus(); + if (typeof dataSourceFunction !== "function") { + model.setError(`${type} is not a supported data type.`); + return; } - catch (error) { - console.log('Error initializing a CesiumVectorData model.', error); + if (!dataSource) { + dataSource = new dataSourceFunction(label); + } + if (!dataSource) { + model.setError("Failed 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) { - if (!recreate) { - return dataSource - } else { - // If we are recreating the model, remove all entities first. - // see https://stackoverflow.com/questions/31426796/loading-updated-data-with-geojsondatasource-in-cesium-js - dataSource.entities.removeAll(); - // Make sure the CesiumWidgetView re-renders the data - model.set('displayReady', false); - } - } - - model.resetStatus(); - if (!cesiumOptions || !cesiumOptions.data) { - model.set('status', 'error'); - model.set('statusDetails', 'Vector data source is missing: A URL or data object is required') - return - } + // There is no data to load for a CustomDataSource + if (type === "CustomDataSource") { + model.set("cesiumModel", dataSource); + model.setListeners(); + model.setReady(); + model.runVisualizers(); + return; + } - if (dataSourceFunction && typeof dataSourceFunction === 'function') { + // For GeoJSON and CZML data sources + if (!cesiumOptions || !cesiumOptions.data) { + model.setError( + "No data was provided to create a Cesium DataSource model." + ); + return; + } + const data = JSON.parse(JSON.stringify(cesiumOptions.data)); + delete cesiumOptions.data; + dataSource + .load(data, cesiumOptions) + .then(function (loadedData) { + model.set("cesiumModel", loadedData); if (!recreate) { - dataSource = new dataSourceFunction(label) + model.setListeners(); } - - const data = cesiumOptions.data; - delete cesiumOptions.data - - 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) - }) + 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.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 entities = this.getEntities(); + const entityCollection = this.getEntityCollection(); + this.set("displayReady", false); + + 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 { - 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 + entityCollection.show = false; + if (model.get("opacity") === 0) model.set("visible", false); } } - catch (error) { - console.log( - 'Failed to create a Cesium Model for a CesiumVectorData model' + - '. Error details: ' + error - ); + + 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; + if (!visualizers || !visualizers.length) { + this.whenVisualizersReady(this.runVisualizers.bind(this)); + return; + } + const time = Cesium.JulianDate.now(); + let displayReadyNow = true + for (let x = 0; x < visualizers.length; x++) { + displayReadyNow = visualizers[x].update(time) && displayReadyNow; + } + if (!displayReadyNow) { + setTimeout(this.runVisualizers.bind(this), 300); + } else { + this.set("displayReady", true); + } + 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 {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; + 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 { - 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 visualizers = model.get("cesiumModel")?._visualizers; + if (visualizers && visualizers.length) { + clearInterval(interval); + callBack(); } - catch (error) { - console.log( - 'There was an error setting listeners in a CesiumVectorData model' + - '. Error details: ' + error - ); + }, 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(); + 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); + } + }, + + /** + * 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.getEntityCollection(); + 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(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(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') - 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; - } - 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); + } + 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); } - }, 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.show = visible; } - 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 - 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 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) - } - 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) { - console.log( - 'There was an error updating CesiumVectorData feature visibility' + - '. 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 + 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 ); - }) - }, - - }); - - return CesiumVectorData; - - } -); + 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 (e) { + console.log("Error getting bounding sphere.", e); + }); + }, + } + ); + + return CesiumVectorData; +}); diff --git a/src/js/models/maps/assets/MapAsset.js b/src/js/models/maps/assets/MapAsset.js index cccbcf4b0..e02f5391c 100644 --- a/src/js/models/maps/assets/MapAsset.js +++ b/src/js/models/maps/assets/MapAsset.js @@ -1,895 +1,930 @@ -'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. - */ - - /** - * 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; - - if (!assetConfig || typeof assetConfig !== 'object') { - assetConfig = {} - } else { - assetConfig = JSON.parse(JSON.stringify(assetConfig)) - } +"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. + * @property {Boolean} [hideInLayerList = false] Set to true to hide this asset + * from the layer list. + */ + 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, + hideInLayerList: false, + }; + }, + + /** + * 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. + * @property {boolean} [hideInLayerList] - Set to true to hide this asset from the + * layer list. + */ + + /** + * 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. + * @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; - // Set the color palette - if (assetConfig.colorPalette) { - this.set('colorPalette', new AssetColorPalette(assetConfig.colorPalette)) - } + if (!assetConfig || typeof assetConfig !== "object") { + assetConfig = {}; + } else { + assetConfig = JSON.parse(JSON.stringify(assetConfig)); + } - // The map asset cannot be visible on the map if there was an error loading - // the asset - this.listenTo(this, 'change:status', function (model, status) { - if (status === 'error') { - this.set('visible', false) - } - }) - this.listenTo(this, 'change:visible', function (model, visible) { - if (this.get('status') === 'error') { - this.set('visible', false) - } - }) + // 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) - } + // 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') { + this.setListeners(); + } catch (e) { + console.log("Error initializing a MapAsset model", e); + } + }, + + /** + * 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 + */ + handleError: function () { + this.set("originalVisibility", this.get("visible")); + 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; + } 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", this.setListeners); + + // Listen for changes to the cesiumOptions object + this.stopListening(this, "change:cesiumOptions"); + this.listenTo(this, "change:cesiumOptions", function () { + this.createCesiumModel(true); + }); - const setSelectFeaturesListeners = function () { - const mapModel = this.get('mapModel') - if (!mapModel) { return } - const selectedFeatures = mapModel.get('selectedFeatures') + 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 = {}; - this.stopListening(selectedFeatures, 'update'); - this.listenTo(selectedFeatures, 'update', this.updateAppearance) + if (!customProperties || !Object.keys(customProperties).length) { + return properties; + } - this.stopListening(mapModel, 'change:selectedFeatures') - this.listenTo(mapModel, 'change:selectedFeatures', function () { - this.updateAppearance() - setSelectFeaturesListeners() - }) + if (!properties || typeof properties !== "object") { + properties = {}; + } + 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); } - setSelectFeaturesListeners.call(this) - this.stopListening(this, 'change:mapModel', setSelectFeaturesListeners) - this.listenTo(this, 'change:mapModel', setSelectFeaturesListeners) - } + formattedProperties[key] = formattedValue; + }); } - catch (error) { - console.log( - 'There was an error initializing a MapAsset model' + - '. Error details: ' + error - ); - } - }, - - /** - * 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.get('selectedFeatures').containsFeature(feature) - }, - - /** - * Checks if a feature from the map (a Cesium object) is the type of - * feature that this map asset model contains. For example, if a - * Cesium3DTilesetFeature is passed to this function, this function - * will return true if it is a Cesium3DTileset model, and false if it - * is a CesiumVectorData model. - * @param {Cesium.Cesium3DTilesetFeature|Cesium.Entity} feature - * @returns {boolean} true if the feature is an instance of the feature - * type set on the asset model, false otherwise. - * @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 = {}; - - if (!customProperties || !Object.keys(customProperties).length) { - return properties - } - - if (!properties || typeof properties !== 'object') { - properties = {} - } - - 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: ' + + // 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 + ); + 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 = {}; } - }, - - /** - * 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); - } + let formattedDate = ""; + if (!config || !config.format) { return formattedDate; } - catch (error) { - console.log( - 'There was an error formatting a date for a Feature model' + - '. Error details: ' + error - ); - return ''; + const value = properties[config.property]; + if (value) { + formattedDate = dayjs(value).format(config.format); } - }, - - /** - * 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; + 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 formatting a string for a Feature model' + - '. Error details: ' + error - ); - return ''; + let formattedString = ""; + if (!config || !config.value) { + return formattedString; } - }, - - // 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') + 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(); }) - } - 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) + .then(function (data) { + if (model.isSVG(data)) { + model.updateIcon(data); } }) - } - 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 + .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); } - 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 - } - }, - - /** - * 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; } - }, - - // /** - // * 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; - - } -); + 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; +}); diff --git a/src/js/views/maps/CesiumWidgetView.js b/src/js/views/maps/CesiumWidgetView.js index a9837adeb..b62983725 100644 --- a/src/js/views/maps/CesiumWidgetView.js +++ b/src/js/views/maps/CesiumWidgetView.js @@ -1,1349 +1,1599 @@ -'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 - */ - mapAssetRenderFunctions: [ - { - types: ['Cesium3DTileset'], - renderFunction: 'add3DTileset' - }, - { - types: ['GeoJsonDataSource', 'CzmlDataSource'], - renderFunction: 'addVectorData' - }, - { - types: ['BingMapsImageryProvider', 'IonImageryProvider', 'TileMapServiceImageryProvider', 'WebMapTileServiceImageryProvider', 'WebMapServiceImageryProvider', 'OpenStreetMapImageryProvider'], - renderFunction: 'addImagery' - }, - { - types: ['CesiumTerrainProvider'], - renderFunction: 'updateTerrain' - } - ], - - /** - * 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; - } - } - - // 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() - } - - - } catch (e) { - console.log('Failed to initialize a CesiumWidgetView. Error message: ' + e); - } - +"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", }, - - /** - * 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; + { + types: ["GeoJsonDataSource", "CzmlDataSource", "CustomDataSource"], + 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; } + } - // 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 - }); - - // 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; + 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; + } - // Decrease the amount the camera must change before the changed event is - // raised. - view.camera.percentChanged = 0.1 + // Save a reference to this view + const view = this; + // Insert the template into the view + view.$el.html(view.template({})); - // Disable HDR lighting for better performance and to avoid changing imagery colors. - view.scene.highDynamicRange = false; - view.scene.globe.enableLighting = false; + // Create the Cesium Widget + view.renderWidget(); - // 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 - ); - }); + // Configure the lighting on the globe + view.setLighting(); - // 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) - }) + // Prepare Cesium to handle vector datasources (e.g. + // geoJsonDataSources) + view.setUpDataSourceDisplay(); - view.setListeners(); - view.addLayers(); + // 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(); - // Go to the home position, if one is set. - view.flyHome(0) + // Render the layers + view.addLayers(); - // If users are allowed to click on features for more details, - // initialize picking behavior on the map. - if (view.model.get('showFeatureInfo')) { - view.initializePicking() - } + // Go to the home position, if one is set. + view.flyHome(0); - return this + // Set the map up so that selected features may be highlighted + view.setUpSilhouettes(); + 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. 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, + }); + 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 (error) { - console.log( - 'Failed to render a CesiumWidgetView. Error details: ' + error - ); + } catch (e) { + console.log("Error calling post render functions:", e); + } + }, + + /** + * 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 () { + const view = this; + const dataSources = view.dataSourceDisplay.dataSources; + if (!dataSources || !dataSources.length) { + return; + } + const time = view.clock.currentTime; + 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) { + displayReady = displayReady && visualizer.update(time); + }); + } + view.dataSourceDisplay._ready = displayReady; + return displayReady; + }, + + /** + * 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 + 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); + }, + + /** + * 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); } - }, - - /** - * Set all of the listeners for the CesiumWidgetView. This function is - * called during the render function. - * @since 2.26.0 - */ - setListeners: function () { - + }); + }, + + /** + * 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; - - // 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 () { + // 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); + }); + }; + 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); + 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; + const now = new Date().getTime(); + if (now - lastCall < delay) return; + this.setHoveredFeaturesLastCall = now; + const pickedFeature = view.scene.pick(position); + view.interactions.setHoveredFeatures([pickedFeature]); + }, + + /** + * React when the user interacts with the map. + * @since x.x.x + */ + setInteractionListeners: function () { + const interactions = this.interactions; + const hoveredFeatures = interactions.get("hoveredFeatures"); + this.stopListening(hoveredFeatures, "change update"); + this.listenTo(hoveredFeatures, "change update", this.updateCursor); + }, + + /** + * 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 () { + 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"); + if (layers && layers.length) { + const layersReverse = layers.last(layers.length).reverse(); + layersReverse.forEach(function (layer) { + view.addAsset(layer); + }); + } - /** - * Add all of the model's layers to the map. This function is called - * during the render function. - * @since 2.26.0 - */ - addLayers: function () { + // 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 { + + // A target is required + if (!target) return; 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) + 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); + 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; - } - } - catch (error) { - console.log( - 'There was an error calling post render functions 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; } - }, - /** - * 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) { + // 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. + // 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; + } - 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 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) { - // 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 + view.dataSourceDisplay._ready = true + view.getBoundingSphereFromEntity(entity).then(function (entityBoundingSphere) { + view.flyTo(entityBoundingSphere, options); + }); + return; + } + if (target.type && target.type == "GeoPoint") { + view.flyTo(target.toJSON(), options); + return; } - catch (error) { - console.log( - 'There was an error updating the data source display in a CesiumWidgetView' + - '. Error details: ' + error - ); + + if ( + typeof target === "object" && + typeof target.longitude === "number" && + typeof target.latitude === "number" + ) { + const pointTarget = view.positionToFlightTarget(target); + view.flyTo(pointTarget, 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) - }) - }) + // 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); } - - 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) - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - } - catch (error) { - console.log( - 'There was an error initializing picking in a CesiumWidgetView' + - '. Error details: ' + error - ); + // Fly to the target + view.camera.flyTo(target); + view.resetZoomTarget(); } - }, - - /** - * 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(); - }, + } catch (e) { + console.log("Failed to navigate to a target in Cesium.", e); + } + }, + + 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."); + } - /** - * 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 + } else { + clearInterval(interval); + resolve(entityBoundingSphere); } - - // 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 (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; + 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); - - // 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; + // 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; - 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'; - } - } - - // Slow this function down a little. Picking is quite slow. - const updateCursorThrottled = _.throttle(updateCursor, 150) + // 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); - // 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)) - } - } + view.geodesic.setEndPoints(leftCartographic, rightCartographic); - // 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 - ); - } - }, + const onePixelInMeters = view.geodesic.surfaceDistance; - /** - * 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 - ); - - view.geodesic.setEndPoints(leftCartographic, rightCartographic); - - const onePixelInMeters = view.geodesic.surfaceDistance; + checkAndRenderAsset(); - 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) - } + 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') + } 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) { - 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) - }, - - /** - * 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) - }, - - /** - * 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() - }, - /** - * 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() { - 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 new file mode 100644 index 000000000..368117680 --- /dev/null +++ b/src/js/views/maps/DrawToolView.js @@ -0,0 +1,473 @@ +"use strict"; + +define(["backbone", "models/connectors/GeoPoints-CesiumPolygon", "models/connectors/GeoPoints-CesiumPoints", "collections/maps/GeoPoints"], function ( + Backbone, + GeoPointsVectorData, + GeoPointsCesiumPoints, + GeoPoints +) { + /** + * @class DrawTool + * @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 + * @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", + + /** + * Class to use for the buttons + * @type {string} + */ + 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. + * @type {DrawToolButtonOptions[]} + */ + 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: "reset", + }, + { + name: "save", + label: "Save", + icon: "save", + method: "save", + }, + ], + + /** + * 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: {}, + + /** + * 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, + + /** + * The Cesium map model to draw on. This must be the same model that the + * mapWidget is using. + * @type {Map} + */ + mapModel: undefined, + + /** + * 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} + */ + layer: undefined, + + /** + * The GeoPoints collection that stores the points of the polygon that is + * being drawn. + * @type {GeoPoints} + */ + 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.3, + + /** + * Initializes the DrawTool + * @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.mapModel = options.model; + if (!this.mapModel) { + console.warn("No map model was provided."); + return; + } + // Add models & collections and add interactions, layer, connector, + // points, and originalAction properties to this view + this.setUpMapModel(); + this.setUpLayer(); + this.setUpConnectors(); + }, + + /** + * Sets up the map model and adds the interactions and originalAction + * properties to this view. + */ + 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: "CustomDataSource", + label: "Your Polygon", + description: "The polygon that you are drawing on the map", + hideInLayerList: true, + outlineColor: this.color, + highlightColor: this.color, + opacity: this.opacity, + colorPalette: { + colors: [ + { + color: this.color, + }, + ], + }, + }) + }, + + /** + * 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 + */ + 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.polygonConnector.connect(); + this.pointsConnector.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); + }, + + /** + * Resets the draw tool to its initial state. + */ + reset: function () { + this.setMode(false); + this.clearPoints(); + this.removeClickListeners(); + }, + + /** + * Removes the polygon object from the map + */ + removeLayer: function () { + if (!this.mapModel || !this.layer) return; + this.polygonConnector.disconnect(); + this.polygonConnector.set("vectorLayer", null); + this.pointsConnector.disconnect(); + this.pointsConnector.set("vectorLayer", null); + this.mapModel.removeAsset(this.layer); + }, + + /** + * Renders the DrawTool + * @returns {DrawTool} Returns the view + */ + render: function () { + if(!this.mapModel) { + this.showError("No map model was provided."); + return this; + } + this.renderToolbar(); + return this; + }, + + /** + * 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. + */ + showError: function (message) { + const str = `` + + ` The draw tool is not available. ${message}`; + this.el.innerHTML = str; + }, + + /** + * 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 () { + const view = this; + const el = this.el; + + // 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); + }); + }, + + /** + * Sends the polygon coordinates to a callback function to do something + * with them. + * @param {Function} callback - The callback function to send the polygon + * coordinates to. + */ + save: function (callback) { + this.setMode(false); + if(callback && typeof callback === "function") { + callback(this.points.toJSON()); + } + }, + + /** + * 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); + } + }, + + /** + * 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. + */ + setMode: function (mode) { + if (this.mode === mode) return; + this.mode = mode; + if (mode) { + if (!this.listeningForClicks) this.setClickListeners(); + this.activateButton(mode); + } else { + this.resetButtonStyles(); + this.removeClickListeners(); + } + }, + + /** + * 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.classList.add(this.buttonClassActive); + }, + + /** + * 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) { + if (this.buttonEls.hasOwnProperty(button)) { + const buttonEl = this.buttonEls[button]; + buttonEl.classList.remove(this.buttonClassActive); + } + } + }, + + /** + * Removes the click listeners from the map model and sets the + * clickFeatureAction back to its original value. + */ + removeClickListeners: function () { + const handler = this.clickHandler; + const originalAction = this.originalAction; + if (handler) { + handler.stopListening(); + handler.clear(); + this.clickHandler = null; + } + this.mapModel.set("clickFeatureAction", originalAction); + this.listeningForClicks = false; + }, + + /** + * Set listeners to call the handleClick method when the user clicks on + * the map. + */ + 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(); + } + ); + 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(); + } + }); + }, + + /** + * 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. + */ + handleClick: function (throttle = 50) { + // Prevent double clicks + if (this.clickActionBlocked) return; + this.clickActionBlocked = true; + setTimeout(() => { + this.clickActionBlocked = false; + }, throttle); + // Add the point to the polygon + if (this.mode === "draw") { + const point = this.interactions.get("clickedPosition"); + this.addPoint({ + latitude: point.get("latitude"), + longitude: point.get("longitude"), + height: point.get("height"), + mapWidgetCoords: point.get("mapWidgetCoords"), + }); + } + }, + + /** + * Clears the polygon that is being drawn + */ + onClose: function () { + this.removeLayer(); + this.removeClickListeners(); + }, + } + ); + + return DrawTool; +}); diff --git a/src/js/views/maps/FeatureInfoView.js b/src/js/views/maps/FeatureInfoView.js index 6aa935474..7323aec67 100644 --- a/src/js/views/maps/FeatureInfoView.js +++ b/src/js/views/maps/FeatureInfoView.js @@ -490,7 +490,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. */ @@ -499,7 +499,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/LayerListView.js b/src/js/views/maps/LayerListView.js index e55ce24ee..3cca479b7 100644 --- a/src/js/views/maps/LayerListView.js +++ b/src/js/views/maps/LayerListView.js @@ -79,12 +79,41 @@ 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 remove reset', this.render); + } + } catch (e) { + console.log('Failed to set listeners:', e); + } + }, + /** * Renders this view * @return {LayerListView} Returns the rendered view element @@ -108,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 }) 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 ad50f2da7..e8eac5e69 100644 --- a/src/js/views/maps/ToolbarView.js +++ b/src/js/views/maps/ToolbarView.js @@ -1,4 +1,3 @@ - 'use strict'; define( @@ -8,8 +7,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 +18,8 @@ define( Template, Map, // Sub-views - LayerListView + LayerListView, + DrawTool ) { /** @@ -170,8 +171,14 @@ define( label: 'Home', icon: 'home', action: function (view, model) { - model.trigger('flyHome') + model.flyHome(); } + }, + { + label: 'Draw', + icon: 'pencil', + view: DrawTool, + viewOptions: {} } ], @@ -452,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); } }, @@ -498,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; @@ -510,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); } }, 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); diff --git a/test/config/tests.json b/test/config/tests.json index a558e0dec..712ea5fa6 100644 --- a/test/config/tests.json +++ b/test/config/tests.json @@ -23,6 +23,15 @@ "./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/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", "./js/specs/unit/models/connectors/Map-Search-Filters.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..794c88501 --- /dev/null +++ b/test/js/specs/unit/collections/maps/GeoPoints.spec.js @@ -0,0 +1,396 @@ +// "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 () { + this.geoPoints = new GeoPoints(); + }); + + /* Tear down */ + 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); + }); + }); + }); +}); 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..9c62ee4f5 --- /dev/null +++ b/test/js/specs/unit/models/connectors/GeoPoints-Cesium.spec.js @@ -0,0 +1,46 @@ +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 () { + this.geoPointsCesium = new GeoPointsCesium(); + }); + + /* Tear down */ + 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 new file mode 100644 index 000000000..f9bd6996c --- /dev/null +++ b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPoints.spec.js @@ -0,0 +1,45 @@ +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 () { + // Create a new GeoPointsCesiumPoints instance + this.geoPointsCesiumPoints = new GeoPointsCesiumPoints(); + }); + + /* Tear down */ + 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 new file mode 100644 index 000000000..2ae22d56e --- /dev/null +++ b/test/js/specs/unit/models/connectors/GeoPoints-CesiumPolygon.spec.js @@ -0,0 +1,26 @@ +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 () { + this.geoPointsCesiumPolygon = new GeoPointsCesiumPolygon(); + }); + + /* Tear down */ + 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 new file mode 100644 index 000000000..15d70c087 --- /dev/null +++ b/test/js/specs/unit/models/maps/GeoBoundingBox.spec.js @@ -0,0 +1,109 @@ + + +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 () { + this.geoBoundingBox = new GeoBoundingBox(); + }); + + /* Tear down */ + 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/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/GeoUtilities.spec.js b/test/js/specs/unit/models/maps/GeoUtilities.spec.js new file mode 100644 index 000000000..367197a66 --- /dev/null +++ b/test/js/specs/unit/models/maps/GeoUtilities.spec.js @@ -0,0 +1,33 @@ + +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); + }); + }); + + 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 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/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) 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 = """