diff --git a/.babelrc b/.babelrc index fcc5468eca1..f3d11ffe5a0 100644 --- a/.babelrc +++ b/.babelrc @@ -7,7 +7,7 @@ "useBuiltIns": "usage" } ], - "@babel/preset-react", + ["@babel/preset-react", { "runtime": "automatic" }], ["@babel/typescript", { "allowNamespaces": true }] ], "plugins": [ diff --git a/.gitignore b/.gitignore index 96f50f40ded..5b4206bdf97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Update .prettierignore whenever this file changes docs/ node_modules/ .idea/ @@ -17,6 +18,7 @@ package-lock.json *.swp dist .history/ +ts-out/ # Catalog index generation files catalog-index*.json diff --git a/.prettierignore b/.prettierignore index 62a39ac9c00..54c6fab53a1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -20,6 +20,7 @@ package-lock.json *.swp dist .history/ +ts-out/ # Catalog index generation files catalog-index*.json diff --git a/CHANGES.md b/CHANGES.md index 9102ed6cfed..a99993c3ea2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,36 @@ # Change Log -#### next release (8.3.10) +#### next release (8.5.0) +- **Breaking changes:** + - Upgrade TypeScript to 5.2 + - Switch Babel configuration to new JSX transform +- Improve tsconfig files +- Update `thredds-catalog-crawler` to `0.0.6` - [The next improvement] +#### 8.4.1 - 2023-12-08 + +- Temporary UX fixes for clipping box: + - An option to zoom to clipping box + - An option to re-position the clipping box + - Trigger repositioning of clipping box when the user enables clipping box for the first time + - Cursor and scale point handle changes (makes it much easier to grasp) + - More robust interaction with the box +- Fix a bug where `DragPoints` was interfering with pedstrian mode mouse movements. +- Update `webpack` to `4.47.0` to support Node >= 18 without extra command line parameters. +- Add support for multiple `urls` for `GeoJsonCatalogItem`. +- Automatically explode GeoJSON `MultiPoint` features to `Point` features. +- Add new table styling traits - `scaleByDistance` and `disableDepthTestDistance`. +- Add support for `LineString` and `MultiLineString` when using `GeoJsonCatalogItem` in `CZML` mode. + +#### 8.4.0 - 2023-12-01 + +- **Breaking change:** Replaced `node-sass` with (dart) `sass` + - You will need to update your `TerriaMap` to use `sass` instead of `node-sass`. +- Added `apiColumns` to `ApiTableCatalogItem` - this can now be used to specify `responseDataPath` per table column. +- `ArcGisMapServerCatalogItem` will now use "pre-cached tiles" if available if no (or all) `layers` are specified. + #### 8.3.9 - 2023-11-24 - **Breaking change:** new Search Provider model diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index dbe93be473d..4d2a86cd1a2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -64,7 +64,7 @@ reports via email addressed to at least 2 of the following community leaders: - Ana Belgun: ana.belgun@data61.csiro.au - Stephen Davies: stephen.davies@data61.csiro.au -- Mats Henrikson: mats.henrikson@data61.csiro.au +- Nick Forbes-Smith: nick.forbes-smith@data61.csiro.au - Peter Hassall: peter.hassall@data61.csiro.au All complaints will be reviewed and investigated promptly and fairly. diff --git a/README.md b/README.md index 3c7be658c61..9ab810111a4 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Sites we're aware of that are using TerriaJS. These are not endorsements or test ### Technical -- NodeJS v14 or v16 are supported +- NodeJS v16, v18 and v20 are supported - Built in TypeScript & ES2020+ JavaScript, compiled with Babel to ES5. - Supports modern browsers (recent versions of Microsoft Edge, Mozilla Firefox & Google Chrome). - [TerriaJS Server component](https://github.com/TerriajS/TerriaJS-Server) runs in NodeJS and provides proxying for web services that don't support CORS or require authentication. Instead of using TerriaJS-Sever proxy service, an alternative proxying service URL can be specified. See [Specify an alternative proxy server URL](/doc/connecting-to-data/cross-origin-resource-sharing.md) diff --git a/buildprocess/configureWebpack.js b/buildprocess/configureWebpack.js index b4ca392a087..081e14dce6b 100644 --- a/buildprocess/configureWebpack.js +++ b/buildprocess/configureWebpack.js @@ -126,7 +126,7 @@ function configureWebpack( useBuiltIns: "usage" } ], - "@babel/preset-react", + ["@babel/preset-react", { runtime: "automatic" }], ["@babel/typescript", { allowNamespaces: true }] ], plugins: [ diff --git a/doc/connecting-to-data/customizing-data-appearance/feature-info-template.md b/doc/connecting-to-data/customizing-data-appearance/feature-info-template.md index 8ecc4553955..70b6b24ae16 100644 --- a/doc/connecting-to-data/customizing-data-appearance/feature-info-template.md +++ b/doc/connecting-to-data/customizing-data-appearance/feature-info-template.md @@ -30,11 +30,11 @@ The template will replace all occurrences of `{{property}}` with the value of th The result is: - + instead of: - + You can provide a template to use for the name of the collapsible section (eg. to replace `RGB` in the example above), like so: @@ -150,7 +150,7 @@ If `{{Pixel Value}}` equals to `150` and `{{feature.data.layerId}}` to `2`, the For features with time-varying table-based data structures (eg. CSV, SOS2, SDMX-JSON, if there is a time column), the feature info panel also includes a chart of the data over time, eg. - + You can place this chart in your template using `{{terria.timeSeries.chart}}`. Alternatively, you can access the following component information: diff --git a/doc/contributing/problems-and-solutions.md b/doc/contributing/problems-and-solutions.md index f389e97ef33..d356fdd1e82 100644 --- a/doc/contributing/problems-and-solutions.md +++ b/doc/contributing/problems-and-solutions.md @@ -80,23 +80,14 @@ nvm install 16 nvm use 16 ``` ---- - ### Problem -Python errors when building NodeJS dependencies (eg `node-sass`). This is common on M1/M2 macs. - -### Solution - -You may need to install Python2 to build NodeJS dependencies (like `node-sass`) - -We recommend using [`pyenv`](https://github.com/pyenv/pyenv#installation) to install Python2. +When building TerriaMap/TerriaJS I see the following error -Follow installation instructions [here](https://github.com/pyenv/pyenv#installation). +``` +Error: error:0308010C:digital envelope routines::unsupported +``` -Then run the following to install Python 2.7.18 and use it: +### Solution -```bash -pyenv install 2.7.18 -pyenv shell 2.7.18 -``` +Update to TerriaJS 8.4.1. diff --git a/lib/Core/arraysAreEqual.js b/lib/Core/arraysAreEqual.js deleted file mode 100644 index 6845bf22e76..00000000000 --- a/lib/Core/arraysAreEqual.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; - -var defined = require("terriajs-cesium/Source/Core/defined").default; - -function arraysAreEqual(left, right) { - if (left === right) { - return true; - } - - if (!defined(left) || !defined(right) || left.length !== right.length) { - return false; - } - - for (var i = 0; i < left.length; ++i) { - if (left[i] !== right[i]) { - return false; - } - } - - return true; -} - -module.exports = arraysAreEqual; diff --git a/lib/Core/arraysAreEqual.ts b/lib/Core/arraysAreEqual.ts new file mode 100644 index 00000000000..01a3873774d --- /dev/null +++ b/lib/Core/arraysAreEqual.ts @@ -0,0 +1,19 @@ +import isDefined from "./isDefined"; + +export default function arraysAreEqual(left: T[], right: T[]) { + if (left === right) { + return true; + } + + if (!isDefined(left) || !isDefined(right) || left.length !== right.length) { + return false; + } + + for (var i = 0; i < left.length; ++i) { + if (left[i] !== right[i]) { + return false; + } + } + + return true; +} diff --git a/lib/Core/setsAreEqual.ts b/lib/Core/setsAreEqual.ts new file mode 100644 index 00000000000..3ccbc235899 --- /dev/null +++ b/lib/Core/setsAreEqual.ts @@ -0,0 +1,17 @@ +export function setsAreEqual(left: Set | T[], right: Set | T[]) { + if (Array.isArray(left)) left = new Set(left); + + if (Array.isArray(right)) right = new Set(right); + + if (left === right) { + return true; + } + + if (left.size !== right.size) { + return false; + } + + const union = new Set([...left, ...right]); + + return union.size === left.size && union.size === right.size; +} diff --git a/lib/Map/DragPoints/DragPoints.js b/lib/Map/DragPoints/DragPoints.js index eb845a0f181..fc50c39646b 100644 --- a/lib/Map/DragPoints/DragPoints.js +++ b/lib/Map/DragPoints/DragPoints.js @@ -43,6 +43,13 @@ DragPoints.prototype.setUp = function () { } }; +/** + * Destroy drag points helper. The instance becomes unusable after calling destroy. + */ +DragPoints.prototype.destroy = function () { + this._dragPointsHelper.destroy(); +}; + /** * The drag count is an indication of how long the user dragged for. If it's really small, perhaps the user clicked, * but a mousedown/mousemove/mouseup event trio was triggered anyway. It solves a problem where in leaflet the click diff --git a/lib/ModelMixins/ClippingMixin.ts b/lib/ModelMixins/ClippingMixin.ts index 7ecdd0f77fc..960804fb51a 100644 --- a/lib/ModelMixins/ClippingMixin.ts +++ b/lib/ModelMixins/ClippingMixin.ts @@ -1,25 +1,39 @@ import i18next from "i18next"; -import { action, computed, toJS, makeObservable, override } from "mobx"; +import { + action, + computed, + makeObservable, + observable, + override, + toJS, + untracked +} from "mobx"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; import clone from "terriajs-cesium/Source/Core/clone"; import Color from "terriajs-cesium/Source/Core/Color"; +import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid"; import HeadingPitchRoll from "terriajs-cesium/Source/Core/HeadingPitchRoll"; import Matrix3 from "terriajs-cesium/Source/Core/Matrix3"; import Matrix4 from "terriajs-cesium/Source/Core/Matrix4"; import Transforms from "terriajs-cesium/Source/Core/Transforms"; import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource"; +import DataSource from "terriajs-cesium/Source/DataSources/DataSource"; import ClippingPlane from "terriajs-cesium/Source/Scene/ClippingPlane"; import ClippingPlaneCollection from "terriajs-cesium/Source/Scene/ClippingPlaneCollection"; import AbstractConstructor from "../Core/AbstractConstructor"; import filterOutUndefined from "../Core/filterOutUndefined"; +import runLater from "../Core/runLater"; import BoxDrawing from "../Models/BoxDrawing"; +import Cesium from "../Models/Cesium"; import CommonStrata from "../Models/Definition/CommonStrata"; import Model from "../Models/Definition/Model"; import updateModelFromJson from "../Models/Definition/updateModelFromJson"; import SelectableDimensions, { - SelectableDimension + SelectableDimension, + SelectableDimensionCheckboxGroup } from "../Models/SelectableDimensions/SelectableDimensions"; +import Icon from "../Styled/Icon"; import ClippingPlanesTraits from "../Traits/TraitsClasses/ClippingPlanesTraits"; import HeadingPitchRollTraits from "../Traits/TraitsClasses/HeadingPitchRollTraits"; import LatLonHeightTraits from "../Traits/TraitsClasses/LatLonHeightTraits"; @@ -27,8 +41,21 @@ import LatLonHeightTraits from "../Traits/TraitsClasses/LatLonHeightTraits"; type BaseType = Model & SelectableDimensions; function ClippingMixin>(Base: T) { - abstract class ClippingMixin extends Base { + abstract class ClippingMixinBase extends Base { private _clippingBoxDrawing?: BoxDrawing; + + /** + * Indicates whether we are currently zooming to the clipping box + */ + @observable + _isZoomingToClippingBox: boolean = false; + + /** + * A trigger for activating the clipping box repositioning UI for this item. + */ + @observable + repositionClippingBoxTrigger = false; + abstract clippingPlanesOriginMatrix(): Matrix4; private clippingPlaneModelMatrix: Matrix4 = Matrix4.IDENTITY.clone(); @@ -38,12 +65,15 @@ function ClippingMixin>(Base: T) { makeObservable(this); } + get hasClippingMixin() { + return true; + } + @computed get inverseClippingPlanesOriginMatrix(): Matrix4 { return Matrix4.inverse(this.clippingPlanesOriginMatrix(), new Matrix4()); } - @computed private get simpleClippingPlaneCollection() { if (!this.clippingPlanes) { return; @@ -110,6 +140,14 @@ function ClippingMixin>(Base: T) { unionClippingRegions: this.clippingBox.clipDirection === "outside", enabled: this.clippingBox.clipModel }); + + untracked(() => { + Matrix4.multiply( + this.inverseClippingPlanesOriginMatrix, + this.clippingBoxTransform, + this.clippingPlaneModelMatrix + ); + }); clippingPlaneCollection.modelMatrix = this.clippingPlaneModelMatrix; return clippingPlaneCollection; } @@ -127,56 +165,55 @@ function ClippingMixin>(Base: T) { } @computed - private get clippingBoxDrawing(): BoxDrawing | undefined { - const options = this.clippingBox; - const cesium = this.terria.cesium; - if ( - !cesium || - !options.enableFeature || - !options.clipModel || - !options.showClippingBox - ) { - if (this._clippingBoxDrawing) { - this._clippingBoxDrawing = undefined; - } - return; - } - - const clippingPlanesOriginMatrix = this.clippingPlanesOriginMatrix(); - + private get clippingBoxDimensions(): Cartesian3 { const dimensions = new Cartesian3( this.clippingBox.dimensions.length ?? 100, this.clippingBox.dimensions.width ?? 100, this.clippingBox.dimensions.height ?? 100 ); + return dimensions; + } + + @computed + private get clippingBoxHpr(): HeadingPitchRoll | undefined { + const { heading, pitch, roll } = this.clippingBox.rotation; + return heading !== undefined && pitch !== undefined && roll !== undefined + ? HeadingPitchRoll.fromDegrees(heading, pitch, roll) + : undefined; + } + + @computed + private get clippingBoxPosition(): Cartesian3 { + const dimensions = this.clippingBoxDimensions; + const clippingPlanesOriginMatrix = this.clippingPlanesOriginMatrix(); let position = LatLonHeightTraits.toCartesian(this.clippingBox.position); if (!position) { // Use clipping plane origin as position but height set to 0 so that the box is grounded. const cartographic = Cartographic.fromCartesian( Matrix4.getTranslation(clippingPlanesOriginMatrix, new Cartesian3()) ); - cartographic.height = dimensions.z / 2; - position = Cartographic.toCartesian( - cartographic, - cesium.scene.globe.ellipsoid, - new Cartesian3() - ); + // If the translation is at the center of the ellipsoid then this cartographic could be undefined. + // Although it is not reflected in the typescript type. + if (cartographic) { + cartographic.height = dimensions.z / 2; + position = Ellipsoid.WGS84.cartographicToCartesian( + cartographic, + new Cartesian3() + ); + } } - let hpr: HeadingPitchRoll | undefined; - if ( - this.clippingBox.rotation.heading !== undefined && - this.clippingBox.rotation.pitch !== undefined && - this.clippingBox.rotation.roll !== undefined - ) { - hpr = HeadingPitchRoll.fromDegrees( - this.clippingBox.rotation.heading, - this.clippingBox.rotation.pitch, - this.clippingBox.rotation.roll - ); - } + // Nothing we can do - assign to zero + position ??= Cartesian3.ZERO.clone(); + return position; + } + @computed + private get clippingBoxTransform(): Matrix4 { + const hpr = this.clippingBoxHpr; + const position = this.clippingBoxPosition; + const dimensions = this.clippingBoxDimensions; const boxTransform = Matrix4.multiply( hpr ? Matrix4.fromRotationTranslation( @@ -187,7 +224,26 @@ function ClippingMixin>(Base: T) { Matrix4.fromScale(dimensions, new Matrix4()), new Matrix4() ); + return boxTransform; + } + + @computed + get clippingBoxDrawing(): BoxDrawing | undefined { + const options = this.clippingBox; + const cesium = this.terria.cesium; + if ( + !cesium || + !options.enableFeature || + !options.clipModel || + !options.showClippingBox + ) { + if (this._clippingBoxDrawing) { + this._clippingBoxDrawing = undefined; + } + return; + } + const boxTransform = this.clippingBoxTransform; Matrix4.multiply( this.inverseClippingPlanesOriginMatrix, boxTransform, @@ -251,113 +307,270 @@ function ClippingMixin>(Base: T) { return this._clippingBoxDrawing; } + @computed + private get isClippingBoxPlaced() { + const { longitude, latitude, height } = this.clippingBox.position; + return ( + longitude !== undefined && + latitude !== undefined && + height !== undefined + ); + } + @override get selectableDimensions(): SelectableDimension[] { if (!this.clippingBox.enableFeature) { return super.selectableDimensions; } + const checkboxGroupInputs: SelectableDimensionCheckboxGroup["selectableDimensions"] = + this.repositionClippingBoxTrigger + ? [ + /* don't show options when repositioning clipping box */ + ] + : [ + { + // Checkbox to show/hide clipping box + id: "show-clip-editor-ui", + type: "checkbox", + selectedId: this.clippingBox.showClippingBox ? "true" : "false", + disable: this.clippingBox.clipModel === false, + options: [ + { + id: "true", + name: i18next.t("models.clippingBox.showClippingBox") + }, + { + id: "false", + name: i18next.t("models.clippingBox.showClippingBox") + } + ], + setDimensionValue: (stratumId, value) => { + this.clippingBox.setTrait( + stratumId, + "showClippingBox", + value === "true" + ); + } + }, + { + // Checkbox to clamp/unclamp box to ground + id: "clamp-box-to-ground", + type: "checkbox", + selectedId: this.clippingBox.keepBoxAboveGround + ? "true" + : "false", + disable: + this.clippingBox.clipModel === false || + this.clippingBox.showClippingBox === false, + options: [ + { + id: "true", + name: i18next.t("models.clippingBox.keepBoxAboveGround") + }, + { + id: "false", + name: i18next.t("models.clippingBox.keepBoxAboveGround") + } + ], + setDimensionValue: (stratumId, value) => { + this.clippingBox.setTrait( + stratumId, + "keepBoxAboveGround", + value === "true" + ); + } + }, + { + // Dropdown to change the clipping direction + id: "clip-direction", + name: i18next.t("models.clippingBox.clipDirection.name"), + type: "select", + selectedId: this.clippingBox.clipDirection, + disable: + this.clippingBox.clipModel === false || + this.clippingBox.showClippingBox === false, + options: [ + { + id: "inside", + name: i18next.t( + "models.clippingBox.clipDirection.options.inside" + ) + }, + { + id: "outside", + name: i18next.t( + "models.clippingBox.clipDirection.options.outside" + ) + } + ], + setDimensionValue: (stratumId, value) => { + this.clippingBox.setTrait(stratumId, "clipDirection", value); + } + }, + ...this.repositioningAndZoomingDimensions + ]; + return [ ...super.selectableDimensions, { + // Checkbox group that also enables/disables the clipping behaviour altogether type: "checkbox-group", id: "clipping-box", selectedId: this.clippingBox.clipModel ? "true" : "false", options: [ { id: "true", - name: i18next.t("models.clippingBox.clipModel") + name: `${i18next.t("models.clippingBox.clipModel")}` }, { id: "false", name: i18next.t("models.clippingBox.clipModel") } ], - setDimensionValue: (stratumId, value) => { - this.clippingBox.setTrait(stratumId, "clipModel", value === "true"); - }, - selectableDimensions: [ - { - id: "show-clip-editor-ui", - type: "checkbox", - selectedId: this.clippingBox.showClippingBox ? "true" : "false", - disable: this.clippingBox.clipModel === false, - options: [ - { - id: "true", - name: i18next.t("models.clippingBox.showClippingBox") - }, - { - id: "false", - name: i18next.t("models.clippingBox.showClippingBox") - } - ], - setDimensionValue: (stratumId, value) => { - this.clippingBox.setTrait( - stratumId, - "showClippingBox", - value === "true" - ); - } - }, - { - id: "clamp-box-to-ground", - type: "checkbox", - selectedId: this.clippingBox.keepBoxAboveGround - ? "true" - : "false", - disable: - this.clippingBox.clipModel === false || - this.clippingBox.showClippingBox === false, - options: [ - { - id: "true", - name: i18next.t("models.clippingBox.keepBoxAboveGround") - }, - { - id: "false", - name: i18next.t("models.clippingBox.keepBoxAboveGround") - } - ], - setDimensionValue: (stratumId, value) => { - this.clippingBox.setTrait( - stratumId, - "keepBoxAboveGround", - value === "true" - ); - } - }, - { - id: "clip-direction", - name: i18next.t("models.clippingBox.clipDirection.name"), - type: "select", - selectedId: this.clippingBox.clipDirection, - disable: this.clippingBox.clipModel === false, - options: [ - { - id: "inside", - name: i18next.t( - "models.clippingBox.clipDirection.options.inside" - ) - }, - { - id: "outside", - name: i18next.t( - "models.clippingBox.clipDirection.options.outside" - ) - } - ], - setDimensionValue: (stratumId, value) => { - this.clippingBox.setTrait(stratumId, "clipDirection", value); - } + emptyText: "Click on map to position clipping box", + setDimensionValue: action((stratumId, value) => { + const clipModel = value === "true"; + this.clippingBox.setTrait(stratumId, "clipModel", clipModel); + + // Trigger clipping box repositioning UI if the feature is enabled + // and a box position is not already set. + const triggerClippingBoxRepositioning = !this.isClippingBoxPlaced; + + if (triggerClippingBoxRepositioning) { + this.repositionClippingBoxTrigger = true; } - ] + }), + selectableDimensions: checkboxGroupInputs } ]; } + + /** + * Returns controls for repositioning and zooming to clipping box. Note + * that these are temporary features that are enabled through a feature + * flag. It will get removed once we switch to a new design for a global + * clipping box. + */ + @computed + private get repositioningAndZoomingDimensions(): SelectableDimensionCheckboxGroup["selectableDimensions"] { + const repositioningAndZoomingInputs: SelectableDimensionCheckboxGroup["selectableDimensions"] = + [ + { + // Button to zoom to clipping box + id: "zoom-to-clipping-box-button", + type: "button", + value: "Zoom to   ", + icon: this._isZoomingToClippingBox ? "spinner" : Icon.GLYPHS.search, + disable: + this.clippingBox.clipModel === false || + this.clippingBoxDrawing === undefined, + setDimensionValue: () => { + if (!this._isZoomingToClippingBox) { + this._zoomToClippingBox(); + } + } + }, + { + id: "reposition-clipping-box", + type: "button", + value: "Reposition", + icon: Icon.GLYPHS.geolocation, + disable: + this.clippingBox.clipModel === false || + this.clippingBoxDrawing === undefined, + setDimensionValue: action(() => { + // Disable repositioning tool if already active + if (this.repositionClippingBoxTrigger) { + this.repositionClippingBoxTrigger = false; + return; + } + + // Enable repositioning tool, but first disable it for other workbench items + this.terria.workbench.items.forEach((it) => { + if (ClippingMixin.isMixedInto(it)) { + it.repositionClippingBoxTrigger = false; + } + }); + this.repositionClippingBoxTrigger = true; + }) + } + ]; + return repositioningAndZoomingInputs; + } + + /** + * Initiates zooming to the clipping box if it is rendered on the map. + * Times out in 3 seconds if zooming is not possible. + * + * Also sets the observable variable `_isZoomingToClippingBox` to indicate the + * zooming status. + */ + _zoomToClippingBox() { + const dataSource = this.clippingBoxDrawing?.dataSource; + const cesium = this.terria.cesium; + if (!dataSource || !cesium) { + return; + } + + this._isZoomingToClippingBox = true; + zoomToDataSourceWithTimeout( + dataSource, + 3000, // timeout after 3 seconds if we cannot zoom for some reason + cesium + ) + .catch(() => { + /* ignore errors */ + }) + .finally( + action(() => { + this._isZoomingToClippingBox = false; + }) + ); + } } - return ClippingMixin; + return ClippingMixinBase; +} + +/** + * Zooms to the given dataSource and returns a promise that fullfills when the + * zoom action is complete. If the dataSource has not been rendered on the map, + * we wait for `timeoutMilliseconds` before rejecting the promise. + */ +function zoomToDataSourceWithTimeout( + dataSource: DataSource, + timeoutMilliseconds: number, + cesium: Cesium +): Promise { + // DataSources rendered on the map + const renderedDataSources = cesium.dataSources; + if (renderedDataSources.contains(dataSource)) { + return cesium.doZoomTo(dataSource); + } else { + // Create a promise that waits for the dataSource to be added to map or + // timeout to complete whichever happens first + return new Promise((resolve, reject) => { + let removeListener = renderedDataSources.dataSourceAdded.addEventListener( + (_, added) => { + if (added === dataSource) { + removeListener(); + resolve(cesium.doZoomTo(dataSource)); + } + } + ); + runLater(removeListener, timeoutMilliseconds).then(reject); + }); + } +} + +namespace ClippingMixin { + export interface Instance + extends InstanceType> {} + + export function isMixedInto(model: any): model is Instance { + return model?.hasClippingMixin === true; + } } export default ClippingMixin; diff --git a/lib/ModelMixins/GeojsonMixin.ts b/lib/ModelMixins/GeojsonMixin.ts index 14684953008..5eacc8fee0c 100644 --- a/lib/ModelMixins/GeojsonMixin.ts +++ b/lib/ModelMixins/GeojsonMixin.ts @@ -7,6 +7,8 @@ import { Geometries, Geometry, GeometryCollection, + LineString, + MultiLineString, MultiPoint, MultiPolygon, Point, @@ -29,10 +31,10 @@ import { } from "mobx"; import { createTransformer } from "mobx-utils"; import { + Feature as ProtomapsFeature, GeomType, LineSymbolizer, - PolygonSymbolizer, - Feature as ProtomapsFeature + PolygonSymbolizer } from "protomaps"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; @@ -75,8 +77,8 @@ import { isJson } from "../Core/loadBlob"; import StandardCssColors from "../Core/StandardCssColors"; import TerriaError, { networkRequestError } from "../Core/TerriaError"; import ProtomapsImageryProvider, { - GEOJSON_SOURCE_LAYER_NAME, GeojsonSource, + GEOJSON_SOURCE_LAYER_NAME, ProtomapsData } from "../Map/ImageryProvider/ProtomapsImageryProvider"; import Reproject from "../Map/Vector/Reproject"; @@ -396,6 +398,7 @@ function GeoJsonMixin>(Base: T) { points = points?.entities.values.length === 0 ? undefined : points; points ? (points.show = this.show) : null; + return filterOutUndefined([ points, this._dataSource, @@ -478,6 +481,7 @@ function GeoJsonMixin>(Base: T) { protected async forceLoadMapItems(): Promise { const czmlTemplate = this.czmlTemplate; const filterByProperties = this.filterByProperties; + const explodeMultiPoints = this.explodeMultiPoints; let geoJson: FeatureCollectionWithCrs | undefined; @@ -534,6 +538,14 @@ function GeoJsonMixin>(Base: T) { continue; } + if (explodeMultiPoints && feature.geometry.type === "MultiPoint") { + // Replace the MultiPoint with equivalent Point features and repeat + // the iteration to pick up the exploded features. + features.splice(i, 1, ...explodeMultiPoint(feature)); + i--; + continue; + } + geoJsonWgs84.features.push(feature); // Add feature index to FEATURE_ID_PROP ("_id_") feature property @@ -902,10 +914,12 @@ function GeoJsonMixin>(Base: T) { czml.properties ?? {}, stringifyFeatureProperties(feature.properties ?? {}) ); + rootCzml.push(czml); } else if ( - feature.geometry?.type === "Polygon" || - (feature.geometry?.type === "MultiPolygon" && czmlTemplate?.polygon) + (feature.geometry?.type === "Polygon" || + feature.geometry?.type === "MultiPolygon") && + czmlTemplate?.polygon ) { const czml = clone(czmlTemplate ?? {}, true); @@ -948,6 +962,59 @@ function GeoJsonMixin>(Base: T) { czml.polygon.positions = { cartographicDegrees: positions }; czml.polygon.holes = { cartographicDegrees: holes }; + czml.properties = Object.assign( + czml.properties ?? {}, + stringifyFeatureProperties(feature.properties ?? {}) + ); + rootCzml.push(czml); + } + } else if ( + (feature?.geometry?.type === "LineString" || + feature.geometry?.type === "MultiLineString") && + (czmlTemplate?.polyline || + czmlTemplate?.polylineVolume || + czmlTemplate?.wall || + czmlTemplate?.corridor) + ) { + const czml = clone(czmlTemplate ?? {}, true); + + // To handle both Polygon and MultiPolygon - transform Polygon coords into MultiPolygon coords + const multiLineString = + feature.geometry?.type === "LineString" + ? [(feature.geometry as LineString).coordinates] + : (feature.geometry as MultiLineString).coordinates; + + // Loop through Polygons in MultiPolygon + for (let j = 0; j < multiLineString.length; j++) { + const geom = multiLineString[j]; + const positions: number[] = []; + + geom.forEach((coords) => { + if (isJsonNumber(this.czmlTemplate?.heightOffset)) { + coords[2] = (coords[2] ?? 0) + this.czmlTemplate!.heightOffset; + } + positions.push(coords[0], coords[1], coords[2]); + }); + + // Add positions to all CZML line like features + if (czml.polyline) { + czml.polyline.positions = { cartographicDegrees: positions }; + } + + if (czml.polylineVolume) { + czml.polylineVolume.positions = { + cartographicDegrees: positions + }; + } + + if (czml.wall) { + czml.wall.positions = { cartographicDegrees: positions }; + } + + if (czml.corridor) { + czml.corridor.positions = { cartographicDegrees: positions }; + } + czml.properties = Object.assign( czml.properties ?? {}, stringifyFeatureProperties(feature.properties ?? {}) @@ -1382,6 +1449,22 @@ export function isGeometries(json: any): json is Geometries { ); } +/** + * Returns the points in a MultiPoint as separate Point features. + */ +function explodeMultiPoint(feature: Feature): Feature[] { + return feature.geometry?.type === "MultiPoint" + ? feature.geometry.coordinates.map((coordinates) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates + } as Point, + properties: feature.properties + })) + : []; +} + export function toFeatureCollection( json: any ): FeatureCollectionWithCrs | undefined { @@ -1448,7 +1531,7 @@ function createPolylineFromPolygon( createEntitiesFromHoles(entities, hierarchy.holes, entity); } -async function reprojectToGeographic( +export async function reprojectToGeographic( geoJson: FeatureCollectionWithCrs, proj4ServiceBaseUrl?: string ): Promise { diff --git a/lib/Models/BoxDrawing.ts b/lib/Models/BoxDrawing.ts index ae40c667da8..f86fca053f1 100644 --- a/lib/Models/BoxDrawing.ts +++ b/lib/Models/BoxDrawing.ts @@ -1,11 +1,12 @@ import throttle from "lodash-es/throttle"; import { + makeObservable, observable, onBecomeObserved, - onBecomeUnobserved, - makeObservable + onBecomeUnobserved } from "mobx"; import ArcType from "terriajs-cesium/Source/Core/ArcType"; +import BoundingSphere from "terriajs-cesium/Source/Core/BoundingSphere"; import Cartesian2 from "terriajs-cesium/Source/Core/Cartesian2"; import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3"; import Cartographic from "terriajs-cesium/Source/Core/Cartographic"; @@ -16,6 +17,7 @@ import HeadingPitchRoll from "terriajs-cesium/Source/Core/HeadingPitchRoll"; import IntersectionTests from "terriajs-cesium/Source/Core/IntersectionTests"; import JulianDate from "terriajs-cesium/Source/Core/JulianDate"; import KeyboardEventModifier from "terriajs-cesium/Source/Core/KeyboardEventModifier"; +import CesiumMath from "terriajs-cesium/Source/Core/Math"; import Matrix3 from "terriajs-cesium/Source/Core/Matrix3"; import Matrix4 from "terriajs-cesium/Source/Core/Matrix4"; import Plane from "terriajs-cesium/Source/Core/Plane"; @@ -30,15 +32,13 @@ import CallbackProperty from "terriajs-cesium/Source/DataSources/CallbackPropert import ColorMaterialProperty from "terriajs-cesium/Source/DataSources/ColorMaterialProperty"; import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource"; import Entity from "terriajs-cesium/Source/DataSources/Entity"; -import ModelGraphics from "terriajs-cesium/Source/DataSources/ModelGraphics"; import PlaneGraphics from "terriajs-cesium/Source/DataSources/PlaneGraphics"; import PolylineDashMaterialProperty from "terriajs-cesium/Source/DataSources/PolylineDashMaterialProperty"; import PositionProperty from "terriajs-cesium/Source/DataSources/PositionProperty"; import Axis from "terriajs-cesium/Source/Scene/Axis"; -import ColorBlendMode from "terriajs-cesium/Source/Scene/ColorBlendMode"; import Scene from "terriajs-cesium/Source/Scene/Scene"; -import ShadowMode from "terriajs-cesium/Source/Scene/ShadowMode"; import isDefined from "../Core/isDefined"; +import { CustomCursorType, getCustomCssCursor } from "./BoxDrawing/cursors"; import Cesium from "./Cesium"; export type ChangeEvent = { @@ -64,7 +64,7 @@ type Interactable = { onMouseOver: (mouseMove: MouseMove) => void; onMouseOut: (mouseMove: MouseMove) => void; onPick: (click: MouseClick) => void; - onRelease: (click: MouseClick) => void; + onRelease: () => void; onDrag: (mouseMove: MouseMove) => void; }; @@ -112,7 +112,6 @@ type ScalePoint = Entity & Interactable & CameraAware & { position: PositionProperty; - model: ModelGraphics; oppositeScalePoint: ScalePoint; axisLine: Entity; }; @@ -244,7 +243,7 @@ export default class BoxDrawing { @observable public dataSource: CustomDataSource; - public keepBoxAboveGround: boolean; + private _keepBoxAboveGround = false; private drawNonUniformScaleGrips: boolean; @@ -274,9 +273,17 @@ export default class BoxDrawing { // Scale points on the box defined as cesium entities with additional properties private readonly scalePoints: ScalePoint[] = []; + private readonly edges: Edge[] = []; + private isHeightUpdateInProgress: boolean = false; private terrainHeightEstimate: number = 0; + // Flag to turn scaling interaction on or off + private _enableScaling = true; + + // Flag to turn rotation interaction on or off + private _enableRotation = true; + /** * A private constructor. Use {@link BoxDrawing.fromTransform} or {@link BoxDrawing.fromTranslationRotationScale} to create instances. */ @@ -369,6 +376,43 @@ export default class BoxDrawing { this.updateBox(); } + get keepBoxAboveGround() { + return this._keepBoxAboveGround; + } + + set keepBoxAboveGround(value: boolean) { + if (this._keepBoxAboveGround === value) { + return; + } + + this._keepBoxAboveGround = value; + this.setBoxAboveGround().then(() => { + this.onChange?.({ + modelMatrix: this.modelMatrix, + translationRotationScale: this.trs, + isFinished: true + }); + }); + } + + get enableScaling() { + return this._enableScaling; + } + + set enableScaling(enable: boolean) { + this._enableScaling = enable; + this.scalePoints.forEach((scalePoint) => (scalePoint.show = enable)); + } + + get enableRotation() { + return this._enableRotation; + } + + set enableRotation(enable: boolean) { + this._enableRotation = enable; + this.edges.forEach((edge) => (edge.show = enable)); + } + /** * Moves the box by the provided moveStep with optional clamping applied so that the * box does not go underground. @@ -403,6 +447,19 @@ export default class BoxDrawing { }; })(); + /** + * Set the box position + */ + setPosition(position: Cartesian3) { + const moveStep = Cartesian3.subtract( + position, + this.trs.translation, + new Cartesian3() + ); + this.moveBoxWithClamping(moveStep); + this.updateBox(); + } + /** * Update the terrain height estimate at the current box position. * @@ -431,12 +488,21 @@ export default class BoxDrawing { return; } + const boxCenter = + this.trs.translation && + Cartographic.fromCartesian( + this.trs.translation, + undefined, + scratchBoxCenter + ); + + if (!boxCenter) { + this.terrainHeightEstimate = 0; + return; + } + this.isHeightUpdateInProgress = true; - const boxCenter = Cartographic.fromCartesian( - this.trs.translation, - undefined, - scratchBoxCenter - ); + try { const [floor] = await sampleTerrainMostDetailed(terrainProvider, [ Cartographic.clone(boxCenter, scratchFloor) @@ -450,13 +516,13 @@ export default class BoxDrawing { }; })(); - setBoxAboveGround() { + async setBoxAboveGround(): Promise { if (!this.keepBoxAboveGround) { return; } // Get the latest terrain height estimate and update the box position - this.updateTerrainHeightEstimate(true).then(() => { + return this.updateTerrainHeightEstimate(true).then(() => { this.moveBoxWithClamping(Cartesian3.ZERO); this.updateBox(); }); @@ -562,6 +628,10 @@ export default class BoxDrawing { return; } + if (state.is === "picked") { + handleRelease(); + } + if (state.is === "hovering") { state.entity.onMouseOut({ startPosition: click.position, @@ -581,13 +651,13 @@ export default class BoxDrawing { entity.onPick(click); }; - const handleRelease = (click: MouseClick) => { + const handleRelease = () => { if (state.is === "picked") { this.cesium.isFeaturePickingPaused = state.beforePickState.isFeaturePickingPaused; scene.screenSpaceCameraController.enableInputs = state.beforePickState.enableInputs; - state.entity.onRelease(click); + state.entity.onRelease(); state = { is: "none" }; } }; @@ -665,6 +735,9 @@ export default class BoxDrawing { const handler = { destroy: () => { eventHandler.destroy(); + // When destroying the eventHandler make sure we also release any + // picked entities and not leave them hanging + handleRelease(); scene.canvas.removeEventListener("mouseout", onMouseOutCanvas); } }; @@ -711,7 +784,7 @@ export default class BoxDrawing { localEdges.forEach((localEdge) => { const edge = this.createEdge(localEdge); this.dataSource.entities.add(edge); - //this.edges.push(edge); + this.edges.push(edge); }); } @@ -731,8 +804,20 @@ export default class BoxDrawing { -1, new Cartesian3() ); - const scalePoint1 = this.createScalePoint(pointLocal1); - const scalePoint2 = this.createScalePoint(pointLocal2); + const scalePoint1 = this.createScalePoint( + pointLocal1, + Cartesian3.normalize( + Cartesian3.subtract(pointLocal1, pointLocal2, new Cartesian3()), + new Cartesian3() + ) + ); + const scalePoint2 = this.createScalePoint( + pointLocal2, + Cartesian3.normalize( + Cartesian3.subtract(pointLocal2, pointLocal1, new Cartesian3()), + new Cartesian3() + ) + ); scalePoint1.oppositeScalePoint = scalePoint2; scalePoint2.oppositeScalePoint = scalePoint1; const axisLine = this.createScaleAxisLine(scalePoint1, scalePoint2); @@ -764,7 +849,7 @@ export default class BoxDrawing { const style: Readonly = { fillColor: Color.WHITE.withAlpha(0.1), outlineColor: Color.WHITE, - highlightFillColor: Color.WHITE.withAlpha(0.1), + highlightFillColor: Color.WHITE.withAlpha(0.2), highlightOutlineColor: Color.CYAN }; let isHighlighted = false; @@ -825,6 +910,7 @@ export default class BoxDrawing { const scratchMoveStep = new Cartesian3(); const scratchPickPosition = new Cartesian3(); + const isTopOrBottomSide = axis === Axis.Z; const moveStartPos = new Cartesian2(); const pickedPointOffset = new Cartesian3(); let dragStart = false; @@ -1025,7 +1111,9 @@ export default class BoxDrawing { const onMouseOver = () => { highlightAllSides(); - setCanvasCursor(scene, "grab"); + isTopOrBottomSide + ? setCanvasCursor(scene, "n-resize") + : setCustomCanvasCursor(scene, "grab", "ew-resize"); }; const onMouseOut = () => { @@ -1037,7 +1125,9 @@ export default class BoxDrawing { Cartesian2.clone(click.position, moveStartPos); dragStart = true; highlightAllSides(); - setCanvasCursor(scene, "grabbing"); + isTopOrBottomSide + ? setCanvasCursor(scene, "n-resize") + : setCustomCanvasCursor(scene, "grabbing", "ew-resize"); }; const onPickDisabled = () => { @@ -1071,8 +1161,6 @@ export default class BoxDrawing { Cartesian3.dot(normalWc, scene.camera.direction) >= 0; }; - const isTopOrBottomSide = axis === Axis.Z; - // Call enabledFn only if movement is is allowed for this side, otherwise call disabledFn const ifActionEnabled = ( enabledFn: (...args: any[]) => any, @@ -1140,7 +1228,7 @@ export default class BoxDrawing { const onMouseOver = () => { if (isDraggableEdge) { isHighlighted = true; - setCanvasCursor(scene, "pointer"); + setCustomCanvasCursor(scene, "rotate", "pointer"); } }; @@ -1154,7 +1242,7 @@ export default class BoxDrawing { const onPick = () => { if (isDraggableEdge) { isHighlighted = true; - setCanvasCursor(scene, "pointer"); + setCustomCanvasCursor(scene, "rotate", "pointer"); } }; @@ -1216,12 +1304,16 @@ export default class BoxDrawing { * @param pointLocal The scale point in local coordinates. * @returns ScalePoint A cesium entity representing the scale point. */ - private createScalePoint(pointLocal: Cartesian3): ScalePoint { + private createScalePoint( + pointLocal: Cartesian3, + direction: Cartesian3 + ): ScalePoint { const scene = this.scene; const position = new Cartesian3(); + const offsetPosition = new Cartesian3(); const style: Readonly = { - cornerPointColor: Color.RED, - facePointColor: Color.BLUE, + cornerPointColor: Color.RED.brighten(0.5, new Color()), + facePointColor: Color.BLUE.brighten(0.5, new Color()), dimPointColor: Color.GREY.withAlpha(0.2) }; let isFacingCamera = false; @@ -1234,29 +1326,85 @@ export default class BoxDrawing { : style.dimPointColor; }; + const scalePointRadii = new Cartesian3(); + const scratchBoundingSphere = new BoundingSphere(); + const updateScalePointRadii = ( + position: Cartesian3, + boxScale: Cartesian3 + ) => { + // Get size of a pixel in metres at the position of the bounding shpere + position.clone(scratchBoundingSphere.center); + scratchBoundingSphere.radius = 1; + const pixelSize = scene.camera.getPixelSize( + scratchBoundingSphere, + scene.drawingBufferWidth, + scene.drawingBufferHeight + ); + + const maxBoxScale = Cartesian3.maximumComponent(boxScale); + + // Compute radius equivalent to 10 pixels or 0.1 times the box scale whichever is smaller + const radius = Math.min(pixelSize * 10, maxBoxScale * 0.1); + scalePointRadii.x = radius; + scalePointRadii.y = radius; + scalePointRadii.z = radius; + return scalePointRadii; + }; + + const scratchOffset = new Cartesian3(); + const scratchMatrix = new Matrix4(); const update = () => { + // Update grip position Matrix4.multiplyByPoint(this.modelMatrix, pointLocal, position); + + // Update the size of scale points + updateScalePointRadii(position, this.trs.scale); + + // Compute an offset for grips that lie on a face. Without the offset, + // half of the grip will be inside the box thus reducing the clickable + // surface area and creating a bad user experience. So, we want to push + // most of the grip outside the box. Here we compute an offset 0.9 times + // the radius of the point and in an outward direction from the center of + // the box. + const offset = isCornerPoint + ? Cartesian3.ZERO // skip for corner points + : Cartesian3.multiplyByScalar( + // Transform the direction into world co-ordinates, but ignore the scaling + Matrix4.multiplyByPointAsVector( + Matrix4.setScale(this.modelMatrix, Cartesian3.ONE, scratchMatrix), + direction, + scratchOffset + ), + // assuming the grip point has uniform radii + scalePointRadii.x * 0.9, + scratchOffset + ); + + Cartesian3.add(position, offset, offsetPosition); }; + let isHighlighted = false; + const scratchColor = new Color(); const scalePoint: ScalePoint = new Entity({ - position: new CallbackProperty(() => position, false) as any, + position: new CallbackProperty(() => offsetPosition, false) as any, orientation: new CallbackProperty( () => Quaternion.IDENTITY, false ) as any, - model: { - uri: require("file-loader!../../wwwroot/models/Box.glb"), - minimumPixelSize: 12, - maximumScale: new CallbackProperty( - // Clamp the maximum size of the scale grip to the 0.15 times the - // size of the minimum side - () => 0.15 * Cartesian3.minimumComponent(this.trs.scale), + + // Sphere for the scale point + ellipsoid: { + radii: new CallbackProperty( + // update scale point radii to reflect camera distance changes + () => updateScalePointRadii(position, this.trs.scale), false ), - shadows: ShadowMode.DISABLED, - color: new CallbackProperty(() => getColor(), false), - // Forces the above color ignoring the color specified in gltf material - colorBlendMode: ColorBlendMode.REPLACE + material: new ColorMaterialProperty( + new CallbackProperty( + () => getColor().brighten(isHighlighted ? -0.5 : 0.0, scratchColor), + false + ) + ) } }) as ScalePoint; @@ -1265,26 +1413,40 @@ export default class BoxDrawing { const xDot = Math.abs(Cartesian3.dot(new Cartesian3(1, 0, 0), axisLocal)); const yDot = Math.abs(Cartesian3.dot(new Cartesian3(0, 1, 0), axisLocal)); const zDot = Math.abs(Cartesian3.dot(new Cartesian3(0, 0, 1), axisLocal)); - const cursorDirection = - xDot === 1 || yDot === 1 - ? "ew-resize" - : zDot === 1 - ? "ns-resize" - : "nesw-resize"; - const isCornerPoint = xDot && yDot && zDot; const isProportionalScaling = isCornerPoint; - const onMouseOver = () => { + // Return the angle in clockwise direction to rotate the mouse + // cursor so that it points towards the center of the box. + const getCursorRotation = (mousePos: Cartesian2) => { + const boxCenter = scene.cartesianToCanvasCoordinates( + this.trs.translation + ); + // mouse coords relative to the box center + const x = mousePos.x - boxCenter.x; + const y = mousePos.y - boxCenter.y; + + // Math.atan2 gives the angle the (x, y) point makes with the positive + // x-axes in the clockwise direction + const angle = CesiumMath.toDegrees(Math.atan2(y, x)); + return angle; + }; + + const onMouseOver = (mouseMove: MouseMove) => { scalePoint.axisLine.show = true; highlightScalePoint(); - setCanvasCursor(scene, cursorDirection); + //cursor(mouseMove.endPosition); + //setCanvasCursor(scene, cursorDirection); + const cursorRotation = getCursorRotation(mouseMove.endPosition); + setCustomCanvasCursor(scene, "resize", "ew-resize", cursorRotation); }; - const onPick = () => { + const onPick = (mouseClick: MouseClick) => { scalePoint.axisLine.show = true; highlightScalePoint(); - setCanvasCursor(scene, cursorDirection); + + const cursorRotation = getCursorRotation(mouseClick.position); + setCustomCanvasCursor(scene, "resize", "ew-resize", cursorRotation); }; const onRelease = () => { @@ -1433,15 +1595,11 @@ export default class BoxDrawing { }; const highlightScalePoint = () => { - const model = scalePoint.model; - model.silhouetteColor = Color.YELLOW as any; - model.silhouetteSize = 1 as any; + isHighlighted = true; }; const unHighlightScalePoint = () => { - const model = scalePoint.model; - model.silhouetteColor = undefined; - model.silhouetteSize = 0 as any; + isHighlighted = false; }; scalePoint.onPick = onPick; @@ -1710,6 +1868,22 @@ function setCanvasCursor(scene: Scene, cursorType: string) { scene.canvas.style.cursor = cursorType; } +/** + * Set canvas cursor to the custom cursor also applying the rotation on the cursor + * + * @param type Custom cursor type + * @param fallback The standard cusrsor to use as fallback (See https://developer.mozilla.org/en-US/docs/Web/CSS/cursor) + * @param rotation Then angle in clockwise direction to rotate the custom cursor + */ +function setCustomCanvasCursor( + scene: Scene, + type: CustomCursorType, + fallback: string, + rotation = 0 +) { + setCanvasCursor(scene, getCustomCssCursor({ type, fallback, rotation })); +} + /** * Returns the Cartesian position for the window position. */ diff --git a/lib/Models/BoxDrawing/cursors.ts b/lib/Models/BoxDrawing/cursors.ts new file mode 100644 index 00000000000..4d8efbbe76a --- /dev/null +++ b/lib/Models/BoxDrawing/cursors.ts @@ -0,0 +1,38 @@ +/** + * Define only the SVG geometry for individual custom cursors + */ +const CUSTOM_CURSOR_GEOMETRIES = { + rotate: ``, + + resize: ``, + + grabbing: ``, + + grab: `` +}; + +export type CustomCursorType = keyof typeof CUSTOM_CURSOR_GEOMETRIES; + +/** + * Return the CSS value for a custom cursor with rotation applied. + * + * The returned value can be set like: element.style.cursor = getCustomCssCursor(...) + * + * @param name Name of the custom cursor + * @param rotation The clockwise angle in degrees to rotate the cursor + * @param The CSS value to set. + */ +export function getCustomCssCursor(opts: { + type: CustomCursorType; + rotation: number; + fallback: string; +}): string { + const { type, rotation, fallback } = opts; + const geometry = CUSTOM_CURSOR_GEOMETRIES[type]; + + // Build a complete SVG element with rotation applied + const svg = `${geometry}`; + const dataUrl = `data:image/svg+xml,${svg}`; + const cursor = `url("${dataUrl}") 32 32, ${fallback}`; + return cursor; +} diff --git a/lib/Models/Catalog/CatalogItems/ApiTableCatalogItem.ts b/lib/Models/Catalog/CatalogItems/ApiTableCatalogItem.ts index 79eca606dd6..0de57c9b073 100644 --- a/lib/Models/Catalog/CatalogItems/ApiTableCatalogItem.ts +++ b/lib/Models/Catalog/CatalogItems/ApiTableCatalogItem.ts @@ -185,8 +185,21 @@ export class ApiTableCatalogItem extends AutoRefreshingMixin( this.apiResponses.forEach((response) => { this.columns.forEach((col, mappingIdx) => { if (!isDefined(col.name)) return; - // Append the new value to the correct column - columnMajorTable[mappingIdx].push(`${response[col.name] ?? ""}`); + + // If ApiColumnTraits has a responseDataPath, use that to get the value + const dataPath = this.apiColumns.find( + (c) => c.name === col.name + )?.responseDataPath; + + if (dataPath) { + columnMajorTable[mappingIdx].push( + `${getResponseDataPath(response, dataPath) ?? ""}` + ); + } + // Otherwise, use column name as the path + else { + columnMajorTable[mappingIdx].push(`${response[col.name] ?? ""}`); + } }); }); diff --git a/lib/Models/Catalog/CatalogItems/GeoJsonCatalogItem.ts b/lib/Models/Catalog/CatalogItems/GeoJsonCatalogItem.ts index 83f70fa6ec4..0a3018ea7fb 100644 --- a/lib/Models/Catalog/CatalogItems/GeoJsonCatalogItem.ts +++ b/lib/Models/Catalog/CatalogItems/GeoJsonCatalogItem.ts @@ -8,14 +8,19 @@ import loadJson from "../../../Core/loadJson"; import readJson from "../../../Core/readJson"; import TerriaError from "../../../Core/TerriaError"; import GeoJsonMixin, { + FeatureCollectionWithCrs, + reprojectToGeographic, toFeatureCollection } from "../../../ModelMixins/GeojsonMixin"; import GeoJsonCatalogItemTraits from "../../../Traits/TraitsClasses/GeoJsonCatalogItemTraits"; import CreateModel from "../../Definition/CreateModel"; import HasLocalData from "../../HasLocalData"; import Terria from "../../Terria"; -import { ModelConstructorParameters } from "../../Definition/Model"; +import Model, { ModelConstructorParameters } from "../../Definition/Model"; import proxyCatalogItemUrl from "../proxyCatalogItemUrl"; +import ApiRequestTraits from "../../../Traits/TraitsClasses/ApiRequestTraits"; +import filterOutUndefined from "../../../Core/filterOutUndefined"; +import { featureCollection, FeatureCollection } from "@turf/helpers"; class GeoJsonCatalogItem extends GeoJsonMixin(CreateModel(GeoJsonCatalogItemTraits)) @@ -46,6 +51,45 @@ class GeoJsonCatalogItem return isDefined(this._file); } + /** + * Tries to build a FeatureCollection from partial geojson data + */ + private featureCollectionFromPartialData( + jsonData: JsonValue + ): FeatureCollectionWithCrs | undefined { + if (Array.isArray(jsonData)) { + // Array that isn't a feature collection + const fc = toFeatureCollection( + jsonData.map((item) => { + let geojson: any = item; + + if (this.responseGeoJsonPath !== undefined) { + geojson = _get(item, this.responseGeoJsonPath); + // Clear geojson so that it doesn't appear again in its own properties + _set(item as object, this.responseGeoJsonPath, undefined); + } + + if (typeof geojson === "string") { + geojson = JSON.parse(geojson); + } + + // add extra properties back to geojson so they appear in feature info + geojson.properties = item; + return geojson; + }) + ); + if (fc) return fc; + } else if ( + isJsonObject(jsonData, false) && + typeof jsonData.type === "string" + ) { + // Actual geojson + const fc = toFeatureCollection(jsonData); + if (fc) return fc; + } + return undefined; + } + protected async forceLoadGeojsonData() { let jsonData: JsonValue | undefined = undefined; @@ -67,73 +111,100 @@ class GeoJsonCatalogItem jsonData = await readJson(this._file); } } + // We have multiple sources. + else if (this.urls.length > 0) { + // Map each source to a FeatureCollection and then merge them to build a + // single FeatureCollection + const promises = this.urls.map(async (source) => { + const json = await this.fetchSource(source); + const fc = this.featureCollectionFromPartialData(json); + // We need to reproject the FeatureCollection here as we will loose + // specific CRS information when merging the multiple FCs. + const geojson = await (fc + ? reprojectToGeographic( + fc, + this.terria.configParameters.proj4ServiceBaseUrl + ) + : undefined); + return geojson; + }); + const featureCollections = filterOutUndefined( + await Promise.all(promises) + ); + + // Forced type casting required as TS not happy with assigning + // FeatureCollection to JsonValue + jsonData = mergeFeatureCollections( + featureCollections + ) as any as JsonValue; + } // GeojsonTraits.url else if (this.url) { - // URL to zipped fle - if (isZip(this.url)) { - if (typeof FileReader === "undefined") { - throw fileApiNotSupportedError(this.terria); - } - const body = this.requestData ? toJS(this.requestData) : undefined; - const blob = await loadBlob( - proxyCatalogItemUrl(this, this.url), - undefined, - body - ); - jsonData = await parseZipJsonBlob(blob); - } else { - jsonData = await loadJson( - proxyCatalogItemUrl(this, this.url), - undefined, - this.requestData ? toJS(this.requestData) : undefined, - this.postRequestDataAsFormData - ); - if (this.responseDataPath) { - jsonData = _get(jsonData, this.responseDataPath); - } - } + jsonData = await this.fetchSource(this); } if (jsonData === undefined) { throw TerriaError.from("Failed to load geojson"); } - if (Array.isArray(jsonData)) { - // Array that isn't a feature collection - const fc = toFeatureCollection( - jsonData.map((item) => { - let geojson: any = item; + const fc = this.featureCollectionFromPartialData(jsonData); + if (fc) { + return fc; + } - if (this.responseGeoJsonPath !== undefined) { - geojson = _get(item, this.responseGeoJsonPath); - // Clear geojson so that it doesn't appear again in its own properties - _set(item as object, this.responseGeoJsonPath, undefined); - } + throw TerriaError.from( + "Invalid geojson data - only FeatureCollection and Feature are supported" + ); + } - if (typeof geojson === "string") { - geojson = JSON.parse(geojson); - } + private async fetchSource( + source: Model + ): Promise { + const url = source.url; + if (!url) { + return; + } - // add extra properties back to geojson so they appear in feature info - geojson.properties = item; - return geojson; - }) + let jsonData; + // URL to zipped fle + if (isZip(url)) { + if (typeof FileReader === "undefined") { + throw fileApiNotSupportedError(this.terria); + } + const body = source.requestData ? toJS(source.requestData) : undefined; + const blob = await loadBlob( + proxyCatalogItemUrl(this, url), + undefined, + body ); - if (fc) return fc; - } else if ( - isJsonObject(jsonData, false) && - typeof jsonData.type === "string" - ) { - // Actual geojson - const fc = toFeatureCollection(jsonData); - if (fc) return fc; + jsonData = await parseZipJsonBlob(blob); + } else { + jsonData = await loadJson( + proxyCatalogItemUrl(this, url), + undefined, + source.requestData ? toJS(source.requestData) : undefined, + source.postRequestDataAsFormData + ); + if (source.responseDataPath) { + jsonData = _get(jsonData, source.responseDataPath); + } } - throw TerriaError.from( - "Invalid geojson data - only FeatureCollection and Feature are supported" - ); + return jsonData; } } +/** + * Reduce an array of FeatureCollection into a single FeatureCollection. + * + * Note that this only accumulates the features and ignores any properties set + * on the individual FeatureCollection. + */ +function mergeFeatureCollections( + featureCollections: Array +): FeatureCollection { + return featureCollection(featureCollections.map((fc) => fc.features).flat()); +} + export function fileApiNotSupportedError(terria: Terria) { return new TerriaError({ title: i18next.t("models.userData.fileApiNotSupportedTitle"), diff --git a/lib/Models/Catalog/Esri/ArcGisMapServerCatalogItem.ts b/lib/Models/Catalog/Esri/ArcGisMapServerCatalogItem.ts index 05bab35c9e7..7b5d03fbc92 100644 --- a/lib/Models/Catalog/Esri/ArcGisMapServerCatalogItem.ts +++ b/lib/Models/Catalog/Esri/ArcGisMapServerCatalogItem.ts @@ -1,9 +1,11 @@ import i18next from "i18next"; import uniqWith from "lodash-es/uniqWith"; import { computed, makeObservable, override, runInAction } from "mobx"; +import moment from "moment"; import WebMercatorTilingScheme from "terriajs-cesium/Source/Core/WebMercatorTilingScheme"; import ArcGisMapServerImageryProvider from "terriajs-cesium/Source/Scene/ArcGisMapServerImageryProvider"; import URI from "urijs"; +import TerriaError, { networkRequestError } from "../../../Core/TerriaError"; import createDiscreteTimesFromIsoSegments from "../../../Core/createDiscreteTimes"; import createTransformerAllowUndefined from "../../../Core/createTransformerAllowUndefined"; import filterOutUndefined from "../../../Core/filterOutUndefined"; @@ -11,7 +13,7 @@ import isDefined from "../../../Core/isDefined"; import loadJson from "../../../Core/loadJson"; import replaceUnderscores from "../../../Core/replaceUnderscores"; import { scaleDenominatorToLevel } from "../../../Core/scaleToDenominator"; -import TerriaError, { networkRequestError } from "../../../Core/TerriaError"; +import { setsAreEqual } from "../../../Core/setsAreEqual"; import Proj4Definitions from "../../../Map/Vector/Proj4Definitions"; import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin"; import DiscretelyTimeVaryingMixin from "../../../ModelMixins/DiscretelyTimeVaryingMixin"; @@ -27,16 +29,15 @@ import LegendTraits, { } from "../../../Traits/TraitsClasses/LegendTraits"; import { RectangleTraits } from "../../../Traits/TraitsClasses/MappableTraits"; import CreateModel from "../../Definition/CreateModel"; -import createStratumInstance from "../../Definition/createStratumInstance"; import LoadableStratum from "../../Definition/LoadableStratum"; import { BaseModel, ModelConstructorParameters } from "../../Definition/Model"; import StratumFromTraits from "../../Definition/StratumFromTraits"; import StratumOrder from "../../Definition/StratumOrder"; +import createStratumInstance from "../../Definition/createStratumInstance"; import getToken from "../../getToken"; import proxyCatalogItemUrl from "../proxyCatalogItemUrl"; import MinMaxLevelMixin from "./../../../ModelMixins/MinMaxLevelMixin"; import { Extent, Layer, MapServer } from "./ArcGisInterfaces"; -import moment from "moment"; const proj4 = require("proj4").default; @@ -552,7 +553,13 @@ export default class ArcGisMapServerCatalogItem extends UrlMixin( /** Only used "pre-cached" tiles if we aren't requesting any specific layers * If the `layersArray` property is specified, we request individual dynamic layers and ignore the fused map cache. */ - usePreCachedTilesIfAvailable: this.layersArray.length == 0, + usePreCachedTilesIfAvailable: + this.layersArray.length === 0 || + !this.layers || + setsAreEqual( + this.layersArray.map((l) => l.id), + stratum.allLayers.map((l) => l.id) + ), mapServerData: stratum.mapServer, token: stratum.token, credit: this.attribution diff --git a/lib/Models/SelectableDimensions/SelectableDimensions.ts b/lib/Models/SelectableDimensions/SelectableDimensions.ts index 341535a1085..b3632662699 100644 --- a/lib/Models/SelectableDimensions/SelectableDimensions.ts +++ b/lib/Models/SelectableDimensions/SelectableDimensions.ts @@ -53,7 +53,9 @@ export interface ColorDimension extends Dimension { export interface ButtonDimension extends Dimension { readonly value?: string; - readonly icon?: IconGlyph; + readonly icon?: + | IconGlyph // Any Icon glyph + | "spinner"; // Animated spinner icon } export type SelectableDimensionType = @@ -117,6 +119,11 @@ export interface SelectableDimensionCheckboxGroup EnumDimension<"true" | "false"> { type: "checkbox-group"; + /** + * Text to show if the group is empty + */ + emptyText?: string; + // We don't allow nested groups for now to keep the UI simple readonly selectableDimensions: Exclude< SelectableDimension, diff --git a/lib/Models/UserDrawing.ts b/lib/Models/UserDrawing.ts index b2ea987192e..a4b59302941 100644 --- a/lib/Models/UserDrawing.ts +++ b/lib/Models/UserDrawing.ts @@ -64,7 +64,9 @@ export default class UserDrawing extends MappableMixin( ) => void; private readonly onCleanUp?: () => void; private readonly invisible?: boolean; - private readonly dragHelper: DragPoints; + + // helper for dragging points around + private dragHelper?: DragPoints; pointEntities: CustomDataSource; otherEntities: CustomDataSource; @@ -150,14 +152,6 @@ export default class UserDrawing extends MappableMixin( this.drawRectangle = defaultValue(options.drawRectangle, false); this.invisible = options.invisible; - - // helper for dragging points around - this.dragHelper = new DragPoints(options.terria, (customDataSource) => { - if (typeof this.onPointMoved === "function") { - this.onPointMoved(customDataSource); - } - this.prepareToAddNewPoint(); - }); } protected forceLoadMapItems(): Promise { @@ -194,6 +188,13 @@ export default class UserDrawing extends MappableMixin( } enterDrawMode() { + // Create and setup a new dragHelper + this.dragHelper = new DragPoints(this.terria, (customDataSource) => { + if (typeof this.onPointMoved === "function") { + this.onPointMoved(customDataSource); + } + this.prepareToAddNewPoint(); + }); this.dragHelper.setUp(); // If we have finished a polygon, don't allow more points to be drawn. In future, perhaps support multiple polygons. @@ -338,13 +339,14 @@ export default class UserDrawing extends MappableMixin( this.pointEntities.entities.removeAll(); } this.pointEntities.entities.add(pointEntity); - this.dragHelper.updateDraggableObjects(this.pointEntities); + this.dragHelper?.updateDraggableObjects(this.pointEntities); if (isDefined(this.onPointClicked)) { this.onPointClicked(this.pointEntities); } } endDrawing() { + this.dragHelper?.destroy(); if (this.disposePickedFeatureSubscription) { this.disposePickedFeatureSubscription(); } @@ -427,13 +429,14 @@ export default class UserDrawing extends MappableMixin( // getDragCount helps us determine if the point was actually dragged rather than clicked. If it was // dragged, we shouldn't treat it as a clicked-existing-point scenario. if ( + this.dragHelper && this.dragHelper.getDragCount() < 10 && !this.clickedExistingPoint(pickedFeatures.features) ) { // No existing point was picked, so add a new point this.addPointToPointEntities("Another Point", pickedPoint); } else { - this.dragHelper.resetDragCount(); + this.dragHelper?.resetDragCount(); } reaction.dispose(); diff --git a/lib/ReactViews/Analytics/invoke-function.scss b/lib/ReactViews/Analytics/invoke-function.scss index 8e3cb5b6c11..c7872fd47bc 100644 --- a/lib/ReactViews/Analytics/invoke-function.scss +++ b/lib/ReactViews/Analytics/invoke-function.scss @@ -1,5 +1,6 @@ @import "~terriajs-variables"; @import "../../Sass/common/mixins"; +@import "../../Sass/common/_buttons.scss"; .invoke-function { padding: $padding-small; @@ -31,7 +32,8 @@ } } -@include empty-module("description") .btn { +@include empty-module("description"); +.btn { composes: btn from "../../Sass/common/_buttons.scss"; composes: btn-primary from "../../Sass/common/_buttons.scss"; } diff --git a/lib/ReactViews/Analytics/invoke-function.scss.d.ts b/lib/ReactViews/Analytics/invoke-function.scss.d.ts index afec4c0b728..654f8f526ea 100644 --- a/lib/ReactViews/Analytics/invoke-function.scss.d.ts +++ b/lib/ReactViews/Analytics/invoke-function.scss.d.ts @@ -1,12 +1,61 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { + '_buttons__btn-primary': string; 'btn': string; + 'btn--add-to-map': string; + 'btn--catalog': string; + 'btn--catalog-item': string; + 'btn--close-modal': string; + 'btn--group-indicator': string; + 'btn--loading-on-map': string; + 'btn--map': string; + 'btn--radio': string; + 'btn--remove-from-map': string; + 'btn--search-clear': string; + 'btn--secondary': string; + 'btn--small': string; + 'btn--tab': string; + 'btn--tertiary': string; + 'btn--tertiary-dark': string; + 'btn-grey': string; + 'btn-large': string; + 'btn-primary': string; + 'btn-primary--hover': string; + 'btn-small': string; + 'btn-transparent': string; + 'btnAddToMap': string; + 'btnCatalog': string; + 'btnCatalogItem': string; + 'btnCloseModal': string; + 'btnGrey': string; + 'btnGroupIndicator': string; + 'btnLarge': string; + 'btnLoadingOnMap': string; + 'btnMap': string; + 'btnPrimary': string; + 'btnPrimaryHover': string; + 'btnRadio': string; + 'btnRemoveFromMap': string; + 'btnSearchClear': string; + 'btnSecondary': string; + 'btnSmall': string; + 'btnTab': string; + 'btnTertiary': string; + 'btnTertiaryDark': string; + 'btnTransparent': string; + 'buttonsBtnPrimary': string; 'content': string; 'description': string; 'footer': string; 'invoke-function': string; 'invokeFunction': string; + 'is-active': string; + 'is-open': string; + 'is-previewed': string; + 'isActive': string; + 'isOpen': string; + 'isPreviewed': string; } declare var cssExports: CssExports; export = cssExports; diff --git a/lib/ReactViews/DataCatalog/data-catalog-group.scss b/lib/ReactViews/DataCatalog/data-catalog-group.scss index 8ffeaf20eb1..b7cb3e0a226 100644 --- a/lib/ReactViews/DataCatalog/data-catalog-group.scss +++ b/lib/ReactViews/DataCatalog/data-catalog-group.scss @@ -112,7 +112,7 @@ .catalog-group { composes: list-reset from "../../Sass/common/_base.scss"; padding-left: $padding; - padding-top: $padding / 2; + padding-top: calc($padding / 2); &--lower-level { margin-left: 20px; border-left: 1px solid $grey-lighter; diff --git a/lib/ReactViews/Map/Panels/SettingPanel.tsx b/lib/ReactViews/Map/Panels/SettingPanel.tsx index f8b48c1b0a1..3136840b204 100644 --- a/lib/ReactViews/Map/Panels/SettingPanel.tsx +++ b/lib/ReactViews/Map/Panels/SettingPanel.tsx @@ -318,7 +318,7 @@ class SettingPanel extends React.Component { } onClick={(event) => this.selectBaseMap(baseMap.item, event)} onMouseEnter={this.mouseEnterBaseMap.bind(this, baseMap)} - onMouseLeave={this.mouseLeaveBaseMap.bind(this, baseMap)} + onMouseLeave={this.mouseLeaveBaseMap.bind(this)} onFocus={this.mouseEnterBaseMap.bind(this, baseMap)} > {baseMap.item === this.props.terria.mainViewer.baseMap ? ( diff --git a/lib/ReactViews/Mobile/mobile-header.scss b/lib/ReactViews/Mobile/mobile-header.scss index d536a6607c4..faa0eb31ed3 100644 --- a/lib/ReactViews/Mobile/mobile-header.scss +++ b/lib/ReactViews/Mobile/mobile-header.scss @@ -47,7 +47,7 @@ svg { position: absolute; top: $padding; - left: $padding/2; + left: calc($padding / 2); fill: #ffffff; } } diff --git a/lib/ReactViews/SelectableDimensions/Button.tsx b/lib/ReactViews/SelectableDimensions/Button.tsx index d4f6a504af5..5657be1280a 100644 --- a/lib/ReactViews/SelectableDimensions/Button.tsx +++ b/lib/ReactViews/SelectableDimensions/Button.tsx @@ -6,12 +6,14 @@ import { StyledIcon } from "../../Styled/Icon"; import Text from "../../Styled/Text"; import { parseCustomMarkdownToReactWithOptions } from "../Custom/parseCustomMarkdownToReact"; import Button from "../../Styled/Button"; +import AnimatedSpinnerIcon from "../../Styled/AnimatedSpinnerIcon"; export const SelectableDimensionButton: React.FC<{ id: string; dim: SelectableDimensionButtonModel; }> = ({ id, dim }) => { - const icon = dim.icon; + const iconGlyph = dim.icon; + const iconProps = { light: true, styledWidth: "16px", styledHeight: "16px" }; return (