"use strict";
define([
"jquery",
"underscore",
"backbone",
"cesium",
"models/maps/assets/MapAsset",
], function ($, _, Backbone, Cesium, MapAsset) {
const PROVIDER_TYPES = {
WMTS: "WebMapTileServiceImageryProvider",
WMS: "WebMapServiceImageryProvider",
TMS: "TileMapServiceImageryProvider",
ION: "IonImageryProvider",
OSM: "OpenStreetMapImageryProvider",
BING: "BingMapsImageryProvider",
NATURAL_EARTH: "NaturalEarthII",
USGS: "USGSImageryTopo",
};
const OSM_DEFAULT_URL = "https://tile.openstreetmap.org/";
const BING_DEFAULT_URL = "https://dev.virtualearth.net";
const VALIDATION_MESSAGES = {
WMTS_URL: "Enter a tile URL with {TileMatrix}, {TileCol}, and {TileRow}.",
ION_ID: "Enter a Cesium Ion asset ID as a whole number greater than 0.",
SERVICE_URL: "Enter the URL of the imagery service.",
WMS_LAYERS:
"Enter at least one layer name from the WMS service. Separate multiple names with commas.",
RECTANGLE:
"Enter all four coordinates in degrees, or leave all four blank. Longitudes must be between -180 and 180; latitudes between -90 and 90. South must be less than or equal to north.",
};
/**
* @classdesc A CesiumImagery Model contains the information required for Cesium to
* request and draw high-resolution image tiles using several standards (Cesium
* "imagery providers"), including Cesium Ion and Bing Maps. Imagery layers have
* brightness, contrast, gamma, hue, and saturation properties that can be dynamically
* changed.
* @classcategory Models/Maps/Assets
* @class CesiumImagery
* @name CesiumImagery
* @extends MapAsset
* @since 2.18.0
* @constructor
*/
var CesiumImagery = MapAsset.extend(
/** @lends CesiumImagery.prototype */ {
/**
* The name of this type of model
* @type {string}
*/
type: "CesiumImagery",
/**
* Options that are supported for creating imagery tiles. Any properties provided
* here are passed to the Cesium constructor function, so other properties that
* are documented in Cesium are also supported. See
* {@link https://cesium.com/learn/cesiumjs/ref-doc/BingMapsImageryProvider.html#.ConstructorOptions}
* and
* {@link https://cesium.com/learn/cesiumjs/ref-doc/IonImageryProvider.html#.ConstructorOptions}.
* @typedef {Object} CesiumImagery#cesiumOptions
* @property {string|number} ionAssetId - If this imagery is hosted by Cesium
* Ion, then Ion asset ID.
* @property {string|number} key - A key or token required to access the tiles.
* For example, if this is a BingMapsImageryProvider, then the Bing maps key. If
* one is required and not set, the model will look in the {@link AppModel} for a
* key, for example, {@link AppModel#bingMapsKey}
* @property {'GeographicTilingScheme'|'WebMercatorTilingScheme'} tilingScheme -
* The tiling scheme to use when constructing an imagery provider. If not set,
* Cesium uses WebMercatorTilingScheme by default.
* @property {Number[]} rectangle - The rectangle covered by the layer. The list
* of west, south, east, north bounding degree coordinates, respectively. This
* will be passed to Cesium.Rectangle.fromDegrees to define the bounding box of
* the imagery layer. If left undefined, the layer will cover the entire globe.
*/
/**
* Default attributes for CesiumImagery models
* @name CesiumImagery#defaults
* @extends MapAsset#defaults
* @type {Object}
* @property {'BingMapsImageryProvider'|'IonImageryProvider'|'TileMapServiceImageryProvider'|'WebMapTileServiceImageryProvider'} type
* A string indicating a Cesium Imagery Provider type. See
* {@link https://cesium.com/learn/cesiumjs-learn/cesiumjs-imagery/#more-imagery-providers}
* @property {Cesium.ImageryLayer} cesiumModel A 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/ImageryLayer.html?classFilter=ImageryLayer}
* and
* {@link https://cesium.com/learn/cesiumjs/ref-doc/?classFilter=ImageryProvider}
* @property {CesiumImagery#cesiumOptions} cesiumOptions options that are passed
* to the function that creates the Cesium model. The properties of options are
* specific to each type of asset.
*/
defaults: function () {
return _.extend(this.constructor.__super__.defaults(), {
type: "",
cesiumModel: null,
cesiumOptions: {},
// brightness: 1, contrast: 1, gamma: 1, hue: 0, saturation: 1,
});
},
/**
* Executed when a new CesiumImagery model is created.
* @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the
* attributes, which will be set on the model.
*/
initialize: function (assetConfig) {
try {
MapAsset.prototype.initialize.call(this, assetConfig);
if (this.get("type") === PROVIDER_TYPES.USGS) {
this.initUSGSImageryTopo(assetConfig);
}
this.createCesiumModelWhenVisible();
this.getThumbnail();
} catch (e) {
console.log("Error initializing a CesiumImagery model: ", e);
}
},
/**
* Save the current imagery source. Provider creation expands shortcuts
* and converts options on a copy, leaving these attributes as config.
* @returns {MapConfig#MapAssetConfig} A detached object of settings to save.
* @since 2.41.0
*/
toConfig() {
return {
...MapAsset.prototype.toConfig.call(this),
type: this.get("type"),
cesiumOptions: this.getCesiumOptions(),
};
},
/**
* Set default metadata for the USGS Imagery Topo asset.
* @param {MapConfig#MapAssetConfig} [assetConfig] The initial values of the
* attributes, which will be set on the model.
*/
initUSGSImageryTopo: function (assetConfig) {
try {
if (!assetConfig.moreInfoLink) {
this.set(
"moreInfoLink",
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryTopo/MapServer",
);
}
if (!assetConfig.attribution) {
this.set(
"attribution",
"USGS The National Map: Orthoimagery and US Topo. Data refreshed January, 2022.",
);
}
if (!assetConfig.description) {
this.set(
"description",
"USGS Imagery Topo is a tile cache base map of orthoimagery in The National Map and US Topo vectors visible to the 1:9,028 scale.",
);
}
if (!assetConfig.label) {
this.set("label", "USGS Imagery Topo");
}
} catch (error) {
console.log(
"There was an error initializing USGSImageryTopo in a CesiumImagery" +
". Error details: " +
error,
);
}
},
/**
* Creates a Cesium ImageryLayer that contains information about how the imagery
* should render in Cesium. See
* {@link https://cesium.com/learn/cesiumjs/ref-doc/ImageryLayer.html?classFilter=ImageryLay}
* @param {Boolean} recreate - Set recreate to true to force the function create
* the Cesium Model again. Otherwise, if a cesium model already exists, that is
* returned instead.
*/
createCesiumModel: function (recreate = false) {
var model = this;
const cesiumOptions = this.getCesiumOptions() || {};
let type = this.get("type");
// If the cesium model already exists, don't create it again unless specified
if (!recreate && this.get("cesiumModel")) {
console.log("returning existing cesium model");
return this.get("cesiumModel");
}
model.resetStatus();
var initialAppearance = {
alpha: this.get("opacity"),
show: this.get("visible"),
saturation: this.get("saturation"),
// TODO: brightness, contrast, gamma, etc.
};
if (type === PROVIDER_TYPES.NATURAL_EARTH) {
type = PROVIDER_TYPES.TMS;
cesiumOptions.url = Cesium.buildModuleUrl(
"Assets/Textures/NaturalEarthII",
);
} else if (type === PROVIDER_TYPES.USGS) {
type = PROVIDER_TYPES.WMS;
cesiumOptions.url =
"https://basemap.nationalmap.gov:443/arcgis/services/USGSImageryTopo/MapServer/WmsServer";
cesiumOptions.layers = "0";
cesiumOptions.parameters = {
transparent: true,
format: "image/png",
};
} else if (type === PROVIDER_TYPES.BING) {
cesiumOptions.url = cesiumOptions.url || BING_DEFAULT_URL;
cesiumOptions.key =
cesiumOptions.key || MetacatUI.AppConfig.bingMapsKey;
} else if (type === PROVIDER_TYPES.ION) {
cesiumOptions.assetId = Number(cesiumOptions.ionAssetId);
delete cesiumOptions.ionAssetId;
cesiumOptions.accessToken =
cesiumOptions.cesiumToken || MetacatUI.appModel.get("cesiumToken");
} else if (type === PROVIDER_TYPES.OSM) {
cesiumOptions.url = cesiumOptions.url || OSM_DEFAULT_URL;
}
if (cesiumOptions && cesiumOptions.tilingScheme) {
const ts = cesiumOptions.tilingScheme;
const availableTS = [
"GeographicTilingScheme",
"WebMercatorTilingScheme",
];
if (availableTS.indexOf(ts) > -1) {
cesiumOptions.tilingScheme = new Cesium[ts]();
} else {
console.log(
`${ts} is not a valid tiling scheme. Using WebMercatorTilingScheme`,
);
cesiumOptions.tilingScheme = new Cesium.WebMercatorTilingScheme();
}
}
if (cesiumOptions.rectangle) {
cesiumOptions.rectangle = Cesium.Rectangle.fromDegrees(
...cesiumOptions.rectangle,
);
}
const providerFunction = Cesium[type];
if (providerFunction && typeof providerFunction === "function") {
let provider = new providerFunction(cesiumOptions);
provider.readyPromise
.then(function () {
// Imagery must be converted from a Cesium Imagery Provider to a Cesium
// Imagery Layer. See
// https://cesium.com/learn/cesiumjs-learn/cesiumjs-imagery/#imagery-providers-vs-layers
const imageryLayer = new Cesium.ImageryLayer(
provider,
initialAppearance,
);
imageryLayer.mapAssetModel = model;
model.set("cesiumModel", imageryLayer);
model.set("status", "ready");
model.setListeners();
})
.otherwise(function (error) {
// See https://cesium.com/learn/cesiumjs/ref-doc/RequestErrorEvent.html
let details = error;
// Write a helpful error message
switch (error.statusCode) {
case 404:
details = "The resource was not found (error code 404).";
break;
case 500:
details = "There was a server error (error code 500).";
break;
}
model.set("status", "error");
model.set("statusDetails", details);
});
} else {
model.set("status", "error");
model.set(
"statusDetails",
type + " is not a supported imagery type.",
);
}
},
/**
* Set listeners that update the cesium model when the backbone model is updated.
*/
setListeners: function () {
try {
var cesiumModel = this.get("cesiumModel");
if (!cesiumModel) {
this.listenToOnce(this, "change:cesiumModel", this.setListeners);
return;
}
// Make sure the listeners are only set once!
this.stopListening(this);
this.listenTo(this, "change:opacity", function (model, opacity) {
cesiumModel.alpha = opacity;
// 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");
});
this.listenTo(this, "change:visible", function (model, visible) {
cesiumModel.show = visible;
// 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 setting listeners in a cesium Imagery model" +
". Error details: " +
error,
);
}
},
/**
* Returns true when this imagery layer has at least one drawable tile
* in cache and is currently shown in the scene.
* @param {Cesium.Scene} scene The scene where this imagery is rendered.
* @returns {boolean} True when imagery should be considered display-ready.
* @since 2.41.0
*/
isDisplayReadyInScene: function (scene) {
const imageryLayer = this.get("cesiumModel");
if (!imageryLayer || !scene || !scene.imageryLayers) {
return false;
}
if (!imageryLayer.show || !scene.imageryLayers.contains(imageryLayer)) {
return false;
}
const imageryCache = imageryLayer._imageryCache;
if (!imageryCache || typeof imageryCache !== "object") {
return false;
}
const cacheEntries = Object.values(imageryCache);
if (!cacheEntries.length) {
return false;
}
const imageryState = Cesium.ImageryState;
return cacheEntries.some(function (entry) {
if (!entry) {
return false;
}
return (
entry.state === imageryState.READY ||
entry.state === imageryState.TEXTURE_LOADED
);
});
},
/**
* Wrap this layer's imagery provider so outstanding tile requests can
* be counted directly from the promises Cesium creates, instead of
* inferred from Cesium's internal tile cache. The cache can retain
* stale/orphaned entries in a transient state indefinitely - for
* example, an ancestor tile kept around for level-of-detail fallback
* that Cesium stops reprocessing once it's no longer needed for the
* current view - which would otherwise look like "still loading" even
* though there is no real network/processing activity. Counting
* requestImage calls avoids that: a call either returns undefined
* synchronously (Cesium declined to make a request - no work done) or
* a promise that settles when the real work finishes.
* @param {Cesium.ImageryProvider} provider The provider to wrap.
* @param {Function} onPendingCountChange Called with the current
* pending request count whenever it changes.
* @returns {Function} A function that restores the original
* requestImage function.
* @since 2.41.0
*/
wrapRequestImageForPendingCount: function (
provider,
onPendingCountChange,
) {
if (!provider || typeof provider.requestImage !== "function") {
return function () {};
}
const originalRequestImage = provider.requestImage.bind(provider);
let pendingCount = 0;
let isUnwrapped = false;
provider.requestImage = function (x, y, level, request) {
const result = originalRequestImage(x, y, level, request);
if (result && typeof result.then === "function") {
pendingCount += 1;
onPendingCountChange(pendingCount);
const onSettled = function () {
if (isUnwrapped) {
return;
}
pendingCount = Math.max(0, pendingCount - 1);
onPendingCountChange(pendingCount);
};
result.then(onSettled, onSettled);
}
return result;
};
return function unwrap() {
isUnwrapped = true;
if (provider.requestImage !== originalRequestImage) {
provider.requestImage = originalRequestImage;
}
};
},
/**
* Start watching scene render events to mark this asset display-ready
* after its first drawable tile, and wrap the imagery provider to
* continuously track whether the layer currently has outstanding tile
* requests. Unlike displayReady (a one-time latch), tilesLoading is
* kept in sync for as long as the layer is in the scene, so the map
* loading indicator does not get stuck when no tiles are ever in view
* (or when Cesium abandons stale cache entries), and keeps working
* while panning brings new tiles into view after the first one loads.
* @param {object} context A context object with a Cesium scene.
* @param {Cesium.Scene} context.scene The scene where imagery is drawn.
* @since 2.41.0
*/
startLoadingStateTracking: function (context) {
const scene = context?.scene;
const model = this;
this.stopLoadingStateTracking();
const imageryLayer = this.get("cesiumModel");
if (!scene || !imageryLayer) {
return;
}
model.set("tilesLoading", false);
let isCanceled = false;
let removePostRenderListener = null;
let unwrapRequestImage = null;
const cleanupListeners = function () {
if (isCanceled) {
return;
}
isCanceled = true;
if (typeof removePostRenderListener === "function") {
removePostRenderListener();
removePostRenderListener = null;
}
if (typeof unwrapRequestImage === "function") {
unwrapRequestImage();
unwrapRequestImage = null;
}
model.loadingStateTrackerCancel = null;
};
const checkDisplayReady = function () {
if (isCanceled) {
return;
}
if (model.get("status") === "error") {
model.set("tilesLoading", false);
cleanupListeners();
return;
}
if (
model.get("displayReady") !== true &&
model.isDisplayReadyInScene(scene)
) {
model.set("displayReady", true);
}
};
unwrapRequestImage = model.wrapRequestImageForPendingCount(
imageryLayer.imageryProvider,
function (pendingCount) {
if (isCanceled) {
return;
}
model.set("tilesLoading", pendingCount > 0);
},
);
removePostRenderListener =
scene.postRender.addEventListener(checkDisplayReady);
this.loadingStateTrackerCancel = cleanupListeners;
checkDisplayReady();
},
/**
* Stop any pending display-ready/loading-state watcher for this
* imagery layer.
* @since 2.41.0
*/
stopLoadingStateTracking: function () {
if (typeof this.loadingStateTrackerCancel === "function") {
this.loadingStateTrackerCancel();
}
this.loadingStateTrackerCancel = null;
this.set("tilesLoading", null);
},
/**
* Gets a Cesium Bounding Sphere that can be used to navigate to view the full
* extent of the imagery. See
* {@link https://cesium.com/learn/cesiumjs/ref-doc/BoundingSphere.html}
* @returns {Promise} Returns a promise that resolves to a Cesium Bounding Sphere
* when ready
*/
getBoundingSphere: function () {
return this.whenReady()
.then(function (model) {
return model.get("cesiumModel").getViewableRectangle();
})
.then(function (rectangle) {
return Cesium.BoundingSphere.fromRectangle3D(rectangle);
});
},
/**
* Requests a tile from the imagery provider that is at the center of the layer's
* bounding box and at the minimum level. Once the image is fetched, sets its URL
* on the thumbnail property of this model. This function is first called when the
* layer initialized, but waits for the cesiumModel to be ready.
*/
getThumbnail: function () {
try {
if (this.get("status") !== "ready") {
this.listenToOnce(this, "change:status", this.getThumbnail);
return;
}
const model = this;
const cesImageryLayer = this.get("cesiumModel");
const provider = cesImageryLayer.imageryProvider;
const rect = cesImageryLayer.rectangle;
var x = (rect.east + rect.west) / 2;
var y = (rect.north + rect.south) / 2;
var level = provider.minimumLevel;
provider
.requestImage(x, y, level)
.then(function (response) {
let data = response.blob;
let objectURL = null;
if (!data && response instanceof ImageBitmap) {
objectURL = model.getDataUriFromBitmap(response);
} else {
objectURL = URL.createObjectURL(data);
}
model.set("thumbnail", objectURL);
})
.otherwise(function (e) {
console.log(
"Error requesting an image tile to use as a thumbnail for an " +
"Imagery Layer. Error message: " +
e,
);
});
} catch (error) {
console.log(
"There was an error getting a thumbnail for a CesiumImagery layer" +
". Error details: " +
error,
);
}
},
/**
* Gets a data URI from a bitmap image.
* @param {ImageBitmap} bitmap The bitmap image to convert to a data URI
* @returns {String} Returns a string containing the requested data URI.
*/
getDataUriFromBitmap: function (imageBitmap) {
try {
const canvas = document.createElement("canvas");
canvas.width = imageBitmap.width;
canvas.height = imageBitmap.height;
const ctx = canvas.getContext("2d");
// y-flip the image - Natural Earth II bitmaps appear upside down otherwise
// TODO: Test with other imagery layers
ctx.translate(0, imageBitmap.height);
ctx.scale(1, -1);
ctx.drawImage(imageBitmap, 0, 0);
return canvas.toDataURL();
} catch (error) {
console.log(
"There was an error converting an ImageBitmap to a data URL" +
". Error details: " +
error,
);
}
},
/**
* Validate candidate imagery source options without changing them.
* @param {object} attrs The candidate model attributes
* @returns {object|undefined} Field messages, or no error when valid
* @since 2.41.0
*/
validate(attrs) {
const source = attrs.cesiumOptions || {};
const errors = {};
if (attrs.type === PROVIDER_TYPES.WMTS) {
const isTileTemplate =
typeof source.url === "string" &&
["{TileMatrix}", "{TileCol}", "{TileRow}"].every((placeholder) =>
source.url.includes(placeholder),
);
const isKvpService =
typeof source.url === "string" &&
source.url.trim() &&
// Cesium also supports a single {s} subdomain token in KVP URLs.
!source.url.replace("{s}", "").includes("{") &&
["layer", "style", "tileMatrixSetID"].every(
(key) => typeof source[key] === "string",
);
if (!isTileTemplate && !isKvpService) {
errors["cesiumOptions.url"] = VALIDATION_MESSAGES.WMTS_URL;
}
}
if (attrs.type === PROVIDER_TYPES.ION) {
const id =
typeof source.ionAssetId === "number" ||
typeof source.ionAssetId === "string"
? Number(source.ionAssetId)
: NaN;
if (!Number.isInteger(id) || id <= 0) {
errors["cesiumOptions.ionAssetId"] = VALIDATION_MESSAGES.ION_ID;
}
}
const url =
attrs.type === PROVIDER_TYPES.BING
? source.url || BING_DEFAULT_URL
: source.url;
if (
[
PROVIDER_TYPES.WMS,
PROVIDER_TYPES.TMS,
PROVIDER_TYPES.BING,
].includes(attrs.type) &&
(typeof url !== "string" || !url.trim())
) {
errors["cesiumOptions.url"] = VALIDATION_MESSAGES.SERVICE_URL;
}
if (
attrs.type === PROVIDER_TYPES.WMS &&
(typeof source.layers !== "string" || !source.layers.trim())
) {
errors["cesiumOptions.layers"] = VALIDATION_MESSAGES.WMS_LAYERS;
}
const { rectangle } = source;
if (
rectangle !== undefined &&
!(
Array.isArray(rectangle) &&
rectangle.length === 4 &&
rectangle.every(Number.isFinite) &&
Math.abs(rectangle[0]) <= 180 &&
Math.abs(rectangle[1]) <= 90 &&
Math.abs(rectangle[2]) <= 180 &&
Math.abs(rectangle[3]) <= 90 &&
rectangle[1] <= rectangle[3]
)
) {
errors["cesiumOptions.rectangle"] = VALIDATION_MESSAGES.RECTANGLE;
}
return Object.keys(errors).length ? errors : undefined;
},
},
);
/**
* Imagery source type identifiers shared by the model and editor.
* @type {Object<string, string>}
* @since 2.41.0
*/
CesiumImagery.PROVIDER_TYPES = PROVIDER_TYPES;
/**
* Standard OpenStreetMap tile server URL shared by the model and editor.
* @type {string}
* @since 2.41.0
*/
CesiumImagery.OSM_DEFAULT_URL = OSM_DEFAULT_URL;
/**
* Standard Bing Maps service URL shared by the model and editor.
* @type {string}
* @since 2.41.0
*/
CesiumImagery.BING_DEFAULT_URL = BING_DEFAULT_URL;
return CesiumImagery;
});