/** * @module olcs.core */ import {linear as linearEasing} from 'ol/easing.js'; import olLayerTile from 'ol/layer/Tile.js'; import olLayerImage from 'ol/layer/Image.js'; import {get as getProjection, transformExtent} from 'ol/proj.js'; import olSourceImageStatic from 'ol/source/ImageStatic.js'; import olSourceImageWMS from 'ol/source/ImageWMS.js'; import olSourceTileImage from 'ol/source/TileImage.js'; import olSourceTileWMS from 'ol/source/TileWMS.js'; import olSourceVectorTile from 'ol/source/VectorTile.js'; import {defaultImageLoadFunction} from 'ol/source/Image.js'; import olcsCoreOLImageryProvider from './core/OLImageryProvider.js'; import olcsUtil from './util.js'; import MVTImageryProvider from './MVTImageryProvider.js'; import VectorTileLayer from 'ol/layer/VectorTile.js'; import {getCenter as getExtentCenter} from 'ol/extent'; const exports = {}; /** * @typedef {Object} CesiumUrlDefinition * @property {string} url * @property {string} subdomains */ /** * Options for rotate around axis core function. * @typedef {Object} RotateAroundAxisOption * @property {number} [duration] * @property {function(number): number} [easing] * @property {function(): void} [callback] */ /** * @typedef {Object} LayerWithParents * @property {import('ol/layer/Base.js').default} layer * @property {Array} parents */ /** * Compute the pixel width and height of a point in meters using the * camera frustum. * @param {!Cesium.Scene} scene * @param {!Cesium.Cartesian3} target * @return {!Cesium.Cartesian2} the pixel size * @api */ exports.computePixelSizeAtCoordinate = function(scene, target) { const camera = scene.camera; const canvas = scene.canvas; const frustum = camera.frustum; const distance = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.subtract( camera.position, target, new Cesium.Cartesian3())); return frustum.getPixelDimensions(canvas.clientWidth, canvas.clientHeight, distance, scene.pixelRatio, new Cesium.Cartesian2()); }; /** * Compute bounding box around a target point. * @param {!Cesium.Scene} scene * @param {!Cesium.Cartesian3} target * @param {number} amount Half the side of the box, in pixels. * @return {Array} bottom left and top right * coordinates of the box */ exports.computeBoundingBoxAtTarget = function(scene, target, amount) { const pixelSize = exports.computePixelSizeAtCoordinate(scene, target); const transform = Cesium.Transforms.eastNorthUpToFixedFrame(target); const bottomLeft = Cesium.Matrix4.multiplyByPoint( transform, new Cesium.Cartesian3(-pixelSize.x * amount, -pixelSize.y * amount, 0), new Cesium.Cartesian3()); const topRight = Cesium.Matrix4.multiplyByPoint( transform, new Cesium.Cartesian3(pixelSize.x * amount, pixelSize.y * amount, 0), new Cesium.Cartesian3()); return Cesium.Ellipsoid.WGS84.cartesianArrayToCartographicArray( [bottomLeft, topRight]); }; /** * * @param {!ol.geom.Geometry} geometry * @param {number} height * @api */ exports.applyHeightOffsetToGeometry = function(geometry, height) { geometry.applyTransform((input, output, stride) => { console.assert(input === output); if (stride !== undefined && stride >= 3) { for (let i = 0; i < output.length; i += stride) { output[i + 2] = output[i + 2] + height; } } return output; }); }; /** * @param {ol.Coordinate} coordinates * @param {number=} rotation * @param {!Cesium.Cartesian3=} translation * @param {!Cesium.Cartesian3=} scale * @return {!Cesium.Matrix4} * @api */ exports.createMatrixAtCoordinates = function(coordinates, rotation = 0, translation = Cesium.Cartesian3.ZERO, scale = new Cesium.Cartesian3(1, 1, 1)) { const position = exports.ol4326CoordinateToCesiumCartesian(coordinates); const rawMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(position); const quaternion = Cesium.Quaternion.fromAxisAngle(Cesium.Cartesian3.UNIT_Z, -rotation); const rotationMatrix = Cesium.Matrix4.fromTranslationQuaternionRotationScale(translation, quaternion, scale); return Cesium.Matrix4.multiply(rawMatrix, rotationMatrix, new Cesium.Matrix4()); }; /** * @param {!Cesium.Camera} camera * @param {number} angle * @param {!Cesium.Cartesian3} axis * @param {!Cesium.Matrix4} transform * @param {RotateAroundAxisOption=} opt_options * @api */ exports.rotateAroundAxis = function(camera, angle, axis, transform, opt_options) { const clamp = Cesium.Math.clamp; const defaultValue = Cesium.defaultValue; const options = opt_options || {}; const duration = defaultValue(options.duration, 500); // ms const easing = defaultValue(options.easing, linearEasing); const callback = options.callback; let lastProgress = 0; const oldTransform = new Cesium.Matrix4(); const start = Date.now(); const step = function() { const timestamp = Date.now(); const timeDifference = timestamp - start; const progress = easing(clamp(timeDifference / duration, 0, 1)); console.assert(progress >= lastProgress); camera.transform.clone(oldTransform); const stepAngle = (progress - lastProgress) * angle; lastProgress = progress; camera.lookAtTransform(transform); camera.rotate(axis, stepAngle); camera.lookAtTransform(oldTransform); if (progress < 1) { window.requestAnimationFrame(step); } else { if (callback) { callback(); } } }; window.requestAnimationFrame(step); }; /** * @param {!Cesium.Scene} scene * @param {number} heading * @param {!Cesium.Cartesian3} bottomCenter * @param {RotateAroundAxisOption=} opt_options * @api */ exports.setHeadingUsingBottomCenter = function(scene, heading, bottomCenter, opt_options) { const camera = scene.camera; // Compute the camera position to zenith quaternion const angleToZenith = exports.computeAngleToZenith(scene, bottomCenter); const axis = camera.right; const quaternion = Cesium.Quaternion.fromAxisAngle(axis, angleToZenith); const rotation = Cesium.Matrix3.fromQuaternion(quaternion); // Get the zenith point from the rotation of the position vector const vector = new Cesium.Cartesian3(); Cesium.Cartesian3.subtract(camera.position, bottomCenter, vector); const zenith = new Cesium.Cartesian3(); Cesium.Matrix3.multiplyByVector(rotation, vector, zenith); Cesium.Cartesian3.add(zenith, bottomCenter, zenith); // Actually rotate around the zenith normal const transform = Cesium.Matrix4.fromTranslation(zenith); const rotateAroundAxis = exports.rotateAroundAxis; rotateAroundAxis(camera, heading, zenith, transform, opt_options); }; /** * Get the 3D position of the given pixel of the canvas. * @param {!Cesium.Scene} scene * @param {!Cesium.Cartesian2} pixel * @return {!Cesium.Cartesian3|undefined} * @api */ exports.pickOnTerrainOrEllipsoid = function(scene, pixel) { const ray = scene.camera.getPickRay(pixel); const target = scene.globe.pick(ray, scene); return target || scene.camera.pickEllipsoid(pixel); }; /** * Get the 3D position of the point at the bottom-center of the screen. * @param {!Cesium.Scene} scene * @return {!Cesium.Cartesian3|undefined} * @api */ exports.pickBottomPoint = function(scene) { const canvas = scene.canvas; const bottom = new Cesium.Cartesian2( canvas.clientWidth / 2, canvas.clientHeight); return exports.pickOnTerrainOrEllipsoid(scene, bottom); }; /** * Get the 3D position of the point at the center of the screen. * @param {!Cesium.Scene} scene * @return {!Cesium.Cartesian3|undefined} * @api */ exports.pickCenterPoint = function(scene) { const canvas = scene.canvas; const center = new Cesium.Cartesian2( canvas.clientWidth / 2, canvas.clientHeight / 2); return exports.pickOnTerrainOrEllipsoid(scene, center); }; /** * Compute the signed tilt angle on globe, between the opposite of the * camera direction and the target normal. Return undefined if there is no * intersection of the camera direction with the globe. * @param {!Cesium.Scene} scene * @return {number|undefined} * @api */ exports.computeSignedTiltAngleOnGlobe = function(scene) { const camera = scene.camera; const ray = new Cesium.Ray(camera.position, camera.direction); let target = scene.globe.pick(ray, scene); if (!target) { // no tiles in the area were loaded? const ellipsoid = Cesium.Ellipsoid.WGS84; const obj = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); if (obj) { target = Cesium.Ray.getPoint(ray, obj.start); } } if (!target) { return undefined; } const normal = new Cesium.Cartesian3(); Cesium.Ellipsoid.WGS84.geocentricSurfaceNormal(target, normal); const angleBetween = exports.signedAngleBetween; const angle = angleBetween(camera.direction, normal, camera.right) - Math.PI; return Cesium.Math.convertLongitudeRange(angle); }; /** * Compute the ray from the camera to the bottom-center of the screen. * @param {!Cesium.Scene} scene * @return {!Cesium.Ray} */ exports.bottomFovRay = function(scene) { const camera = scene.camera; const fovy2 = camera.frustum.fovy / 2; const direction = camera.direction; const rotation = Cesium.Quaternion.fromAxisAngle(camera.right, fovy2); const matrix = Cesium.Matrix3.fromQuaternion(rotation); const vector = new Cesium.Cartesian3(); Cesium.Matrix3.multiplyByVector(matrix, direction, vector); return new Cesium.Ray(camera.position, vector); }; /** * Compute the angle between two Cartesian3. * @param {!Cesium.Cartesian3} first * @param {!Cesium.Cartesian3} second * @param {!Cesium.Cartesian3} normal Normal to test orientation against. * @return {number} */ exports.signedAngleBetween = function(first, second, normal) { // We are using the dot for the angle. // Then the cross and the dot for the sign. const a = new Cesium.Cartesian3(); const b = new Cesium.Cartesian3(); const c = new Cesium.Cartesian3(); Cesium.Cartesian3.normalize(first, a); Cesium.Cartesian3.normalize(second, b); Cesium.Cartesian3.cross(a, b, c); const cosine = Cesium.Cartesian3.dot(a, b); const sine = Cesium.Cartesian3.magnitude(c); // Sign of the vector product and the orientation normal const sign = Cesium.Cartesian3.dot(normal, c); const angle = Math.atan2(sine, cosine); return sign >= 0 ? angle : -angle; }; /** * Compute the rotation angle around a given point, needed to reach the * zenith position. * At a zenith position, the camera direction is going througth the earth * center and the frustrum bottom ray is going through the chosen pivot * point. * The bottom-center of the screen is a good candidate for the pivot point. * @param {!Cesium.Scene} scene * @param {!Cesium.Cartesian3} pivot Point around which the camera rotates. * @return {number} * @api */ exports.computeAngleToZenith = function(scene, pivot) { // This angle is the sum of the angles 'fy' and 'a', which are defined // using the pivot point and its surface normal. // Zenith | camera // \ | / // \fy| / // \ |a/ // \|/pivot const camera = scene.camera; const fy = camera.frustum.fovy / 2; const ray = exports.bottomFovRay(scene); const direction = Cesium.Cartesian3.clone(ray.direction); Cesium.Cartesian3.negate(direction, direction); const normal = new Cesium.Cartesian3(); Cesium.Ellipsoid.WGS84.geocentricSurfaceNormal(pivot, normal); const left = new Cesium.Cartesian3(); Cesium.Cartesian3.negate(camera.right, left); const a = exports.signedAngleBetween(normal, direction, left); return a + fy; }; /** * Convert an OpenLayers extent to a Cesium rectangle. * @param {ol.Extent} extent Extent. * @param {ol.ProjectionLike} projection Extent projection. * @return {Cesium.Rectangle} The corresponding Cesium rectangle. * @api */ exports.extentToRectangle = function(extent, projection) { if (extent && projection) { const ext = transformExtent(extent, projection, 'EPSG:4326'); return Cesium.Rectangle.fromDegrees(ext[0], ext[1], ext[2], ext[3]); } else { return null; } }; /** * @param {!ol.Map} olMap * @param {!ol.source.Source} source * @param {!ol.View} viewProj * @param {!ol.layer.Base} olLayer * @return {!Cesium.ImageryProvider} */ exports.sourceToImageryProvider = function(olMap, source, viewProj, olLayer) { const skip = source.get('olcs_skip'); if (skip) { return null; } let provider = null; // Convert ImageWMS to TileWMS if (source instanceof olSourceImageWMS && source.getUrl() && source.getImageLoadFunction() === defaultImageLoadFunction) { const sourceProps = { 'olcs.proxy': source.get('olcs.proxy'), 'olcs.extent': source.get('olcs.extent'), 'olcs.projection': source.get('olcs.projection'), 'olcs.imagesource': source }; source = new olSourceTileWMS({ url: source.getUrl(), attributions: source.getAttributions(), projection: source.getProjection(), params: source.getParams() }); source.setProperties(sourceProps); } if (source instanceof olSourceTileImage) { let projection = olcsUtil.getSourceProjection(source); if (!projection) { // if not explicit, assume the same projection as view projection = viewProj; } if (exports.isCesiumProjection(projection)) { provider = new olcsCoreOLImageryProvider(olMap, source, viewProj); } // Projection not supported by Cesium else { return null; } } else if (source instanceof olSourceImageStatic) { let projection = olcsUtil.getSourceProjection(source); if (!projection) { projection = viewProj; } if (exports.isCesiumProjection(projection)) { provider = new Cesium.SingleTileImageryProvider({ url: source.getUrl(), rectangle: new Cesium.Rectangle.fromDegrees( source.getImageExtent()[0], source.getImageExtent()[1], source.getImageExtent()[2], source.getImageExtent()[3] ) }); } // Projection not supported by Cesium else { return null; } } else if (source instanceof olSourceVectorTile) { let projection = olcsUtil.getSourceProjection(source); if (!projection) { projection = viewProj; } if (skip === false) { // MVT is experimental, it should be whitelisted to be synchronized const fromCode = projection.getCode().split(':')[1]; const urls = source.urls.map(u => u.replace(fromCode, '3857')); const extent = olLayer.getExtent(); const rectangle = exports.extentToRectangle(extent, projection); const minimumLevel = source.get('olcs_minimumLevel'); const attributionsFunction = source.getAttributions(); const styleFunction = olLayer.getStyleFunction(); let credit; if (extent && attributionsFunction) { const center = getExtentCenter(extent); credit = attributionsFunctionToCredits(attributionsFunction, 0, center, extent)[0]; } provider = new MVTImageryProvider({ credit, rectangle, minimumLevel, styleFunction, urls }); return provider; } return null; // FIXME: it is disabled by default right now } else { // sources other than TileImage|ImageStatic are currently not supported return null; } return provider; }; /** * Creates Cesium.ImageryLayer best corresponding to the given ol.layer.Layer. * Only supports raster layers and static images * @param {!ol.Map} olMap * @param {!ol.layer.Base} olLayer * @param {!ol.proj.Projection} viewProj Projection of the view. * @return {?Cesium.ImageryLayer} null if not possible (or supported) * @api */ exports.tileLayerToImageryLayer = function(olMap, olLayer, viewProj) { if (!(olLayer instanceof olLayerTile) && !(olLayer instanceof olLayerImage) && !(olLayer instanceof VectorTileLayer)) { return null; } const source = olLayer.getSource(); if (!source) { return null; } let provider = source.get('olcs_provider'); if (!provider) { provider = this.sourceToImageryProvider(olMap, source, viewProj, olLayer); } if (!provider) { return null; } const layerOptions = {}; const forcedExtent = /** @type {ol.Extent} */ (olLayer.get('olcs.extent')); const ext = forcedExtent || olLayer.getExtent(); if (ext) { layerOptions.rectangle = exports.extentToRectangle(ext, viewProj); } const cesiumLayer = new Cesium.ImageryLayer(provider, layerOptions); return cesiumLayer; }; /** * Synchronizes the layer rendering properties (opacity, visible) * to the given Cesium ImageryLayer. * @param {olcsx.LayerWithParents} olLayerWithParents * @param {!Cesium.ImageryLayer} csLayer * @api */ exports.updateCesiumLayerProperties = function(olLayerWithParents, csLayer) { let opacity = 1; let visible = true; [olLayerWithParents.layer].concat(olLayerWithParents.parents).forEach((olLayer) => { const layerOpacity = olLayer.getOpacity(); if (layerOpacity !== undefined) { opacity *= layerOpacity; } const layerVisible = olLayer.getVisible(); if (layerVisible !== undefined) { visible &= layerVisible; } }); csLayer.alpha = opacity; csLayer.show = visible; }; /** * Convert a 2D or 3D OpenLayers coordinate to Cesium. * @param {ol.Coordinate} coordinate Ol3 coordinate. * @return {!Cesium.Cartesian3} Cesium cartesian coordinate * @api */ exports.ol4326CoordinateToCesiumCartesian = function(coordinate) { const coo = coordinate; return coo.length > 2 ? Cesium.Cartesian3.fromDegrees(coo[0], coo[1], coo[2]) : Cesium.Cartesian3.fromDegrees(coo[0], coo[1]); }; /** * Convert an array of 2D or 3D OpenLayers coordinates to Cesium. * @param {Array.} coordinates Ol3 coordinates. * @return {!Array.} Cesium cartesian coordinates * @api */ exports.ol4326CoordinateArrayToCsCartesians = function(coordinates) { console.assert(coordinates !== null); const toCartesian = exports.ol4326CoordinateToCesiumCartesian; const cartesians = []; for (let i = 0; i < coordinates.length; ++i) { cartesians.push(toCartesian(coordinates[i])); } return cartesians; }; /** * Reproject an OpenLayers geometry to EPSG:4326 if needed. * The geometry will be cloned only when original projection is not EPSG:4326 * and the properties will be shallow copied. * @param {!T} geometry * @param {!ol.ProjectionLike} projection * @return {!T} * @template T * @api */ exports.olGeometryCloneTo4326 = function(geometry, projection) { console.assert(projection); const proj4326 = getProjection('EPSG:4326'); const proj = getProjection(projection); if (proj !== proj4326) { const properties = geometry.getProperties(); geometry = geometry.clone(); geometry.transform(proj, proj4326); geometry.setProperties(properties); } return geometry; }; /** * Convert an OpenLayers color to Cesium. * @param {ol.Color|CanvasGradient|CanvasPattern|string} olColor * @return {!Cesium.Color} * @api */ exports.convertColorToCesium = function(olColor) { olColor = olColor || 'black'; if (Array.isArray(olColor)) { return new Cesium.Color( Cesium.Color.byteToFloat(olColor[0]), Cesium.Color.byteToFloat(olColor[1]), Cesium.Color.byteToFloat(olColor[2]), olColor[3] ); } else if (typeof olColor == 'string') { return Cesium.Color.fromCssColorString(olColor); } else if (olColor instanceof CanvasPattern || olColor instanceof CanvasGradient) { // Render the CanvasPattern/CanvasGradient into a canvas that will be sent to Cesium as material const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = canvas.height = 256; ctx.fillStyle = olColor; ctx.fillRect(0, 0, canvas.width, canvas.height); return new Cesium.ImageMaterialProperty({ image: canvas }); } console.assert(false, 'impossible'); }; /** * Convert an OpenLayers url to Cesium. * @param {string} url * @return {!CesiumUrlDefinition} * @api */ exports.convertUrlToCesium = function(url) { let subdomains = ''; const re = /\{(\d|[a-z])-(\d|[a-z])\}/; const match = re.exec(url); if (match) { url = url.replace(re, '{s}'); const startCharCode = match[1].charCodeAt(0); const stopCharCode = match[2].charCodeAt(0); let charCode; for (charCode = startCharCode; charCode <= stopCharCode; ++charCode) { subdomains += String.fromCharCode(charCode); } } return { url, subdomains }; }; /** * Animate the return to a top-down view from the zenith. * The camera is rotated to orient to the North. * @param {!ol.Map} map * @param {!Cesium.Scene} scene * @return {Promise} * @api */ exports.resetToNorthZenith = function(map, scene) { return new Promise((resolve, reject) => { const camera = scene.camera; const pivot = exports.pickBottomPoint(scene); if (!pivot) { reject('Could not get bottom pivot'); return; } const currentHeading = map.getView().getRotation(); if (currentHeading === undefined) { reject('The view is not initialized'); return; } const angle = exports.computeAngleToZenith(scene, pivot); // Point to North exports.setHeadingUsingBottomCenter(scene, currentHeading, pivot); // Go to zenith const transform = Cesium.Matrix4.fromTranslation(pivot); const axis = camera.right; const options = { callback: () => { const view = map.getView(); exports.normalizeView(view); resolve(); } }; exports.rotateAroundAxis(camera, -angle, axis, transform, options); }); }; /** * @param {!Cesium.Scene} scene * @param {number} angle in radian * @return {Promise} * @api */ exports.rotateAroundBottomCenter = function(scene, angle) { return new Promise((resolve, reject) => { const camera = scene.camera; const pivot = exports.pickBottomPoint(scene); if (!pivot) { reject('could not get bottom pivot'); return; } const options = {callback: resolve}; const transform = Cesium.Matrix4.fromTranslation(pivot); const axis = camera.right; const rotateAroundAxis = exports.rotateAroundAxis; rotateAroundAxis(camera, -angle, axis, transform, options); }); }; /** * Set the OpenLayers view to a specific rotation and * the nearest resolution. * @param {ol.View} view * @param {number=} angle * @api */ exports.normalizeView = function(view, angle = 0) { const resolution = view.getResolution(); view.setRotation(angle); if (view.constrainResolution) { view.setResolution(view.constrainResolution(resolution)); } else { view.setResolution(view.getConstrainedResolution(resolution)); } }; /** * Check if the given projection is managed by Cesium (WGS84 or Mercator Spheric) * * @param {ol.proj.Projection} projection Projection to check. * @returns {boolean} Whether it's managed by Cesium. */ exports.isCesiumProjection = function(projection) { const is3857 = projection === getProjection('EPSG:3857'); const is4326 = projection === getProjection('EPSG:4326'); return is3857 || is4326; }; export function attributionsFunctionToCredits(attributionsFunction, zoom, center, extent) { const frameState = { viewState: {zoom, center}, extent, }; if (!attributionsFunction) { return []; } let attributions = attributionsFunction(frameState); if (!Array.isArray(attributions)) { attributions = [attributions]; } return attributions.map(html => new Cesium.Credit(html, true)); } export default exports; /** * calculate the distance between camera and centerpoint based on the resolution and latitude value * @param {number} resolution Number of map units per pixel. * @param {number} latitude Latitude in radians. * @param {import('cesium').Scene} scene. * @param {import('ol/proj/Projection').default} projection View projection. * @return {number} The calculated distance. * @api */ export function calcDistanceForResolution(resolution, latitude, scene, projection) { const canvas = scene.canvas; const camera = scene.camera; const fovy = camera.frustum.fovy; // vertical field of view console.assert(!isNaN(fovy)); const metersPerUnit = projection.getMetersPerUnit(); // number of "map units" visible in 2D (vertically) const visibleMapUnits = resolution * canvas.clientHeight; // The metersPerUnit does not take latitude into account, but it should // be lower with increasing latitude -- we have to compensate. // In 3D it is not possible to maintain the resolution at more than one point, // so it only makes sense to use the latitude of the "target" point. const relativeCircumference = Math.cos(Math.abs(latitude)); // how many meters should be visible in 3D const visibleMeters = visibleMapUnits * metersPerUnit * relativeCircumference; // distance required to view the calculated length in meters // // fovy/2 // |\ // x | \ // |--\ // visibleMeters/2 const requiredDistance = (visibleMeters / 2) / Math.tan(fovy / 2); // NOTE: This calculation is not absolutely precise, because metersPerUnit // is a great simplification. It does not take ellipsoid/terrain into account. return requiredDistance; } /** * calculate the resolution based on a distance(camera to position) and latitude value * @param {number} distance * @param {number} latitude * @param {import('cesium').Scene} scene. * @param {import('ol/proj/Projection').default} projection View projection. * @return {number} The calculated resolution. * @api */ export function calcResolutionForDistance(distance, latitude, scene, projection) { // See the reverse calculation (calcDistanceForResolution) for details const canvas = scene.canvas; const camera = scene.camera; const fovy = camera.frustum.fovy; // vertical field of view console.assert(!isNaN(fovy)); const metersPerUnit = projection.getMetersPerUnit(); const visibleMeters = 2 * distance * Math.tan(fovy / 2); const relativeCircumference = Math.cos(Math.abs(latitude)); const visibleMapUnits = visibleMeters / metersPerUnit / relativeCircumference; const resolution = visibleMapUnits / canvas.clientHeight; return resolution; }