diff --git a/.env.template b/.env.template new file mode 100644 index 00000000..058a3907 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +# Custom hostname for starting the Webpack Dev server +WEBPACK_DEV_SERVER_HOSTNAME = my-custome-hostname.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4980267e..650f6b72 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,8 @@ dist *.http -temp/ \ No newline at end of file +temp/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c935b9e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added +### Changed +### Fixed +### Removed + +## 2024 July Release + +## Sentinel-1 Explorer + +### Added +- add Temporal Composite Tool +- add Change Detection Tool +- add Index Mask Tool +- add Temporal Profile Tool +- add Orbit Direction Filter +- lock relative orbit orbit direction for Change Detection tool and Temporal Composite Tool +- show Foot Print for Change Compare and Temporal Composite tool +- add documentation panel + +## Landsat Explorer + +### Added +- add Raster Function Templates of the Landsat Level-2 service + +### Changed +- Scene Info table should display ID in one line +- use `useImageryLayerByObjectId` custom hook from `shared/hooks` to get Landsat Layer +- use `getFeatureByObjectId` from `shared/services/helpers` +- use `getExtentByObjectId` from `shared/services/helpers` +- use `intersectWithImageryScene` from `shared/services/helpers` +- use `identify` from `shared/services/helpers` +- update `queryAvailableScenes` in `/@shared/store/Landsat/thunks` to use `deduplicateListOfImageryScenes` +- use `@shared/components/ImageryLayer/ImageryLayerByObjectID` instead of `LandsatLayer` +- use `@shared/components/SwipeWidget/SwipeWidget4ImageryLayers` +- `` should be passed as a child components to `Calendar`. +- update `` to use `useShouldShowSecondaryControls` hook +- use `` `from @shared/components/MapPopup` +- use `Change Compare Tool` from `@shared/components/ChandCompareTool` +- update `MaskLayer` to use `ImageryLayerWithPixelFilter` +- update `ChangeCompareLayer` to use `ImageryLayerWithPixelFilter` + +## Shared + +### Added +- add tooltip and click to copy feature to Scene Info component +- add Play/Pause button to AnimationDownloadPanel +- include estimated area calculation for Mask tool +- include estimated area calculation for Change Detection +- display current map scale and pixel resolution in Custom Attribution component +- add Documentation Panel + +### Changed +- upgrade @arcgis/core to use version 4.29 +- update animation panel to re-fetch animation images when user minimizes bottom panel +- use `.env` to save `WEBPACK_DEV_SERVER_HOSTNAME` +- add Zoom2ExtentContainer to shared components +- update map popup to include X/Y coordinates diff --git a/README.md b/README.md index 2967860b..4529e34e 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ This repository contains a collection of Imagery Explorer web applications devel - [Getting Started](#getting-started) - [Landsat Explorer](#landsat-explorer) - [Sentinel-2 Landcover Explorer](#sentinel-2-land-cover-explorer) +- [Sentinel-1 Explorer](#sentinel-1-explorer) ## Getting Started Before you begin, make sure you have a fresh version of [Node.js](https://nodejs.org/en/) and NPM installed. The current Long Term Support (LTS) release is an ideal starting point. @@ -52,12 +53,12 @@ Before running the application, update the `landsat-level-2` URLs in the [`confi To run and test the app on your local machine: ```sh -npm run start-landsat +npm run start:landsat ``` To build the app, you can run the command below, this will place all files needed for deployment into the `/dist/landsat` directory. ```sh -npm run build-landsat +npm run build:landsat ``` ### Resources @@ -91,17 +92,65 @@ The Sentinel-2 Land Cover Explorer app provides dynamic visual and statistical c ### Usage To run and test the app on your local machine: ```sh -npm run start-landcover +npm run start:landcover ``` To build the app, you can run the command below, this will place all files needed for deployment into the `/dist/landcover-explorer` directory. ```sh -npm run build-landcover +npm run build:landcover ``` ### Resources - [Global Land Cover Revealed](https://www.esri.com/arcgis-blog/products/arcgis-living-atlas/imagery/global-land-cover-revealed/) +## Sentinel-1 Explorer + +Sentinel-1 SAR imagery helps to track and document land use and land change associated with climate change, urbanization, drought, wildfire, deforestation, and other natural processes and human activity. + +Through an intuitive user experience, this app leverages a variety of ArcGIS capabilities to explore and begin to unlock the wealth of information that Sentinel-1 provides. + +[View it live](https://livingatlas.arcgis.com/sentinel1explorer/) + +![App](./public/thumbnails/sentinel1-explorer.jpg) + +### Features: +- Visual exploration of a Dynamic global mosaic of the best available Sentinel-1 scenes. +- On-the-fly multispectral band combinations and indices for visualization and analysis. +- Interactive Find a Scene by location, sensor, time, and cloud cover. +- Visual change by time, and comparison of different renderings, with Swipe and Animation modes. +- Analysis such as threshold masking and temporal profiles for vegetation, water, land surface temperature, and more. + +### Usage +Before running the application, update the `"sentinel-1` URLs in the [`config.json`](./src/config.json) to use the URL of your service proxy for [Sentinel-1 RTC](https://sentinel1.imagery1.arcgis.com/arcgis/rest/services/Sentinel1RTC/ImageServer). + +[`config.json`](./src/config.json): +```js +{ + //... + "services": { + "sentinel-1": { + "development": "URL_OF_YOUR_PROXY_SERVICE_FOR_SENTINEL_1", + "production": "URL_OF_YOUR_PROXY_SERVICE_FOR_SENTINEL_1" + } + } +} +``` + +To run and test the app on your local machine: +```sh +npm run start:sentinel1 +``` + +To build the app, you can run the command below, this will place all files needed for deployment into the `/dist/sentinel1-explorer` directory. +```sh +npm run build:sentinel1 +``` + +### Sentinel-1 RTC Imagery Service Licensing +- Sentinel-1 RTC Source Imagery – The source imagery is hosted on Microsoft Planetary Computer under an open [CC BY 4.0 license](https://creativecommons.org/licenses/by/4.0/). +- Sentinel-1 RTC Image Service - This work is licensed under the Esri Master License Agreement. [View Summary](https://downloads2.esri.com/arcgisonline/docs/tou_summary.pdf) | [View Terms of Use](https://www.esri.com/en-us/legal/terms/full-master-agreement) + + ## Issues Find a bug or want to request a new feature? Please let us know by submitting an issue. diff --git a/e2e/base.config.ts b/e2e/base.config.ts new file mode 100644 index 00000000..df5f0360 --- /dev/null +++ b/e2e/base.config.ts @@ -0,0 +1,78 @@ +import type { PlaywrightTestConfig, } from "@playwright/test"; +import { devices } from "@playwright/test"; +import { config } from "dotenv"; +config({ + path: '../.env' +}) + +export const DEV_SERVER_URL = process.env.WEBPACK_DEV_SERVER_HOSTNAME || 'https://localhost:8080' + +export const baseConfig:PlaywrightTestConfig = { + testDir: './', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + ignoreHTTPSErrors: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run start', + url: DEV_SERVER_URL, + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true + }, +} \ No newline at end of file diff --git a/e2e/landsat/landsat.spec.ts b/e2e/landsat/landsat.spec.ts new file mode 100644 index 00000000..f98edde9 --- /dev/null +++ b/e2e/landsat/landsat.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { DEV_SERVER_URL } from '../base.config'; + +test('has title', async ({ page }) => { + await page.goto(DEV_SERVER_URL); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Landsat Explorer/); +}); + +// test('get started link', async ({ page }) => { +// await page.goto('https://playwright.dev/'); + +// // Click the get started link. +// await page.getByRole('link', { name: 'Get started' }).click(); + +// // Expects page to have a heading with the name of Installation. +// await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +// }); diff --git a/e2e/playwright.landsat.config.ts b/e2e/playwright.landsat.config.ts new file mode 100644 index 00000000..c18a74b7 --- /dev/null +++ b/e2e/playwright.landsat.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; +import { baseConfig } from './base.config'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...baseConfig, + testDir: './landsat', + /* Run your local dev server before starting the tests */ + webServer: { + ...baseConfig.webServer, + command: 'npm run start:landsat', + }, +}); diff --git a/e2e/playwright.sentinel1.config.ts b/e2e/playwright.sentinel1.config.ts new file mode 100644 index 00000000..a003b753 --- /dev/null +++ b/e2e/playwright.sentinel1.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; +import { baseConfig } from './base.config'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...baseConfig, + testDir: './sentinel1', + /* Run your local dev server before starting the tests */ + webServer: { + ...baseConfig.webServer, + command: 'npm run start:sentinel1', + }, +}); diff --git a/e2e/sentinel1/sentinel1.spec.ts b/e2e/sentinel1/sentinel1.spec.ts new file mode 100644 index 00000000..78591f95 --- /dev/null +++ b/e2e/sentinel1/sentinel1.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { DEV_SERVER_URL } from '../base.config'; + +test('has title', async ({ page }) => { + await page.goto(DEV_SERVER_URL); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Sentinel-1 Explorer/); +}); + +// test('get started link', async ({ page }) => { +// await page.goto('https://playwright.dev/'); + +// // Click the get started link. +// await page.getByRole('link', { name: 'Get started' }).click(); + +// // Expects page to have a heading with the name of Installation. +// await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +// }); diff --git a/jest.config.js b/jest.config.js index ef7c600b..7790746c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,7 +24,7 @@ module.exports = { // // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: ['\\\\node_modules\\\\'], - testPathIgnorePatterns: ['/__jest_utils__/'], + testPathIgnorePatterns: ['/__jest_utils__/', 'e2e'], // // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href // testURL: 'http://localhost', diff --git a/package-lock.json b/package-lock.json index 31e10d0e..6362f854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@babel/preset-react": "^7.24.1", "@babel/preset-typescript": "^7.24.1", "@babel/runtime": "^7.24.1", + "@playwright/test": "^1.45.0", "@storybook/addon-essentials": "^7.0.7", "@storybook/addon-interactions": "^7.0.7", "@storybook/addon-links": "^7.0.7", @@ -48,6 +49,7 @@ "@types/classnames": "^2.2.10", "@types/d3": "5.16", "@types/jest": "^29.5.11", + "@types/node": "^20.14.9", "@types/react-redux": "^7.1.16", "@types/react-responsive": "^8.0.2", "@types/shortid": "^0.0.29", @@ -60,6 +62,7 @@ "copy-webpack-plugin": "^9.0.0", "css-loader": "^5.2.6", "css-minimizer-webpack-plugin": "^3.0.1", + "dotenv": "^16.4.5", "eslint": "8.54", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", @@ -4554,6 +4557,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.0.tgz", + "integrity": "sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==", + "dev": true, + "dependencies": { + "playwright": "1.45.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.15", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.15.tgz", @@ -10355,6 +10373,12 @@ } } }, + "node_modules/@storybook/builder-webpack5/node_modules/@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "node_modules/@storybook/builder-webpack5/node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -11547,6 +11571,12 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/core-common/node_modules/@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "node_modules/@storybook/core-common/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -12423,6 +12453,12 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/core-webpack/node_modules/@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "node_modules/@storybook/csf": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.2.tgz", @@ -12818,6 +12854,12 @@ } } }, + "node_modules/@storybook/preset-react-webpack/node_modules/@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "node_modules/@storybook/preset-react-webpack/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -13009,6 +13051,18 @@ } } }, + "node_modules/@storybook/react-webpack5/node_modules/@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, + "node_modules/@storybook/react/node_modules/@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "node_modules/@storybook/react/node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -14679,10 +14733,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "16.18.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.25.tgz", - "integrity": "sha512-rUDO6s9Q/El1R1I21HG4qw/LstTHCPO/oQNAwI/4b2f9EWvMnqt4d3HJwPMawfZ3UvodB8516Yg+VAq54YM+eA==", - "dev": true + "version": "20.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", + "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/node-fetch": { "version": "2.6.7", @@ -18870,12 +18927,15 @@ "dev": true }, "node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "dev": true, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dotenv-expand": { @@ -26582,6 +26642,36 @@ "node": ">=10" } }, + "node_modules/playwright": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.0.tgz", + "integrity": "sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==", + "dev": true, + "dependencies": { + "playwright-core": "1.45.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.0.tgz", + "integrity": "sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/polished": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", @@ -35195,6 +35285,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.0.tgz", + "integrity": "sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==", + "dev": true, + "requires": { + "playwright": "1.45.0" + } + }, "@polka/url": { "version": "1.0.0-next.15", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.15.tgz", @@ -38805,6 +38904,12 @@ "webpack-virtual-modules": "^0.4.3" }, "dependencies": { + "@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -39582,6 +39687,12 @@ "ts-dedent": "^2.0.0" }, "dependencies": { + "@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -40129,6 +40240,14 @@ "@storybook/types": "7.0.7", "@types/node": "^16.0.0", "ts-dedent": "^2.0.0" + }, + "dependencies": { + "@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + } } }, "@storybook/csf": { @@ -40402,6 +40521,12 @@ "source-map": "^0.7.3" } }, + "@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -40485,6 +40610,12 @@ "util-deprecate": "^1.0.2" }, "dependencies": { + "@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + }, "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -40539,6 +40670,14 @@ "@storybook/preset-react-webpack": "7.0.7", "@storybook/react": "7.0.7", "@types/node": "^16.0.0" + }, + "dependencies": { + "@types/node": { + "version": "16.18.101", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.101.tgz", + "integrity": "sha512-AAsx9Rgz2IzG8KJ6tXd6ndNkVcu+GYB6U/SnFAaokSPNx2N7dcIIfnighYUNumvj6YS2q39Dejz5tT0NCV7CWA==", + "dev": true + } } }, "@storybook/router": { @@ -41813,10 +41952,13 @@ "dev": true }, "@types/node": { - "version": "16.18.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.25.tgz", - "integrity": "sha512-rUDO6s9Q/El1R1I21HG4qw/LstTHCPO/oQNAwI/4b2f9EWvMnqt4d3HJwPMawfZ3UvodB8516Yg+VAq54YM+eA==", - "dev": true + "version": "20.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", + "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } }, "@types/node-fetch": { "version": "2.6.7", @@ -45029,9 +45171,9 @@ } }, "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "dev": true }, "dotenv-expand": { @@ -50715,6 +50857,22 @@ "find-up": "^5.0.0" } }, + "playwright": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.0.tgz", + "integrity": "sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.45.0" + } + }, + "playwright-core": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.0.tgz", + "integrity": "sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==", + "dev": true + }, "polished": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", diff --git a/package.json b/package.json index c1283934..bfa1cb46 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,21 @@ "main": "index.js", "scripts": { "test": "jest --passWithNoTests", - "start-landsat": "webpack serve --mode development --open --env app=landsat", - "start-landcover": "webpack serve --mode development --open --env app=landcover-explorer", - "start-spectral-sampling-tool": "webpack serve --mode development --open --env app=spectral-sampling-tool", - "start-landsat-surface-temp": "webpack serve --mode development --open --env app=landsat-surface-temp", - "build-landsat": "webpack --mode production --env app=landsat", - "build-landcover": "webpack --mode production --env app=landcover-explorer", - "build-spectral-sampling-tool": "webpack --mode production --env app=spectral-sampling-tool", - "build-landsat-surface-temp": "webpack --mode production --env app=landsat-surface-temp", + "start:landsat": "webpack serve --mode development --open --env app=landsat", + "start:landcover": "webpack serve --mode development --open --env app=landcover-explorer", + "start:sentinel1": "webpack serve --mode development --open --env app=sentinel1-explorer", + "start:spectral-sampling-tool": "webpack serve --mode development --open --env app=spectral-sampling-tool", + "start:surface-temp": "webpack serve --mode development --open --env app=landsat-surface-temp", + "build:landsat": "webpack --mode production --env app=landsat", + "build:landcover": "webpack --mode production --env app=landcover-explorer", + "build:sentinel1": "webpack --mode production --env app=sentinel1-explorer", + "build:spectral-sampling-tool": "webpack --mode production --env app=spectral-sampling-tool", + "build:surface-temp": "webpack --mode production --env app=landsat-surface-temp", "prepare": "husky install", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "e2e:landsat": "npx playwright test --config e2e/playwright.landsat.config.ts", + "e2e:sentinel1": "npx playwright test --config e2e/playwright.sentinel1.config.ts" }, "lint-staged": { "src/**/*.{ts,tsx,json}": [ @@ -51,6 +55,7 @@ "@babel/preset-react": "^7.24.1", "@babel/preset-typescript": "^7.24.1", "@babel/runtime": "^7.24.1", + "@playwright/test": "^1.45.0", "@storybook/addon-essentials": "^7.0.7", "@storybook/addon-interactions": "^7.0.7", "@storybook/addon-links": "^7.0.7", @@ -63,6 +68,7 @@ "@types/classnames": "^2.2.10", "@types/d3": "5.16", "@types/jest": "^29.5.11", + "@types/node": "^20.14.9", "@types/react-redux": "^7.1.16", "@types/react-responsive": "^8.0.2", "@types/shortid": "^0.0.29", @@ -75,6 +81,7 @@ "copy-webpack-plugin": "^9.0.0", "css-loader": "^5.2.6", "css-minimizer-webpack-plugin": "^3.0.1", + "dotenv": "^16.4.5", "eslint": "8.54", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", diff --git a/public/esri-favicon-light-32.png b/public/esri-favicon-light-32.png new file mode 100644 index 00000000..11e9a291 Binary files /dev/null and b/public/esri-favicon-light-32.png differ diff --git a/public/index.html b/public/index.html index edc05b18..ce791fea 100644 --- a/public/index.html +++ b/public/index.html @@ -5,14 +5,11 @@ <%= htmlWebpackPlugin.options.title %> - + - - - + + + diff --git a/public/thumbnails/sentinel1-explorer.jpg b/public/thumbnails/sentinel1-explorer.jpg new file mode 100644 index 00000000..e5d26b7a Binary files /dev/null and b/public/thumbnails/sentinel1-explorer.jpg differ diff --git a/src/config.json b/src/config.json index 3e318399..ab494a57 100644 --- a/src/config.json +++ b/src/config.json @@ -16,6 +16,14 @@ "pathname": "/landcoverexplorer", "entrypoint": "/src/landcover-explorer/index.tsx" }, + "sentinel1-explorer": { + "title": "Esri | Sentinel-1 Explorer", + "webmapId": "260548c9e7934ef5b20a5f48467a3c1d", + "description": "Through an intuitive user experience, this app leverages a variety of ArcGIS capabilities to explore and begin to unlock the wealth of information that Sentinel-1 provides", + "animationMetadataSources": "Esri, European Space Agency", + "pathname": "/sentinel1explorer", + "entrypoint": "/src/sentinel-1-explorer/index.tsx" + }, "spectral-sampling-tool": { "title": "Spectral Sampling Tool", "webmapId": "81609bbe235942919ad27c77e42c600e", @@ -35,6 +43,10 @@ "landsat-level-2": { "development": "https://utility.arcgis.com/usrsvcs/servers/f89d8adb0d5141a7a5820e8a6375480e/rest/services/LandsatC2L2/ImageServer", "production": "https://utility.arcgis.com/usrsvcs/servers/125204cf060644659af558f4f6719b0f/rest/services/LandsatC2L2/ImageServer" + }, + "sentinel-1": { + "development": "https://utility.arcgis.com/usrsvcs/servers/ae20c269388b4ea3acac60b9bf2a319f/rest/services/Sentinel1RTC/ImageServer", + "production": "https://utility.arcgis.com/usrsvcs/servers/6cd9e108e0b84784afb476269296fdd7/rest/services/Sentinel1RTC/ImageServer" } } } diff --git a/src/landcover-explorer/components/ControlPanel/AnimationControls/AnimationStatusControls.tsx b/src/landcover-explorer/components/ControlPanel/AnimationControls/AnimationStatusControls.tsx index e78b7971..6c9682f6 100644 --- a/src/landcover-explorer/components/ControlPanel/AnimationControls/AnimationStatusControls.tsx +++ b/src/landcover-explorer/components/ControlPanel/AnimationControls/AnimationStatusControls.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React from 'react'; import AnimationStatusButton from './AnimationStatusButton'; import { useSelector } from 'react-redux'; diff --git a/src/landcover-explorer/components/ControlPanel/ModeSelector/ModeSelectorContainer.tsx b/src/landcover-explorer/components/ControlPanel/ModeSelector/ModeSelectorContainer.tsx index 86a32341..45deb154 100644 --- a/src/landcover-explorer/components/ControlPanel/ModeSelector/ModeSelectorContainer.tsx +++ b/src/landcover-explorer/components/ControlPanel/ModeSelector/ModeSelectorContainer.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { ContainerOfSecondaryControls } from '@shared/components/ModeSelector'; import React from 'react'; import { ModeSelector } from './ModeSelector'; diff --git a/src/landcover-explorer/components/ControlPanel/ModeSelector/index.ts b/src/landcover-explorer/components/ControlPanel/ModeSelector/index.ts index 17595a47..d2813b4d 100644 --- a/src/landcover-explorer/components/ControlPanel/ModeSelector/index.ts +++ b/src/landcover-explorer/components/ControlPanel/ModeSelector/index.ts @@ -1 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { ModeSelectorContainer as ModeSelector } from './ModeSelectorContainer'; diff --git a/src/landcover-explorer/components/ControlPanel/TimeSelector/Dropdown.tsx b/src/landcover-explorer/components/ControlPanel/TimeSelector/Dropdown.tsx deleted file mode 100644 index a1e7ce73..00000000 --- a/src/landcover-explorer/components/ControlPanel/TimeSelector/Dropdown.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// /* Copyright 2024 Esri -// * -// * Licensed under the Apache License Version 2.0 (the "License"); -// * you may not use this file except in compliance with the License. -// * You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ - -// import classNames from 'classnames'; -// import React, { FC, useRef, useState } from 'react'; -// import useOnClickOutside from '@shared/hooks/useOnClickOutside'; - -// type DropdownData = { -// /** -// * value of this item -// */ -// value: string; -// /** -// * label text will be displayed -// */ -// label?: string; -// /** -// * If true, this item is selected -// */ -// active: boolean; -// }; - -// type Props = { -// data: DropdownData[]; -// disabled?: boolean; -// onChange: (val: string) => void; -// }; - -// const Dropdown: FC = ({ data, disabled, onChange }: Props) => { -// const [shouldShowOptions, setShouldShowOptions] = useState(false); - -// const containerRef = useRef(); - -// useOnClickOutside(containerRef, () => { -// setShouldShowOptions(false); -// }); - -// const getLabelForActiveItem = () => { -// let activeItem = data.find((d) => d.active); - -// activeItem = activeItem || data[0]; - -// return activeItem.label || activeItem.value; -// }; - -// return ( -//
-//
{ -// setShouldShowOptions(true); -// }} -// > -// {getLabelForActiveItem()} - -// -// -// -// -//
- -// {shouldShowOptions && ( -//
-// {data.map((d, index) => { -// const { value, label } = d; - -// return ( -//
{ -// onChange(value); -// setShouldShowOptions(false); -// }} -// > -// {label || value} -//
-// ); -// })} -//
-// )} -//
-// ); -// }; - -// export default Dropdown; diff --git a/src/landcover-explorer/components/ControlPanel/TimeSelector/index.ts b/src/landcover-explorer/components/ControlPanel/TimeSelector/index.ts index f9d9a1b5..c2a3f524 100644 --- a/src/landcover-explorer/components/ControlPanel/TimeSelector/index.ts +++ b/src/landcover-explorer/components/ControlPanel/TimeSelector/index.ts @@ -1 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { TimeSelectorContainer as TimeSelector } from './TimeSelectorContainer'; diff --git a/src/landcover-explorer/components/DownloadPanel/LulcFootprintsLayer.tsx b/src/landcover-explorer/components/DownloadPanel/LulcFootprintsLayer.tsx index 42efb631..6f46e6d0 100644 --- a/src/landcover-explorer/components/DownloadPanel/LulcFootprintsLayer.tsx +++ b/src/landcover-explorer/components/DownloadPanel/LulcFootprintsLayer.tsx @@ -54,7 +54,7 @@ const LulcFootprintsLayer: FC = ({ availableYears, mapView }: Props) => { // behavior in order to display your own popup mapView.popupEnabled = false; mapView.popup.dockEnabled = false; - mapView.popup.collapseEnabled = false; + // mapView.popup.collapseEnabled = false; addEventHandlers(); }; diff --git a/src/landcover-explorer/components/MapView/MapView.tsx b/src/landcover-explorer/components/MapView/MapView.tsx index 4e7e9f11..39649f77 100644 --- a/src/landcover-explorer/components/MapView/MapView.tsx +++ b/src/landcover-explorer/components/MapView/MapView.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // import React, { useEffect, useState, useRef } from 'react'; // import ArcGISMapView from '@arcgis/core/views/MapView'; diff --git a/src/landcover-explorer/components/Popup/Popup.tsx b/src/landcover-explorer/components/Popup/Popup.tsx index af942116..eaded9ca 100644 --- a/src/landcover-explorer/components/Popup/Popup.tsx +++ b/src/landcover-explorer/components/Popup/Popup.tsx @@ -229,7 +229,9 @@ const Popup: FC = ({ mapView }: Props) => { // behavior in order to display your own popup mapView.popupEnabled = false; mapView.popup.dockEnabled = false; - mapView.popup.collapseEnabled = false; + mapView.popup.visibleElements = { + collapseButton: false, + }; mapView.on('click', async (evt) => { mapViewOnClickHandlerRef.current(evt.mapPoint, evt.x); diff --git a/src/landcover-explorer/components/SwipeWidget/SwipeWidget.tsx b/src/landcover-explorer/components/SwipeWidget/SwipeWidget.tsx deleted file mode 100644 index 01b756b2..00000000 --- a/src/landcover-explorer/components/SwipeWidget/SwipeWidget.tsx +++ /dev/null @@ -1,191 +0,0 @@ -// /* Copyright 2024 Esri -// * -// * Licensed under the Apache License Version 2.0 (the "License"); -// * you may not use this file except in compliance with the License. -// * You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ - -// import './style.css'; -// import React, { FC, useEffect, useMemo, useRef } from 'react'; - -// import Swipe from '@arcgis/core/widgets/Swipe'; -// import IMapView from '@arcgis/core/views/MapView'; -// import useLandCoverLayer from '../LandcoverLayer/useLandCoverLayer'; -// // import IImageryLayer from '@arcgis/core/layers/ImageryLayer'; -// import useSentinel2Layer from '../Sentinel2Layer/useSentinel2Layer'; -// // import { LandCoverClassification } from '@shared/services/sentinel-2-10m-landcover/rasterAttributeTable'; -// import * as reactiveUtils from '@arcgis/core/core/reactiveUtils'; - -// type Props = { -// /** -// * If true, display sentinel 2 imagery layer -// */ -// shouldShowSentinel2Layer: boolean; -// /** -// * The year that will be used to get the leading layer -// */ -// yearForLeadingLayer: number; -// /** -// * The year that will be used to get the trailing layer -// */ -// yearForTailingLayer: number; -// /** -// * Indicate if Swipe Widget is visible -// */ -// visible: boolean; -// /** -// * Map view that contains Swipe Widget -// */ -// mapView?: IMapView; -// /** -// * Fires when user drag and change swipe position -// */ -// positionOnChange: (position: number) => void; -// /** -// * Fires when user hover in/out handler element, which toggle dispalys the reference info -// */ -// referenceInfoOnToggle: (shouldDisplay: boolean) => void; -// }; - -// /** -// * Swipe Widget to compare land cover layers from two different years -// */ -// const SwipeWidget: FC = ({ -// shouldShowSentinel2Layer, -// yearForLeadingLayer, -// yearForTailingLayer, -// mapView, -// visible, -// positionOnChange, -// referenceInfoOnToggle, -// }: Props) => { -// const swipeWidgetRef = useRef(); - -// const leadingLandCoverLayer = useLandCoverLayer({ -// year: yearForLeadingLayer, -// visible: visible && shouldShowSentinel2Layer === false, -// }); - -// const leadingSentinel2Layer = useSentinel2Layer({ -// year: yearForLeadingLayer, -// visible: visible && shouldShowSentinel2Layer, -// }); - -// const trailingLandcoverLayer = useLandCoverLayer({ -// year: yearForTailingLayer, -// visible: visible && shouldShowSentinel2Layer === false, -// }); - -// const trailingSentinel2Layer = useSentinel2Layer({ -// year: yearForTailingLayer, -// visible: visible && shouldShowSentinel2Layer, -// }); - -// const init = async () => { -// mapView.map.addMany([ -// leadingLandCoverLayer, -// leadingSentinel2Layer, -// trailingLandcoverLayer, -// trailingSentinel2Layer, -// ]); - -// swipeWidgetRef.current = new Swipe({ -// view: mapView, -// leadingLayers: [leadingLandCoverLayer, leadingSentinel2Layer], -// trailingLayers: [trailingLandcoverLayer, trailingSentinel2Layer], -// direction: 'horizontal', -// position: 50, // position set to middle of the view (50%) -// visible, -// }); - -// // console.log(swipeWidgetRef.current) -// mapView.ui.add(swipeWidgetRef.current); - -// reactiveUtils.watch( -// () => swipeWidgetRef.current.position, -// (position: number) => { -// // console.log('position changes for swipe widget', position); -// positionOnChange(position); -// } -// ); - -// swipeWidgetRef.current.when(() => { -// addMouseEventHandlers(); -// }); -// }; - -// const addMouseEventHandlers = () => { -// const handleElem = document.querySelector('.esri-swipe__container'); - -// if (!handleElem) { -// return; -// } - -// handleElem.addEventListener('mouseenter', () => { -// // console.log('mouseenter'); -// referenceInfoOnToggle(true); -// }); - -// handleElem.addEventListener('mouseleave', () => { -// // console.log('mouseleave'); -// referenceInfoOnToggle(false); -// }); - -// // console.log(handleElem) -// }; - -// useEffect(() => { -// // initiate swipe widget -// if ( -// !swipeWidgetRef.current && -// leadingLandCoverLayer && -// leadingSentinel2Layer && -// trailingLandcoverLayer && -// trailingSentinel2Layer -// ) { -// init(); -// } -// }, [ -// mapView, -// leadingLandCoverLayer, -// leadingSentinel2Layer, -// trailingLandcoverLayer, -// trailingSentinel2Layer, -// ]); - -// // useEffect(() => { -// // if (swipeWidgetRef.current) { -// // toggleDisplayLayers(); -// // } -// // }, [shouldShowSentinel2Layer]); - -// useEffect(() => { -// if (swipeWidgetRef.current) { -// swipeWidgetRef.current.visible = visible; - -// if (visible) { -// // why need to wait for 1 second?? -// // for some reason '.esri-swipe__container' won't be ready -// // if we call `addMouseEventHandlers` right after swipe widget becomes visible, -// // tried using swipeWidgetRef.current.when but that didn't work either, -// // so the only workaround that I can come up with add this moment is using a setTimeout, -// // not a decent solution, definitely need to be fixed in future -// setTimeout(() => { -// addMouseEventHandlers(); -// }, 1000); -// } -// } -// }, [visible]); - -// return null; -// }; - -// export default SwipeWidget; diff --git a/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Landcover.tsx b/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Landcover.tsx index d38494b7..dbd9f5ac 100644 --- a/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Landcover.tsx +++ b/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Landcover.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { FC } from 'react'; import useLandCoverLayer from '../LandcoverLayer/useLandCoverLayer'; import { useSelector } from 'react-redux'; diff --git a/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Sentinel2.tsx b/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Sentinel2.tsx index e23ef26d..c72f72c1 100644 --- a/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Sentinel2.tsx +++ b/src/landcover-explorer/components/SwipeWidget/SwipeWidget4Sentinel2.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { FC } from 'react'; import useLandCoverLayer from '../LandcoverLayer/useLandCoverLayer'; import { useSelector } from 'react-redux'; diff --git a/src/landcover-explorer/components/SwipeWidget/index.ts b/src/landcover-explorer/components/SwipeWidget/index.ts index 012d49c7..e4b82e64 100644 --- a/src/landcover-explorer/components/SwipeWidget/index.ts +++ b/src/landcover-explorer/components/SwipeWidget/index.ts @@ -1,2 +1,17 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { SwipeWidget4Landcover } from './SwipeWidget4Landcover'; export { SwipeWidget4Sentinel2 } from './SwipeWidget4Sentinel2'; diff --git a/src/landcover-explorer/hooks/useSaveAppState2HashParams.tsx b/src/landcover-explorer/hooks/useSaveAppState2HashParams.tsx index 1300b160..f63a3c08 100644 --- a/src/landcover-explorer/hooks/useSaveAppState2HashParams.tsx +++ b/src/landcover-explorer/hooks/useSaveAppState2HashParams.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { FC, useEffect } from 'react'; import { selectYearsForSwipeWidgetLayers } from '@shared/store/LandcoverExplorer/selectors'; diff --git a/src/landcover-explorer/store/index.ts b/src/landcover-explorer/store/index.ts index 7e5c0054..405a2493 100644 --- a/src/landcover-explorer/store/index.ts +++ b/src/landcover-explorer/store/index.ts @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import configureAppStore from '@shared/store/configureStore'; import { getPreloadedState } from './getPreloadedState'; diff --git a/src/landsat-explorer/components/AnalyzeToolSelector/AnalyzeToolSelector.tsx b/src/landsat-explorer/components/AnalyzeToolSelector/AnalyzeToolSelector.tsx new file mode 100644 index 00000000..9fa23d17 --- /dev/null +++ b/src/landsat-explorer/components/AnalyzeToolSelector/AnalyzeToolSelector.tsx @@ -0,0 +1,45 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { AnalysisToolSelector } from '@shared/components/AnalysisToolSelector'; +import { AnalyzeToolSelectorData } from '@shared/components/AnalysisToolSelector/AnalysisToolSelectorContainer'; + +const data: AnalyzeToolSelectorData[] = [ + { + tool: 'mask', + title: 'Index', + subtitle: 'mask', + }, + { + tool: 'trend', + title: 'Temporal', + subtitle: 'profile', + }, + { + tool: 'spectral', + title: 'Spectral', + subtitle: 'profile', + }, + { + tool: 'change', + title: 'Change', + subtitle: 'detection', + }, +]; + +export const AnalyzeToolSelector4Landsat = () => { + return ; +}; diff --git a/src/landsat-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx b/src/landsat-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx index 4de49780..b83d9baf 100644 --- a/src/landsat-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx +++ b/src/landsat-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx @@ -13,49 +13,42 @@ * limitations under the License. */ -import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; -import { PixelRangeSlider } from '@shared/components/PixelRangeSlider'; -import { - selectedRangeUpdated, - spectralIndex4ChangeCompareToolChanged, -} from '@shared/store/ChangeCompareTool/reducer'; -import { - selectChangeCompareLayerIsOn, - selectSpectralIndex4ChangeCompareTool, - selectUserSelectedRangeInChangeCompareTool, -} from '@shared/store/ChangeCompareTool/selectors'; +// import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +// // import { PixelRangeSlider } from '@shared/components/PixelRangeSlider'; +// import { +// // selectedRangeUpdated, +// selectedOption4ChangeCompareToolChanged, +// } from '@shared/store/ChangeCompareTool/reducer'; +// import { +// selectChangeCompareLayerIsOn, +// selectSelectedOption4ChangeCompareTool, +// selectUserSelectedRangeInChangeCompareTool, +// } from '@shared/store/ChangeCompareTool/selectors'; import { selectActiveAnalysisTool } from '@shared/store/ImageryScene/selectors'; import { SpectralIndex } from '@typing/imagery-service'; import classNames from 'classnames'; import React from 'react'; -import { useDispatch } from 'react-redux'; +// import { useDispatch } from 'react-redux'; import { useSelector } from 'react-redux'; -import { getChangeCompareLayerColorrampAsCSSGradient } from '../ChangeLayer/helpers'; +// import { getChangeCompareLayerColorrampAsCSSGradient } from '../ChangeLayer/helpers'; +import { + ChangeCompareToolHeader, + ChangeCompareToolControls, +} from '@shared/components/ChangeCompareTool'; -export const ChangeCompareToolContainer = () => { - const dispatch = useDispatch(); +const LEGEND_LABEL_TEXT = ['decrease', 'no change', 'increase']; +export const ChangeCompareToolContainer = () => { const tool = useSelector(selectActiveAnalysisTool); - const selectedRange = useSelector( - selectUserSelectedRangeInChangeCompareTool - ); - - const selectedSpectralIndex = useSelector( - selectSpectralIndex4ChangeCompareTool - ); - - const isChangeLayerOn = useSelector(selectChangeCompareLayerIsOn); - if (tool !== 'change') { return null; } return (
- { label: 'MOISTURE INDEX', }, ]} - selectedValue={selectedSpectralIndex} - tooltipText={ - 'Compare and report changes between two selected images. Change is always calculated and reported chronologically from oldest to newest.' - } - dropdownMenuSelectedItemOnChange={(val) => { - dispatch( - spectralIndex4ChangeCompareToolChanged( - val as SpectralIndex - ) - ); - }} /> - - {isChangeLayerOn ? ( -
-
-
-
- - { - dispatch(selectedRangeUpdated(vals)); - }} - min={-2} - max={2} - steps={0.1} - tickLabels={[-2, -1, 0, 1, 2]} - countOfTicks={17} - showSliderTooltip={true} - /> - -
-
-
- decrease -
-
- no change -
-
- increase -
-
-
-
- ) : ( -
-

- Select two scenes, SCENE A and SCENE B, and then click - VIEW CHANGE. -

-
- )} +
); }; diff --git a/src/landsat-explorer/components/ChangeLayer/ChangeLayer.tsx b/src/landsat-explorer/components/ChangeLayer/ChangeLayer.tsx deleted file mode 100644 index 0ce89d67..00000000 --- a/src/landsat-explorer/components/ChangeLayer/ChangeLayer.tsx +++ /dev/null @@ -1,297 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { FC, useEffect, useRef, useState } from 'react'; -import ImageryLayer from '@arcgis/core/layers/ImageryLayer'; -import { LANDSAT_LEVEL_2_SERVICE_URL } from '@shared/services/landsat-level-2/config'; -import MapView from '@arcgis/core/views/MapView'; -import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; -import PixelBlock from '@arcgis/core/layers/support/PixelBlock'; -import GroupLayer from '@arcgis/core/layers/GroupLayer'; -import { getBandIndexesBySpectralIndex } from '@shared/services/landsat-level-2/helpers'; -import { SpectralIndex } from '@typing/imagery-service'; -import { QueryParams4ImageryScene } from '@shared/store/ImageryScene/reducer'; -import { getLandsatFeatureByObjectId } from '@shared/services/landsat-level-2/getLandsatScenes'; -import { formattedDateString2Unixtimestamp } from '@shared/utils/date-time/formatDateString'; -import { getPixelColor } from './helpers'; - -type Props = { - mapView?: MapView; - groupLayer?: GroupLayer; - /** - * name of selected spectral index - */ - spectralIndex: SpectralIndex; - /** - * query params of the first selected Landsat scene - */ - queryParams4SceneA: QueryParams4ImageryScene; - /** - * query params of the second selected Landsat scene - */ - queryParams4SceneB: QueryParams4ImageryScene; - /** - * visibility of the landsat layer - */ - visible?: boolean; - /** - * user selected mask index range - */ - selectedRange: number[]; -}; - -type PixelData = { - pixelBlock: PixelBlock; -}; - -/** - * This function retrieves a raster function that can be used to visualize changes between two input Landsat scenes. - * The output raster function applies an `Arithmetic` operation to calculate the difference of a selected spectral index - * between two input rasters. - * - * @param spectralIndex - The user-selected spectral index to analyze changes. - * @param queryParams4SceneA - Query parameters for the first selected Landsat scene. - * @param queryParams4SceneB - Query parameters for the second selected Landsat scene. - * @returns A Raster Function that contains the `Arithmetic` function to visualize spectral index changes. - * - * @see https://developers.arcgis.com/documentation/common-data-types/raster-function-objects.htm - */ -export const getRasterFunction4ChangeLayer = async ( - /** - * name of selected spectral index - */ - spectralIndex: SpectralIndex, - /** - * query params of the first selected Landsat scene - */ - queryParams4SceneA: QueryParams4ImageryScene, - /** - * query params of the second selected Landsat scene - */ - queryParams4SceneB: QueryParams4ImageryScene -): Promise => { - if (!spectralIndex) { - return null; - } - - if ( - !queryParams4SceneA?.objectIdOfSelectedScene || - !queryParams4SceneB?.objectIdOfSelectedScene - ) { - return null; - } - - // Sort query parameters by acquisition date in ascending order. - const [ - queryParams4SceneAcquiredInEarlierDate, - queryParams4SceneAcquiredInLaterDate, - ] = [queryParams4SceneA, queryParams4SceneB].sort((a, b) => { - return ( - formattedDateString2Unixtimestamp(a.acquisitionDate) - - formattedDateString2Unixtimestamp(b.acquisitionDate) - ); - }); - - try { - // Get the band index for the selected spectral index. - const bandIndex = getBandIndexesBySpectralIndex(spectralIndex); - - // Retrieve the feature associated with the later acquired Landsat scene. - const feature = await getLandsatFeatureByObjectId( - queryParams4SceneAcquiredInLaterDate?.objectIdOfSelectedScene - ); - - return new RasterFunction({ - // the Clip function clips a raster using a rectangular shape according to the extents defined, - // or clips a raster to the shape of an input polygon feature class. - functionName: 'Clip', - functionArguments: { - // a polygon or envelope - ClippingGeometry: feature.geometry, - // use 1 to keep image inside of the geometry - ClippingType: 1, - Raster: { - // The `Arithmetic` function performs an arithmetic operation between two rasters. - rasterFunction: 'Arithmetic', - rasterFunctionArguments: { - Raster: { - rasterFunction: 'BandArithmetic', - rasterFunctionArguments: { - Raster: `$${queryParams4SceneAcquiredInLaterDate.objectIdOfSelectedScene}`, - Method: 0, - BandIndexes: bandIndex, - }, - outputPixelType: 'F32', - }, - Raster2: { - rasterFunction: 'BandArithmetic', - rasterFunctionArguments: { - Raster: `$${queryParams4SceneAcquiredInEarlierDate.objectIdOfSelectedScene}`, - Method: 0, - BandIndexes: bandIndex, - }, - outputPixelType: 'F32', - }, - // 1=esriRasterPlus, 2=esriRasterMinus, 3=esriRasterMultiply, 4=esriRasterDivide, 5=esriRasterPower, 6=esriRasterMode - Operation: 2, - // default 0; 0=esriExtentFirstOf, 1=esriExtentIntersectionOf, 2=esriExtentUnionOf, 3=esriExtentLastOf - ExtentType: 1, - // 0=esriCellsizeFirstOf, 1=esriCellsizeMinOf, 2=esriCellsizeMaxOf, 3=esriCellsizeMeanOf, 4=esriCellsizeLastOf - CellsizeType: 0, - }, - outputPixelType: 'F32', - }, - }, - }); - } catch (err) { - console.error(err); - - // handle any potential errors and return null in case of failure. - return null; - } -}; - -export const ChangeLayer: FC = ({ - mapView, - groupLayer, - spectralIndex, - queryParams4SceneA, - queryParams4SceneB, - visible, - selectedRange, -}) => { - const layerRef = useRef(); - - const selectedRangeRef = useRef(); - - /** - * initialize landsat layer using mosaic created using the input year - */ - const init = async () => { - const rasterFunction = await getRasterFunction4ChangeLayer( - spectralIndex, - queryParams4SceneA, - queryParams4SceneB - ); - - layerRef.current = new ImageryLayer({ - // URL to the imagery service - url: LANDSAT_LEVEL_2_SERVICE_URL, - mosaicRule: null, - format: 'lerc', - rasterFunction, - visible, - pixelFilter: maskPixels, - effect: 'drop-shadow(2px, 2px, 3px, #000)', - }); - - groupLayer.add(layerRef.current); - }; - - const maskPixels = (pixelData: PixelData) => { - // const color = colorRef.current || [255, 255, 255]; - - const { pixelBlock } = pixelData || {}; - - if (!pixelBlock) { - return; - } - - const { pixels, width, height } = pixelBlock; - - if (!pixels) { - return; - } - - const p1 = pixels[0]; - - const n = pixels[0].length; - - if (!pixelBlock.mask) { - pixelBlock.mask = new Uint8Array(n); - } - - const pr = new Uint8Array(n); - const pg = new Uint8Array(n); - const pb = new Uint8Array(n); - - const numPixels = width * height; - - const [min, max] = selectedRangeRef.current || [0, 0]; - - for (let i = 0; i < numPixels; i++) { - if (p1[i] < min || p1[i] > max || p1[i] === 0) { - pixelBlock.mask[i] = 0; - continue; - } - - const color = getPixelColor(p1[i]); - - pixelBlock.mask[i] = 1; - - pr[i] = color[0]; - pg[i] = color[1]; - pb[i] = color[2]; - } - - pixelBlock.pixels = [pr, pg, pb]; - - pixelBlock.pixelType = 'u8'; - }; - - useEffect(() => { - if (groupLayer && !layerRef.current) { - init(); - } - }, [groupLayer]); - - useEffect(() => { - (async () => { - if (!layerRef.current) { - return; - } - - layerRef.current.rasterFunction = - (await getRasterFunction4ChangeLayer( - spectralIndex, - queryParams4SceneA, - queryParams4SceneB - )) as any; - })(); - }, [spectralIndex, queryParams4SceneA, queryParams4SceneB]); - - useEffect(() => { - if (!layerRef.current) { - return; - } - - layerRef.current.visible = visible; - - if (visible) { - // reorder it to make sure it is the top most layer on the map - groupLayer.reorder(layerRef.current, mapView.map.layers.length - 1); - } - }, [visible]); - - useEffect(() => { - selectedRangeRef.current = selectedRange; - - if (layerRef.current) { - layerRef.current.redraw(); - } - }, [selectedRange]); - - return null; -}; diff --git a/src/landsat-explorer/components/ChangeLayer/ChangeLayerContainer.tsx b/src/landsat-explorer/components/ChangeLayer/ChangeLayerContainer.tsx index 830bc438..574676b2 100644 --- a/src/landsat-explorer/components/ChangeLayer/ChangeLayerContainer.tsx +++ b/src/landsat-explorer/components/ChangeLayer/ChangeLayerContainer.tsx @@ -14,7 +14,7 @@ */ import MapView from '@arcgis/core/views/MapView'; -import React, { FC, useMemo } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { selectActiveAnalysisTool, @@ -23,22 +23,146 @@ import { selectQueryParams4SecondaryScene, } from '@shared/store/ImageryScene/selectors'; import GroupLayer from '@arcgis/core/layers/GroupLayer'; -import { ChangeLayer } from './ChangeLayer'; +// import { ChangeLayer } from './ChangeLayer'; import { selectChangeCompareLayerIsOn, - selectSpectralIndex4ChangeCompareTool, + selectFullPixelValuesRangeInChangeCompareTool, + selectSelectedOption4ChangeCompareTool, selectUserSelectedRangeInChangeCompareTool, } from '@shared/store/ChangeCompareTool/selectors'; +import { getBandIndexesBySpectralIndex } from '@shared/services/landsat-level-2/helpers'; +import { SpectralIndex } from '@typing/imagery-service'; +import { QueryParams4ImageryScene } from '@shared/store/ImageryScene/reducer'; +import { getLandsatFeatureByObjectId } from '@shared/services/landsat-level-2/getLandsatScenes'; +import { formattedDateString2Unixtimestamp } from '@shared/utils/date-time/formatDateString'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; +import { LANDSAT_LEVEL_2_SERVICE_URL } from '@shared/services/landsat-level-2/config'; +import { getPixelColor4ChangeCompareLayer } from '@shared/components/ChangeCompareTool/helpers'; +import { ImageryLayerWithPixelFilter } from '@shared/components/ImageryLayerWithPixelFilter'; +import { useCalculateTotalAreaByPixelsCount } from '@shared/hooks/useCalculateTotalAreaByPixelsCount'; +import { useDispatch } from 'react-redux'; +import { countOfVisiblePixelsChanged } from '@shared/store/Map/reducer'; type Props = { mapView?: MapView; groupLayer?: GroupLayer; }; +/** + * This function retrieves a raster function that can be used to visualize changes between two input Landsat scenes. + * The output raster function applies an `Arithmetic` operation to calculate the difference of a selected spectral index + * between two input rasters. + * + * @param spectralIndex - The user-selected spectral index to analyze changes. + * @param queryParams4SceneA - Query parameters for the first selected Landsat scene. + * @param queryParams4SceneB - Query parameters for the second selected Landsat scene. + * @returns A Raster Function that contains the `Arithmetic` function to visualize spectral index changes. + * + * @see https://developers.arcgis.com/documentation/common-data-types/raster-function-objects.htm + */ +export const getRasterFunction4ChangeLayer = async ( + /** + * name of selected spectral index + */ + spectralIndex: SpectralIndex, + /** + * query params of the first selected Landsat scene + */ + queryParams4SceneA: QueryParams4ImageryScene, + /** + * query params of the second selected Landsat scene + */ + queryParams4SceneB: QueryParams4ImageryScene +): Promise => { + if (!spectralIndex) { + return null; + } + + if ( + !queryParams4SceneA?.objectIdOfSelectedScene || + !queryParams4SceneB?.objectIdOfSelectedScene + ) { + return null; + } + + // Sort query parameters by acquisition date in ascending order. + const [ + queryParams4SceneAcquiredInEarlierDate, + queryParams4SceneAcquiredInLaterDate, + ] = [queryParams4SceneA, queryParams4SceneB].sort((a, b) => { + return ( + formattedDateString2Unixtimestamp(a.acquisitionDate) - + formattedDateString2Unixtimestamp(b.acquisitionDate) + ); + }); + + try { + // Get the band index for the selected spectral index. + const bandIndex = getBandIndexesBySpectralIndex(spectralIndex); + + // Retrieve the feature associated with the later acquired Landsat scene. + const feature = await getLandsatFeatureByObjectId( + queryParams4SceneAcquiredInLaterDate?.objectIdOfSelectedScene + ); + + return new RasterFunction({ + // the Clip function clips a raster using a rectangular shape according to the extents defined, + // or clips a raster to the shape of an input polygon feature class. + functionName: 'Clip', + functionArguments: { + // a polygon or envelope + ClippingGeometry: feature.geometry, + // use 1 to keep image inside of the geometry + ClippingType: 1, + Raster: { + // The `Arithmetic` function performs an arithmetic operation between two rasters. + rasterFunction: 'Arithmetic', + rasterFunctionArguments: { + Raster: { + rasterFunction: 'BandArithmetic', + rasterFunctionArguments: { + Raster: `$${queryParams4SceneAcquiredInLaterDate.objectIdOfSelectedScene}`, + Method: 0, + BandIndexes: bandIndex, + }, + outputPixelType: 'F32', + }, + Raster2: { + rasterFunction: 'BandArithmetic', + rasterFunctionArguments: { + Raster: `$${queryParams4SceneAcquiredInEarlierDate.objectIdOfSelectedScene}`, + Method: 0, + BandIndexes: bandIndex, + }, + outputPixelType: 'F32', + }, + // 1=esriRasterPlus, 2=esriRasterMinus, 3=esriRasterMultiply, 4=esriRasterDivide, 5=esriRasterPower, 6=esriRasterMode + Operation: 2, + // default 0; 0=esriExtentFirstOf, 1=esriExtentIntersectionOf, 2=esriExtentUnionOf, 3=esriExtentLastOf + ExtentType: 1, + // 0=esriCellsizeFirstOf, 1=esriCellsizeMinOf, 2=esriCellsizeMaxOf, 3=esriCellsizeMeanOf, 4=esriCellsizeLastOf + CellsizeType: 0, + }, + outputPixelType: 'F32', + }, + }, + }); + } catch (err) { + console.error(err); + + // handle any potential errors and return null in case of failure. + return null; + } +}; + export const ChangeLayerContainer: FC = ({ mapView, groupLayer }) => { + const dispatach = useDispatch(); + const mode = useSelector(selectAppMode); - const spectralIndex = useSelector(selectSpectralIndex4ChangeCompareTool); + const spectralIndex = useSelector( + selectSelectedOption4ChangeCompareTool + ) as SpectralIndex; const changeCompareLayerIsOn = useSelector(selectChangeCompareLayerIsOn); @@ -52,6 +176,12 @@ export const ChangeLayerContainer: FC = ({ mapView, groupLayer }) => { selectUserSelectedRangeInChangeCompareTool ); + const fullPixelValueRange = useSelector( + selectFullPixelValuesRangeInChangeCompareTool + ); + + const [rasterFunction, setRasterFunction] = useState(); + const isVisible = useMemo(() => { if (mode !== 'analysis') { return false; @@ -68,6 +198,10 @@ export const ChangeLayerContainer: FC = ({ mapView, groupLayer }) => { return false; } + if (!rasterFunction) { + return false; + } + return changeCompareLayerIsOn; }, [ mode, @@ -75,17 +209,54 @@ export const ChangeLayerContainer: FC = ({ mapView, groupLayer }) => { changeCompareLayerIsOn, queryParams4SceneA, queryParams4SceneB, + rasterFunction, ]); + useEffect(() => { + (async () => { + const rasterFunction = await getRasterFunction4ChangeLayer( + spectralIndex, + queryParams4SceneA, + queryParams4SceneB + ); + + setRasterFunction(rasterFunction); + })(); + }, [spectralIndex, queryParams4SceneA, queryParams4SceneB]); + + // return ( + // + // ); + + useCalculateTotalAreaByPixelsCount({ + objectId: + queryParams4SceneA?.objectIdOfSelectedScene || + queryParams4SceneB?.objectIdOfSelectedScene, + serviceURL: LANDSAT_LEVEL_2_SERVICE_URL, + pixelSize: mapView.resolution, + }); + return ( - { + dispatach(countOfVisiblePixelsChanged(visiblePixels)); + }} /> ); }; diff --git a/src/landsat-explorer/components/LandsatDynamicModeInfo/LandsatDynamicModeInfo.tsx b/src/landsat-explorer/components/LandsatDynamicModeInfo/LandsatDynamicModeInfo.tsx new file mode 100644 index 00000000..cdbea766 --- /dev/null +++ b/src/landsat-explorer/components/LandsatDynamicModeInfo/LandsatDynamicModeInfo.tsx @@ -0,0 +1,23 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DynamicModeInfo } from '@shared/components/DynamicModeInfo'; +import React from 'react'; + +export const LandsatDynamicModeInfo = () => { + return ( + + ); +}; diff --git a/src/landsat-explorer/components/LandsatLayer/LandsatLayer.tsx b/src/landsat-explorer/components/LandsatLayer/LandsatLayer.tsx index 098b652a..53713270 100644 --- a/src/landsat-explorer/components/LandsatLayer/LandsatLayer.tsx +++ b/src/landsat-explorer/components/LandsatLayer/LandsatLayer.tsx @@ -14,17 +14,16 @@ */ import MapView from '@arcgis/core/views/MapView'; -import React, { FC, useEffect } from 'react'; -import useLandsatLayer from './useLandsatLayer'; -import { useSelector } from 'react-redux'; -import { - selectQueryParams4SceneInSelectedMode, - selectAppMode, - selectActiveAnalysisTool, -} from '@shared/store/ImageryScene/selectors'; -import { selectAnimationStatus } from '@shared/store/UI/selectors'; +import React, { FC, useEffect, useMemo } from 'react'; import GroupLayer from '@arcgis/core/layers/GroupLayer'; -import { selectChangeCompareLayerIsOn } from '@shared/store/ChangeCompareTool/selectors'; +// import { selectChangeCompareLayerIsOn } from '@shared/store/ChangeCompareTool/selectors'; +import ImageryLayerByObjectID from '@shared/components/ImageryLayer/ImageryLayerByObjectID'; +import { + LANDSAT_LEVEL_2_SERVICE_SORT_FIELD, + LANDSAT_LEVEL_2_SERVICE_SORT_VALUE, + LANDSAT_LEVEL_2_SERVICE_URL, +} from '@shared/services/landsat-level-2/config'; +import MosaicRule from '@arcgis/core/layers/support/MosaicRule'; type Props = { mapView?: MapView; @@ -32,70 +31,88 @@ type Props = { }; const LandsatLayer: FC = ({ mapView, groupLayer }: Props) => { - const mode = useSelector(selectAppMode); - - const { rasterFunctionName, objectIdOfSelectedScene } = - useSelector(selectQueryParams4SceneInSelectedMode) || {}; - - const animationStatus = useSelector(selectAnimationStatus); - - const analysisTool = useSelector(selectActiveAnalysisTool); - - const changeCompareLayerIsOn = useSelector(selectChangeCompareLayerIsOn); - - const getVisibility = () => { - if (mode === 'dynamic') { - return true; - } - - if (mode === 'find a scene' || mode === 'spectral sampling') { - return objectIdOfSelectedScene !== null; - } - - if (mode === 'analysis') { - // no need to show landsat layer when user is viewing change layer in the change compare tool - if (analysisTool === 'change' && changeCompareLayerIsOn) { - return false; - } - - return objectIdOfSelectedScene !== null; - } - - // when in animate mode, only need to show landsat layer if animation is not playing - if ( - mode === 'animate' && - objectIdOfSelectedScene && - animationStatus === null - ) { - return true; - } - - return false; - }; - - const getObjectId = () => { - // should ignore the object id of selected scene if in dynamic mode, - if (mode === 'dynamic') { - return null; - } - - return objectIdOfSelectedScene; - }; - - const layer = useLandsatLayer({ - visible: getVisibility(), - rasterFunction: rasterFunctionName, - objectId: getObjectId(), - }); - - useEffect(() => { - if (groupLayer && layer) { - groupLayer.add(layer); - groupLayer.reorder(layer, 0); - } - }, [groupLayer, layer]); - - return null; + // const mode = useSelector(selectAppMode); + + // const { rasterFunctionName, objectIdOfSelectedScene } = + // useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + // const animationStatus = useSelector(selectAnimationStatus); + + // const analysisTool = useSelector(selectActiveAnalysisTool); + + // const changeCompareLayerIsOn = useSelector(selectChangeCompareLayerIsOn); + + // const getVisibility = () => { + // if (mode === 'dynamic') { + // return true; + // } + + // if (mode === 'find a scene' || mode === 'spectral sampling') { + // return objectIdOfSelectedScene !== null; + // } + + // if (mode === 'analysis') { + // // no need to show landsat layer when user is viewing change layer in the change compare tool + // if (analysisTool === 'change' && changeCompareLayerIsOn) { + // return false; + // } + + // return objectIdOfSelectedScene !== null; + // } + + // // when in animate mode, only need to show landsat layer if animation is not playing + // if ( + // mode === 'animate' && + // objectIdOfSelectedScene && + // animationStatus === null + // ) { + // return true; + // } + + // return false; + // }; + + // const getObjectId = () => { + // // should ignore the object id of selected scene if in dynamic mode, + // if (mode === 'dynamic') { + // return null; + // } + + // return objectIdOfSelectedScene; + // }; + + // const layer = useLandsatLayer({ + // visible: getVisibility(), + // rasterFunction: rasterFunctionName, + // objectId: getObjectId(), + // }); + + // useEffect(() => { + // if (groupLayer && layer) { + // groupLayer.add(layer); + // groupLayer.reorder(layer, 0); + // } + // }, [groupLayer, layer]); + + // return null; + + const defaultMosaicRule = useMemo(() => { + return new MosaicRule({ + ascending: true, + method: 'attribute', + operation: 'first', + sortField: LANDSAT_LEVEL_2_SERVICE_SORT_FIELD, + sortValue: LANDSAT_LEVEL_2_SERVICE_SORT_VALUE, + }); + }, []); + + return ( + + ); }; export default LandsatLayer; diff --git a/src/landsat-explorer/components/Layout/Layout.tsx b/src/landsat-explorer/components/Layout/Layout.tsx index 5a8432ac..c036d53d 100644 --- a/src/landsat-explorer/components/Layout/Layout.tsx +++ b/src/landsat-explorer/components/Layout/Layout.tsx @@ -28,13 +28,13 @@ import { selectAppMode, } from '@shared/store/ImageryScene/selectors'; import { AnimationControl } from '@shared/components/AnimationControl'; -import { AnalysisToolSelector } from '@shared/components/AnalysisToolSelector'; -import { TrendTool } from '../TrendTool'; + +import { TrendTool } from '../TemporalProfileTool'; import { MaskTool } from '../MaskTool'; import { SwipeLayerSelector } from '@shared/components/SwipeLayerSelector'; import { useSaveAppState2HashParams } from '@shared/hooks/useSaveAppState2HashParams'; import { IS_MOBILE_DEVICE } from '@shared/constants/UI'; -import { DynamicModeInfo } from '@shared/components/DynamicModeInfo'; +// import { DynamicModeInfo } from '@shared/components/DynamicModeInfo'; import { SpectralTool } from '../SpectralTool'; import { ChangeCompareLayerSelector } from '@shared/components/ChangeCompareLayerSelector'; import { ChangeCompareTool } from '../ChangeCompareTool'; @@ -43,6 +43,10 @@ import { useQueryAvailableLandsatScenes } from '@landsat-explorer/hooks/useQuery import { LandsatRasterFunctionSelector } from '../RasterFunctionSelector'; import { LandsatInterestingPlaces } from '../InterestingPlaces'; import { LandsatMissionFilter } from '../LandsatMissionFilter'; +import { AnalyzeToolSelector4Landsat } from '../AnalyzeToolSelector/AnalyzeToolSelector'; +import { useShouldShowSecondaryControls } from '@shared/hooks/useShouldShowSecondaryControls'; +import { CloudFilter } from '@shared/components/CloudFilter'; +import { LandsatDynamicModeInfo } from '../LandsatDynamicModeInfo/LandsatDynamicModeInfo'; const Layout = () => { const mode = useSelector(selectAppMode); @@ -51,8 +55,10 @@ const Layout = () => { const dynamicModeOn = mode === 'dynamic'; - const shouldShowSecondaryControls = - mode === 'swipe' || mode === 'animate' || mode === 'analysis'; + // const shouldShowSecondaryControls = + // mode === 'swipe' || mode === 'animate' || mode === 'analysis'; + + const shouldShowSecondaryControls = useShouldShowSecondaryControls(); /** * This custom hook gets invoked whenever the acquisition year, map center, or selected landsat missions @@ -68,7 +74,7 @@ const Layout = () => {
- +
@@ -88,7 +94,7 @@ const Layout = () => { - + )} @@ -102,7 +108,7 @@ const Layout = () => {
{dynamicModeOn ? ( <> - + ) : ( @@ -110,6 +116,7 @@ const Layout = () => {
+
diff --git a/src/landsat-explorer/components/Map/Map.tsx b/src/landsat-explorer/components/Map/Map.tsx index 568c2b71..c5ba314a 100644 --- a/src/landsat-explorer/components/Map/Map.tsx +++ b/src/landsat-explorer/components/Map/Map.tsx @@ -16,7 +16,7 @@ import React, { FC } from 'react'; import MapViewContainer from '@shared/components/MapView/MapViewContainer'; import { LandsatLayer } from '../LandsatLayer'; -import { SwipeWidget } from '../SwipeWidget'; +// import { SwipeWidget } from '../SwipeWidget'; import { AnimationLayer } from '@shared/components/AnimationLayer'; import { MaskLayer } from '../MaskLayer'; import { GroupLayer } from '@shared/components/GroupLayer'; @@ -26,7 +26,6 @@ import { Popup } from '../PopUp'; import { MapPopUpAnchorPoint } from '@shared/components/MapPopUpAnchorPoint'; import { HillshadeLayer } from '@shared/components/HillshadeLayer/HillshadeLayer'; import { ChangeLayer } from '../ChangeLayer'; -import { ZoomToExtent } from '../ZoomToExtent'; import { ScreenshotWidget } from '@shared/components/ScreenshotWidget/ScreenshotWidget'; import { MapMagnifier } from '@shared/components/MapMagnifier'; import CustomMapArrtribution from '@shared/components/CustomMapArrtribution/CustomMapArrtribution'; @@ -36,6 +35,8 @@ import { LANDSAT_LEVEL_2_SERVICE_URL } from '@shared/services/landsat-level-2/co import { useDispatch } from 'react-redux'; import { updateQueryLocation4TrendTool } from '@shared/store/TrendTool/thunks'; import { updateQueryLocation4SpectralProfileTool } from '@shared/store/SpectralProfileTool/thunks'; +import { SwipeWidget4ImageryLayers } from '@shared/components/SwipeWidget/SwipeWidget4ImageryLayers'; +import { ZoomToExtent } from '@shared/components/ZoomToExtent'; const Map = () => { const dispatch = useDispatch(); @@ -59,7 +60,10 @@ const Map = () => { - + {/* */} + { nativeScale={113386} tooltip={"Zoom to Landsat's native resolution"} /> - + diff --git a/src/landsat-explorer/components/MaskLayer/MaskLayer.tsx b/src/landsat-explorer/components/MaskLayer/MaskLayer.tsx deleted file mode 100644 index e9f4f9b8..00000000 --- a/src/landsat-explorer/components/MaskLayer/MaskLayer.tsx +++ /dev/null @@ -1,249 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { FC, useEffect, useRef, useState } from 'react'; -import ImageryLayer from '@arcgis/core/layers/ImageryLayer'; -import MosaicRule from '@arcgis/core/layers/support/MosaicRule'; -import { LANDSAT_LEVEL_2_SERVICE_URL } from '@shared/services/landsat-level-2/config'; -import MapView from '@arcgis/core/views/MapView'; -import { getMosaicRule } from '../LandsatLayer/useLandsatLayer'; -import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; -import PixelBlock from '@arcgis/core/layers/support/PixelBlock'; -import GroupLayer from '@arcgis/core/layers/GroupLayer'; -import { getBandIndexesBySpectralIndex } from '@shared/services/landsat-level-2/helpers'; -import { SpectralIndex } from '@typing/imagery-service'; - -type Props = { - mapView?: MapView; - groupLayer?: GroupLayer; - /** - * name of selected spectral index that will be used to create raster function render the mask layer - */ - spectralIndex: SpectralIndex; - /** - * object id of the selected Landsat scene - */ - objectId?: number; - /** - * visibility of the landsat layer - */ - visible?: boolean; - /** - * user selected mask index range - */ - selectedRange: number[]; - /** - * color in format of [red, green, blue] - */ - color: number[]; - /** - * opacity of the mask layer - */ - opacity: number; - /** - * if true, use the mask layer to clip the landsat scene via blend mode - */ - shouldClip: boolean; -}; - -type PixelData = { - pixelBlock: PixelBlock; -}; - -export const getRasterFunctionBySpectralIndex = async ( - spectralIndex: SpectralIndex -): Promise => { - if (!spectralIndex) { - return null; - } - - return new RasterFunction({ - functionName: 'BandArithmetic', - outputPixelType: 'f32', - functionArguments: { - Method: 0, - BandIndexes: getBandIndexesBySpectralIndex(spectralIndex), - }, - }); -}; - -export const MaskLayer: FC = ({ - mapView, - groupLayer, - spectralIndex, - objectId, - visible, - selectedRange, - color, - opacity, - shouldClip, -}) => { - const layerRef = useRef(); - - const selectedRangeRef = useRef(); - - const colorRef = useRef(); - - /** - * initialize landsat layer using mosaic created using the input year - */ - const init = async () => { - const mosaicRule = objectId ? await getMosaicRule(objectId) : null; - - const rasterFunction = await getRasterFunctionBySpectralIndex( - spectralIndex - ); - - layerRef.current = new ImageryLayer({ - // URL to the imagery service - url: LANDSAT_LEVEL_2_SERVICE_URL, - mosaicRule, - format: 'lerc', - rasterFunction, - visible, - pixelFilter: maskPixels, - blendMode: shouldClip ? 'destination-atop' : null, - effect: 'drop-shadow(2px, 2px, 3px, #000)', - }); - - groupLayer.add(layerRef.current); - }; - - const maskPixels = (pixelData: PixelData) => { - const color = colorRef.current || [255, 255, 255]; - - const { pixelBlock } = pixelData || {}; - - if (!pixelBlock) { - return; - } - - const { pixels, width, height } = pixelBlock; - - if (!pixels) { - return; - } - - const p1 = pixels[0]; - - const n = pixels[0].length; - - if (!pixelBlock.mask) { - pixelBlock.mask = new Uint8Array(n); - } - - const pr = new Uint8Array(n); - const pg = new Uint8Array(n); - const pb = new Uint8Array(n); - - const numPixels = width * height; - - const [min, max] = selectedRangeRef.current || [0, 0]; - - for (let i = 0; i < numPixels; i++) { - if (p1[i] < min || p1[i] > max || p1[i] === 0) { - // should exclude pixels that are outside of the user selected range and - // pixels with value of 0 since those are pixels - // outside of the mask layer's actual boundary - pixelBlock.mask[i] = 0; - continue; - } - - pixelBlock.mask[i] = 1; - - pr[i] = color[0]; - pg[i] = color[1]; - pb[i] = color[2]; - } - - pixelBlock.pixels = [pr, pg, pb]; - - pixelBlock.pixelType = 'u8'; - }; - - useEffect(() => { - if (groupLayer && !layerRef.current) { - init(); - } - }, [groupLayer]); - - useEffect(() => { - (async () => { - if (!layerRef.current) { - return; - } - - layerRef.current.rasterFunction = - (await getRasterFunctionBySpectralIndex(spectralIndex)) as any; - })(); - }, [spectralIndex]); - - useEffect(() => { - (async () => { - if (!layerRef.current) { - return; - } - - layerRef.current.mosaicRule = await getMosaicRule(objectId); - })(); - }, [objectId]); - - useEffect(() => { - if (!layerRef.current) { - return; - } - - layerRef.current.visible = visible; - - if (visible) { - // reorder it to make sure it is the top most layer on the map - groupLayer.reorder(layerRef.current, mapView.map.layers.length - 1); - } - }, [visible]); - - useEffect(() => { - selectedRangeRef.current = selectedRange; - colorRef.current = color; - - if (layerRef.current) { - layerRef.current.redraw(); - } - }, [selectedRange, color]); - - useEffect(() => { - if (layerRef.current) { - layerRef.current.opacity = opacity; - } - }, [opacity]); - - useEffect(() => { - if (layerRef.current) { - // layerRef.current.blendMode = shouldClip - // ? 'destination-atop' - // : null; - - if (shouldClip) { - layerRef.current.blendMode = 'destination-atop'; - } else { - // in order to reset the blend mode to null, - // we need to remove the exiting mask layer and create a new instance of the mask layer - groupLayer.remove(layerRef.current); - init(); - } - } - }, [shouldClip]); - - return null; -}; diff --git a/src/landsat-explorer/components/MaskLayer/MaskLayerContainer.tsx b/src/landsat-explorer/components/MaskLayer/MaskLayerContainer.tsx index 7a5f3e31..cebad5f1 100644 --- a/src/landsat-explorer/components/MaskLayer/MaskLayerContainer.tsx +++ b/src/landsat-explorer/components/MaskLayer/MaskLayerContainer.tsx @@ -14,14 +14,15 @@ */ import MapView from '@arcgis/core/views/MapView'; -import React, { FC, useMemo } from 'react'; -import { MaskLayer } from './MaskLayer'; +import React, { FC, useEffect, useMemo } from 'react'; +// import { MaskLayer } from './MaskLayer'; import { useSelector } from 'react-redux'; import { - selectMaskOptions, + selectMaskLayerPixelValueRange, selectShouldClipMaskLayer, selectMaskLayerOpcity, - selectSpectralIndex4MaskTool, + selectSelectedIndex4MaskTool, + selectMaskLayerPixelColor, } from '@shared/store/MaskTool/selectors'; import { selectActiveAnalysisTool, @@ -29,18 +30,55 @@ import { selectQueryParams4SceneInSelectedMode, } from '@shared/store/ImageryScene/selectors'; import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { SpectralIndex } from '@typing/imagery-service'; +import { ImageryLayerWithPixelFilter } from '@shared/components/ImageryLayerWithPixelFilter'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; +import { getBandIndexesBySpectralIndex } from '@shared/services/landsat-level-2/helpers'; +import { + LANDSAT_LEVEL_2_SERVICE_URL, + LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT, + LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT, +} from '@shared/services/landsat-level-2/config'; +import { useCalculateTotalAreaByPixelsCount } from '@shared/hooks/useCalculateTotalAreaByPixelsCount'; +import { useDispatch } from 'react-redux'; +import { countOfVisiblePixelsChanged } from '@shared/store/Map/reducer'; type Props = { mapView?: MapView; groupLayer?: GroupLayer; }; +export const getRasterFunctionBySpectralIndex = ( + spectralIndex: SpectralIndex +): RasterFunction => { + if (!spectralIndex) { + return null; + } + + return new RasterFunction({ + functionName: 'BandArithmetic', + outputPixelType: 'f32', + functionArguments: { + Method: 0, + BandIndexes: getBandIndexesBySpectralIndex(spectralIndex), + }, + }); +}; + export const MaskLayerContainer: FC = ({ mapView, groupLayer }) => { + const dispatach = useDispatch(); + const mode = useSelector(selectAppMode); - const spectralIndex = useSelector(selectSpectralIndex4MaskTool); + const spectralIndex = useSelector( + selectSelectedIndex4MaskTool + ) as SpectralIndex; + + const { selectedRange } = useSelector(selectMaskLayerPixelValueRange); - const { selectedRange, color } = useSelector(selectMaskOptions); + const pixelColor = useSelector(selectMaskLayerPixelColor); const opacity = useSelector(selectMaskLayerOpcity); @@ -63,17 +101,50 @@ export const MaskLayerContainer: FC = ({ mapView, groupLayer }) => { return true; }, [mode, anailysisTool, objectIdOfSelectedScene]); + const rasterFunction = useMemo(() => { + return getRasterFunctionBySpectralIndex(spectralIndex); + }, [spectralIndex]); + + const fullPixelValueRange = useMemo(() => { + if (spectralIndex === 'temperature celcius') { + return [ + LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS, + ]; + } + + if (spectralIndex === 'temperature farhenheit') { + return [ + LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT, + LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT, + ]; + } + + return [-1, 1]; + }, [spectralIndex]); + + useCalculateTotalAreaByPixelsCount({ + objectId: objectIdOfSelectedScene, + serviceURL: LANDSAT_LEVEL_2_SERVICE_URL, + pixelSize: mapView.resolution, + }); + return ( - { + dispatach(countOfVisiblePixelsChanged(visiblePixels)); + }} /> ); }; diff --git a/src/landsat-explorer/components/MaskTool/MaskToolContainer.tsx b/src/landsat-explorer/components/MaskTool/MaskToolContainer.tsx index c5348949..4d7a13a9 100644 --- a/src/landsat-explorer/components/MaskTool/MaskToolContainer.tsx +++ b/src/landsat-explorer/components/MaskTool/MaskToolContainer.tsx @@ -16,14 +16,17 @@ import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; // import { PixelRangeSlider as MaskLayerPixelRangeSlider4SpectralIndex } from '@shared/components/MaskTool/PixelRangeSlider'; // import { PixelRangeSlider as MaskLayerPixelRangeSlider4SurfaceTemp } from './PixelRangeSlider4SurfaceTemp'; -import { MaskLayerRenderingControls } from '@shared/components/MaskTool'; -import { spectralIndex4MaskToolChanged } from '@shared/store/MaskTool/reducer'; import { - selectSpectralIndex4MaskTool, - selectMaskOptions, + MaskLayerRenderingControls, + MaskToolWarnigMessage, +} from '@shared/components/MaskTool'; +import { selectedIndex4MaskToolChanged } from '@shared/store/MaskTool/reducer'; +import { + selectSelectedIndex4MaskTool, + selectMaskLayerPixelValueRange, // selectActiveAnalysisTool, } from '@shared/store/MaskTool/selectors'; -import { updateSelectedRange } from '@shared/store/MaskTool/thunks'; +import { updateMaskLayerSelectedRange } from '@shared/store/MaskTool/thunks'; import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { useSelector } from 'react-redux'; @@ -40,15 +43,16 @@ import { SurfaceTempCelsiusPixelRangeSlider, SurfaceTempFarhenheitPixelRangeSlider, } from './SurfaceTempPixelRangeSlider'; +import { TotalVisibleAreaInfo } from '@shared/components/TotalAreaInfo/TotalAreaInfo'; export const MaskToolContainer = () => { const dispatch = useDispatch(); const tool = useSelector(selectActiveAnalysisTool); - const selectedSpectralIndex = useSelector(selectSpectralIndex4MaskTool); + const selectedSpectralIndex = useSelector(selectSelectedIndex4MaskTool); - const maskOptions = useSelector(selectMaskOptions); + const maskOptions = useSelector(selectMaskLayerPixelValueRange); const { objectIdOfSelectedScene } = useSelector(selectQueryParams4SceneInSelectedMode) || {}; @@ -76,7 +80,7 @@ export const MaskToolContainer = () => { // } // if (spectralIndex) { - // dispatch(spectralIndex4MaskToolChanged(spectralIndex)); + // dispatch(selectedIndex4MaskToolChanged(spectralIndex)); // } // }, [queryParams4MainScene?.rasterFunctionName]); @@ -114,25 +118,20 @@ export const MaskToolContainer = () => { tooltipText={MASK_TOOL_HEADER_TOOLTIP} dropdownMenuSelectedItemOnChange={(val) => { dispatch( - spectralIndex4MaskToolChanged(val as SpectralIndex) + selectedIndex4MaskToolChanged(val as SpectralIndex) ); }} /> {shouldBeDisabled ? ( -
-

- Select a scene to calculate a mask for the selected - index. -

-
+ ) : ( <> -
+
+
+ +
+ {selectedSpectralIndex === 'temperature celcius' && ( )} @@ -148,7 +147,9 @@ export const MaskToolContainer = () => { min={-1} max={1} valuesOnChange={(values) => { - dispatch(updateSelectedRange(values)); + dispatch( + updateMaskLayerSelectedRange(values) + ); }} countOfTicks={17} tickLabels={[-1, -0.5, 0, 0.5, 1]} diff --git a/src/landsat-explorer/components/MaskTool/SurfaceTempPixelRangeSlider.tsx b/src/landsat-explorer/components/MaskTool/SurfaceTempPixelRangeSlider.tsx index 2ec7eb98..4d95f15c 100644 --- a/src/landsat-explorer/components/MaskTool/SurfaceTempPixelRangeSlider.tsx +++ b/src/landsat-explorer/components/MaskTool/SurfaceTempPixelRangeSlider.tsx @@ -14,11 +14,11 @@ */ import { - selectSpectralIndex4MaskTool, - selectMaskOptions, + selectSelectedIndex4MaskTool, + selectMaskLayerPixelValueRange, // selectActiveAnalysisTool, } from '@shared/store/MaskTool/selectors'; -import { updateSelectedRange } from '@shared/store/MaskTool/thunks'; +import { updateMaskLayerSelectedRange } from '@shared/store/MaskTool/thunks'; import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { useSelector } from 'react-redux'; @@ -34,9 +34,9 @@ import { export const SurfaceTempCelsiusPixelRangeSlider = () => { const dispatch = useDispatch(); - const selectedSpectralIndex = useSelector(selectSpectralIndex4MaskTool); + const selectedSpectralIndex = useSelector(selectSelectedIndex4MaskTool); - const maskOptions = useSelector(selectMaskOptions); + const maskOptions = useSelector(selectMaskLayerPixelValueRange); if (selectedSpectralIndex !== 'temperature celcius') { return null; @@ -49,7 +49,7 @@ export const SurfaceTempCelsiusPixelRangeSlider = () => { max={LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS} steps={1} valuesOnChange={(values) => { - dispatch(updateSelectedRange(values)); + dispatch(updateMaskLayerSelectedRange(values)); }} countOfTicks={0} tickLabels={[-30, -15, 0, 15, 30, 45, 60, 75, 90]} @@ -61,9 +61,9 @@ export const SurfaceTempCelsiusPixelRangeSlider = () => { export const SurfaceTempFarhenheitPixelRangeSlider = () => { const dispatch = useDispatch(); - const selectedSpectralIndex = useSelector(selectSpectralIndex4MaskTool); + const selectedSpectralIndex = useSelector(selectSelectedIndex4MaskTool); - const maskOptions = useSelector(selectMaskOptions); + const maskOptions = useSelector(selectMaskLayerPixelValueRange); const rangeValues = useMemo(() => { return [ @@ -87,7 +87,7 @@ export const SurfaceTempFarhenheitPixelRangeSlider = () => { Math.trunc(((value - 32) * 5) / 9) ); - dispatch(updateSelectedRange(values)); + dispatch(updateMaskLayerSelectedRange(values)); }} countOfTicks={0} tickLabels={[-20, 0, 30, 60, 90, 120, 150, 180]} diff --git a/src/landsat-explorer/components/PopUp/PopUp.tsx b/src/landsat-explorer/components/PopUp/PopUp.tsx deleted file mode 100644 index c8ccabd0..00000000 --- a/src/landsat-explorer/components/PopUp/PopUp.tsx +++ /dev/null @@ -1,244 +0,0 @@ -// /* Copyright 2024 Esri -// * -// * Licensed under the Apache License Version 2.0 (the "License"); -// * you may not use this file except in compliance with the License. -// * You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ - -// // import './PopUp.css'; -// import React, { FC, useCallback, useEffect, useRef } from 'react'; -// import MapView from '@arcgis/core/views/MapView'; -// import Point from '@arcgis/core/geometry/Point'; -// import { useSelector } from 'react-redux'; -// import { -// selectActiveAnalysisTool, -// selectAppMode, -// selectQueryParams4MainScene, -// selectQueryParams4SecondaryScene, -// } from '@shared/store/ImageryScene/selectors'; -// import { selectSwipeWidgetHandlerPosition } from '@shared/store/Map/selectors'; -// import { useDispatch } from 'react-redux'; -// import { popupAnchorLocationChanged } from '@shared/store/Map/reducer'; -// import { getLoadingIndicator, getMainContent } from './helper'; -// import { watch } from '@arcgis/core/core/reactiveUtils'; -// import { -// getPixelValuesFromIdentifyTaskResponse, -// identify, -// } from '@shared/services/landsat-level-2/identify'; -// import { getFormattedLandsatScenes } from '@shared/services/landsat-level-2/getLandsatScenes'; -// import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; -// // import { canBeConvertedToNumber } from '@shared/utils/snippets/canBeConvertedToNumber'; - -// type Props = { -// mapView?: MapView; -// }; - -// type MapViewOnClickHandler = (mapPoint: Point, mousePointX: number) => void; - -// let controller: AbortController = null; - -// /** -// * Check and see if user clicked on the left side of the swipe widget -// * @param swipePosition position of the swipe handler, value should be bewteen 0 - 100 -// * @param mapViewWidth width of the map view container -// * @param mouseX x position of the mouse click event -// * @returns boolean indicates if clicked on left side -// */ -// const didClickOnLeftSideOfSwipeWidget = ( -// swipePosition: number, -// mapViewWidth: number, -// mouseX: number -// ) => { -// const widthOfLeftHalf = mapViewWidth * (swipePosition / 100); -// return mouseX <= widthOfLeftHalf; -// }; - -// export const Popup: FC = ({ mapView }: Props) => { -// const dispatch = useDispatch(); - -// const mode = useSelector(selectAppMode); - -// const analysisTool = useSelector(selectActiveAnalysisTool); - -// const queryParams4MainScene = useSelector(selectQueryParams4MainScene); - -// const queryParams4SecondaryScene = useSelector( -// selectQueryParams4SecondaryScene -// ); - -// const swipePosition = useSelector(selectSwipeWidgetHandlerPosition); - -// const openPopupRef = useRef(); - -// const closePopUp = (message: string) => { -// console.log('calling closePopUp', message); - -// if (controller) { -// controller.abort(); -// } - -// mapView.closePopup(); - -// dispatch(popupAnchorLocationChanged(null)); -// }; - -// openPopupRef.current = async (mapPoint: Point, mousePointX: number) => { -// // no need to show pop-up if in Animation Mode -// if (mode === 'animate') { -// return; -// } - -// // no need to show pop-up when using Trend or Spectral Profile Tool -// if ( -// mode === 'analysis' && -// (analysisTool === 'trend' || analysisTool === 'spectral') -// ) { -// return; -// } - -// dispatch(popupAnchorLocationChanged(mapPoint.toJSON())); - -// mapView.popup.open({ -// title: null, -// location: mapPoint, -// content: getLoadingIndicator(), -// }); - -// try { -// let queryParams = queryParams4MainScene; - -// // in swipe mode, we need to use the query Params based on position of mouse click event -// if (mode === 'swipe') { -// queryParams = didClickOnLeftSideOfSwipeWidget( -// swipePosition, -// mapView.width, -// mousePointX -// ) -// ? queryParams4MainScene -// : queryParams4SecondaryScene; -// } - -// if (controller) { -// controller.abort(); -// } - -// controller = new AbortController(); - -// const res = await identify({ -// point: mapPoint, -// objectId: -// mode !== 'dynamic' -// ? queryParams?.objectIdOfSelectedScene -// : null, -// abortController: controller, -// }); - -// // console.log(res) - -// const features = res?.catalogItems?.features; - -// if (!features.length) { -// throw new Error('cannot find landsat scene'); -// } - -// const sceneData = getFormattedLandsatScenes(features)[0]; - -// const bandValues: number[] = -// getPixelValuesFromIdentifyTaskResponse(res); - -// if (!bandValues) { -// throw new Error('identify task does not return band values'); -// } -// // console.log(bandValues) - -// const title = `${sceneData.satellite} | ${formatInUTCTimeZone( -// sceneData.acquisitionDate, -// 'MMM dd, yyyy' -// )}`; - -// mapView.openPopup({ -// // Set the popup's title to the coordinates of the location -// title: title, -// location: mapPoint, // Set the location of the popup to the clicked location -// content: getMainContent(bandValues, mapPoint), -// }); -// } catch (err: any) { -// console.error( -// 'failed to open popup for landsat scene', -// err.message -// ); - -// // no need to close popup if the user just clicked on a different location before -// // the popup data is returned -// if (err.message.includes('aborted')) { -// return; -// } - -// closePopUp('close because error happened during data fetching'); -// } -// }; - -// const init = async () => { -// // It's necessary to overwrite the default click for the popup -// // behavior in order to display your own popup -// mapView.popupEnabled = false; -// mapView.popup.dockEnabled = false; -// mapView.popup.collapseEnabled = false; -// mapView.popup.alignment = 'bottom-right'; - -// mapView.on('click', (evt) => { -// openPopupRef.current(evt.mapPoint, evt.x); -// }); - -// watch( -// () => mapView.popup.visible, -// (newVal, oldVal) => { -// // this callback sometimes get triggered before the popup get launched for the first time -// // therefore we should only proceed when both the new value and old value if ready -// if (newVal === undefined || oldVal === undefined) { -// return; -// } - -// if (oldVal === true && newVal === false) { -// // need to call closePopup whne popup becomes invisible -// // so the Popup anchor location can also be removed from the map -// closePopUp( -// 'close because mapView.popup.visible becomes false' -// ); -// } -// } -// ); -// }; - -// useEffect(() => { -// if (mapView) { -// init(); -// } -// }, [mapView]); - -// useEffect(() => { -// if (mapView) { -// if (!mapView?.popup?.visible) { -// return; -// } - -// closePopUp('close because app state has changed'); -// } -// }, [ -// mode, -// analysisTool, -// queryParams4MainScene?.objectIdOfSelectedScene, -// queryParams4SecondaryScene?.objectIdOfSelectedScene, -// swipePosition, -// ]); - -// return null; -// }; diff --git a/src/landsat-explorer/components/PopUp/PopupContainer.tsx b/src/landsat-explorer/components/PopUp/PopupContainer.tsx index 8f5c0ea6..e4937f79 100644 --- a/src/landsat-explorer/components/PopUp/PopupContainer.tsx +++ b/src/landsat-explorer/components/PopUp/PopupContainer.tsx @@ -29,13 +29,12 @@ import { // import { popupAnchorLocationChanged } from '@shared/store/Map/reducer'; import { getMainContent } from './helper'; // import { watch } from '@arcgis/core/core/reactiveUtils'; -import { - getPixelValuesFromIdentifyTaskResponse, - identify, -} from '@shared/services/landsat-level-2/identify'; import { getFormattedLandsatScenes } from '@shared/services/landsat-level-2/getLandsatScenes'; import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; import { MapPopup, MapPopupData } from '@shared/components/MapPopup/MapPopup'; +import { identify } from '@shared/services/helpers/identify'; +import { LANDSAT_LEVEL_2_SERVICE_URL } from '@shared/services/landsat-level-2/config'; +import { getPixelValuesFromIdentifyTaskResponse } from '@shared/services/helpers/getPixelValuesFromIdentifyTaskResponse'; // import { canBeConvertedToNumber } from '@shared/utils/snippets/canBeConvertedToNumber'; type Props = { @@ -76,12 +75,14 @@ export const PopupContainer: FC = ({ mapView }) => { controller = new AbortController(); const res = await identify({ + serviceURL: LANDSAT_LEVEL_2_SERVICE_URL, point: mapPoint, - objectId: + objectIds: mode !== 'dynamic' - ? queryParams?.objectIdOfSelectedScene + ? [queryParams?.objectIdOfSelectedScene] : null, abortController: controller, + maxItemCount: 1, }); // console.log(res) diff --git a/src/landsat-explorer/components/PopUp/helper.ts b/src/landsat-explorer/components/PopUp/helper.ts index fbcca4c9..396014bd 100644 --- a/src/landsat-explorer/components/PopUp/helper.ts +++ b/src/landsat-explorer/components/PopUp/helper.ts @@ -18,6 +18,7 @@ import { getValFromThermalBand, } from '@shared/services/landsat-level-2/helpers'; import Point from '@arcgis/core/geometry/Point'; +import { getPopUpContentWithLocationInfo } from '@shared/components/MapPopup/helper'; // export const getLoadingIndicator = () => { // const popupDiv = document.createElement('div'); @@ -26,8 +27,8 @@ import Point from '@arcgis/core/geometry/Point'; // }; export const getMainContent = (values: number[], mapPoint: Point) => { - const lat = Math.round(mapPoint.latitude * 1000) / 1000; - const lon = Math.round(mapPoint.longitude * 1000) / 1000; + // const lat = Math.round(mapPoint.latitude * 1000) / 1000; + // const lon = Math.round(mapPoint.longitude * 1000) / 1000; // const popupDiv = document.createElement('div'); @@ -60,17 +61,15 @@ export const getMainContent = (values: number[], mapPoint: Point) => { const waterIndex = calcSpectralIndex('water', values).toFixed(3); - return ` + const content = `
Surface Temp: ${surfaceTempInfo}
NDVI: ${vegetationIndex} MNDWI: ${waterIndex}
-
-

x ${lon}

-

y ${lat}

-
`; + + return getPopUpContentWithLocationInfo(mapPoint, content); }; diff --git a/src/landsat-explorer/components/PopUp/index.ts b/src/landsat-explorer/components/PopUp/index.ts index 7049eb82..cb92293f 100644 --- a/src/landsat-explorer/components/PopUp/index.ts +++ b/src/landsat-explorer/components/PopUp/index.ts @@ -1 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { PopupContainer as Popup } from './PopupContainer'; diff --git a/src/landsat-explorer/components/SceneInfo/SceneInfoContainer.tsx b/src/landsat-explorer/components/SceneInfo/SceneInfoContainer.tsx index ddc564ec..46d226b8 100644 --- a/src/landsat-explorer/components/SceneInfo/SceneInfoContainer.tsx +++ b/src/landsat-explorer/components/SceneInfo/SceneInfoContainer.tsx @@ -55,12 +55,13 @@ export const SceneInfoContainer = () => { // therefore we need to split it into two separate rows { name: 'Scene ID', - value: name.slice(0, 17), - }, - { - name: '', - value: name.slice(17), + value: name, //name.slice(0, 17), + clickToCopy: true, }, + // { + // name: '', + // value: name.slice(17), + // }, { name: 'Satellite', value: satellite, diff --git a/src/landsat-explorer/components/TemporalProfileTool/LandsatTemporalProfileChart.tsx b/src/landsat-explorer/components/TemporalProfileTool/LandsatTemporalProfileChart.tsx new file mode 100644 index 00000000..43e57acb --- /dev/null +++ b/src/landsat-explorer/components/TemporalProfileTool/LandsatTemporalProfileChart.tsx @@ -0,0 +1,32 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useTemporalProfileDataAsChartData } from './useTemporalProfileDataAsChartData'; +import { useCustomDomain4YScale } from './useCustomDomain4YScale'; +import { TemporalProfileChart } from '@shared/components/TemporalProfileChart'; + +export const LandsatTemporalProfileChart = () => { + const chartData = useTemporalProfileDataAsChartData(); + + const customDomain4YScale = useCustomDomain4YScale(chartData); + + return ( + + ); +}; diff --git a/src/landsat-explorer/components/TemporalProfileTool/LandsatTemporalProfileTool.tsx b/src/landsat-explorer/components/TemporalProfileTool/LandsatTemporalProfileTool.tsx new file mode 100644 index 00000000..948ce7c6 --- /dev/null +++ b/src/landsat-explorer/components/TemporalProfileTool/LandsatTemporalProfileTool.tsx @@ -0,0 +1,207 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +import { + TemporalProfileToolControls, + TemporalProfileToolHeader, +} from '@shared/components/TemproalProfileTool'; +// import { getProfileData } from '@shared/services/landsat-2/getProfileData'; +import { + // acquisitionMonth4TrendToolChanged, + // // samplingTemporalResolutionChanged, + // trendToolDataUpdated, + selectedIndex4TrendToolChanged, + // queryLocation4TrendToolChanged, + // trendToolOptionChanged, + // acquisitionYear4TrendToolChanged, +} from '@shared/store/TrendTool/reducer'; +// import { +// selectAcquisitionMonth4TrendTool, +// // selectActiveAnalysisTool, +// // selectSamplingTemporalResolution, +// selectTrendToolData, +// selectQueryLocation4TrendTool, +// selectSelectedIndex4TrendTool, +// selectAcquisitionYear4TrendTool, +// selectTrendToolOption, +// } from '@shared/store/TrendTool/selectors'; +// import { +// resetTrendToolData, +// updateQueryLocation4TrendTool, +// updateTrendToolData, +// } from '@shared/store/TrendTool/thunks'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +// import { +// // getFormatedDateString, +// getMonthFromFormattedDateString, +// getYearFromFormattedDateString, +// } from '@shared/utils/date-time/formatDateString'; +import { + selectActiveAnalysisTool, + // selectQueryParams4MainScene, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import { SpectralIndex, TemporalProfileData } from '@typing/imagery-service'; +// import { selectLandsatMissionsToBeExcluded } from '@shared/store/Landsat/selectors'; +import { TrendChart } from '.'; +// import { batch } from 'react-redux'; +// import { debounce } from '@shared/utils/snippets/debounce'; +import { useUpdateTemporalProfileToolData } from '@shared/components/TemproalProfileTool/useUpdateTemporalProfileToolData'; +import { useSyncSelectedYearAndMonth4TemporalProfileTool } from '@shared/components/TemproalProfileTool/useSyncSelectedYearAndMonth'; +import { + FetchTemporalProfileDataFunc, + IntersectWithImagerySceneFunc, +} from '@shared/store/TrendTool/thunks'; +import { Point } from '@arcgis/core/geometry'; +import { intersectWithLandsatScene } from '@shared/services/landsat-level-2/getLandsatScenes'; +import { getDataForTrendTool } from '@shared/services/landsat-level-2/getTemporalProfileData'; +import { selectLandsatMissionsToBeExcluded } from '@shared/store/Landsat/selectors'; +import { selectError4TemporalProfileTool } from '@shared/store/TrendTool/selectors'; + +export const LandsatTemporalProfileTool = () => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const missionsToBeExcluded = useSelector(selectLandsatMissionsToBeExcluded); + + const { rasterFunctionName, acquisitionDate, objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + /** + * this function will be invoked by the updateTemporalProfileToolData thunk function + * to check if the query location intersects with the selected landsat scene using the input object ID. + */ + const intersectWithImageryScene = useCallback( + async ( + queryLocation: Point, + objectId: number, + abortController: AbortController + ) => { + const res = await intersectWithLandsatScene( + queryLocation, + objectId, + abortController + ); + + return res; + }, + [] + ); + + /** + * this function will be invoked by the updateTemporalProfileToolData thunk function + * to retrieve the temporal profile data from landsat service + */ + const fetchTemporalProfileData: FetchTemporalProfileDataFunc = useCallback( + async ( + queryLocation: Point, + acquisitionMonth: number, + acquisitionYear: number, + abortController: AbortController + ) => { + // console.log('calling fetchTemporalProfileData for landsat') + + const data: TemporalProfileData[] = await getDataForTrendTool({ + queryLocation, + acquisitionMonth, + acquisitionYear, + abortController, + missionsToBeExcluded, + }); + + return data; + }, + [missionsToBeExcluded] + ); + + /** + * This custom hook triggers updateTemporalProfileToolData thunk function to get temporal profile data when query location, acquisition date or other options are changed. + */ + useUpdateTemporalProfileToolData( + fetchTemporalProfileData, + intersectWithImageryScene + ); + + /** + * This custom hook update the `acquisitionMonth` and `acquisitionMonth` property of the Trend Tool State + * to keep it in sync with the acquisition date of selected imagery scene + */ + useSyncSelectedYearAndMonth4TemporalProfileTool(); + + useEffect(() => { + if (rasterFunctionName) { + return; + } + + // when user selects a different renderer for the selected landsat scene, + // we want to try to sync the selected spectral index for the profile tool because + // that is probably what the user is interested in seeing + let spectralIndex: SpectralIndex = null; + + if (/Temperature/i.test(rasterFunctionName)) { + spectralIndex = 'temperature farhenheit'; + } else if (/NDVI/.test(rasterFunctionName)) { + spectralIndex = 'vegetation'; + } + + if (spectralIndex) { + dispatch(selectedIndex4TrendToolChanged(spectralIndex)); + } + }, [rasterFunctionName]); + + if (tool !== 'trend') { + return null; + } + + return ( +
+ + +
+ +
+ + +
+ ); +}; diff --git a/src/shared/services/helpers/getMosaicRuleByObjectId.ts b/src/landsat-explorer/components/TemporalProfileTool/index.ts similarity index 71% rename from src/shared/services/helpers/getMosaicRuleByObjectId.ts rename to src/landsat-explorer/components/TemporalProfileTool/index.ts index 710c1f84..068b5ae7 100644 --- a/src/shared/services/helpers/getMosaicRuleByObjectId.ts +++ b/src/landsat-explorer/components/TemporalProfileTool/index.ts @@ -13,11 +13,5 @@ * limitations under the License. */ -export const getMosaicRuleByObjectId = (objectId: number) => { - return { - ascending: false, - lockRasterIds: [objectId], - mosaicMethod: 'esriMosaicLockRaster', - where: `objectid in (${objectId})`, - }; -}; +export { LandsatTemporalProfileTool as TrendTool } from './LandsatTemporalProfileTool'; +export { LandsatTemporalProfileChart as TrendChart } from './LandsatTemporalProfileChart'; diff --git a/src/landsat-explorer/components/TemporalProfileTool/useCustomDomain4YScale.tsx b/src/landsat-explorer/components/TemporalProfileTool/useCustomDomain4YScale.tsx new file mode 100644 index 00000000..262fdfa2 --- /dev/null +++ b/src/landsat-explorer/components/TemporalProfileTool/useCustomDomain4YScale.tsx @@ -0,0 +1,66 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { selectSelectedIndex4TrendTool } from '@shared/store/TrendTool/selectors'; +import { SpectralIndex } from '@typing/imagery-service'; +import { LineChartDataItem } from '@vannizhang/react-d3-charts/dist/LineChart/types'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT, + LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT, +} from '@shared/services/landsat-level-2/config'; + +export const useCustomDomain4YScale = (chartData: LineChartDataItem[]) => { + const spectralIndex: SpectralIndex = useSelector( + selectSelectedIndex4TrendTool + ) as SpectralIndex; + + const customDomain4YScale = useMemo(() => { + const yValues = chartData.map((d) => d.y); + + // boundary of y axis, for spectral index, the boundary should be -1 and 1 + let yUpperLimit = 1; + let yLowerLimit = -1; + + // temperature is handled differently as we display the actual values in the chart + if (spectralIndex === 'temperature farhenheit') { + yLowerLimit = LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT; + yUpperLimit = LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT; + } + + if (spectralIndex === 'temperature celcius') { + yLowerLimit = LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS; + yUpperLimit = LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS; + } + + // get min and max from the data + let ymin = Math.min(...yValues); + let ymax = Math.max(...yValues); + + // get range between min and max from the data + const yRange = ymax - ymin; + + // adjust ymin and ymax to add 10% buffer to it, but also need to make sure it fits in the upper and lower limit + ymin = Math.max(yLowerLimit, ymin - yRange * 0.1); + ymax = Math.min(yUpperLimit, ymax + yRange * 0.1); + + return [ymin, ymax]; + }, [chartData]); + + return customDomain4YScale; +}; diff --git a/src/landsat-explorer/components/TemporalProfileTool/useTemporalProfileDataAsChartData.tsx b/src/landsat-explorer/components/TemporalProfileTool/useTemporalProfileDataAsChartData.tsx new file mode 100644 index 00000000..37f176a2 --- /dev/null +++ b/src/landsat-explorer/components/TemporalProfileTool/useTemporalProfileDataAsChartData.tsx @@ -0,0 +1,116 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectTrendToolData, + selectSelectedIndex4TrendTool, + selectTrendToolOption, +} from '@shared/store/TrendTool/selectors'; +import { TemporalProfileData } from '@typing/imagery-service'; +import { SpectralIndex } from '@typing/imagery-service'; +import { LineChartDataItem } from '@vannizhang/react-d3-charts/dist/LineChart/types'; +import { + LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT, + LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS, + LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT, +} from '@shared/services/landsat-level-2/config'; +import { calcSpectralIndex } from '@shared/services/landsat-level-2/helpers'; +import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; + +/** + * Converts Landsat temporal profile data to chart data. + * @param temporalProfileData - Array of temporal profile data. + * @param spectralIndex - Spectral index to calculate the value for each data point. + * @param month2month - if true, user is trying to plot month to month trend line for a selected year. + * @returns An array of QuickD3ChartDataItem objects representing the chart data. + * + */ +const convertLandsatTemporalProfileData2ChartData = ( + temporalProfileData: TemporalProfileData[], + spectralIndex: SpectralIndex, + month2month?: boolean +): LineChartDataItem[] => { + if (!temporalProfileData || !temporalProfileData.length) { + return []; + } + + const data = temporalProfileData.map((d) => { + const { acquisitionDate, values } = d; + + // calculate the spectral index that will be used as the y value for each chart vertex + let y = calcSpectralIndex(spectralIndex, values); + + let yMin = -1; + let yMax = 1; + + // justify the y value for surface temperature index to make it not go below the hardcoded y min + if ( + spectralIndex === 'temperature farhenheit' || + spectralIndex === 'temperature celcius' + ) { + yMin = + spectralIndex === 'temperature farhenheit' + ? LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT + : LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS; + + yMax = + spectralIndex === 'temperature farhenheit' + ? LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT + : LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS; + } + + // y should not go below y min + y = Math.max(y, yMin); + + // y should not go beyond y max + y = Math.min(y, yMax); + + const tooltip = `${formatInUTCTimeZone( + acquisitionDate, + 'LLL yyyy' + )}: ${y.toFixed(2)}`; + + return { + x: month2month ? d.acquisitionMonth : d.acquisitionDate, + y, + tooltip, + }; + }); + + return data; +}; + +export const useTemporalProfileDataAsChartData = () => { + const temporalProfileData = useSelector(selectTrendToolData); + + const spectralIndex: SpectralIndex = useSelector( + selectSelectedIndex4TrendTool + ) as SpectralIndex; + + const trendToolOption = useSelector(selectTrendToolOption); + + const chartData = useMemo(() => { + return convertLandsatTemporalProfileData2ChartData( + temporalProfileData, + spectralIndex, + trendToolOption === 'month-to-month' + ); + }, [temporalProfileData, spectralIndex, trendToolOption]); + + return chartData; +}; diff --git a/src/landsat-explorer/components/TrendTool/TrendToolContainer.tsx b/src/landsat-explorer/components/TrendTool/TrendToolContainer.tsx deleted file mode 100644 index edda4fb3..00000000 --- a/src/landsat-explorer/components/TrendTool/TrendToolContainer.tsx +++ /dev/null @@ -1,247 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; -import { TrendToolControls } from '@shared/components/TrendToolControls'; -// import { getProfileData } from '@shared/services/landsat-2/getProfileData'; -import { - acquisitionMonth4TrendToolChanged, - // samplingTemporalResolutionChanged, - trendToolDataUpdated, - spectralIndex4TrendToolChanged, - queryLocation4TrendToolChanged, - trendToolOptionChanged, - acquisitionYear4TrendToolChanged, -} from '@shared/store/TrendTool/reducer'; -import { - selectAcquisitionMonth4TrendTool, - // selectActiveAnalysisTool, - // selectSamplingTemporalResolution, - selectTrendToolData, - selectQueryLocation4TrendTool, - selectSpectralIndex4TrendTool, - selectAcquisitionYear4TrendTool, - selectTrendToolOption, -} from '@shared/store/TrendTool/selectors'; -import { - resetTrendToolData, - updateQueryLocation4TrendTool, - updateTrendToolData, -} from '@shared/store/TrendTool/thunks'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { useSelector } from 'react-redux'; -import { - // getFormatedDateString, - getMonthFromFormattedDateString, - getYearFromFormattedDateString, -} from '@shared/utils/date-time/formatDateString'; -import { - selectActiveAnalysisTool, - selectQueryParams4MainScene, - selectQueryParams4SceneInSelectedMode, -} from '@shared/store/ImageryScene/selectors'; -import { SpectralIndex } from '@typing/imagery-service'; -import { selectLandsatMissionsToBeExcluded } from '@shared/store/Landsat/selectors'; -import { TrendChart } from '.'; -import { batch } from 'react-redux'; -import { debounce } from '@shared/utils/snippets/debounce'; - -export const TrendToolContainer = () => { - const dispatch = useDispatch(); - - const tool = useSelector(selectActiveAnalysisTool); - - const queryLocation = useSelector(selectQueryLocation4TrendTool); - - const acquisitionMonth = useSelector(selectAcquisitionMonth4TrendTool); - - const acquisitionYear = useSelector(selectAcquisitionYear4TrendTool); - - const selectedTrendToolOption = useSelector(selectTrendToolOption); - - const spectralIndex = useSelector(selectSpectralIndex4TrendTool); - - const { rasterFunctionName, acquisitionDate, objectIdOfSelectedScene } = - useSelector(selectQueryParams4SceneInSelectedMode) || {}; - - const missionsToBeExcluded = useSelector(selectLandsatMissionsToBeExcluded); - - const [error, setError] = useState(); - - const updateTrendToolDataDebounced = useCallback( - debounce(async () => { - try { - setError(null); - await dispatch(updateTrendToolData()); - } catch (err) { - setError(err); - } - }, 50), - [] - ); - - useEffect(() => { - if (rasterFunctionName) { - return; - } - - // when user selects a different renderer for the selected landsat scene, - // we want to try to sync the selected spectral index for the profile tool because - // that is probably what the user is interested in seeing - let spectralIndex: SpectralIndex = null; - - if (/Temperature/i.test(rasterFunctionName)) { - spectralIndex = 'temperature farhenheit'; - } else if (/NDVI/.test(rasterFunctionName)) { - spectralIndex = 'vegetation'; - } - - if (spectralIndex) { - dispatch(spectralIndex4TrendToolChanged(spectralIndex)); - } - }, [rasterFunctionName]); - - useEffect(() => { - // remove query location when selected acquisition date is removed - if (!acquisitionDate) { - dispatch(updateQueryLocation4TrendTool(null)); - return; - } - - const month = getMonthFromFormattedDateString(acquisitionDate); - - const year = getYearFromFormattedDateString(acquisitionDate); - - batch(() => { - dispatch(acquisitionMonth4TrendToolChanged(month)); - dispatch(acquisitionYear4TrendToolChanged(year)); - }); - }, [acquisitionDate]); - - // triggered when user selects a new acquisition month that will be used to draw the "year-to-year" trend data - useEffect(() => { - (async () => { - if (tool !== 'trend') { - return; - } - - if (selectedTrendToolOption !== 'year-to-year') { - return; - } - - updateTrendToolDataDebounced(); - })(); - }, [ - acquisitionMonth, - queryLocation, - tool, - selectedTrendToolOption, - missionsToBeExcluded, - ]); - - // triggered when user selects a new acquisition year that will be used to draw the "month-to-month" trend data - useEffect(() => { - (async () => { - if (tool !== 'trend') { - return; - } - - if (selectedTrendToolOption !== 'month-to-month') { - return; - } - - updateTrendToolDataDebounced(); - })(); - }, [ - acquisitionYear, - queryLocation, - tool, - selectedTrendToolOption, - missionsToBeExcluded, - ]); - - if (tool !== 'trend') { - return null; - } - - return ( -
- { - dispatch( - spectralIndex4TrendToolChanged(val as SpectralIndex) - ); - }} - tooltipText={`The least cloudy scenes from the selected time interval will be sampled to show a temporal trend for the selected point and category.`} - /> - -
- {error ? ( -
- - {error?.message || - 'failed to fetch data for trend tool'} - -
- ) : ( - - )} -
- - 0 ? true : false - } - trendOptionOnChange={(data) => { - dispatch(trendToolOptionChanged(data)); - }} - closeButtonOnClick={() => { - // dispatch(trendToolDataUpdated([])); - // dispatch(queryLocation4TrendToolChanged(null)); - dispatch(resetTrendToolData()); - }} - /> -
- ); -}; diff --git a/src/landsat-explorer/store/getPreloadedState.ts b/src/landsat-explorer/store/getPreloadedState4LandsatExplorer.ts similarity index 98% rename from src/landsat-explorer/store/getPreloadedState.ts rename to src/landsat-explorer/store/getPreloadedState4LandsatExplorer.ts index 6baf6824..feda9777 100644 --- a/src/landsat-explorer/store/getPreloadedState.ts +++ b/src/landsat-explorer/store/getPreloadedState4LandsatExplorer.ts @@ -23,7 +23,7 @@ import { getMapCenterFromHashParams, getMaskToolDataFromHashParams, getQueryParams4MainSceneFromHashParams, - getQueryParams4ScenesInAnimationFromHashParams, + getListOfQueryParamsFromHashParams, getQueryParams4SecondarySceneFromHashParams, getSpectralProfileToolDataFromHashParams, getTemporalProfileToolDataFromHashParams, @@ -128,7 +128,7 @@ const getPreloadedImageryScenesState = (): ImageryScenesState => { }; const queryParams4ScenesInAnimation = - getQueryParams4ScenesInAnimationFromHashParams() || []; + getListOfQueryParamsFromHashParams() || []; const queryParamsById: { [key: string]: QueryParams4ImageryScene; diff --git a/src/landsat-explorer/store/index.ts b/src/landsat-explorer/store/index.ts index 98e178da..847aee52 100644 --- a/src/landsat-explorer/store/index.ts +++ b/src/landsat-explorer/store/index.ts @@ -14,7 +14,7 @@ */ import configureAppStore from '@shared/store/configureStore'; -import { getPreloadedState } from './getPreloadedState'; +import { getPreloadedState } from './getPreloadedState4LandsatExplorer'; export const getLandsatExplorerStore = async () => { const preloadedState = await getPreloadedState(); diff --git a/src/landsat-surface-temp/components/Map/Map.tsx b/src/landsat-surface-temp/components/Map/Map.tsx index 517edd67..3aea9e95 100644 --- a/src/landsat-surface-temp/components/Map/Map.tsx +++ b/src/landsat-surface-temp/components/Map/Map.tsx @@ -22,7 +22,7 @@ import { MapPopUpAnchorPoint } from '@shared/components/MapPopUpAnchorPoint'; import { HillshadeLayer } from '@shared/components/HillshadeLayer/HillshadeLayer'; import { ScreenshotWidget } from '@shared/components/ScreenshotWidget/ScreenshotWidget'; -import { ZoomToExtent } from '@landsat-explorer/components/ZoomToExtent'; +// import { ZoomToExtent } from '@landsat-explorer/components/ZoomToExtent'; import { Popup } from '@landsat-explorer/components/PopUp/'; import { MaskLayer } from '@landsat-explorer/components/MaskLayer'; import { LandsatLayer } from '../LandsatLayer'; diff --git a/src/landsat-surface-temp/components/MaskTool/MaskToolContainer.tsx b/src/landsat-surface-temp/components/MaskTool/MaskToolContainer.tsx index 19d5d151..21db7dfc 100644 --- a/src/landsat-surface-temp/components/MaskTool/MaskToolContainer.tsx +++ b/src/landsat-surface-temp/components/MaskTool/MaskToolContainer.tsx @@ -17,13 +17,13 @@ import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; // import { PixelRangeSlider as MaskLayerPixelRangeSlider4SpectralIndex } from '@shared/components/MaskTool/PixelRangeSlider'; // import { PixelRangeSlider as MaskLayerPixelRangeSlider4SurfaceTemp } from './PixelRangeSlider4SurfaceTemp'; import { MaskLayerRenderingControls } from '@shared/components/MaskTool'; -import { spectralIndex4MaskToolChanged } from '@shared/store/MaskTool/reducer'; +import { selectedIndex4MaskToolChanged } from '@shared/store/MaskTool/reducer'; import { - selectSpectralIndex4MaskTool, - selectMaskOptions, + selectSelectedIndex4MaskTool, + selectMaskLayerPixelValueRange, // selectActiveAnalysisTool, } from '@shared/store/MaskTool/selectors'; -import { updateSelectedRange } from '@shared/store/MaskTool/thunks'; +import { updateMaskLayerSelectedRange } from '@shared/store/MaskTool/thunks'; import React, { useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { useSelector } from 'react-redux'; @@ -54,7 +54,7 @@ export const MaskToolContainer = () => { const tool = useSelector(selectActiveAnalysisTool); - const selectedSpectralIndex = useSelector(selectSpectralIndex4MaskTool); + const selectedSpectralIndex = useSelector(selectSelectedIndex4MaskTool); const { objectIdOfSelectedScene } = useSelector(selectQueryParams4SceneInSelectedMode) || {}; @@ -85,7 +85,7 @@ export const MaskToolContainer = () => { tooltipText={MASK_TOOL_HEADER_TOOLTIP} dropdownMenuSelectedItemOnChange={(val) => { dispatch( - spectralIndex4MaskToolChanged(val as SpectralIndex) + selectedIndex4MaskToolChanged(val as SpectralIndex) ); }} /> diff --git a/src/landsat-surface-temp/components/TrendTool/TrendToolContainer.tsx b/src/landsat-surface-temp/components/TrendTool/TrendToolContainer.tsx index eb0f5d7a..a42f8629 100644 --- a/src/landsat-surface-temp/components/TrendTool/TrendToolContainer.tsx +++ b/src/landsat-surface-temp/components/TrendTool/TrendToolContainer.tsx @@ -20,7 +20,7 @@ import { acquisitionMonth4TrendToolChanged, // samplingTemporalResolutionChanged, // trendToolDataUpdated, - spectralIndex4TrendToolChanged, + selectedIndex4TrendToolChanged, // queryLocation4TrendToolChanged, // trendToolOptionChanged, acquisitionYear4TrendToolChanged, @@ -31,13 +31,13 @@ import { // selectSamplingTemporalResolution, // selectTrendToolData, selectQueryLocation4TrendTool, - selectSpectralIndex4TrendTool, + selectSelectedIndex4TrendTool, // selectAcquisitionYear4TrendTool, selectTrendToolOption, // selectIsLoadingData4TrendingTool, } from '@shared/store/TrendTool/selectors'; -import { updateTrendToolData } from '@shared/store/TrendTool/thunks'; -import React, { useEffect, useState } from 'react'; +import { updateTemporalProfileToolData } from '@shared/store/TrendTool/thunks'; +import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useSelector } from 'react-redux'; // import { TemporalProfileChart } from './TrendChart'; @@ -53,26 +53,31 @@ import { selectActiveAnalysisTool, selectQueryParams4MainScene, } from '@shared/store/ImageryScene/selectors'; -import { SpectralIndex } from '@typing/imagery-service'; +import { SpectralIndex, TemporalProfileData } from '@typing/imagery-service'; import { selectLandsatMissionsToBeExcluded } from '@shared/store/Landsat/selectors'; -import { TrendChart } from '@landsat-explorer/components/TrendTool'; +import { TrendChart } from '@landsat-explorer/components/TemporalProfileTool'; +import { useUpdateTemporalProfileToolData } from '@shared/components/TemproalProfileTool/useUpdateTemporalProfileToolData'; +import { Point } from '@arcgis/core/geometry'; +import { getDataForTrendTool } from '@shared/services/landsat-level-2/getTemporalProfileData'; +import { intersectWithLandsatScene } from '@shared/services/landsat-level-2/getLandsatScenes'; +import { useSyncSelectedYearAndMonth4TemporalProfileTool } from '@shared/components/TemproalProfileTool/useSyncSelectedYearAndMonth'; export const TrendToolContainer = () => { const dispatch = useDispatch(); const tool = useSelector(selectActiveAnalysisTool); - const queryLocation = useSelector(selectQueryLocation4TrendTool); + // const queryLocation = useSelector(selectQueryLocation4TrendTool); - const acquisitionMonth = useSelector(selectAcquisitionMonth4TrendTool); + // const acquisitionMonth = useSelector(selectAcquisitionMonth4TrendTool); // const acquisitionYear = useSelector(selectAcquisitionYear4TrendTool); - const selectedTrendToolOption = useSelector(selectTrendToolOption); + // const selectedTrendToolOption = useSelector(selectTrendToolOption); // const temporalProfileData = useSelector(selectTrendToolData); - const spectralIndex = useSelector(selectSpectralIndex4TrendTool); + const spectralIndex = useSelector(selectSelectedIndex4TrendTool); const queryParams4MainScene = useSelector(selectQueryParams4MainScene); @@ -82,48 +87,94 @@ export const TrendToolContainer = () => { // const trendToolOption = useSelector(selectTrendToolOption); - useEffect(() => { - if (!queryParams4MainScene?.acquisitionDate) { - return; - } - - const month = getMonthFromFormattedDateString( - queryParams4MainScene?.acquisitionDate - ); - - const year = getYearFromFormattedDateString( - queryParams4MainScene?.acquisitionDate - ); - - dispatch(acquisitionMonth4TrendToolChanged(month)); - - dispatch(acquisitionYear4TrendToolChanged(year)); - }, [queryParams4MainScene?.acquisitionDate]); - - // triggered when user selects a new acquisition month that will be used to draw the "year-to-year" trend data - useEffect(() => { - (async () => { - if (tool !== 'trend') { - return; - } - - if (selectedTrendToolOption !== 'year-to-year') { - return; - } - - try { - await dispatch(updateTrendToolData()); - } catch (err) { - console.log(err); - } - })(); - }, [ - queryLocation, - tool, - acquisitionMonth, - selectedTrendToolOption, - missionsToBeExcluded, - ]); + const intersectWithImageryScene = useCallback( + async ( + queryLocation: Point, + objectId: number, + abortController: AbortController + ) => { + const res = await intersectWithLandsatScene( + queryLocation, + objectId, + abortController + ); + + return res; + }, + [] + ); + + const fetchTemporalProfileData = useCallback( + async ( + queryLocation: Point, + acquisitionMonth: number, + acquisitionYear: number, + abortController: AbortController + ) => { + // console.log('calling fetchTemporalProfileData for landsat') + + const data: TemporalProfileData[] = await getDataForTrendTool({ + queryLocation, + acquisitionMonth, + acquisitionYear, + abortController, + missionsToBeExcluded, + }); + + return data; + }, + [missionsToBeExcluded] + ); + + // useEffect(() => { + // if (!queryParams4MainScene?.acquisitionDate) { + // return; + // } + + // const month = getMonthFromFormattedDateString( + // queryParams4MainScene?.acquisitionDate + // ); + + // const year = getYearFromFormattedDateString( + // queryParams4MainScene?.acquisitionDate + // ); + + // dispatch(acquisitionMonth4TrendToolChanged(month)); + + // dispatch(acquisitionYear4TrendToolChanged(year)); + // }, [queryParams4MainScene?.acquisitionDate]); + + // // triggered when user selects a new acquisition month that will be used to draw the "year-to-year" trend data + // useEffect(() => { + // (async () => { + // if (tool !== 'trend') { + // return; + // } + + // if (selectedTrendToolOption !== 'year-to-year') { + // return; + // } + + // // try { + // // await dispatch(updateTemporalProfileToolData()); + // // } catch (err) { + // // console.log(err); + // // } + // })(); + // }, [ + // queryLocation, + // tool, + // acquisitionMonth, + // selectedTrendToolOption, + // missionsToBeExcluded, + // ]); + + useSyncSelectedYearAndMonth4TemporalProfileTool(); + + useUpdateTemporalProfileToolData( + fetchTemporalProfileData, + intersectWithImageryScene + ); if (tool !== 'trend') { return null; @@ -146,7 +197,7 @@ export const TrendToolContainer = () => { selectedValue={spectralIndex} dropdownMenuSelectedItemOnChange={(val) => { dispatch( - spectralIndex4TrendToolChanged(val as SpectralIndex) + selectedIndex4TrendToolChanged(val as SpectralIndex) ); }} tooltipText={`The least-cloudy scene from the selected month will be sampled across all years of the imagery archive.`} diff --git a/src/sentinel-1-explorer/components/About/AboutSentinel1Explorer.tsx b/src/sentinel-1-explorer/components/About/AboutSentinel1Explorer.tsx new file mode 100644 index 00000000..b4fc0b2d --- /dev/null +++ b/src/sentinel-1-explorer/components/About/AboutSentinel1Explorer.tsx @@ -0,0 +1,26 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { About } from '@shared/components/About'; +import React from 'react'; +import { AboutSentinel1ExplorerContent } from './AboutSentinel1ExplorerContent'; + +export const AboutSentinel1Explorer = () => { + return ( + + + + ); +}; diff --git a/src/sentinel-1-explorer/components/About/AboutSentinel1ExplorerContent.tsx b/src/sentinel-1-explorer/components/About/AboutSentinel1ExplorerContent.tsx new file mode 100644 index 00000000..028491fc --- /dev/null +++ b/src/sentinel-1-explorer/components/About/AboutSentinel1ExplorerContent.tsx @@ -0,0 +1,208 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { appConfig } from '@shared/config'; +import React from 'react'; + +export const AboutSentinel1ExplorerContent = () => { + return ( +
+
+
+ {appConfig.title} +
+
+ +
+

+ About the data +

+ +

+ Sentinel-1 is a spaceborne Synthetic Aperture Radar (SAR) + imaging system and mission from the European Space Agency + and the European Commission. The mission launched and began + collecting imagery in 2014. +

+ +

+ The Sentinel-1 RTC data in this collection is an analysis + ready product derived from the Ground Range Detected (GRD) + Level-1 products produced by the European Space Agency. + Radiometric Terrain Correction (RTC) accounts for terrain + variations that affect both the position of a given point on + the Earth's surface and the brightness of the radar + return. +

+ +

+ With the ability to see through cloud and smoke cover, and + because it does not rely on solar illumination of the + Earth's surface, Sentinel-1 is able to collect useful + imagery in most weather conditions, during both day and + night. This data is good for wide range of land and maritime + applications, from mapping floods, to deforestation, to oil + spills, and more. +

+
+ +
+

+ About the app +

+ +

+ Sentinel-1 SAR imagery helps to track and document land use + and land change associated with climate change, + urbanization, drought, wildfire, deforestation, and other + natural processes and human activity. +

+ +

+ Through an intuitive user experience, this app leverages a + variety of ArcGIS capabilities to explore and begin to + unlock the wealth of information that Sentinel-1 provides. + Some of the key capabilities include: +

+ +
    +
  • + Visual exploration of a dynamic global mosaic of the + latest available scenes. +
  • +
  • + On-the-fly band/polarization combinations and indices + for visualization and analysis. +
  • +
  • + Interactive Find a Scene by location, time, and orbit + direction. +
  • +
  • + Visual change by time and renderings with Swipe and + Animation modes. +
  • +
  • + Analysis such as threshold masking and temporal profiles + for vegetation, water, land surface temperature, and + more. +
  • +
+
+ +
+

+ Attribution and Terms of Use +

+ +
+

+ Sentinel-1 RTC Imagery – + Esri, Microsoft, European Space Agency, European + Commission +

+
+

+ + Sentinel-1 RTC Source Imagery – Microsoft + +
+ The source imagery is hosted on Microsoft Planetary + Computer under an open{' '} + + CC BY 4.0 license + + . +

+
+ +
+

+ Sentinel-1 RTC Image Service – Esri +

+

+ This work is licensed under the Esri Master License + Agreement.{' '} + + View Summary + {' '} + |{' '} + + View Terms of Use + +

+
+
+ +
+

Sentinel-1 Explorer - Esri

+

+ This app is licensed under the Esri Master License + Agreement.{' '} + + View Summary + {' '} + |{' '} + + View Terms of Use + +

+

+ This app is provided for informational purposes. The + accuracy of the information provided is subject to the + accuracy of the source data. +

+
+ +
+

+ Information contained in the Interesting Places + descriptions was sourced from Wikipedia. +

+
+
+
+ ); +}; diff --git a/src/landsat-explorer/components/SwipeWidget/index.ts b/src/sentinel-1-explorer/components/About/index.ts similarity index 88% rename from src/landsat-explorer/components/SwipeWidget/index.ts rename to src/sentinel-1-explorer/components/About/index.ts index a7869418..134608bd 100644 --- a/src/landsat-explorer/components/SwipeWidget/index.ts +++ b/src/sentinel-1-explorer/components/About/index.ts @@ -13,4 +13,4 @@ * limitations under the License. */ -export { SwipeWidgetContainer as SwipeWidget } from './SwipeWidgetContainer'; +export { AboutSentinel1Explorer } from './AboutSentinel1Explorer'; diff --git a/src/sentinel-1-explorer/components/AnalyzeToolSelector/AnalyzeToolSelector.tsx b/src/sentinel-1-explorer/components/AnalyzeToolSelector/AnalyzeToolSelector.tsx new file mode 100644 index 00000000..10c03e14 --- /dev/null +++ b/src/sentinel-1-explorer/components/AnalyzeToolSelector/AnalyzeToolSelector.tsx @@ -0,0 +1,45 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { AnalysisToolSelector } from '@shared/components/AnalysisToolSelector'; +import { AnalyzeToolSelectorData } from '@shared/components/AnalysisToolSelector/AnalysisToolSelectorContainer'; + +const data: AnalyzeToolSelectorData[] = [ + { + tool: 'mask', + title: 'Index', + subtitle: 'mask', + }, + { + tool: 'trend', + title: 'Temporal', + subtitle: 'profile', + }, + { + tool: 'change', + title: 'Change', + subtitle: 'detecion', + }, + { + tool: 'temporal composite', + title: 'Temporal', + subtitle: 'composite', + }, +]; + +export const Sentinel1AnalyzeToolSelector = () => { + return ; +}; diff --git a/src/sentinel-1-explorer/components/ChangeCompareLayer/ChangeCompareLayerContainer.tsx b/src/sentinel-1-explorer/components/ChangeCompareLayer/ChangeCompareLayerContainer.tsx new file mode 100644 index 00000000..c96fef3f --- /dev/null +++ b/src/sentinel-1-explorer/components/ChangeCompareLayer/ChangeCompareLayerContainer.tsx @@ -0,0 +1,201 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import React, { FC, useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectAppMode, + selectQueryParams4MainScene, + selectQueryParams4SecondaryScene, +} from '@shared/store/ImageryScene/selectors'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { + selectFullPixelValuesRangeInChangeCompareTool, + selectSelectedOption4ChangeCompareTool, + selectUserSelectedRangeInChangeCompareTool, +} from '@shared/store/ChangeCompareTool/selectors'; +import { useChangeCompareLayerVisibility } from '@shared/components/ChangeCompareLayer'; +import { ImageryLayerWithPixelFilter } from '@shared/components/ImageryLayerWithPixelFilter'; +import { + SENTINEL_1_SERVICE_URL, + Sentinel1FunctionName, +} from '@shared/services/sentinel-1/config'; +import { + getPixelColor4ChangeCompareLayer, + sortQueryParams4ImagerySceneByAcquisitionDate, +} from '@shared/components/ChangeCompareTool/helpers'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; +import { ChangeCompareToolOption4Sentinel1 } from '../ChangeCompareTool/ChangeCompareToolContainer'; +import { + divide, + log10, + minus, +} from '@arcgis/core/layers/support/rasterFunctionUtils'; +import { selectPolarizationFilter } from '@shared/store/Sentinel1/selectors'; +import { useDispatch } from 'react-redux'; +import { countOfVisiblePixelsChanged } from '@shared/store/Map/reducer'; +import { useCalculateTotalAreaByPixelsCount } from '@shared/hooks/useCalculateTotalAreaByPixelsCount'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; +}; + +const InputRasterFunctionName: Record< + ChangeCompareToolOption4Sentinel1, + Sentinel1FunctionName +> = { + 'log difference': null, + 'water anomaly': 'Water Anomaly Index Raw', + // vegetation: 'Sentinel-1 DpRVIc Raw', + water: 'SWI Raw', +}; + +export const ChangeCompareLayerContainer: FC = ({ + mapView, + groupLayer, +}) => { + // const mode = useSelector(selectAppMode); + + const dispatach = useDispatch(); + + const selectedOption: ChangeCompareToolOption4Sentinel1 = useSelector( + selectSelectedOption4ChangeCompareTool + ) as ChangeCompareToolOption4Sentinel1; + + const queryParams4SceneA = useSelector(selectQueryParams4MainScene); + + const queryParams4SceneB = useSelector(selectQueryParams4SecondaryScene); + + const selectedRange = useSelector( + selectUserSelectedRangeInChangeCompareTool + ); + + const fullPixelValueRange = useSelector( + selectFullPixelValuesRangeInChangeCompareTool + ); + + const polarizationFilter = useSelector(selectPolarizationFilter); + + const isVisible = useChangeCompareLayerVisibility(); + + const rasterFunction: RasterFunction = useMemo(() => { + if (!isVisible) { + return null; + } + + if ( + !queryParams4SceneA?.objectIdOfSelectedScene || + !queryParams4SceneB?.objectIdOfSelectedScene + ) { + return null; + } + + // Sort query parameters by acquisition date in ascending order. + const [ + queryParams4SceneAcquiredInEarlierDate, + queryParams4SceneAcquiredInLaterDate, + ] = sortQueryParams4ImagerySceneByAcquisitionDate( + queryParams4SceneA, + queryParams4SceneB + ); + + if (selectedOption === 'log difference') { + const rasterFunction: Sentinel1FunctionName = + polarizationFilter === 'VV' + ? 'VV Amplitude with Despeckle' + : 'VH Amplitude with Despeckle'; + + return log10({ + raster: divide({ + raster: new RasterFunction({ + functionName: rasterFunction, + functionArguments: { + raster: + '$' + + queryParams4SceneAcquiredInLaterDate?.objectIdOfSelectedScene, + }, + }), + raster2: new RasterFunction({ + functionName: rasterFunction, + functionArguments: { + raster: + '$' + + queryParams4SceneAcquiredInEarlierDate?.objectIdOfSelectedScene, + }, + }), + }), + outputPixelType: 'f32', + }); + } + + const rasterFunction = InputRasterFunctionName[selectedOption]; + + if (!rasterFunction) { + return null; + } + + return minus({ + raster: new RasterFunction({ + functionName: rasterFunction, + functionArguments: { + raster: + '$' + + queryParams4SceneAcquiredInLaterDate?.objectIdOfSelectedScene, + }, + }), + raster2: new RasterFunction({ + functionName: rasterFunction, + functionArguments: { + raster: + '$' + + queryParams4SceneAcquiredInEarlierDate?.objectIdOfSelectedScene, + }, + }), + outputPixelType: 'f32', + }); + }, [ + isVisible, + selectedOption, + queryParams4SceneA, + queryParams4SceneB, + polarizationFilter, + ]); + + useCalculateTotalAreaByPixelsCount({ + objectId: + queryParams4SceneA?.objectIdOfSelectedScene || + queryParams4SceneB?.objectIdOfSelectedScene, + serviceURL: SENTINEL_1_SERVICE_URL, + pixelSize: mapView.resolution, + }); + + return ( + { + dispatach(countOfVisiblePixelsChanged(visiblePixels)); + }} + /> + ); +}; diff --git a/src/sentinel-1-explorer/components/ChangeCompareLayer/index.ts b/src/sentinel-1-explorer/components/ChangeCompareLayer/index.ts new file mode 100644 index 00000000..baf26c95 --- /dev/null +++ b/src/sentinel-1-explorer/components/ChangeCompareLayer/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ChangeCompareLayerContainer as ChangeCompareLayer4Sentinel1 } from './ChangeCompareLayerContainer'; diff --git a/src/sentinel-1-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx b/src/sentinel-1-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx new file mode 100644 index 00000000..b6849b96 --- /dev/null +++ b/src/sentinel-1-explorer/components/ChangeCompareTool/ChangeCompareToolContainer.tsx @@ -0,0 +1,198 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +import { + ChangeCompareToolHeader, + ChangeCompareToolControls, +} from '@shared/components/ChangeCompareTool'; +import { + fullPixelValuesRangeUpdated, + selectedRangeUpdated, +} from '@shared/store/ChangeCompareTool/reducer'; +import { selectSelectedOption4ChangeCompareTool } from '@shared/store/ChangeCompareTool/selectors'; +// import { PixelRangeSlider } from '@shared/components/PixelRangeSlider'; +// import { +// selectedRangeUpdated, +// selectedOption4ChangeCompareToolChanged, +// } from '@shared/store/ChangeCompareTool/reducer'; +// import { +// selectChangeCompareLayerIsOn, +// selectSelectedOption4ChangeCompareTool, +// selectUserSelectedRangeInChangeCompareTool, +// } from '@shared/store/ChangeCompareTool/selectors'; +import { selectActiveAnalysisTool } from '@shared/store/ImageryScene/selectors'; +// import { SpectralIndex } from '@typing/imagery-service'; +import classNames from 'classnames'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +// import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { PolarizationFilter } from './PolarizationFilter'; +import { RadarIndex } from '@typing/imagery-service'; +import { + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE, + SENTINEL1_WATER_INDEX_PIXEL_RANGE, +} from '@shared/services/sentinel-1/config'; +import { useSyncCalendarDateRange } from '../../hooks/useSyncCalendarDateRange'; +import { TotalVisibleAreaInfo } from '@shared/components/TotalAreaInfo/TotalAreaInfo'; +import { usePrevious } from '@shared/hooks/usePrevious'; + +/** + * the index that user can select for the Change Compare Tool + */ +export type ChangeCompareToolOption4Sentinel1 = + | 'water anomaly' + | 'water' + | 'log difference'; + +const ChangeCompareToolOptions: { + value: ChangeCompareToolOption4Sentinel1; + label: string; +}[] = [ + { + value: 'log difference', + label: 'Log difference', + }, + // { value: 'vegetation', label: ' Dual-pol Radar Vegetation Index' }, + { + value: 'water anomaly', + label: 'Water Anomaly', + }, + { + value: 'water', + label: 'Water Index', + }, +]; + +const [ + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE_MIN, + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE_MAX, +] = SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE; + +const [ + SENTINEL1_WATER_INDEX_PIXEL_RANGE_MIN, + SENTINEL1_WATER_INDEX_PIXEL_RANGE_MAX, +] = SENTINEL1_WATER_INDEX_PIXEL_RANGE; + +export const ChangeCompareToolPixelValueRange4Sentinel1: Record< + ChangeCompareToolOption4Sentinel1, + number[] +> = { + 'log difference': [-0.5, 0.5], + // vegetation: [0, 1], + /** + * For the Water Index (SWI), we have selected a input range of -0.3 to 1, though it may need adjustment. + * The SWI index is not well-documented with a specific range of values. + */ + water: [ + SENTINEL1_WATER_INDEX_PIXEL_RANGE_MIN - + SENTINEL1_WATER_INDEX_PIXEL_RANGE_MAX, + SENTINEL1_WATER_INDEX_PIXEL_RANGE_MAX - + SENTINEL1_WATER_INDEX_PIXEL_RANGE_MIN, + ], + /** + * For Water Anomaly Index, we can use a input range of -2 to 0. Typically, oil appears within the range of -1 to 0. + * The full pixel range of the change compare results is -2 to 2 + */ + 'water anomaly': [ + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE_MIN - + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE_MAX, + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE_MAX - + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE_MIN, + ], +}; + +export const ChangeCompareToolContainer = () => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const selectedOption: ChangeCompareToolOption4Sentinel1 = useSelector( + selectSelectedOption4ChangeCompareTool + ) as ChangeCompareToolOption4Sentinel1; + + const selectedOptionPreviousVal = usePrevious(selectedOption); + + const legendLabelText = useMemo(() => { + // if (selectedOption === 'log difference') { + // return ['lower backscatter', 'higher backscatter']; + // } + + return ['decrease', '', 'increase']; + }, [selectedOption]); + + const comparisonTopic = useMemo(() => { + if (selectedOption === 'log difference') { + return 'Backscatter'; + } + + return ''; + + // const data = ChangeCompareToolOptions.find( + // (d) => d.value === selectedOption + // ); + + // return data?.label || selectedOption; + }, [selectedOption]); + + useSyncCalendarDateRange(); + + useEffect(() => { + // If the previous value of the selected option is null, do not update the pixel range. + // This ensures pixel range values are updated only when the user manually selects a new option. + if (!selectedOptionPreviousVal) { + return; + } + + const pixelValuesRange = + ChangeCompareToolPixelValueRange4Sentinel1[ + selectedOption as ChangeCompareToolOption4Sentinel1 + ]; + + // Update the pixel values range based on the user-selected option. + // Both the full and selected ranges need to be updated because each option has a significantly + // different range. The current selected range might fall outside the full range of the new selection. + if (pixelValuesRange) { + dispatch(fullPixelValuesRangeUpdated(pixelValuesRange)); + dispatch(selectedRangeUpdated(pixelValuesRange)); + } + }, [selectedOption]); + + if (tool !== 'change') { + return null; + } + + return ( +
+ + + + + {selectedOption === 'log difference' && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/sentinel-1-explorer/components/ChangeCompareTool/PolarizationFilter.tsx b/src/sentinel-1-explorer/components/ChangeCompareTool/PolarizationFilter.tsx new file mode 100644 index 00000000..993e295f --- /dev/null +++ b/src/sentinel-1-explorer/components/ChangeCompareTool/PolarizationFilter.tsx @@ -0,0 +1,66 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Dropdown, DropdownData } from '@shared/components/Dropdown'; +import { + Sentinel1PolarizationFilter, + polarizationFilterChanged, +} from '@shared/store/Sentinel1/reducer'; +import { selectPolarizationFilter } from '@shared/store/Sentinel1/selectors'; +import React, { useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; + +export const PolarizationFilter = () => { + const dispatch = useDispatch(); + + const selectedPolarizationFilter = useSelector(selectPolarizationFilter); + + const dropdownMenuData = useMemo(() => { + const data: DropdownData[] = [ + { + value: 'VV' as Sentinel1PolarizationFilter, + label: 'VV Amplitude', + selected: selectedPolarizationFilter === 'VV', + }, + { + value: 'VH' as Sentinel1PolarizationFilter, + label: 'VH Amplitude', + selected: selectedPolarizationFilter === 'VH', + }, + ]; + + return data; + }, [selectedPolarizationFilter]); + + return ( +
+
+ Polarization: +
+ + { + dispatch( + polarizationFilterChanged( + val as Sentinel1PolarizationFilter + ) + ); + }} + /> +
+ ); +}; diff --git a/src/sentinel-1-explorer/components/ChangeCompareTool/index.ts b/src/sentinel-1-explorer/components/ChangeCompareTool/index.ts new file mode 100644 index 00000000..a174cbc0 --- /dev/null +++ b/src/sentinel-1-explorer/components/ChangeCompareTool/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ChangeCompareToolContainer as ChangeCompareTool4Sentinel1 } from './ChangeCompareToolContainer'; diff --git a/src/sentinel-1-explorer/components/DocPanel/Sentinel1DocPanel.tsx b/src/sentinel-1-explorer/components/DocPanel/Sentinel1DocPanel.tsx new file mode 100644 index 00000000..71c5509b --- /dev/null +++ b/src/sentinel-1-explorer/components/DocPanel/Sentinel1DocPanel.tsx @@ -0,0 +1,315 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DocPanel } from '@shared/components/DocPanel/DocPanel'; +import React from 'react'; +import Fig1 from './img/fig1.jpg'; +import Fig2 from './img/fig2.jpg'; +import DataAcquisitionFig1 from './img/data-acquisition-fig1.jpg'; + +export const Sentinel1DocPanel = () => { + return ( + +
+

+ Sentinel-1 SAR Quick Reference Guide +

+ +
+

About This User Guide

+ +

+ This quick reference guide is intended to provide a + technical overview and context for the Sentinel-1 SAR + imagery you see in the Sentinel-1 Explorer. It is not + intended to be a comprehensive technical guide. +

+
+ +
+

Sentinel-1

+ +
+

Mission

+ +

+ The Sentinel-1 mission is designed as a dual + satellite constellation carrying advanced radar + sensors to collect imagery of Earth’s surface both + day and night and in all weather conditions. +

+
+ +
+

Coverage

+ +

+ Total coverage is global and includes imagery from + 2014 to present. However, in December 2021, + Sentinel-1B experienced a power anomaly resulting in + permanent loss of data transmission. From 2022-2024, + the mission has continued with a single satellite, + Sentinel-1A affecting coverage and frequency of + collection. In Q4 2024, Sentinel-1C will launch and + the mission will once again operate as a dual + satellite constellation. +

+
+ +
+

Data Acquisition

+ +

+ Sentinel-1 active sensors collect data leveraging + C-Band synthetic aperture radar (SAR). The sensors + collect data by actively sending and receiving + microwave signals that echo off the Earth’s surface. + The echoes returned to the sensor are known as + backscatter. Measurements of this backscatter + provides information about the characteristics of + the surface at any given location. More on + backscatter below. +

+ +

+ Because microwave signals are not dependent upon + solar illumination and are unimpeded by clouds, + haze, and smoke, Sentinel-1 collects imagery of the + Earth’s surface day and night and in all weather + conditions. +

+ +
+ + +

+ Figure 1. Sentinel-1 leverages light energy in + the microwave portion of the electromagnetic + spectrum. Source EMS Image:{' '} + + NASA, ESA, CSA, Joseph Olmsted (STScI) + +

+
+
+ +
+

Backscatter

+ +

+ As noted in the previous section, the echoes of + microwave signals off the Earth’s surface are known + as backscatter. Two types of information are + obtained from backscatter: amplitude and phase. The + amplitude of backscatter provides information about + the characteristics of the surface at any given + location. For the purposes of this document, we are + only focused on the amplitude of backscatter. More + backscatter received = higher energy return = higher + amplitude = brighter pixels. More on this in the + Image Interpretation section. +

+ +

+ One of the most important factors influencing the + amplitude of backscatter is surface roughness. A + very smooth surface, like water or pavement, results + in high specular reflection where the microwave + signals reflect away from the sensor with little to + no backscatter return. In contrast, a very rough + surface causes diffuse scattering and a relatively + high volume of return. +

+ +
+ + +

+ Figure 1. Source:{' '} + + The SAR Handbook + +

+
+ +

+ As noted above, rougher surfaces produce diffuse + scattering, resulting in more energy returned to the + sensor. Tree canopies tend to cause volumetric + scattering, resulting in relatively lower returns. + Objects such as buildings and ships can cause what + is known as double bounce, resulting in very strong + returns with most of the original signal directed + back to the sensor. This effect is amplified when + the vertical object is adjacent to a very smooth + surface which would otherwise result in specular + reflection (e.g. a ship on smooth water). +

+ +
+ + +

+ Figure 2. Source:{' '} + + The SAR Handbook + +

+
+ +

+ The polarization of the microwave signals can also + contribute to the overall amplitude of backscatter. + Sentinel-1 is a dual polarized sensor. It transmits + microwave signals which are vertically oriented + relative to the plane of the Earth’s surface. While + the backscatter often maintains its original + vertical orientation, echoed signals can change + orientation and return horizontally. This dual + polarization is expressed as VV and VH, where the + first character denotes the transmitted orientation + and the second denotes the received orientation. It + should be noted the scattering types described above + do not contribute equally to each polarization. More + on this in the next section. +

+
+ +
+

Image Interpretation

+ +

+ The Sentinel-1 imagery available in ArcGIS Living + Atlas and used in the Sentinel-1 Explorer do not + include the phase portion of the SAR data, which + includes the measurement of time it takes to + transmit and receive the microwave signals. Instead, + the imagery provides only the amplitude, the amount + of energy returned from the transmitted signals. +

+ +

+ The imagery provided here is Radiometrically Terrain + Corrected (RTC), making it a mapping friendly + product ready for certain types of visualization and + analysis. These include applications such as + flooding, change detection, agriculture, water + quality, deforestation, and more. +

+ +
+

+ When visualizing the Sentinel-1 RTC imagery, + consider the following general guidelines: +

+ +
    +
  1. + Smoother surfaces = Lower backscatter = + Darker pixels +
  2. +
  3. + Rougher surfaces = Higher backscatter = + Brighter pixels +
  4. +
  5. + Water bodies/wet soils = Lower backscatter = + Darker pixels +
  6. +
  7. + Vertical objects = Higher backscatter = + Brighter pixels +
  8. +
  9. + Thicker vegetation = Lower backscatter = + Darker pixels +
  10. +
+
+ +

+ In some cases, multiple factors need to be + considered simultaneously. For example, water bodies + generally have greater signal reflectivity away from + the sensor, resulting in lower backscatter and a + darker appearance. However, a rough water surface + will result in higher backscatter and appear + brighter than a smooth water surface. +

+ +

+ As noted in the previous section, Sentinel-1 is dual + polarized. Each polarization, VV and VH, are stored + as separate bands in the RTC image product, where + band-1 is VV, and band-2 is VH. Each band can be + used independently or in conjunction with one + another, for visualization and analysis. VH signals + are most prevalent in areas of volume scattering. + This makes the VH useful in determining certain land + cover types such as forested vs non-forested areas. +

+
+ +
+

+ References and additional information +

+ +
    +
  1. + + ASF – Introduction to SAR + +
  2. +
  3. + + SERVIR – The SAR Handbook + +
  4. +
+
+
+
+
+ ); +}; diff --git a/src/sentinel-1-explorer/components/DocPanel/img/data-acquisition-fig1.jpg b/src/sentinel-1-explorer/components/DocPanel/img/data-acquisition-fig1.jpg new file mode 100644 index 00000000..edf1cc13 Binary files /dev/null and b/src/sentinel-1-explorer/components/DocPanel/img/data-acquisition-fig1.jpg differ diff --git a/src/sentinel-1-explorer/components/DocPanel/img/fig1.jpg b/src/sentinel-1-explorer/components/DocPanel/img/fig1.jpg new file mode 100644 index 00000000..443e7e42 Binary files /dev/null and b/src/sentinel-1-explorer/components/DocPanel/img/fig1.jpg differ diff --git a/src/sentinel-1-explorer/components/DocPanel/img/fig2.jpg b/src/sentinel-1-explorer/components/DocPanel/img/fig2.jpg new file mode 100644 index 00000000..c3d63392 Binary files /dev/null and b/src/sentinel-1-explorer/components/DocPanel/img/fig2.jpg differ diff --git a/src/shared/components/TrendToolControls/index.ts b/src/sentinel-1-explorer/components/DocPanel/index.ts similarity index 91% rename from src/shared/components/TrendToolControls/index.ts rename to src/sentinel-1-explorer/components/DocPanel/index.ts index ba5316cd..243ae1c2 100644 --- a/src/shared/components/TrendToolControls/index.ts +++ b/src/sentinel-1-explorer/components/DocPanel/index.ts @@ -13,4 +13,4 @@ * limitations under the License. */ -export { TrendToolControls } from './TrendToolControls'; +export { Sentinel1DocPanel } from './Sentinel1DocPanel'; diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/Sentinel1InterestingPlaces.tsx b/src/sentinel-1-explorer/components/InterestingPlaces/Sentinel1InterestingPlaces.tsx new file mode 100644 index 00000000..98f2c376 --- /dev/null +++ b/src/sentinel-1-explorer/components/InterestingPlaces/Sentinel1InterestingPlaces.tsx @@ -0,0 +1,22 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { data } from './data'; +import { InterestingPlaces } from '@shared/components/InterestingPlaces'; + +export const Sentinel1InterestingPlaces = () => { + return ; +}; diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/data.ts b/src/sentinel-1-explorer/components/InterestingPlaces/data.ts new file mode 100644 index 00000000..ffc617db --- /dev/null +++ b/src/sentinel-1-explorer/components/InterestingPlaces/data.ts @@ -0,0 +1,98 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Singapre from './thumbnails/Singapre.jpg'; +import Amazon from './thumbnails/Amazon.jpg'; +import CraterLake from './thumbnails/CraterLakeAlt.jpg'; +import Garig from './thumbnails/Garig.jpg'; +import Richat from './thumbnails/Richat.jpg'; +import Torshavn from './thumbnails/Torshavn.jpg'; + +import { InterestingPlaceData } from '@typing/shared'; + +export const data: InterestingPlaceData[] = [ + { + name: 'Singapore', + location: { + center: [103.82475, 1.25343], + zoom: 13.953, + }, + renderer: 'VV dB Colorized', + label: 'Port of Singapore', + thumbnail: Singapre, + description: + 'Due to its strategic location in maritime Southeast Asia, the city of Singapore is home to one of the of the busiest shipping ports in the world. One fifth of the worlds shipping containers pass through the Port of Singapore. Here you can visually depict shipping vessels in the waters off the southern tip of the Malay Peninsula.', + }, + { + name: 'Crater Lake', + location: { + center: [-122.10872, 42.94143], + zoom: 13, + }, + renderer: 'SWI Colorized', + label: '', + thumbnail: CraterLake, + description: + 'Crater Lake sits in a volcanic crater in South-central Oregon in the Western United States. The lake partially fills the caldera left by the collapse of Mount Mazama thousands of years ago. With a maximum depth of 2,148 feet (655 meters) it is the deepest lake in the United States and ranks tenth deepest in the world. Here, the body of the lake is depicted using the Sentinel-1 SAR Water Index (SWI).', + }, + { + name: 'Tórshavn', + location: { + center: [-6.75967, 62.00664], + zoom: 12, + }, + renderer: 'False Color dB with DRA', + label: 'Tórshavn, Faroe Islands', + thumbnail: Torshavn, + description: + 'Tórshavn is the capital and largest city of the Faroe Islands. It is among the cloudiest places in the world averaging only 2.4 hours of sunshine per day and 840 hours per year. Since SAR signals penetrate clouds, Sentinel-1 can collect imagery of the islands even when they are enshrouded with clouds.', + }, + { + name: 'Amazon Estuary', + location: { + center: [-51.05776, -0.39478], + zoom: 11, + }, + renderer: 'Water Anomaly Index Colorized', + label: '', + thumbnail: Amazon, + description: + 'The Amazon River in South America is the largest river by discharge volume of water in the world and two of the top ten rivers by discharge are tributaries of the Amazon. The river has an average discharge of about 6,591–7,570 km³ (1,581–1,816 mi³) per year, greater than the next seven largest independent rivers combined. The high concentrations of sediment the Amazon carries, and discharges into the Atlantic Ocean, lights up here with this rendering of a water anomaly index.', + }, + { + name: 'Richat', + location: { + center: [-11.398, 21.124], + zoom: 12, + }, + renderer: 'VH dB Colorized', + label: 'Richat Structure (Eye of the Sahara)', + thumbnail: Richat, + description: + 'The Richat Structure, also known as the Eye of the Sahara, is a prominent circular geological feature in the Sahara Desert. It is an eroded geological dome, 40 km (25 mi) in diameter, exposing sedimentary rock in layers that appear as concentric rings.', + }, + { + name: 'Gunak Barlu', + location: { + center: [132.21062, -11.36392], + zoom: 11, + }, + renderer: 'False Color dB with DRA', + label: 'Garig Gunak Barlu National Park', + thumbnail: Garig, + description: + 'Garig Gunak Barlu is a national park in the Northern Territory of Australia on the Cobourg Peninsula. Its name derives from the local Garig language, and the words gunak (land) and barlu (deep water). It is categorized as an IUCN Category II protected area and is home to all six species of Australian marine turtles: green sea turtles, hawksbill sea turtles, flatback sea turtles, leatherback sea turtles, and olive ridley sea turtles.', + }, +]; diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/index.ts b/src/sentinel-1-explorer/components/InterestingPlaces/index.ts new file mode 100644 index 00000000..af6cc8fb --- /dev/null +++ b/src/sentinel-1-explorer/components/InterestingPlaces/index.ts @@ -0,0 +1,17 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Sentinel1InterestingPlaces } from './Sentinel1InterestingPlaces'; +export { data as sentinel1InterestingPlaces } from './data'; diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Amazon.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Amazon.jpg new file mode 100644 index 00000000..9ff206ef Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Amazon.jpg differ diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/CraterLake.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/CraterLake.jpg new file mode 100644 index 00000000..4ddc0b00 Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/CraterLake.jpg differ diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/CraterLakeAlt.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/CraterLakeAlt.jpg new file mode 100644 index 00000000..4f091c93 Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/CraterLakeAlt.jpg differ diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Garig.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Garig.jpg new file mode 100644 index 00000000..9811b891 Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Garig.jpg differ diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Richat.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Richat.jpg new file mode 100644 index 00000000..3b9437d4 Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Richat.jpg differ diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Singapre.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Singapre.jpg new file mode 100644 index 00000000..41f36855 Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Singapre.jpg differ diff --git a/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Torshavn.jpg b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Torshavn.jpg new file mode 100644 index 00000000..5a8a23a7 Binary files /dev/null and b/src/sentinel-1-explorer/components/InterestingPlaces/thumbnails/Torshavn.jpg differ diff --git a/src/sentinel-1-explorer/components/Layout/Layout.tsx b/src/sentinel-1-explorer/components/Layout/Layout.tsx new file mode 100644 index 00000000..d42babc4 --- /dev/null +++ b/src/sentinel-1-explorer/components/Layout/Layout.tsx @@ -0,0 +1,176 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import BottomPanel from '@shared/components/BottomPanel/BottomPanel'; +import { Calendar } from '@shared/components/Calendar'; +import { AppHeader } from '@shared/components/AppHeader'; +import { + ContainerOfSecondaryControls, + ModeSelector, +} from '@shared/components/ModeSelector'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectAppMode, +} from '@shared/store/ImageryScene/selectors'; +import { AnimationControl } from '@shared/components/AnimationControl'; +// import { AnalysisToolSelector } from '@shared/components/AnalysisToolSelector'; +import { SwipeLayerSelector } from '@shared/components/SwipeLayerSelector'; +import { useSaveAppState2HashParams } from '@shared/hooks/useSaveAppState2HashParams'; +import { IS_MOBILE_DEVICE } from '@shared/constants/UI'; +// import { DynamicModeInfo } from '@shared/components/DynamicModeInfo'; +import { appConfig } from '@shared/config'; +import { useQueryAvailableSentinel1Scenes } from '../../hooks/useQueryAvailableSentinel1Scenes'; +import { SceneInfo } from '../SceneInfo'; +import { Sentinel1FunctionSelector } from '../RasterFunctionSelector'; +import { OrbitDirectionFilter } from '../OrbitDirectionFilter'; +import { useShouldShowSecondaryControls } from '@shared/hooks/useShouldShowSecondaryControls'; +import { Sentinel1AnalyzeToolSelector } from '../AnalyzeToolSelector/AnalyzeToolSelector'; +import { TemporalCompositeLayerSelector } from '../TemporalCompositeLayerSelector'; +import { TemporalCompositeTool } from '../TemporalCompositeTool/TemporalCompositeTool'; +import { ChangeCompareLayerSelector } from '@shared/components/ChangeCompareLayerSelector'; +import classNames from 'classnames'; +import { ChangeCompareTool4Sentinel1 } from '../ChangeCompareTool'; +import { Sentinel1TemporalProfileTool } from '../TemporalProfileTool'; +import { Sentinel1MaskTool } from '../MaskTool'; +import { useSaveSentinel1State2HashParams } from '../../hooks/saveSentinel1State2HashParams'; +import { Sentinel1InterestingPlaces } from '../InterestingPlaces'; +import { Sentinel1DynamicModeInfo } from '../Sentinel1DynamicModeInfo/Sentinel1DynamicModeInfo'; +import { Sentinel1DocPanel } from '../DocPanel'; +import { useSyncRenderers } from '@shared/hooks/useSyncRenderers'; + +export const Layout = () => { + const mode = useSelector(selectAppMode); + + const analysisTool = useSelector(selectActiveAnalysisTool); + + const dynamicModeOn = mode === 'dynamic'; + + const shouldShowSecondaryControls = useShouldShowSecondaryControls(); + + /** + * This custom hook gets invoked whenever the acquisition year, map center, or other filters are + * changes, it will dispatch the query that finds the available sentinel-1 scenes. + */ + useQueryAvailableSentinel1Scenes(); + + /** + * save common, app-wide state to URL hash params + */ + useSaveAppState2HashParams(); + + /** + * save sentinel1-explorer related state to URL hash params + */ + useSaveSentinel1State2HashParams(); + + /** + * This custom hook syncs the renderer of the secondary imagery scene with the main scene + */ + useSyncRenderers(); + + if (IS_MOBILE_DEVICE) { + return ( + <> + + +
+ + + +
+
+ + + ); + } + + return ( + <> + + +
+ + + {shouldShowSecondaryControls && ( + + + + + + )} + + {mode === 'analysis' && + analysisTool === 'temporal composite' && ( + + + + )} + + {mode === 'analysis' && analysisTool === 'change' && ( + + + + )} +
+ +
+ {dynamicModeOn ? ( + <> + + + + ) : ( + <> +
+ + + +
+ + {mode === 'analysis' && ( +
+ + + + +
+ )} + + + + )} + + +
+
+ + + ); +}; diff --git a/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/LockedRelativeOrbitFootprintLayer.tsx b/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/LockedRelativeOrbitFootprintLayer.tsx new file mode 100644 index 00000000..a482f29b --- /dev/null +++ b/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/LockedRelativeOrbitFootprintLayer.tsx @@ -0,0 +1,93 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GraphicsLayer from '@arcgis/core/layers/GraphicsLayer'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import MapView from '@arcgis/core/views/MapView'; +import Graphic from '@arcgis/core/Graphic'; +import { IFeature } from '@esri/arcgis-rest-feature-service'; +import React, { FC, useEffect, useMemo, useRef } from 'react'; +import { Point, Polygon } from '@arcgis/core/geometry'; + +type Props = { + visible: boolean; + mapView?: MapView; + groupLayer?: GroupLayer; + /** + * feature that provides geometry of the foot print + */ + footPrintFeature: IFeature; +}; + +export const LockedRelativeOrbitFootprintLayer: FC = ({ + visible, + mapView, + groupLayer, + footPrintFeature, +}) => { + const footprintLayer = useRef(); + + const init = () => { + footprintLayer.current = new GraphicsLayer({ + visible, + }); + + groupLayer.add(footprintLayer.current); + }; + + useEffect(() => { + if (!footprintLayer.current) { + init(); + } + }, [groupLayer]); + + useEffect(() => { + if (footprintLayer.current) { + footprintLayer.current.visible = visible; + } + }, [visible]); + + useEffect(() => { + if (!footprintLayer.current) { + return; + } + + footprintLayer.current.removeAll(); + + if (footPrintFeature) { + const geometry = footPrintFeature.geometry as any; + + const graphic = new Graphic({ + geometry: new Polygon({ + rings: geometry.rings, + spatialReference: geometry.spatialReference, + }), + symbol: { + type: 'simple-fill', // autocasts as new SimpleFillSymbol() + color: [0, 35, 47, 0.2], + outline: { + // autocasts as new SimpleLineSymbol() + color: [191, 238, 254, 1], + width: 1, + }, + } as any, + }); + + footprintLayer.current.add(graphic); + } + }, [footPrintFeature]); + + return null; +}; diff --git a/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/LockedRelativeOrbitFootprintLayerContainer.tsx b/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/LockedRelativeOrbitFootprintLayerContainer.tsx new file mode 100644 index 00000000..3eaea419 --- /dev/null +++ b/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/LockedRelativeOrbitFootprintLayerContainer.tsx @@ -0,0 +1,78 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import MapView from '@arcgis/core/views/MapView'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useLockedRelativeOrbit } from '../../hooks/useLockedRelativeOrbit'; +import { LockedRelativeOrbitFootprintLayer } from './LockedRelativeOrbitFootprintLayer'; +import { IFeature } from '@esri/arcgis-rest-feature-service'; +import { getFeatureByObjectId } from '@shared/services/helpers/getFeatureById'; +import { SENTINEL_1_SERVICE_URL } from '@shared/services/sentinel-1/config'; +import { useSelector } from 'react-redux'; +import { selectQueryParams4SceneInSelectedMode } from '@shared/store/ImageryScene/selectors'; +import { selectLockedRelativeOrbit } from '@shared/store/Sentinel1/selectors'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; +}; + +export const LockedRelativeOrbitFootprintLayerContainer: FC = ({ + mapView, + groupLayer, +}) => { + /** + * Locked relative orbit to be used by the different Analyze tools (e.g. temporal composite and change compare) + */ + const { lockedRelativeOrbit, objectIdOfSceneWithLockedRelativeOrbit } = + useSelector(selectLockedRelativeOrbit) || {}; + + const { objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + const [footPrintFeature, setFootPrintFeature] = useState(); + + const isVisible = useMemo(() => { + // no need to show it if user has already selected an scene + if (objectIdOfSelectedScene) { + return false; + } + + return lockedRelativeOrbit !== undefined; + }, [lockedRelativeOrbit, objectIdOfSelectedScene]); + + useEffect(() => { + (async () => { + const feature = objectIdOfSceneWithLockedRelativeOrbit + ? await getFeatureByObjectId( + SENTINEL_1_SERVICE_URL, + objectIdOfSceneWithLockedRelativeOrbit + ) + : null; + + setFootPrintFeature(feature); + })(); + }, [objectIdOfSceneWithLockedRelativeOrbit]); + + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/index.ts b/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/index.ts new file mode 100644 index 00000000..906e0f77 --- /dev/null +++ b/src/sentinel-1-explorer/components/LockedRelativeOrbitFootprintLayer/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LockedRelativeOrbitFootprintLayerContainer as LockedRelativeOrbitFootprintLayer } from './LockedRelativeOrbitFootprintLayerContainer'; diff --git a/src/sentinel-1-explorer/components/Map/Map.tsx b/src/sentinel-1-explorer/components/Map/Map.tsx new file mode 100644 index 00000000..a9e02829 --- /dev/null +++ b/src/sentinel-1-explorer/components/Map/Map.tsx @@ -0,0 +1,90 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import MapViewContainer from '@shared/components/MapView/MapViewContainer'; +// import { LandsatLayer } from '../LandsatLayer'; +// import { SwipeWidget } from '../SwipeWidget'; +import { AnimationLayer } from '@shared/components/AnimationLayer'; +import { GroupLayer } from '@shared/components/GroupLayer'; +import { AnalysisToolQueryLocation } from '@shared/components/AnalysisToolQueryLocation'; +import { Zoom2NativeScale } from '@shared/components/Zoom2NativeScale/Zoom2NativeScale'; +import { MapPopUpAnchorPoint } from '@shared/components/MapPopUpAnchorPoint'; +import { HillshadeLayer } from '@shared/components/HillshadeLayer/HillshadeLayer'; +// import { ChangeLayer } from '../ChangeLayer'; +import { ScreenshotWidget } from '@shared/components/ScreenshotWidget/ScreenshotWidget'; +import { MapMagnifier } from '@shared/components/MapMagnifier'; +import CustomMapArrtribution from '@shared/components/CustomMapArrtribution/CustomMapArrtribution'; +import { MapActionButtonsGroup } from '@shared/components/MapActionButton'; +import { CopyLinkWidget } from '@shared/components/CopyLinkWidget'; +import { Sentinel1Layer } from '../Sentinel1Layer'; +import { SwipeWidget4ImageryLayers } from '@shared/components/SwipeWidget/SwipeWidget4ImageryLayers'; +import { SENTINEL_1_SERVICE_URL } from '@shared/services/sentinel-1/config'; +import { Popup } from '../Popup'; +import { TemporalCompositeLayer } from '../TemporalCompositeLayer'; +import { ChangeCompareLayer4Sentinel1 } from '../ChangeCompareLayer'; +import { updateQueryLocation4TrendTool } from '@shared/store/TrendTool/thunks'; +import { useDispatch } from 'react-redux'; +import { Sentinel1MaskLayer } from '../MaskLayer'; +import { LockedRelativeOrbitFootprintLayer } from '../LockedRelativeOrbitFootprintLayer'; +import { ZoomToExtent } from '@shared/components/ZoomToExtent'; + +export const Map = () => { + const dispatch = useDispatch(); + + return ( + { + dispatch(updateQueryLocation4TrendTool(point)); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default Map; diff --git a/src/sentinel-1-explorer/components/MaskLayer/Sentinel1MaskLayer.tsx b/src/sentinel-1-explorer/components/MaskLayer/Sentinel1MaskLayer.tsx new file mode 100644 index 00000000..e90cc61c --- /dev/null +++ b/src/sentinel-1-explorer/components/MaskLayer/Sentinel1MaskLayer.tsx @@ -0,0 +1,198 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import MapView from '@arcgis/core/views/MapView'; +import { ImageryLayerWithPixelFilter } from '@shared/components/ImageryLayerWithPixelFilter'; +import React, { + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { + selectSelectedIndex4MaskTool, + selectMaskLayerPixelValueRange, + selectShouldClipMaskLayer, + selectMaskLayerOpcity, + selectMaskLayerPixelColor, + // selectActiveAnalysisTool, +} from '@shared/store/MaskTool/selectors'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectQueryParams4MainScene, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import { RadarIndex } from '@typing/imagery-service'; +import { + SENTINEL_1_SERVICE_URL, + Sentinel1FunctionName, +} from '@shared/services/sentinel-1/config'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; +import { Sentinel1PixelValueRangeByIndex } from '../MaskTool/Sentinel1MaskTool'; +import { WaterLandMaskLayer } from './WaterLandMaskLayer'; +import { useDispatch } from 'react-redux'; +import { countOfVisiblePixelsChanged } from '@shared/store/Map/reducer'; +import { useCalculateTotalAreaByPixelsCount } from '@shared/hooks/useCalculateTotalAreaByPixelsCount'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; +}; + +/** + * Lookup table that maps RadarIndex values to corresponding Sentinel1FunctionName values. + * + * This table is used by the Mask Layer to select the appropriate raster function based on the specified index. + */ +const RasterFunctionNameByIndx: Record = { + ship: 'VV and VH Power with Despeckle', + urban: 'VV and VH Power with Despeckle', + water: 'SWI Raw', + 'water anomaly': 'Water Anomaly Index Raw', +}; + +export const Sentinel1MaskLayer: FC = ({ mapView, groupLayer }) => { + const dispatach = useDispatch(); + + const mode = useSelector(selectAppMode); + + const groupLayer4MaskAndWaterLandLayersRef = useRef(); + + const selectedIndex = useSelector( + selectSelectedIndex4MaskTool + ) as RadarIndex; + + const { selectedRange } = useSelector(selectMaskLayerPixelValueRange); + + const pixelColor = useSelector(selectMaskLayerPixelColor); + + const opacity = useSelector(selectMaskLayerOpcity); + + const shouldClip = useSelector(selectShouldClipMaskLayer); + + const { objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + const anailysisTool = useSelector(selectActiveAnalysisTool); + + const isVisible = useMemo(() => { + if (mode !== 'analysis' || anailysisTool !== 'mask') { + return false; + } + + if (!objectIdOfSelectedScene) { + return false; + } + + return true; + }, [mode, anailysisTool, objectIdOfSelectedScene]); + + const fullPixelValueRange = useMemo(() => { + return ( + Sentinel1PixelValueRangeByIndex[selectedIndex as RadarIndex] || [ + 0, 0, + ] + ); + }, [selectedIndex]); + + const selectedPixelValueRange4Band2 = useMemo(() => { + if (selectedIndex === 'ship' || selectedIndex === 'urban') { + return selectedRange; + } + + return null; + }, [selectedIndex, selectedRange]); + + const rasterFunction = useMemo(() => { + return new RasterFunction({ + functionName: RasterFunctionNameByIndx[selectedIndex], + }); + }, [selectedIndex]); + + const initGroupLayer4MaskAndWaterLandLayers = () => { + groupLayer4MaskAndWaterLandLayersRef.current = new GroupLayer({ + blendMode: shouldClip && isVisible ? 'destination-atop' : 'normal', + visible: isVisible, + }); + + groupLayer.add(groupLayer4MaskAndWaterLandLayersRef.current); + }; + + useCalculateTotalAreaByPixelsCount({ + objectId: objectIdOfSelectedScene, + serviceURL: SENTINEL_1_SERVICE_URL, + pixelSize: mapView.resolution, + }); + + useEffect(() => { + initGroupLayer4MaskAndWaterLandLayers(); + }, []); + + useEffect(() => { + if (!groupLayer4MaskAndWaterLandLayersRef.current) { + return; + } + + // Set blend mode to 'destination-atop' only when shouldClip is true and the mask layer is on. + // If the mask layer is off and the blend mode remains 'destination-atop', the underlying Sentinel-1 Layer becomes invisible. + groupLayer4MaskAndWaterLandLayersRef.current.blendMode = + shouldClip && isVisible ? 'destination-atop' : 'normal'; + }, [shouldClip, isVisible]); + + useEffect(() => { + if (!groupLayer4MaskAndWaterLandLayersRef.current) { + return; + } + + groupLayer4MaskAndWaterLandLayersRef.current.visible = isVisible; + }, [isVisible]); + + if (!groupLayer4MaskAndWaterLandLayersRef.current) { + return null; + } + + return ( + <> + { + dispatach(countOfVisiblePixelsChanged(visiblePixels)); + }} + /> + + + + ); +}; diff --git a/src/sentinel-1-explorer/components/MaskLayer/WaterLandMaskLayer.tsx b/src/sentinel-1-explorer/components/MaskLayer/WaterLandMaskLayer.tsx new file mode 100644 index 00000000..3cb870a6 --- /dev/null +++ b/src/sentinel-1-explorer/components/MaskLayer/WaterLandMaskLayer.tsx @@ -0,0 +1,106 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import ImageryTileLayer from '@arcgis/core/layers/ImageryTileLayer'; +import { mask } from '@arcgis/core/layers/support/rasterFunctionUtils'; +import React, { FC, useEffect, useRef } from 'react'; +import { GLOBAL_WATER_LAND_MASK_LAYER_ITEM_ID } from '../../contans'; + +type WaterLandMaskLayerCategory = 'land' | 'water'; + +type Props = { + /** + * the visibility of this imagery tile layer + */ + visible: boolean; + /** + * the visible category + */ + visibleCategory: WaterLandMaskLayerCategory; + /** + * group layer that contains this water/land mask layer + */ + groupLayer: GroupLayer; +}; + +/** + * Get the mask raster function to filter the pixel values based on the visible category. + * @param visibleCategory - The category of pixels to make visible ('land' or 'water'). + * @returns The raster function to be applied on the imagery tile layer. + */ +const getRasterFunction = (visibleCategory: WaterLandMaskLayerCategory) => { + return mask({ + includedRanges: + visibleCategory === 'land' + ? [[0, 1]] // Pixels in the range [0, 1] are included for 'Not water' and 'No observations'. + : [[3]], // Pixels in the range [3] are included for 'Permanent water'. + noDataValues: [], + }); +}; + +/** + * The Water and Land Mask layer component. This component is used to mask the Sentinel-1 Index Mask layer + * when the selected index is either 'ship' or 'urban'. This masking is necessary because the 'ship' and 'urban' + * indices are calculated using the same method. Distinguishing them requires masking pixels on 'water' versus + * pixels on 'land'. + * @param Props - The props for the component, including visibility, visible category, and group layer. + * @returns A React component that manages the water/land mask layer. + */ +export const WaterLandMaskLayer: FC = ({ + visible, + visibleCategory, + groupLayer, +}) => { + const layerRef = useRef(); + + const init = () => { + layerRef.current = new ImageryTileLayer({ + portalItem: { + id: GLOBAL_WATER_LAND_MASK_LAYER_ITEM_ID, + }, + rasterFunction: getRasterFunction(visibleCategory), + blendMode: visible ? 'destination-in' : 'normal', + visible, + }); + + groupLayer.add(layerRef.current); + }; + + useEffect(() => { + if (groupLayer && !layerRef.current) { + init(); + } + }, [groupLayer]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.visible = visible; + layerRef.current.blendMode = visible ? 'destination-in' : 'normal'; + }, [visible]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.rasterFunction = getRasterFunction(visibleCategory); + }, [visibleCategory]); + + return null; +}; diff --git a/src/sentinel-1-explorer/components/MaskLayer/index.ts b/src/sentinel-1-explorer/components/MaskLayer/index.ts new file mode 100644 index 00000000..25321280 --- /dev/null +++ b/src/sentinel-1-explorer/components/MaskLayer/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Sentinel1MaskLayer } from './Sentinel1MaskLayer'; diff --git a/src/sentinel-1-explorer/components/MaskTool/Sentinel1MaskTool.tsx b/src/sentinel-1-explorer/components/MaskTool/Sentinel1MaskTool.tsx new file mode 100644 index 00000000..08acd262 --- /dev/null +++ b/src/sentinel-1-explorer/components/MaskTool/Sentinel1MaskTool.tsx @@ -0,0 +1,162 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +// import { PixelRangeSlider as MaskLayerPixelRangeSlider4SpectralIndex } from '@shared/components/MaskTool/PixelRangeSlider'; +// import { PixelRangeSlider as MaskLayerPixelRangeSlider4SurfaceTemp } from './PixelRangeSlider4SurfaceTemp'; +import { + MaskLayerRenderingControls, + MaskToolWarnigMessage, + // MaskLayerVisibleAreaInfo, +} from '@shared/components/MaskTool'; +import { selectedIndex4MaskToolChanged } from '@shared/store/MaskTool/reducer'; +import { + selectSelectedIndex4MaskTool, + selectMaskLayerPixelValueRange, + // selectActiveAnalysisTool, +} from '@shared/store/MaskTool/selectors'; +import { updateMaskLayerSelectedRange } from '@shared/store/MaskTool/thunks'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectQueryParams4MainScene, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import classNames from 'classnames'; +import { PixelRangeSlider } from '@shared/components/PixelRangeSlider'; +import { RadarIndex } from '@typing/imagery-service'; +import { + SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE, + SENTINEL1_WATER_INDEX_PIXEL_RANGE, + SENTINEL1_SHIP_AND_URBAN_INDEX_PIXEL_RANGE, +} from '@shared/services/sentinel-1/config'; +import { TotalVisibleAreaInfo } from '@shared/components/TotalAreaInfo/TotalAreaInfo'; + +export const Sentinel1PixelValueRangeByIndex: Record = { + water: SENTINEL1_WATER_INDEX_PIXEL_RANGE, + 'water anomaly': SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE, + ship: SENTINEL1_SHIP_AND_URBAN_INDEX_PIXEL_RANGE, + urban: SENTINEL1_SHIP_AND_URBAN_INDEX_PIXEL_RANGE, +}; + +export const Sentinel1MaskTool = () => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const selectedIndex = useSelector(selectSelectedIndex4MaskTool); + + const maskOptions = useSelector(selectMaskLayerPixelValueRange); + + const { objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + // const queryParams4MainScene = useSelector(selectQueryParams4MainScene); + + const shouldBeDisabled = useMemo(() => { + return !objectIdOfSelectedScene; + }, [objectIdOfSelectedScene]); + + const fullPixelValueRange = useMemo(() => { + return ( + Sentinel1PixelValueRangeByIndex[selectedIndex as RadarIndex] || [ + 0, 0, + ] + ); + }, [selectedIndex]); + + const countOfTicks = useMemo(() => { + // use 5 ticks if water index is selected + if (selectedIndex === 'water') { + return 5; + } + + // return undefined to have the pixel range slider to calculate it dynamically + return undefined; + }, [selectedIndex]); + + // const steps = useMemo(() => { + // return selectedIndex === 'ship' || selectedIndex === 'urban' + // ? 0.01 + // : 0.05; + // }, [selectedIndex]); + + // useEffect(() => { + // dispatch(updateMaskLayerSelectedRange (fullPixelValueRange)); + // }, [fullPixelValueRange]); + + if (tool !== 'mask') { + return null; + } + + return ( +
+ { + dispatch(selectedIndex4MaskToolChanged(val as RadarIndex)); + }} + /> + + {shouldBeDisabled ? ( + + ) : ( + <> +
+
+ +
+ + { + dispatch(updateMaskLayerSelectedRange(values)); + }} + showSliderTooltip={true} + /> +
+ + + + )} +
+ ); +}; diff --git a/src/sentinel-1-explorer/components/MaskTool/index.ts b/src/sentinel-1-explorer/components/MaskTool/index.ts new file mode 100644 index 00000000..60cc6b61 --- /dev/null +++ b/src/sentinel-1-explorer/components/MaskTool/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Sentinel1MaskTool } from './Sentinel1MaskTool'; diff --git a/src/sentinel-1-explorer/components/OrbitDirectionFilter/OrbitDirectionFilter.tsx b/src/sentinel-1-explorer/components/OrbitDirectionFilter/OrbitDirectionFilter.tsx new file mode 100644 index 00000000..c13218e1 --- /dev/null +++ b/src/sentinel-1-explorer/components/OrbitDirectionFilter/OrbitDirectionFilter.tsx @@ -0,0 +1,89 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button } from '@shared/components/Button'; +import { Dropdown, DropdownData } from '@shared/components/Dropdown'; +import { Tooltip } from '@shared/components/Tooltip'; +import { Sentinel1OrbitDirection } from '@typing/imagery-service'; +import classNames from 'classnames'; +import React, { FC, useMemo } from 'react'; + +type Props = { + selectedOrbitDirection: Sentinel1OrbitDirection; + orbitDirectionOnChange: (val: Sentinel1OrbitDirection) => void; +}; + +/** + * This is the filter to select the Orbit Direction of the Sentinel-1 imagery scenes + * @param param0 + * @returns + */ +export const OrbitDirectionFilter: FC = ({ + selectedOrbitDirection, + orbitDirectionOnChange, +}) => { + const data: DropdownData[] = useMemo(() => { + const values: Sentinel1OrbitDirection[] = ['Ascending', 'Descending']; + + return values.map((val) => { + return { + value: val, + selected: val === selectedOrbitDirection, + } as DropdownData; + }); + }, [selectedOrbitDirection]); + + return ( +
+ {/* { + orbitDirectionOnChange(val as Sentinel1OrbitDirection); + }} + /> */} + + +
+ +
+
+ + {data.map((d) => { + return ( +
{ + orbitDirectionOnChange( + d.value as Sentinel1OrbitDirection + ); + }} + className={classNames('px-2 mx-1 cursor-pointer', { + 'bg-custom-light-blue-90': d.selected === true, + 'text-custom-background': d.selected === true, + 'bg-custom-light-blue-5': d.selected === false, + })} + > + {d.value} +
+ ); + })} +
+ ); +}; diff --git a/src/sentinel-1-explorer/components/OrbitDirectionFilter/OrbitDirectionFilterContainer.tsx b/src/sentinel-1-explorer/components/OrbitDirectionFilter/OrbitDirectionFilterContainer.tsx new file mode 100644 index 00000000..5ccd6019 --- /dev/null +++ b/src/sentinel-1-explorer/components/OrbitDirectionFilter/OrbitDirectionFilterContainer.tsx @@ -0,0 +1,36 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { OrbitDirectionFilter } from './OrbitDirectionFilter'; +import { useSelector } from 'react-redux'; +import { selectSentinel1OrbitDirection } from '@shared/store/Sentinel1/selectors'; +import { useDispatch } from 'react-redux'; +import { orbitDirectionChanged } from '@shared/store/Sentinel1/reducer'; + +export const OrbitDirectionFilterContainer = () => { + const dispatch = useDispatch(); + + const selectedOrbitDirection = useSelector(selectSentinel1OrbitDirection); + + return ( + { + dispatch(orbitDirectionChanged(val)); + }} + /> + ); +}; diff --git a/src/sentinel-1-explorer/components/OrbitDirectionFilter/index.ts b/src/sentinel-1-explorer/components/OrbitDirectionFilter/index.ts new file mode 100644 index 00000000..92d8cba5 --- /dev/null +++ b/src/sentinel-1-explorer/components/OrbitDirectionFilter/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OrbitDirectionFilterContainer as OrbitDirectionFilter } from './OrbitDirectionFilterContainer'; diff --git a/src/sentinel-1-explorer/components/Popup/PopupContainer.tsx b/src/sentinel-1-explorer/components/Popup/PopupContainer.tsx new file mode 100644 index 00000000..2740973f --- /dev/null +++ b/src/sentinel-1-explorer/components/Popup/PopupContainer.tsx @@ -0,0 +1,127 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import './PopUp.css'; +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import MapView from '@arcgis/core/views/MapView'; +import Point from '@arcgis/core/geometry/Point'; +import { useSelector } from 'react-redux'; +import { + // selectActiveAnalysisTool, + selectAppMode, + selectQueryParams4MainScene, + selectQueryParams4SecondaryScene, +} from '@shared/store/ImageryScene/selectors'; +import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; +import { MapPopup, MapPopupData } from '@shared/components/MapPopup/MapPopup'; +import { identify } from '@shared/services/helpers/identify'; +import { + SENTINEL_1_SERVICE_URL, + Sentinel1FunctionName, +} from '@shared/services/sentinel-1/config'; +import { getFormattedSentinel1Scenes } from '@shared/services/sentinel-1/getSentinel1Scenes'; +import { getPopUpContentWithLocationInfo } from '@shared/components/MapPopup/helper'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; + +type Props = { + mapView?: MapView; +}; + +let controller: AbortController = null; + +export const PopupContainer: FC = ({ mapView }) => { + const mode = useSelector(selectAppMode); + + const queryParams4MainScene = useSelector(selectQueryParams4MainScene); + + const queryParams4SecondaryScene = useSelector( + selectQueryParams4SecondaryScene + ); + + const [data, setData] = useState(); + + const fetchPopupData = async ( + mapPoint: Point, + clickedOnLeftSideOfSwipeWidget: boolean + ) => { + try { + let queryParams = queryParams4MainScene; + + // in swipe mode, we need to use the query Params based on position of mouse click event + if (mode === 'swipe') { + queryParams = clickedOnLeftSideOfSwipeWidget + ? queryParams4MainScene + : queryParams4SecondaryScene; + } + + if (controller) { + controller.abort(); + } + + controller = new AbortController(); + + const res = await identify({ + serviceURL: SENTINEL_1_SERVICE_URL, + point: mapPoint, + objectIds: + mode !== 'dynamic' + ? [queryParams?.objectIdOfSelectedScene] + : null, + maxItemCount: 1, + resolution: mapView.resolution, + abortController: controller, + }); + + // console.log(res) + + const features = res?.catalogItems?.features; + + if (!features.length) { + throw new Error('cannot find sentinel-1 scene'); + } + + const sceneData = getFormattedSentinel1Scenes(features)[0]; + + // const bandValues: number[] = + // getPixelValuesFromIdentifyTaskResponse(res); + + // if (!bandValues) { + // throw new Error('identify task does not return band values'); + // } + // // console.log(bandValues) + + const title = `Sentinel-1 | ${formatInUTCTimeZone( + sceneData.acquisitionDate, + 'MMM dd, yyyy' + )}`; + + setData({ + // Set the popup's title to the coordinates of the location + title, + location: mapPoint, // Set the location of the popup to the clicked location + content: getPopUpContentWithLocationInfo(mapPoint, ''), + }); + } catch (error: any) { + setData({ + title: undefined, + location: undefined, + content: undefined, + error, + }); + } + }; + + return ; +}; diff --git a/src/sentinel-1-explorer/components/Popup/index.ts b/src/sentinel-1-explorer/components/Popup/index.ts new file mode 100644 index 00000000..cb92293f --- /dev/null +++ b/src/sentinel-1-explorer/components/Popup/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { PopupContainer as Popup } from './PopupContainer'; diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx b/src/sentinel-1-explorer/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx new file mode 100644 index 00000000..9869b626 --- /dev/null +++ b/src/sentinel-1-explorer/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx @@ -0,0 +1,42 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RasterFunctionSelector } from '@shared/components/RasterFunctionSelector'; +import React from 'react'; +import { useSentinel1RasterFunctions } from './useSentinel1RasterFunctions'; + +const TOOLTIP_TEXT = ` +
+

Sentinel-1 SAR sensors send and receive radar pulses that echo off the Earth’s surface. The echoes returned to the sensor are known as radar backscatter. The amplitude (strength) of the backscatter varies depending on the characteristics of the surface at any given location, resulting in variable dark to bright pixels. Consider the following:

+

Smoother surfaces = Lower backscatter = Darker pixels

+

Rougher surfaces = Higher backscatter = Brighter pixels

+

Water bodies/wet soils = Lower backscatter = Darker pixels

+

Vertical objects = Higher backscatter = Brighter pixels

+

Thicker vegetation = Lower backscatter = Darker pixels

+

NOTE: In some cases, multiple factors need to be considered simultaneously. For example, water bodies generally have greater signal reflectivity away from the sensor, resulting in lower backscatter and a darker appearance. However, a rough water surface will result in higher backscatter and appear brighter than a smooth water surface.

+
+`; + +export const RasterFunctionSelectorContainer = () => { + const data = useSentinel1RasterFunctions(); + + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/index.ts b/src/sentinel-1-explorer/components/RasterFunctionSelector/index.ts new file mode 100644 index 00000000..29f30ac0 --- /dev/null +++ b/src/sentinel-1-explorer/components/RasterFunctionSelector/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { RasterFunctionSelectorContainer as Sentinel1FunctionSelector } from './RasterFunctionSelectorContainer'; diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_FalseColorComposite_Legend.png b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_FalseColorComposite_Legend.png new file mode 100644 index 00000000..a352547a Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_FalseColorComposite_Legend.png differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_SingleBandV2_Legend.png b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_SingleBandV2_Legend.png new file mode 100644 index 00000000..065a795c Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_SingleBandV2_Legend.png differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_WaterAnomaly_Legend.png b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_WaterAnomaly_Legend.png new file mode 100644 index 00000000..a5e02c52 Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_WaterAnomaly_Legend.png differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_WaterIndex_Legend.png b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_WaterIndex_Legend.png new file mode 100644 index 00000000..f7e72563 Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/legends/SAR_WaterIndex_Legend.png differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_FalseColor.jpg b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_FalseColor.jpg new file mode 100644 index 00000000..7244aedf Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_FalseColor.jpg differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_VV_VH.jpg b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_VV_VH.jpg new file mode 100644 index 00000000..a95212a0 Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_VV_VH.jpg differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_WaterAnomaly.jpg b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_WaterAnomaly.jpg new file mode 100644 index 00000000..25b6777b Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_WaterAnomaly.jpg differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_WaterIndex.jpg b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_WaterIndex.jpg new file mode 100644 index 00000000..be39d1a0 Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/Render_WaterIndex.jpg differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/placeholder.JPG b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/placeholder.JPG new file mode 100644 index 00000000..0ce25ada Binary files /dev/null and b/src/sentinel-1-explorer/components/RasterFunctionSelector/thumbnails/placeholder.JPG differ diff --git a/src/sentinel-1-explorer/components/RasterFunctionSelector/useSentinel1RasterFunctions.tsx b/src/sentinel-1-explorer/components/RasterFunctionSelector/useSentinel1RasterFunctions.tsx new file mode 100644 index 00000000..e8b569fa --- /dev/null +++ b/src/sentinel-1-explorer/components/RasterFunctionSelector/useSentinel1RasterFunctions.tsx @@ -0,0 +1,84 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo } from 'react'; +import { + SENTINEL1_RASTER_FUNCTION_INFOS, + Sentinel1FunctionName, +} from '@shared/services/sentinel-1/config'; + +import { RasterFunctionInfo } from '@typing/imagery-service'; + +import PlaceholderThumbnail from './thumbnails/placeholder.jpg'; +import Render_VV_VH from './thumbnails/Render_VV_VH.jpg'; +import Render_WaterAnomaly from './thumbnails/Render_WaterAnomaly.jpg'; +import Render_WaterIndex from './thumbnails/Render_WaterIndex.jpg'; +import Render_FalseColor from './thumbnails/Render_FalseColor.jpg'; + +import SAR_FalseColorComposite_Legend from './legends/SAR_FalseColorComposite_Legend.png'; +import SAR_SingleBandV2_Legend from './legends/SAR_SingleBandV2_Legend.png'; +import SAR_WaterAnomaly_Legend from './legends/SAR_WaterAnomaly_Legend.png'; +import SAR_WaterIndex_Legend from './legends/SAR_WaterIndex_Legend.png'; + +const Sentinel1RendererThumbnailByName: Partial< + Record +> = { + 'False Color dB with DRA': Render_FalseColor, + 'VV dB Colorized': Render_VV_VH, + 'VH dB Colorized': Render_VV_VH, + // 'Sentinel-1 DpRVIc Raw': PlaceholderThumbnail, + 'Water Anomaly Index Colorized': Render_WaterAnomaly, + 'SWI Colorized': Render_WaterIndex, + // 'Sentinel-1 RTC Despeckle VH Amplitude': PlaceholderThumbnail, + // 'Sentinel-1 RTC Despeckle VV Amplitude': PlaceholderThumbnail, + // 'NDMI Colorized': LandsatNDMIThumbnail, +}; + +const Sentinel1RendererLegendByName: Partial< + Record +> = { + 'False Color dB with DRA': SAR_FalseColorComposite_Legend, + 'VH dB Colorized': SAR_SingleBandV2_Legend, + 'VV dB Colorized': SAR_SingleBandV2_Legend, + 'Water Anomaly Index Colorized': SAR_WaterAnomaly_Legend, + 'SWI Colorized': SAR_WaterIndex_Legend, +}; + +export const getSentinel1RasterFunctionInfo = (): RasterFunctionInfo[] => { + return SENTINEL1_RASTER_FUNCTION_INFOS.slice(0, 5).map((d) => { + const name: Sentinel1FunctionName = d.name as Sentinel1FunctionName; + + const thumbnail = Sentinel1RendererThumbnailByName[name] || null; + const legend = Sentinel1RendererLegendByName[name] || null; + + return { + ...d, + thumbnail, + legend, + } as RasterFunctionInfo; + }); +}; + +/** + * Get raster function information that includes thumbnail and legend + * @returns + */ +export const useSentinel1RasterFunctions = (): RasterFunctionInfo[] => { + const rasterFunctionInfosWithThumbnail = useMemo(() => { + return getSentinel1RasterFunctionInfo(); + }, []); + + return rasterFunctionInfosWithThumbnail; +}; diff --git a/src/sentinel-1-explorer/components/SceneInfo/SceneInfoContainer.tsx b/src/sentinel-1-explorer/components/SceneInfo/SceneInfoContainer.tsx new file mode 100644 index 00000000..169bd063 --- /dev/null +++ b/src/sentinel-1-explorer/components/SceneInfo/SceneInfoContainer.tsx @@ -0,0 +1,95 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo } from 'react'; +import { + SceneInfoTable, + SceneInfoTableData, +} from '@shared/components/SceneInfoTable'; +import { useDataFromSelectedSentinel1Scene } from './useDataFromSelectedSentinel1Scene'; +import { DATE_FORMAT } from '@shared/constants/UI'; +import { useSelector } from 'react-redux'; +import { selectAppMode } from '@shared/store/ImageryScene/selectors'; +import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; + +export const SceneInfoContainer = () => { + const mode = useSelector(selectAppMode); + + const data = useDataFromSelectedSentinel1Scene(); + + const tableData: SceneInfoTableData[] = useMemo(() => { + if (!data) { + return []; + } + + const { + name, + acquisitionDate, + sensor, + orbitDirection, + polarizationType, + absoluteOrbit, + relativeOrbit, + } = data; + + return [ + // the produt id is too long to be displayed in one row, + // therefore we need to split it into two separate rows + { + name: 'Scene ID', + value: name, //name.slice(0, 22), + clickToCopy: true, + }, + // { + // name: '', + // value: name.slice(22, 44), + // }, + // { + // name: '', + // value: name.slice(44), + // }, + { + name: 'Sensor', + value: sensor, + }, + { + name: 'Acquired', + value: formatInUTCTimeZone(acquisitionDate, DATE_FORMAT), + }, + { + name: 'Orbit Direction', + value: orbitDirection, + }, + { + name: 'Polarization', + value: polarizationType, + }, + { + name: 'Absolute Orbit', + value: absoluteOrbit, + }, + { + name: 'Relative Orbit', + value: relativeOrbit, + }, + ] as SceneInfoTableData[]; + }, [data]); + + if (mode === 'dynamic' || mode === 'analysis') { + return null; + } + + return ; +}; diff --git a/src/landsat-explorer/components/TrendTool/index.ts b/src/sentinel-1-explorer/components/SceneInfo/index.ts similarity index 81% rename from src/landsat-explorer/components/TrendTool/index.ts rename to src/sentinel-1-explorer/components/SceneInfo/index.ts index 4758bbbd..74d3725a 100644 --- a/src/landsat-explorer/components/TrendTool/index.ts +++ b/src/sentinel-1-explorer/components/SceneInfo/index.ts @@ -13,5 +13,4 @@ * limitations under the License. */ -export { TrendToolContainer as TrendTool } from './TrendToolContainer'; -export { TrendChartContainer as TrendChart } from './TrendChartContainer'; +export { SceneInfoContainer as SceneInfo } from './SceneInfoContainer'; diff --git a/src/sentinel-1-explorer/components/SceneInfo/useDataFromSelectedSentinel1Scene.tsx b/src/sentinel-1-explorer/components/SceneInfo/useDataFromSelectedSentinel1Scene.tsx new file mode 100644 index 00000000..497b0245 --- /dev/null +++ b/src/sentinel-1-explorer/components/SceneInfo/useDataFromSelectedSentinel1Scene.tsx @@ -0,0 +1,64 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectAppMode, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import { Sentinel1Scene } from '@typing/imagery-service'; +import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; +import { getSentinel1SceneByObjectId } from '@shared/services/sentinel-1/getSentinel1Scenes'; + +/** + * This custom hook returns the data for the selected Sentinel-1 Scene. + * @returns + */ +export const useDataFromSelectedSentinel1Scene = () => { + const { objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + const mode = useSelector(selectAppMode); + + const animationPlaying = useSelector(selectIsAnimationPlaying); + + const [sentinel1Scene, setSentinel1Scene] = useState(); + + useEffect(() => { + (async () => { + if ( + !objectIdOfSelectedScene || + animationPlaying || + mode === 'analysis' + ) { + // return null; + setSentinel1Scene(null); + return; + } + + try { + const data = await getSentinel1SceneByObjectId( + objectIdOfSelectedScene + ); + setSentinel1Scene(data); + } catch (err) { + console.error(err); + } + })(); + }, [objectIdOfSelectedScene, mode, animationPlaying]); + + return sentinel1Scene; +}; diff --git a/src/sentinel-1-explorer/components/Sentinel1DynamicModeInfo/Sentinel1DynamicModeInfo.tsx b/src/sentinel-1-explorer/components/Sentinel1DynamicModeInfo/Sentinel1DynamicModeInfo.tsx new file mode 100644 index 00000000..b498097b --- /dev/null +++ b/src/sentinel-1-explorer/components/Sentinel1DynamicModeInfo/Sentinel1DynamicModeInfo.tsx @@ -0,0 +1,23 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DynamicModeInfo } from '@shared/components/DynamicModeInfo'; +import React from 'react'; + +export const Sentinel1DynamicModeInfo = () => { + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/components/Sentinel1Layer/Sentinel1Layer.tsx b/src/sentinel-1-explorer/components/Sentinel1Layer/Sentinel1Layer.tsx new file mode 100644 index 00000000..6c09e58e --- /dev/null +++ b/src/sentinel-1-explorer/components/Sentinel1Layer/Sentinel1Layer.tsx @@ -0,0 +1,51 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import React, { FC, useEffect, useMemo } from 'react'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +// import { selectChangeCompareLayerIsOn } from '@shared/store/ChangeCompareTool/selectors'; +import { + SENTINEL1_SERVICE_SORT_FIELD, + SENTINEL1_SERVICE_SORT_VALUE, + SENTINEL_1_SERVICE_URL, +} from '@shared/services/sentinel-1/config'; +import ImageryLayerByObjectID from '@shared/components/ImageryLayer/ImageryLayerByObjectID'; +import MosaicRule from '@arcgis/core/layers/support/MosaicRule'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; +}; + +export const Sentinel1Layer: FC = ({ mapView, groupLayer }: Props) => { + const defaultMosaicRule = useMemo(() => { + return new MosaicRule({ + ascending: true, + method: 'attribute', + operation: 'first', + sortField: SENTINEL1_SERVICE_SORT_FIELD, + sortValue: SENTINEL1_SERVICE_SORT_VALUE, + }); + }, []); + + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/components/Sentinel1Layer/index.ts b/src/sentinel-1-explorer/components/Sentinel1Layer/index.ts new file mode 100644 index 00000000..af4f4255 --- /dev/null +++ b/src/sentinel-1-explorer/components/Sentinel1Layer/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Sentinel1Layer } from './Sentinel1Layer'; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayer/TemporalCompositeLayer.tsx b/src/sentinel-1-explorer/components/TemporalCompositeLayer/TemporalCompositeLayer.tsx new file mode 100644 index 00000000..6dfd183e --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeLayer/TemporalCompositeLayer.tsx @@ -0,0 +1,248 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect, useRef, useState } from 'react'; +import ImageryLayer from '@arcgis/core/layers/ImageryLayer'; +import MapView from '@arcgis/core/views/MapView'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { QueryParams4ImageryScene } from '@shared/store/ImageryScene/reducer'; +import { + SENTINEL_1_SERVICE_URL, + Sentinel1FunctionName, +} from '@shared/services/sentinel-1/config'; +import { + colormap, + compositeBands, +} from '@arcgis/core/layers/support/rasterFunctionUtils.js'; +import Color from '@arcgis/core/Color'; +import AlgorithmicColorRamp from '@arcgis/core/rest/support/AlgorithmicColorRamp'; + +export type ColorBand = 'red' | 'green' | 'blue'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; + /** + * name of the selected raster function + */ + rasterFunctionName: Sentinel1FunctionName; + /** + * object id of the imagery scene to be used as the red band + */ + objectIdOfRedBand: number; + /** + * object id of the imagery scene to be used as the green band + */ + objectIdOfGreenBand: number; + /** + * object id of the imagery scene to be used as the blue band + */ + objectIdOfBlueBand: number; + /** + * Represents the color band selected by the user. If a color band is selected (not null), + * this indicates that the imagery scene associated with this color band should be displayed, + * utilizing the colormap raster function associated with this band to render the layer. + */ + selectedColorband: ColorBand; + /** + * visibility of the Temporal Composite layer + */ + visible?: boolean; +}; + +const getColormapRasterFunction = ( + colorBand: ColorBand, + /** + * object id of the imagery scene to be used as the red band + */ + objectIdOfRedBand: number, + /** + * object id of the imagery scene to be used as the green band + */ + objectIdOfGreenBand: number, + /** + * object id of the imagery scene to be used as the blue band + */ + objectIdOfBlueBand: number, + /** + * name of the raster function to use to get the pixels before applying the colormap + */ + functionName: Sentinel1FunctionName +): RasterFunction => { + let objectId = null; + + if (colorBand === 'red') { + objectId = objectIdOfRedBand; + } else if (colorBand === 'green') { + objectId = objectIdOfGreenBand; + } else if (colorBand === 'blue') { + objectId = objectIdOfBlueBand; + } + + if (!objectId || !colorBand) { + return null; + } + + const inputRasterFunction = new RasterFunction({ + functionName, + functionArguments: { + raster: '$' + objectId, + }, + }); + + const maxRed = colorBand === 'red' ? 255 : 0; + const maxGreen = colorBand === 'green' ? 255 : 0; + const maxBlue = colorBand === 'blue' ? 255 : 0; + + return colormap({ + // colorRampName: "red", + colorRamp: new AlgorithmicColorRamp({ + algorithm: 'hsv', + fromColor: new Color([0, 0, 0]), + toColor: new Color([maxRed, maxGreen, maxBlue]), + }), + raster: inputRasterFunction, + }); +}; + +/** + * Create a composite bands raster function that combines three sentinel-1 scenes + * into a single raster image. + * @see https://developers.arcgis.com/documentation/common-data-types/raster-function-objects.htm#ESRI_SECTION1_190E1B4F19CA447B9B319BB56C193283 + * @see https://developers.arcgis.com/javascript/latest/api-reference/esri-layers-support-rasterFunctionUtils.html#compositeBands + */ +export const getCompositeBandsRasterFunction = ( + /** + * object id of the imagery scene to be used as the red band + */ + objectIdOfRedBand: number, + /** + * object id of the imagery scene to be used as the green band + */ + objectIdOfGreenBand: number, + /** + * object id of the imagery scene to be used as the blue band + */ + objectIdOfBlueBand: number, + /** + * name of the raster function to use to get the pixels before composition + */ + functionName: Sentinel1FunctionName +): RasterFunction => { + if (!objectIdOfRedBand || !objectIdOfGreenBand || !objectIdOfBlueBand) { + return null; + } + + return compositeBands({ + outputPixelType: 'u8', + rasters: [ + objectIdOfRedBand, + objectIdOfGreenBand, + objectIdOfBlueBand, + ].map((oid) => { + return new RasterFunction({ + functionName, + functionArguments: { + raster: '$' + oid, + }, + }); + }), + }); +}; + +export const TemporalCompositeLayer: FC = ({ + mapView, + groupLayer, + rasterFunctionName, + objectIdOfRedBand, + objectIdOfBlueBand, + objectIdOfGreenBand, + selectedColorband, + visible, +}) => { + const layerRef = useRef(); + + const init = () => { + const rasterFunction = selectedColorband + ? getColormapRasterFunction( + selectedColorband, + objectIdOfRedBand, + objectIdOfGreenBand, + objectIdOfBlueBand, + rasterFunctionName + ) + : getCompositeBandsRasterFunction( + objectIdOfRedBand, + objectIdOfGreenBand, + objectIdOfBlueBand, + rasterFunctionName + ); + + layerRef.current = new ImageryLayer({ + // URL to the imagery service + url: SENTINEL_1_SERVICE_URL, + mosaicRule: null, + // format: 'lerc', + rasterFunction, + visible, + }); + + groupLayer.add(layerRef.current); + }; + + useEffect(() => { + if (groupLayer && !layerRef.current) { + init(); + } + }, [groupLayer]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.rasterFunction = selectedColorband + ? getColormapRasterFunction( + selectedColorband, + objectIdOfRedBand, + objectIdOfGreenBand, + objectIdOfBlueBand, + rasterFunctionName + ) + : getCompositeBandsRasterFunction( + objectIdOfRedBand, + objectIdOfGreenBand, + objectIdOfBlueBand, + rasterFunctionName + ); + }, [ + objectIdOfRedBand, + objectIdOfGreenBand, + objectIdOfBlueBand, + selectedColorband, + rasterFunctionName, + ]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.visible = visible; + }, [visible]); + + return null; +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayer/TemporalCompositeLayerContainer.tsx b/src/sentinel-1-explorer/components/TemporalCompositeLayer/TemporalCompositeLayerContainer.tsx new file mode 100644 index 00000000..b2929d52 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeLayer/TemporalCompositeLayerContainer.tsx @@ -0,0 +1,161 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import React, { FC, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectListOfQueryParams, + // selectQueryParams4MainScene, + // selectQueryParams4SecondaryScene, + selectSelectedItemFromListOfQueryParams, +} from '@shared/store/ImageryScene/selectors'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { ColorBand, TemporalCompositeLayer } from './TemporalCompositeLayer'; +import { + selectIsTemporalCompositeLayerOn, + selectRasterFunction4TemporalCompositeTool, +} from '@shared/store/TemporalCompositeTool/selectors'; +import { Sentinel1FunctionName } from '@shared/services/sentinel-1/config'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; +}; + +export const TemporalCompositeLayerContainer: FC = ({ + mapView, + groupLayer, +}) => { + const mode = useSelector(selectAppMode); + + const isTemporalCompositeLayerOn = useSelector( + selectIsTemporalCompositeLayerOn + ); + + const listOfQueryParams = useSelector(selectListOfQueryParams); + + const selectedItemFromListOfQueryParams = useSelector( + selectSelectedItemFromListOfQueryParams + ); + + const anailysisTool = useSelector(selectActiveAnalysisTool); + + const rasterFunctionName: Sentinel1FunctionName = useSelector( + selectRasterFunction4TemporalCompositeTool + ) as Sentinel1FunctionName; + + /** + * Determines the visibility of the temporal composite layer + * @returns {boolean} True if the layer should be visible, otherwise false. + */ + const isVisible = useMemo(() => { + if (mode !== 'analysis') { + return false; + } + + if (anailysisTool !== 'temporal composite') { + return false; + } + + // When displaying the final composite result that combines all three scenes, + // we need to ensure that each scene for the color bands has an assigned object ID. + if ( + isTemporalCompositeLayerOn && + listOfQueryParams[0]?.objectIdOfSelectedScene && + listOfQueryParams[1]?.objectIdOfSelectedScene && + listOfQueryParams[2]?.objectIdOfSelectedScene + ) { + return true; + } + + // When displaying an individual band on the map, + // ensure that the selected imagery scene has an object ID assigned. + if ( + isTemporalCompositeLayerOn === false && + selectedItemFromListOfQueryParams?.objectIdOfSelectedScene + ) { + return true; + } + + // return false to hide the layer + return false; + }, [ + mode, + anailysisTool, + isTemporalCompositeLayerOn, + listOfQueryParams, + selectedItemFromListOfQueryParams, + ]); + + /** + * Determines the selected color band to use for rendering the imagery scene + * when displaying an individual color band on the map. + */ + const selectedColorband: ColorBand = useMemo(() => { + // Set the selected color band to null when displaying the final composite result. + if (isTemporalCompositeLayerOn) { + return null; + } + + // If query parameters are not available or no item is selected, return null. + if (!listOfQueryParams?.length || !selectedItemFromListOfQueryParams) { + return null; + } + + // Determine the color band based on the selected item's unique ID. + if ( + listOfQueryParams[0]?.uniqueId === + selectedItemFromListOfQueryParams.uniqueId + ) { + return 'red'; + } + + if ( + listOfQueryParams[1]?.uniqueId === + selectedItemFromListOfQueryParams.uniqueId + ) { + return 'green'; + } + + if ( + listOfQueryParams[2]?.uniqueId === + selectedItemFromListOfQueryParams.uniqueId + ) { + return 'blue'; + } + + return null; + }, [ + selectedItemFromListOfQueryParams, + listOfQueryParams, + isTemporalCompositeLayerOn, + ]); + + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayer/index.ts b/src/sentinel-1-explorer/components/TemporalCompositeLayer/index.ts new file mode 100644 index 00000000..f07f799e --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeLayer/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TemporalCompositeLayerContainer as TemporalCompositeLayer } from './TemporalCompositeLayerContainer'; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/TemporalCompositeLayerSelector.tsx b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/TemporalCompositeLayerSelector.tsx new file mode 100644 index 00000000..ff7a5750 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/TemporalCompositeLayerSelector.tsx @@ -0,0 +1,275 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; + +import classNames from 'classnames'; + +// import { SpectralIndex } from '@typing/imagery-service'; +import { QueryParams4ImageryScene } from '@shared/store/ImageryScene/reducer'; +import { Button } from '@shared/components/Button'; + +import CompositeIndicatorRed from './images/Composite_Red.png'; +import CompositeIndicatorGreen from './images/Composite_Green.png'; +import CompositeIndicatorBlue from './images/Composite_Blue.png'; +import CompositeIndicatorRGB from './images/Composite_RGB.png'; +import { formatFormattedDateStrInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; + +type Props = { + /** + * Id of the query params for the selected imagery scene + */ + idOfSelectedQueryParams: string; + /** + * query params of the imagery scene to be used as the red band + */ + queryParams4ImagerySceneOfRedBand: QueryParams4ImageryScene; + /** + * query params of the imagery scene to be used as the green band + */ + queryParams4ImagerySceneOfGreenBand: QueryParams4ImageryScene; + /** + * query params of the imagery scene to be used as the blue band + */ + queryParams4ImagerySceneOfBlueBand: QueryParams4ImageryScene; + /** + * if true, show the temporal composite layer + */ + isCompositeLayerOn: boolean; + /** + * if true, the "View Composite" button should be disabled. + * This happens if one of the RGB band layer does not have a acquisition date selected + */ + viewCompositeLayerDisabled: boolean; + /** + * Emits when user selects an imagery scene for a particular band + * @param uniqueId Id of the query params for the imagery scene that user selects + * @returns void + */ + activeSceneOnChange: (uniqueId: string) => void; + /** + * Emits when use clicks on the "View Composite" button + * @returns void + */ + viewCompositeLayerButtonOnClick: () => void; + /** + * Emits when use clicks on the swap button + * @returns void + */ + swapButtonOnClick: (indexOfSceneA: number, indexOfSceneB: number) => void; +}; + +type ButtonTextLabelProps = { + nameOfScene: string; + queryParams: QueryParams4ImageryScene; +}; + +const ButtonTextLabel: FC = ({ + nameOfScene, + queryParams, +}) => { + if (!queryParams || !queryParams.acquisitionDate) { + return ( +
+ Choose {nameOfScene} + {/*
+ {nameOfScene} */} +
+ ); + } + + return ( +
+ + {formatFormattedDateStrInUTCTimeZone( + queryParams.acquisitionDate + )} + +
+ ); +}; + +export const TemporalCompositeLayerSelector: FC = ({ + idOfSelectedQueryParams, + queryParams4ImagerySceneOfRedBand, + queryParams4ImagerySceneOfGreenBand, + queryParams4ImagerySceneOfBlueBand, + isCompositeLayerOn, + viewCompositeLayerDisabled, + activeSceneOnChange, + viewCompositeLayerButtonOnClick, + swapButtonOnClick, +}) => { + if ( + !queryParams4ImagerySceneOfRedBand || + !queryParams4ImagerySceneOfGreenBand || + !queryParams4ImagerySceneOfBlueBand + ) { + return null; + } + + const shouldHighlightScene4Red = + queryParams4ImagerySceneOfRedBand?.uniqueId === + idOfSelectedQueryParams && isCompositeLayerOn === false; + + const shouldHighlightScene4Green = + queryParams4ImagerySceneOfGreenBand?.uniqueId === + idOfSelectedQueryParams && isCompositeLayerOn === false; + + const shouldHighlightScene4Blue = + queryParams4ImagerySceneOfBlueBand?.uniqueId === + idOfSelectedQueryParams && isCompositeLayerOn === false; + + return ( +
+
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+ + + +
+
+ ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/TemporalCompositeLayerSelectorContainer.tsx b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/TemporalCompositeLayerSelectorContainer.tsx new file mode 100644 index 00000000..59d9524f --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/TemporalCompositeLayerSelectorContainer.tsx @@ -0,0 +1,94 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useMemo } from 'react'; +import { TemporalCompositeLayerSelector } from './TemporalCompositeLayerSelector'; +import { useDispatch } from 'react-redux'; +import { + initiateImageryScenes4TemporalCompositeTool, + swapImageryScenesInTemporalCompositeTool, +} from '@shared/store/TemporalCompositeTool/thunks'; +import { useSelector } from 'react-redux'; +import { + selectIdOfSelectedItemInListOfQueryParams, + selectListOfQueryParams, +} from '@shared/store/ImageryScene/selectors'; +import { selectedItemIdOfQueryParamsListChanged } from '@shared/store/ImageryScene/reducer'; +import { isTemporalCompositeLayerOnUpdated } from '@shared/store/TemporalCompositeTool/reducer'; +import { selectIsTemporalCompositeLayerOn } from '@shared/store/TemporalCompositeTool/selectors'; +import { useSyncCalendarDateRange } from '../../hooks/useSyncCalendarDateRange'; + +export const TemporalCompositeLayerSelectorContainer = () => { + const dispatch = useDispatch(); + + const listOfQueryParams = useSelector(selectListOfQueryParams); + + const idOfSelectedQueryParams = useSelector( + selectIdOfSelectedItemInListOfQueryParams + ); + + const isCompositeLayerOn = useSelector(selectIsTemporalCompositeLayerOn); + + const isViewCompositeLayerDisabled = useMemo(() => { + if (!listOfQueryParams || !listOfQueryParams.length) { + return true; + } + + const queryParamsWithoutAcquisitionDate = listOfQueryParams.filter( + (d) => !d.acquisitionDate + ); + + if (queryParamsWithoutAcquisitionDate?.length) { + return true; + } + + return false; + }, [listOfQueryParams]); + + useSyncCalendarDateRange(); + + useEffect(() => { + dispatch(initiateImageryScenes4TemporalCompositeTool(true)); + }, []); + + return ( + { + dispatch(selectedItemIdOfQueryParamsListChanged(uniqueId)); + dispatch(isTemporalCompositeLayerOnUpdated(false)); + }} + viewCompositeLayerButtonOnClick={() => { + dispatch(isTemporalCompositeLayerOnUpdated(true)); + }} + swapButtonOnClick={( + indexOfSceneA: number, + indexOfSceneB: number + ) => { + dispatch( + swapImageryScenesInTemporalCompositeTool( + indexOfSceneA, + indexOfSceneB + ) + ); + }} + /> + ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Blue.png b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Blue.png new file mode 100644 index 00000000..e0c6368f Binary files /dev/null and b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Blue.png differ diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Green.png b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Green.png new file mode 100644 index 00000000..73b0c9ca Binary files /dev/null and b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Green.png differ diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_RGB.png b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_RGB.png new file mode 100644 index 00000000..7f353aef Binary files /dev/null and b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_RGB.png differ diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Red.png b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Red.png new file mode 100644 index 00000000..d4ac31da Binary files /dev/null and b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/images/Composite_Red.png differ diff --git a/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/index.ts b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/index.ts new file mode 100644 index 00000000..bdf3a7b7 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeLayerSelector/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TemporalCompositeLayerSelectorContainer as TemporalCompositeLayerSelector } from './TemporalCompositeLayerSelectorContainer'; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeTool/Controls.tsx b/src/sentinel-1-explorer/components/TemporalCompositeTool/Controls.tsx new file mode 100644 index 00000000..9d12ad90 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeTool/Controls.tsx @@ -0,0 +1,76 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button } from '@shared/components/Button'; +import { Dropdown, DropdownData } from '@shared/components/Dropdown'; +import React, { FC } from 'react'; + +type Props = { + isTemporalCompositeLayerOn: boolean; + /** + * data to populate the Dropdown Menu for list of raster functions + */ + rasterFunctionsDropdownData: DropdownData[]; + /** + * Emits when user selects a new raster function in dropdown list + * @param val + * @returns + */ + rasterFunctionOnChange: (val: string) => void; + /** + * Emits when user clicks on the "Clear Selected Scenes" button + * @returns void + */ + clearSelectedScenesButtonOnClick: () => void; +}; + +export const TemproalCompositeToolControls: FC = ({ + isTemporalCompositeLayerOn, + rasterFunctionsDropdownData, + rasterFunctionOnChange, + clearSelectedScenesButtonOnClick, +}) => { + return ( +
+
+

+ {isTemporalCompositeLayerOn === false + ? 'Select scenes to apply to the Red, Green, and Blue color bands to create a composite RGB image, a hue-based change detection.' + : 'The composite image blends the three Red, Green, and Blue input scenes into a composite RGB image. The resulting colors communicate the varied reflectance across dates.'} +

+
+ +
+
+ Render composite as: +
+ + +
+ +
+
+ Clear all scene selections +
+
+
+ ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeTool/Legend.tsx b/src/sentinel-1-explorer/components/TemporalCompositeTool/Legend.tsx new file mode 100644 index 00000000..6a93535a --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeTool/Legend.tsx @@ -0,0 +1,307 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import RGBComposite from './img/RGBComposite.png'; +import { formatFormattedDateStrInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; +import { ColorGroup, getColorGroup } from './helpers'; +import classNames from 'classnames'; + +type Props = { + isTemporalCompositeLayerOn: boolean; + acquisitionDateOfImageryScene4RedBand: string; + acquisitionDateOfImageryScene4GreenBand: string; + acquisitionDateOfImageryScene4BlueBand: string; +}; + +type LegendLabelProps = { + /** + * name of color band: 'red' | 'green' | 'blue' + */ + colorbandName: 'red' | 'green' | 'blue'; + /** + * acqusition date of the selected imagery scene in format of 'YYYY-MM-DD' + */ + formattedAcquisitionDate: string; +}; + +const SIZE_IMAGE = 120; + +export const LegendLabel: FC = ({ + colorbandName, + formattedAcquisitionDate, +}) => { + return ( + <> + {colorbandName} +
+ + {formattedAcquisitionDate + ? formatFormattedDateStrInUTCTimeZone( + formattedAcquisitionDate + ) + : 'unselected'} + + + ); +}; + +type TooltipData = { + posX: number; + posY: number; + colorGroup: ColorGroup; +}; + +export const TemproalCompositeToolLegend: FC = ({ + isTemporalCompositeLayerOn, + acquisitionDateOfImageryScene4RedBand, + acquisitionDateOfImageryScene4GreenBand, + acquisitionDateOfImageryScene4BlueBand, +}) => { + const canvasRef = useRef(); + const contextRef = useRef(); + + const isDisabled = useMemo(() => { + // legend should only be enabled when composite layer is on + if (isTemporalCompositeLayerOn === false) { + return true; + } + + // legend should be disabled if imagery scene for one of color band does not have acquisition date selected + if ( + !acquisitionDateOfImageryScene4RedBand || + !acquisitionDateOfImageryScene4GreenBand || + !acquisitionDateOfImageryScene4BlueBand + ) { + return true; + } + + return false; + }, [ + isTemporalCompositeLayerOn, + acquisitionDateOfImageryScene4RedBand, + acquisitionDateOfImageryScene4GreenBand, + acquisitionDateOfImageryScene4BlueBand, + ]); + + const [tooltipData, setTooltipData] = useState(); + + const tooltipContent: string[] = useMemo(() => { + if (!tooltipData) { + return null; + } + + if ( + !acquisitionDateOfImageryScene4RedBand || + !acquisitionDateOfImageryScene4GreenBand || + !acquisitionDateOfImageryScene4BlueBand + ) { + return null; + } + + const formattedDateRedBand = formatFormattedDateStrInUTCTimeZone( + acquisitionDateOfImageryScene4RedBand + ); + const formattedDateGreenBand = formatFormattedDateStrInUTCTimeZone( + acquisitionDateOfImageryScene4GreenBand + ); + const formattedDateBlueBand = formatFormattedDateStrInUTCTimeZone( + acquisitionDateOfImageryScene4BlueBand + ); + + if (tooltipData.colorGroup === 'Red') { + return [ + `Higher backscatter:`, + `${formattedDateRedBand}`, + `Lower backscatter:`, + `${formattedDateGreenBand}`, + `${formattedDateBlueBand}`, + ]; + } + + if (tooltipData.colorGroup === 'Green') { + return [ + `Higher backscatter:`, + ` ${formattedDateGreenBand}`, + `Lower backscatter:`, + `${formattedDateRedBand}`, + `${formattedDateBlueBand}`, + ]; + } + + if (tooltipData.colorGroup === 'Blue') { + return [ + `Higher backscatter:`, + `${formattedDateBlueBand}`, + `Lower backscatter:`, + `${formattedDateRedBand}`, + `${formattedDateGreenBand}`, + ]; + } + + if (tooltipData.colorGroup === 'Yellow') { + return [ + `Higher backscatter:`, + `${formattedDateRedBand}`, + `${formattedDateGreenBand}`, + `Lower backscatter:`, + `${formattedDateBlueBand}`, + ]; + } + + if (tooltipData.colorGroup === 'Magenta') { + return [ + `Higher backscatter: `, + `${formattedDateRedBand}`, + `${formattedDateBlueBand}`, + `Lower backscatter: `, + `${formattedDateGreenBand}`, + ]; + } + + if (tooltipData.colorGroup === 'Cyan') { + return [ + `Higher backscatter:`, + ` ${formattedDateGreenBand}`, + `${formattedDateBlueBand}`, + `Lower backscatter:`, + `${formattedDateRedBand}`, + ]; + } + + return null; + }, [ + tooltipData, + acquisitionDateOfImageryScene4RedBand, + acquisitionDateOfImageryScene4GreenBand, + acquisitionDateOfImageryScene4BlueBand, + ]); + + const pickColor = (event: React.MouseEvent) => { + if ( + !acquisitionDateOfImageryScene4RedBand || + !acquisitionDateOfImageryScene4GreenBand || + !acquisitionDateOfImageryScene4BlueBand + ) { + return; + } + + const bounding = canvasRef.current.getBoundingClientRect(); + const x = event.clientX - bounding.left; + const y = event.clientY - bounding.top; + + const pixel = contextRef.current.getImageData(x, y, 1, 1); + const data = pixel.data; + + // const rgbColor = `rgb(${data[0]} ${data[1]} ${data[2]}})`; + + const colorGroup = getColorGroup(data[0], data[1], data[2]); + + const tooltipData: TooltipData = colorGroup + ? { + posX: event.clientX, + posY: event.clientY, + colorGroup, + } + : null; + + setTooltipData(tooltipData); + }; + + useEffect(() => { + const img = new Image(); + img.src = RGBComposite; + + const canvas = canvasRef.current; + contextRef.current = canvas.getContext('2d'); + + img.addEventListener('load', () => { + contextRef.current.drawImage(img, 0, 0, SIZE_IMAGE, SIZE_IMAGE); + }); + }, []); + + return ( +
+
+ + +
+ +
+ +
+ +
+ +
+ +
+
+ +

+ Lighter colors are higher overall backscatter and darker colors + are lower. +

+ + {tooltipData && tooltipContent && ( +
+ {/* {tooltipContent[0]} +
+ {tooltipContent[1]} */} + {tooltipContent.map((text, index) => { + const key = `${text}-${index}`; + return ( +

+ {text} +

+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeTool/TemporalCompositeTool.tsx b/src/sentinel-1-explorer/components/TemporalCompositeTool/TemporalCompositeTool.tsx new file mode 100644 index 00000000..497b95b7 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeTool/TemporalCompositeTool.tsx @@ -0,0 +1,139 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useMemo } from 'react'; +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectListOfQueryParams, + selectQueryParams4MainScene, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import classNames from 'classnames'; +import { + selectIsTemporalCompositeLayerOn, + selectRasterFunction4TemporalCompositeTool, +} from '@shared/store/TemporalCompositeTool/selectors'; +import { Sentinel1FunctionName } from '@shared/services/sentinel-1/config'; +import { rasterFunction4TemporalCompositeToolChanged } from '@shared/store/TemporalCompositeTool/reducer'; +import { TemproalCompositeToolLegend } from './Legend'; +import { TemproalCompositeToolControls } from './Controls'; +import { DropdownData } from '@shared/components/Dropdown'; +import { initialChangeCompareToolState } from '@shared/store/ChangeCompareTool/reducer'; +import { initiateImageryScenes4TemporalCompositeTool } from '@shared/store/TemporalCompositeTool/thunks'; +import { Tooltip } from '@shared/components/Tooltip'; + +export const TemporalCompositeTool = () => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const isTemporalCompositeLayerOn = useSelector( + selectIsTemporalCompositeLayerOn + ); + + const rasterFunction = useSelector( + selectRasterFunction4TemporalCompositeTool + ); + + const listOfQueryParams = useSelector(selectListOfQueryParams); + + const rasterFunctionDropdownOptions: DropdownData[] = useMemo(() => { + const VVdBRasterFunction: Sentinel1FunctionName = 'VV dB Colorized'; + const VHdBRasterFunction: Sentinel1FunctionName = 'VH dB Colorized'; + + const data = [ + { + value: VVdBRasterFunction, + label: 'V V dB', + }, + { + value: VHdBRasterFunction, + label: 'V H dB', + }, + ]; + + return data.map((d) => { + return { + ...d, + selected: d.value === rasterFunction, + }; + }); + }, [rasterFunction]); + + if (tool !== 'temporal composite') { + return null; + } + + return ( +
+
+ For example, elements with a high backscatter in the scene used as the red band will have a stronger red hue in the composite image. Elements with a high backscatter in the red scene and the blue scene, and low backscatter in the green scene, will appear purple. And so on. Areas with more consistent backscatter, meaning little to no change over time, will appear as shades of gray.' + } + width={400} + > + + + + Composite +
+ +
+
+ { + dispatch( + rasterFunction4TemporalCompositeToolChanged(val) + ); + }} + clearSelectedScenesButtonOnClick={() => { + // call this thunk function to reset the list of imagery scenes to be used for temporal composite tool + dispatch( + initiateImageryScenes4TemporalCompositeTool( + false + ) + ); + }} + /> +
+ +
+ +
+
+
+ ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeTool/helpers.ts b/src/sentinel-1-explorer/components/TemporalCompositeTool/helpers.ts new file mode 100644 index 00000000..acde1e61 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalCompositeTool/helpers.ts @@ -0,0 +1,59 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const THRESHOLD = (255 / 3) * 2; + +export type ColorGroup = + | 'Red' + | 'Green' + | 'Blue' + | 'Yellow' + | 'Magenta' + | 'Cyan'; + +export const getColorGroup = (r: number, g: number, b: number): ColorGroup => { + // Check if the color is Red + if (r > THRESHOLD && r > g && r > b && g < THRESHOLD && b < THRESHOLD) { + return 'Red'; + } + + // Check if the color is Green + if (g > THRESHOLD && g > r && g > b && r < THRESHOLD && b < THRESHOLD) { + return 'Green'; + } + + // Check if the color is Blue + if (b > THRESHOLD && b > r && b > g && r < THRESHOLD && g < THRESHOLD) { + return 'Blue'; + } + + // Check if the color is Yellow + if (r > THRESHOLD && g > THRESHOLD && r > b && g > b && b < THRESHOLD) { + return 'Yellow'; + } + + // Check if the color is Magenta + if (r > THRESHOLD && b > THRESHOLD && r > g && b > g && g < THRESHOLD) { + return 'Magenta'; + } + + // Check if the color is Cyan + if (g > THRESHOLD && g > THRESHOLD && g > r && b > r && r < THRESHOLD) { + return 'Cyan'; + } + + // If none of the above conditions are met, return 'null' + return null; +}; diff --git a/src/sentinel-1-explorer/components/TemporalCompositeTool/img/RGBComposite.png b/src/sentinel-1-explorer/components/TemporalCompositeTool/img/RGBComposite.png new file mode 100644 index 00000000..85c0ab2f Binary files /dev/null and b/src/sentinel-1-explorer/components/TemporalCompositeTool/img/RGBComposite.png differ diff --git a/src/sentinel-1-explorer/components/TemporalProfileTool/Sentinel1TemporalProfileChart.tsx b/src/sentinel-1-explorer/components/TemporalProfileTool/Sentinel1TemporalProfileChart.tsx new file mode 100644 index 00000000..d1cfb24f --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalProfileTool/Sentinel1TemporalProfileChart.tsx @@ -0,0 +1,32 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useSentinel1TemporalProfileDataAsChartData } from './useTemporalProfileDataAsChartData'; +import { useCustomDomain4YScale } from './useCustomDomain4YScale'; +import { TemporalProfileChart } from '@shared/components/TemporalProfileChart'; + +export const Sentinel1TemporalProfileChart = () => { + const chartData = useSentinel1TemporalProfileDataAsChartData(); + + const customDomain4YScale = useCustomDomain4YScale(chartData); + + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalProfileTool/Sentinel1TemporalProfileTool.tsx b/src/sentinel-1-explorer/components/TemporalProfileTool/Sentinel1TemporalProfileTool.tsx new file mode 100644 index 00000000..549f1e6f --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalProfileTool/Sentinel1TemporalProfileTool.tsx @@ -0,0 +1,171 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +import { + TemporalProfileToolControls, + TemporalProfileToolHeader, +} from '@shared/components/TemproalProfileTool'; +// import { getProfileData } from '@shared/services/landsat-2/getProfileData'; +// import { +// // acquisitionMonth4TrendToolChanged, +// // // samplingTemporalResolutionChanged, +// // trendToolDataUpdated, +// selectedIndex4TrendToolChanged, +// // queryLocation4TrendToolChanged, +// // trendToolOptionChanged, +// // acquisitionYear4TrendToolChanged, +// } from '@shared/store/TrendTool/reducer'; +// import { +// selectAcquisitionMonth4TrendTool, +// // selectActiveAnalysisTool, +// // selectSamplingTemporalResolution, +// selectTrendToolData, +// selectQueryLocation4TrendTool, +// selectSelectedIndex4TrendTool, +// selectAcquisitionYear4TrendTool, +// selectTrendToolOption, +// } from '@shared/store/TrendTool/selectors'; +// import { +// resetTrendToolData, +// updateQueryLocation4TrendTool, +// updateTrendToolData, +// } from '@shared/store/TrendTool/thunks'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import { + RadarIndex, + SpectralIndex, + TemporalProfileData, +} from '@typing/imagery-service'; +import { useUpdateTemporalProfileToolData } from '@shared/components/TemproalProfileTool/useUpdateTemporalProfileToolData'; +import { useSyncSelectedYearAndMonth4TemporalProfileTool } from '@shared/components/TemproalProfileTool/useSyncSelectedYearAndMonth'; +import { + FetchTemporalProfileDataFunc, + IntersectWithImagerySceneFunc, +} from '@shared/store/TrendTool/thunks'; +import { Point } from '@arcgis/core/geometry'; +import { selectError4TemporalProfileTool } from '@shared/store/TrendTool/selectors'; +import { intersectWithSentinel1Scene } from '@shared/services/sentinel-1/getSentinel1Scenes'; +import { getSentinel1TemporalProfileData } from '@shared/services/sentinel-1/getTemporalProfileData'; +import { selectSentinel1OrbitDirection } from '@shared/store/Sentinel1/selectors'; +import { Sentinel1TemporalProfileChart } from './Sentinel1TemporalProfileChart'; + +export const Sentinel1TemporalProfileTool = () => { + // const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + // const orbitDirection = useSelector(selectSentinel1OrbitDirection); + + const { objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + // const error = useSelector(selectError4TemporalProfileTool); + + /** + * this function will be invoked by the updateTemporalProfileToolData thunk function + * to check if the query location intersects with the selected sentinel-1 scene using the input object ID. + */ + const intersectWithImageryScene: IntersectWithImagerySceneFunc = + useCallback( + async ( + queryLocation: Point, + objectId: number, + abortController: AbortController + ) => { + const res = await intersectWithSentinel1Scene( + queryLocation, + objectId, + abortController + ); + + return res; + }, + [] + ); + + /** + * this function will be invoked by the updateTemporalProfileToolData thunk function + * to retrieve the temporal profile data from sentinel-1 service + */ + const fetchTemporalProfileData: FetchTemporalProfileDataFunc = useCallback( + async ( + queryLocation: Point, + acquisitionMonth: number, + acquisitionYear: number, + abortController: AbortController + ) => { + const data: TemporalProfileData[] = + await getSentinel1TemporalProfileData({ + queryLocation, + objectId: objectIdOfSelectedScene, + acquisitionMonth, + acquisitionYear, + abortController, + }); + + return data; + }, + [objectIdOfSelectedScene] + ); + + /** + * This custom hook triggers updateTemporalProfileToolData thunk function to get temporal profile data when query location, acquisition date or other options are changed. + */ + useUpdateTemporalProfileToolData( + fetchTemporalProfileData, + intersectWithImageryScene + ); + + /** + * This custom hook update the `acquisitionMonth` and `acquisitionMonth` property of the Trend Tool State + * to keep it in sync with the acquisition date of selected imagery scene + */ + useSyncSelectedYearAndMonth4TemporalProfileTool(); + + if (tool !== 'trend') { + return null; + } + + return ( +
+ + +
+ +
+ + +
+ ); +}; diff --git a/src/sentinel-1-explorer/components/TemporalProfileTool/index.ts b/src/sentinel-1-explorer/components/TemporalProfileTool/index.ts new file mode 100644 index 00000000..4e86d3c4 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalProfileTool/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Sentinel1TemporalProfileTool } from './Sentinel1TemporalProfileTool'; diff --git a/src/sentinel-1-explorer/components/TemporalProfileTool/useCustomDomain4YScale.tsx b/src/sentinel-1-explorer/components/TemporalProfileTool/useCustomDomain4YScale.tsx new file mode 100644 index 00000000..63370611 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalProfileTool/useCustomDomain4YScale.tsx @@ -0,0 +1,53 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import { getSentinel1PixelValueRangeByRadarIndex } from '@shared/services/sentinel-1/helper'; +// import { selectSelectedIndex4TrendTool } from '@shared/store/TrendTool/selectors'; +// import { RadarIndex, SpectralIndex } from '@typing/imagery-service'; +import { LineChartDataItem } from '@vannizhang/react-d3-charts/dist/LineChart/types'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +/** + * This custom hook returns the custom domain for Y Scale that will be used to render the Sentinel-1 Temporal Profile Chart + * @param chartData + * @returns + */ +export const useCustomDomain4YScale = (chartData: LineChartDataItem[]) => { + // const selectedIndex: RadarIndex = useSelector( + // selectSelectedIndex4TrendTool + // ) as RadarIndex; + + const customDomain4YScale = useMemo(() => { + const yValues = chartData.map((d) => d.y); + + // boundary of y axis, for spectral index, the boundary should be -1 and 1 + // const [yLowerLimit, yUpperLimit] = getSentinel1PixelValueRangeByRadarIndex(selectedIndex); + + // get min and max from the data + let ymin = Math.min(...yValues); + let ymax = Math.max(...yValues); + + // get range between min and max from the data + const yRange = ymax - ymin; + + ymin = ymin - yRange * 0.1; //Math.max(yLowerLimit, ymin - (yRange * 0.1)); + ymax = ymax + yRange * 0.1; //Math.min(yUpperLimit, ymax + (yRange * 0.1)); + + return [ymin, ymax]; + }, [chartData]); + + return customDomain4YScale; +}; diff --git a/src/sentinel-1-explorer/components/TemporalProfileTool/useTemporalProfileDataAsChartData.tsx b/src/sentinel-1-explorer/components/TemporalProfileTool/useTemporalProfileDataAsChartData.tsx new file mode 100644 index 00000000..2dfa1de7 --- /dev/null +++ b/src/sentinel-1-explorer/components/TemporalProfileTool/useTemporalProfileDataAsChartData.tsx @@ -0,0 +1,97 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectTrendToolData, + selectSelectedIndex4TrendTool, + selectTrendToolOption, +} from '@shared/store/TrendTool/selectors'; +import { RadarIndex, TemporalProfileData } from '@typing/imagery-service'; +import { SpectralIndex } from '@typing/imagery-service'; +import { LineChartDataItem } from '@vannizhang/react-d3-charts/dist/LineChart/types'; +import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone'; +import { + calcRadarIndex, + getSentinel1PixelValueRangeByRadarIndex, +} from '@shared/services/sentinel-1/helper'; + +/** + * Converts Sentinel-1 temporal profile data to chart data. + * @param temporalProfileData - Array of temporal profile data. + * @param spectralIndex - Spectral index to calculate the value for each data point. + * @param month2month - if true, user is trying to plot month to month trend line for a selected year. + * @returns An array of QuickD3ChartDataItem objects representing the chart data. + * + */ +const convertSentinel1TemporalProfileData2ChartData = ( + temporalProfileData: TemporalProfileData[], + selectedIndx: RadarIndex, + month2month?: boolean +): LineChartDataItem[] => { + if (!temporalProfileData || !temporalProfileData.length) { + return []; + } + + const data = temporalProfileData.map((d) => { + const { acquisitionDate, values } = d; + + // calculate the radar index that will be used as the y value for each chart vertex + const y = calcRadarIndex(selectedIndx, values); + // console.log(y) + + // const [yMin, yMax] = getSentinel1PixelValueRangeByRadarIndex(selectedIndx) + + // // y should not go below y min + // y = Math.max(y, yMin); + + // // y should not go beyond y max + // y = Math.min(y, yMax); + + const tooltip = `${formatInUTCTimeZone( + acquisitionDate, + 'LLL yyyy' + )}: ${y.toFixed(4)}`; + + return { + x: month2month ? d.acquisitionMonth : d.acquisitionDate, + y, + tooltip, + }; + }); + + return data; +}; + +export const useSentinel1TemporalProfileDataAsChartData = () => { + const temporalProfileData = useSelector(selectTrendToolData); + + const selectedIndx: RadarIndex = useSelector( + selectSelectedIndex4TrendTool + ) as RadarIndex; + + const trendToolOption = useSelector(selectTrendToolOption); + + const chartData = useMemo(() => { + return convertSentinel1TemporalProfileData2ChartData( + temporalProfileData, + selectedIndx, + trendToolOption === 'month-to-month' + ); + }, [temporalProfileData, selectedIndx, trendToolOption]); + + return chartData; +}; diff --git a/src/sentinel-1-explorer/components/WaterLandMaskLayer4WaterAnomaly/WaterLandMaskLayer4WaterAnomaly.tsx b/src/sentinel-1-explorer/components/WaterLandMaskLayer4WaterAnomaly/WaterLandMaskLayer4WaterAnomaly.tsx new file mode 100644 index 00000000..3e000237 --- /dev/null +++ b/src/sentinel-1-explorer/components/WaterLandMaskLayer4WaterAnomaly/WaterLandMaskLayer4WaterAnomaly.tsx @@ -0,0 +1,70 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import MapView from '@arcgis/core/views/MapView'; +import { Sentinel1FunctionName } from '@shared/services/sentinel-1/config'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import React, { FC, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { WaterLandMaskLayer } from '../MaskLayer/WaterLandMaskLayer'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; +}; + +export const WaterLandMaskLayer4WaterAnomaly: FC = ({ + mapView, + groupLayer, +}) => { + const { rasterFunctionName } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + const mode = useSelector(selectAppMode); + + const analyzeTool = useSelector(selectActiveAnalysisTool); + + const isVisible = useMemo(() => { + if (mode === 'analysis') { + return false; + } + + if (mode === 'animate') { + return false; + } + + const rft4Sentinel1: Sentinel1FunctionName = + rasterFunctionName as Sentinel1FunctionName; + + if (rft4Sentinel1 === 'Water Anomaly Index Colorized') { + return true; + } + + return false; + }, [rasterFunctionName, mode, analyzeTool]); + + return ( + + ); +}; diff --git a/src/sentinel-1-explorer/contans/index.ts b/src/sentinel-1-explorer/contans/index.ts new file mode 100644 index 00000000..7b4f6841 --- /dev/null +++ b/src/sentinel-1-explorer/contans/index.ts @@ -0,0 +1,21 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * JRC yearly classification data, with the oceans filled + * @see https://www.arcgis.com/home/item.html?id=f61d078c63bf49c497a1fb0bb9ad7d71 + */ +export const GLOBAL_WATER_LAND_MASK_LAYER_ITEM_ID = + 'f61d078c63bf49c497a1fb0bb9ad7d71'; diff --git a/src/sentinel-1-explorer/hooks/saveSentinel1State2HashParams.tsx b/src/sentinel-1-explorer/hooks/saveSentinel1State2HashParams.tsx new file mode 100644 index 00000000..8aa7a152 --- /dev/null +++ b/src/sentinel-1-explorer/hooks/saveSentinel1State2HashParams.tsx @@ -0,0 +1,35 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + selectPolarizationFilter, + selectSentinel1OrbitDirection, + selectSentinel1State, +} from '@shared/store/Sentinel1/selectors'; +import { saveSentinel1StateToHashParams } from '@shared/utils/url-hash-params/sentinel1'; +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; + +export const useSaveSentinel1State2HashParams = () => { + const orbitDirection = useSelector(selectSentinel1OrbitDirection); + + const polarizationFilter = useSelector(selectPolarizationFilter); + + const sentinel1State = useSelector(selectSentinel1State); + + useEffect(() => { + saveSentinel1StateToHashParams(sentinel1State); + }, [orbitDirection, polarizationFilter]); +}; diff --git a/src/sentinel-1-explorer/hooks/useLockedRelativeOrbit.tsx b/src/sentinel-1-explorer/hooks/useLockedRelativeOrbit.tsx new file mode 100644 index 00000000..6b74ed17 --- /dev/null +++ b/src/sentinel-1-explorer/hooks/useLockedRelativeOrbit.tsx @@ -0,0 +1,148 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectListOfQueryParams, + selectQueryParams4MainScene, + selectQueryParams4SceneInSelectedMode, + selectQueryParams4SecondaryScene, +} from '@shared/store/ImageryScene/selectors'; +import { Sentinel1Scene } from '@typing/imagery-service'; +import { getSentinel1SceneByObjectId } from '@shared/services/sentinel-1/getSentinel1Scenes'; +import { + lockedRelativeOrbitInfoChanged, + LockedRelativeOrbitInfo, +} from '@shared/store/Sentinel1/reducer'; +import { useDispatch } from 'react-redux'; + +/** + * Custom hook that determines the relative orbit (and the object Id of associated sentinel 1 scene) to be used by the different Analyze tools (e.g. temporal composite and change compare). + * These tools require all scenes selected by the user to have the same relative orbit. + * This hook tries to find the first scene that the user has selected and uses the relative orbit of that scene to + * query the rest of the scenes. + * + * @returns void + */ +export const useLockedRelativeOrbit = () => { + const dispatch = useDispatch(); + + const mode = useSelector(selectAppMode); + + const analysisTool = useSelector(selectActiveAnalysisTool); + + const queryParams = useSelector(selectQueryParams4SceneInSelectedMode); + + const queryParamsOfMainScene = useSelector(selectQueryParams4MainScene); + + const queryParamsOfSecondaryScene = useSelector( + selectQueryParams4SecondaryScene + ); + + const listOfQueryParams = useSelector(selectListOfQueryParams); + + const [sentinel1Scene, setSentinel1Scene] = useState(); + + /** + * useMemo hook to compute the relative orbit based on the mode, active analysis tool, and selected Sentinel-1 scene. + * The relative orbit is only relevant when the mode is 'analysis' and the analysis tool is 'temporal composite' or 'change compare'. + */ + const lockedRelativeOrbit: LockedRelativeOrbitInfo = useMemo(() => { + if (mode !== 'analysis' || !sentinel1Scene) { + return null; + } + + if ( + analysisTool !== 'temporal composite' && + analysisTool !== 'change' + ) { + return null; + } + + if (!sentinel1Scene) { + return null; + } + + const { relativeOrbit, objectId } = sentinel1Scene; + + return { + lockedRelativeOrbit: relativeOrbit, + objectIdOfSceneWithLockedRelativeOrbit: objectId, + }; + }, [mode, analysisTool, sentinel1Scene]); + + /** + * useEffect hook to update the selected Sentinel-1 scene when the mode, analysis tool, or query parameters change. + * It asynchronously fetches the scene data using the first valid object ID from the list of query parameters. + */ + useEffect(() => { + (async () => { + if (mode !== 'analysis') { + return setSentinel1Scene(null); + } + + if ( + analysisTool !== 'temporal composite' && + analysisTool !== 'change' + ) { + return setSentinel1Scene(null); + } + + // object id of the first scene that the user has selected + // the relative orbit of this scene will be used to query subsequent scenes + // so that we can gurantee that all scenes used in the 'temporal composite' and 'change compare' tool are + // acquired from the same relative orbit + let objectIdOfSelectedScene: number = null; + + if (analysisTool === 'temporal composite') { + // Find the first item in the list that has an associated object ID + for (const item of listOfQueryParams) { + if (item.objectIdOfSelectedScene) { + objectIdOfSelectedScene = item.objectIdOfSelectedScene; + break; + } + } + } else if (analysisTool === 'change') { + objectIdOfSelectedScene = + queryParamsOfMainScene?.objectIdOfSelectedScene || + queryParamsOfSecondaryScene?.objectIdOfSelectedScene; + } + + // if no items in the list has object id selected, then set the sentinel1 scene to null + if (!objectIdOfSelectedScene) { + return setSentinel1Scene(null); + } + + // Fetch the selected Sentinel-1 scene data + const data = await getSentinel1SceneByObjectId( + objectIdOfSelectedScene + ); + + setSentinel1Scene(data); + })(); + }, [queryParams?.objectIdOfSelectedScene, mode, analysisTool]); + + useEffect(() => { + dispatch(lockedRelativeOrbitInfoChanged(lockedRelativeOrbit)); + }, [lockedRelativeOrbit]); + + // return { + // lockedRelativeOrbit, + // objectIdOfSceneWithLockedRelativeOrbit: sentinel1Scene?.objectId, + // }; +}; diff --git a/src/sentinel-1-explorer/hooks/useQueryAvailableSentinel1Scenes.tsx b/src/sentinel-1-explorer/hooks/useQueryAvailableSentinel1Scenes.tsx new file mode 100644 index 00000000..a7806e4a --- /dev/null +++ b/src/sentinel-1-explorer/hooks/useQueryAvailableSentinel1Scenes.tsx @@ -0,0 +1,112 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { selectMapCenter } from '@shared/store/Map/selectors'; +import { useDispatch } from 'react-redux'; +// import { updateObjectIdOfSelectedScene } from '@shared/store/ImageryScene/thunks'; +import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; +import { queryAvailableSentinel1Scenes } from '@shared/store/Sentinel1/thunks'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectListOfQueryParams, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import { + selectLockedRelativeOrbit, + selectSentinel1OrbitDirection, +} from '@shared/store/Sentinel1/selectors'; +import { Sentinel1Scene } from '@typing/imagery-service'; +import { getSentinel1SceneByObjectId } from '@shared/services/sentinel-1/getSentinel1Scenes'; +import { useLockedRelativeOrbit } from './useLockedRelativeOrbit'; +import { shouldForceSceneReselectionUpdated } from '@shared/store/ImageryScene/reducer'; +import { usePrevious } from '@shared/hooks/usePrevious'; +// import { selectAcquisitionYear } from '@shared/store/ImageryScene/selectors'; + +/** + * This custom hook queries the landsat service and find landsat scenes + * that were acquired within the selected date range and intersect with the center of the map screen + * @returns + */ +export const useQueryAvailableSentinel1Scenes = (): void => { + const dispatch = useDispatch(); + + const mode = useSelector(selectAppMode); + + const analysisTool = useSelector(selectActiveAnalysisTool); + + const queryParams = useSelector(selectQueryParams4SceneInSelectedMode); + + const acquisitionDateRange = queryParams?.acquisitionDateRange; + + const isAnimationPlaying = useSelector(selectIsAnimationPlaying); + + /** + * current map center + */ + const center = useSelector(selectMapCenter); + + const orbitDirection = useSelector(selectSentinel1OrbitDirection); + + const previousOrbitDirection = usePrevious(orbitDirection); + + const { lockedRelativeOrbit } = + useSelector(selectLockedRelativeOrbit) || {}; + + /** + * This custom hook helps to determine the Locked relative orbit to be used by the Analyze tools to ensure all Sentinel-1 + * scenes selected by the user to have the same relative orbit. + */ + useLockedRelativeOrbit(); + + useEffect(() => { + if (!center || !acquisitionDateRange) { + return; + } + + if (isAnimationPlaying) { + return; + } + + // Set `shouldForceSceneReselection` to true when the user makes a new selection of the orbit direction filter. + // This will force the `useFindSelectedSceneByDate` custom hook to disregard the currently selected scene and + // select a new scene based on the current state of all filters. + if ( + previousOrbitDirection && + orbitDirection !== previousOrbitDirection + ) { + dispatch(shouldForceSceneReselectionUpdated(true)); + } + + dispatch( + queryAvailableSentinel1Scenes({ + acquisitionDateRange, + relativeOrbit: lockedRelativeOrbit, + }) + ); + }, [ + center, + acquisitionDateRange?.startDate, + acquisitionDateRange?.endDate, + isAnimationPlaying, + orbitDirection, + lockedRelativeOrbit, + // dualPolarizationOnly, + ]); + + return null; +}; diff --git a/src/sentinel-1-explorer/hooks/useSyncCalendarDateRange.tsx b/src/sentinel-1-explorer/hooks/useSyncCalendarDateRange.tsx new file mode 100644 index 00000000..a2e9c885 --- /dev/null +++ b/src/sentinel-1-explorer/hooks/useSyncCalendarDateRange.tsx @@ -0,0 +1,80 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { syncImageryScenesDateRangeForChangeCompareTool } from '@shared/store/ChangeCompareTool/thunks'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +import { syncImageryScenesDateRangeForTemporalCompositeTool } from '@shared/store/TemporalCompositeTool/thunks'; +import { getDateRangeForPast12Month } from '@shared/utils/date-time/getTimeRange'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; + +const DATE_RANGE_OF_PAST_12_MONTH = getDateRangeForPast12Month(); + +/** + * Custom hook that triggers a thunk function to update the query parameters of Imagery Scenes + * for the Temporal Composite Tool, syncing them with the default acquisition date range + * (past 12 months) using the updated date range selected by the user. + */ +export const useSyncCalendarDateRange = () => { + const dispatch = useDispatch(); + + const mode = useSelector(selectAppMode); + + const analyzeTool = useSelector(selectActiveAnalysisTool); + + const queryParams = useSelector(selectQueryParams4SceneInSelectedMode); + + useEffect(() => { + if (!queryParams?.acquisitionDateRange) { + return; + } + + // Only sync the calendar date range if in 'analysis' mode + // and using the 'temporal composite' or 'change' tool + if ( + mode !== 'analysis' || + (analyzeTool !== 'temporal composite' && analyzeTool !== 'change') + ) { + return; + } + + const { startDate, endDate } = queryParams?.acquisitionDateRange || {}; + + // Skip syncing if the currently selected date range equals the date range of the past 12 months + if ( + startDate === DATE_RANGE_OF_PAST_12_MONTH.startDate && + endDate === DATE_RANGE_OF_PAST_12_MONTH.endDate + ) { + return; + } + + dispatch( + syncImageryScenesDateRangeForTemporalCompositeTool( + queryParams?.acquisitionDateRange + ) + ); + + dispatch( + syncImageryScenesDateRangeForChangeCompareTool( + queryParams?.acquisitionDateRange + ) + ); + }, [queryParams?.acquisitionDateRange]); +}; diff --git a/src/sentinel-1-explorer/index.tsx b/src/sentinel-1-explorer/index.tsx new file mode 100644 index 00000000..4d166f6b --- /dev/null +++ b/src/sentinel-1-explorer/index.tsx @@ -0,0 +1,58 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import '@arcgis/core/assets/esri/themes/dark/main.css'; +import '@shared/styles/index.css'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider as ReduxProvider } from 'react-redux'; +import { getSentinel1ExplorerStore } from './store'; +import ErrorBoundary from '@shared/components/ErrorBoundary/ErrorBoundary'; +import { Map } from './components/Map/Map'; +import { Layout } from './components/Layout/Layout'; +import { AboutSentinel1Explorer } from './components/About'; +import { ErrorPage } from '@shared/components/ErrorPage'; +import { getTimeExtentOfSentinel1Service } from '@shared/services/sentinel-1/getTimeExtent'; +import AppContextProvider from '@shared/contexts/AppContextProvider'; +import { SENTINEL1_RASTER_FUNCTION_INFOS } from '@shared/services/sentinel-1/config'; + +(async () => { + const root = createRoot(document.getElementById('root')); + + try { + const store = await getSentinel1ExplorerStore(); + + const timeExtent = await getTimeExtentOfSentinel1Service(); + // console.log(timeExtent); + + root.render( + + + + + + + + + + ); + } catch (err) { + console.log(err); + root.render(); + } +})(); diff --git a/src/sentinel-1-explorer/store/getPreloadedState4Sentinel1Explorer.ts b/src/sentinel-1-explorer/store/getPreloadedState4Sentinel1Explorer.ts new file mode 100644 index 00000000..5860dd87 --- /dev/null +++ b/src/sentinel-1-explorer/store/getPreloadedState4Sentinel1Explorer.ts @@ -0,0 +1,301 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import { PartialRootState } from './configureStore'; + +import { initialMapState, MapState } from '@shared/store/Map/reducer'; +import { + getAnimationSpeedFromHashParams, + getChangeCompareToolDataFromHashParams, + getHashParamValueByKey, + getMapCenterFromHashParams, + getMaskToolDataFromHashParams, + getQueryParams4MainSceneFromHashParams, + getListOfQueryParamsFromHashParams, + getQueryParams4SecondarySceneFromHashParams, + getSpectralProfileToolDataFromHashParams, + getTemporalProfileToolDataFromHashParams, + getTemporalCompositeToolDataFromHashParams, +} from '@shared/utils/url-hash-params'; +import { MAP_CENTER, MAP_ZOOM } from '@shared/constants/map'; +// import { initialUIState, UIState } from './UI/reducer'; +import { + AnalysisTool, + AppMode, + DefaultQueryParams4ImageryScene, + initialImagerySceneState, + ImageryScenesState, + QueryParams4ImageryScene, + // QueryParams4ImageryScene, +} from '@shared/store/ImageryScene/reducer'; +import { IS_MOBILE_DEVICE } from '@shared/constants/UI'; +import { initialUIState, UIState } from '@shared/store/UI/reducer'; +import { PartialRootState } from '@shared/store/configureStore'; +import { Sentinel1FunctionName } from '@shared/services/sentinel-1/config'; +import { + TemporalCompositeToolState, + initialState4TemporalCompositeTool, +} from '@shared/store/TemporalCompositeTool/reducer'; +import { + ChangeCompareToolState, + initialChangeCompareToolState, +} from '@shared/store/ChangeCompareTool/reducer'; +import { + ChangeCompareToolOption4Sentinel1, + ChangeCompareToolPixelValueRange4Sentinel1, +} from '../components/ChangeCompareTool/ChangeCompareToolContainer'; +import { + TrendToolState, + initialTrendToolState, +} from '@shared/store/TrendTool/reducer'; +import { RadarIndex } from '@typing/imagery-service'; +import { + MaskToolState, + initialMaskToolState, + MaskToolPixelValueRangeBySpectralIndex, + DefaultPixelValueRangeBySelectedIndex, +} from '@shared/store/MaskTool/reducer'; +import { + initialSentinel1State, + Sentinel1State, +} from '@shared/store/Sentinel1/reducer'; +import { getSentinel1StateFromHashParams } from '@shared/utils/url-hash-params/sentinel1'; +import { getRandomElement } from '@shared/utils/snippets/getRandomElement'; +import { sentinel1InterestingPlaces } from '../components/InterestingPlaces/'; + +/** + * Map location info that contains center and zoom info from URL Hash Params + */ +const mapLocationFromHashParams = getMapCenterFromHashParams(); + +/** + * Use the location of a randomly selected interesting place if there is no map location info + * found in the URL hash params. + */ +const randomInterestingPlace = !mapLocationFromHashParams + ? getRandomElement(sentinel1InterestingPlaces) + : null; + +const getPreloadedMapState = (): MapState => { + let mapLocation = mapLocationFromHashParams; + + if (!mapLocation) { + mapLocation = randomInterestingPlace?.location; + } + + // show map labels if there is no `hideMapLabels` in hash params + const showMapLabel = getHashParamValueByKey('hideMapLabels') === null; + + // show terrain if there is no `hideTerrain` in hash params + const showTerrain = getHashParamValueByKey('hideTerrain') === null; + + const showBasemap = getHashParamValueByKey('hideBasemap') === null; + + return { + ...initialMapState, + center: mapLocation?.center || MAP_CENTER, + zoom: mapLocation?.zoom || MAP_ZOOM, + showMapLabel, + showTerrain, + showBasemap, + }; +}; + +const getPreloadedImageryScenesState = (): ImageryScenesState => { + let mode: AppMode = + (getHashParamValueByKey('mode') as AppMode) || 'dynamic'; + + // user is only allowed to use the "dynamic" mode when using mobile device + if (IS_MOBILE_DEVICE) { + mode = 'dynamic'; + } + + const defaultRasterFunction: Sentinel1FunctionName = + 'False Color dB with DRA'; + + // Attempt to extract query parameters from the URL hash. + // If not found, fallback to using the default values along with the raster function from a randomly selected interesting location, + // which will serve as the map center. + const queryParams4MainScene = getQueryParams4MainSceneFromHashParams() || { + ...DefaultQueryParams4ImageryScene, + rasterFunctionName: + randomInterestingPlace?.renderer || defaultRasterFunction, + }; + + const queryParams4SecondaryScene = + getQueryParams4SecondarySceneFromHashParams() || { + ...DefaultQueryParams4ImageryScene, + rasterFunctionName: null, + }; + + const listOfQueryParams = getListOfQueryParamsFromHashParams() || []; + + const queryParamsById: { + [key: string]: QueryParams4ImageryScene; + } = {}; + + const tool = getHashParamValueByKey('tool') as AnalysisTool; + + for (const queryParams of listOfQueryParams) { + queryParamsById[queryParams.uniqueId] = queryParams; + } + + return { + ...initialImagerySceneState, + mode, + tool: tool || 'mask', + queryParams4MainScene, + queryParams4SecondaryScene, + queryParamsList: { + byId: queryParamsById, + ids: listOfQueryParams.map((d) => d.uniqueId), + selectedItemID: listOfQueryParams[0] + ? listOfQueryParams[0].uniqueId + : null, + }, + // idOfSelectedItemInListOfQueryParams: queryParams4ScenesInAnimation[0] + // ? queryParams4ScenesInAnimation[0].uniqueId + // : null, + }; +}; + +const getPreloadedUIState = (): UIState => { + const animationSpeed = getAnimationSpeedFromHashParams(); + + const proloadedUIState: UIState = { + ...initialUIState, + nameOfSelectedInterestingPlace: randomInterestingPlace?.name || '', + }; + + if (animationSpeed) { + proloadedUIState.animationSpeed = animationSpeed; + proloadedUIState.animationStatus = 'loading'; + } + + return proloadedUIState; +}; + +const getPreloadedTemporalCompositeToolState = + (): TemporalCompositeToolState => { + const data = getTemporalCompositeToolDataFromHashParams(); + + const defaultRasterFunction: Sentinel1FunctionName = 'VV dB Colorized'; + + const proloadedState: TemporalCompositeToolState = { + ...initialState4TemporalCompositeTool, + ...data, + rasterFunction: data?.rasterFunction || defaultRasterFunction, + }; + + return proloadedState; + }; + +const getPreloadedChangeCompareToolState = (): ChangeCompareToolState => { + const changeCompareToolData = getChangeCompareToolDataFromHashParams(); + + const selectedOption: ChangeCompareToolOption4Sentinel1 = + changeCompareToolData?.selectedOption + ? (changeCompareToolData?.selectedOption as ChangeCompareToolOption4Sentinel1) + : 'log difference'; + + const fullPixelValuesRange = + ChangeCompareToolPixelValueRange4Sentinel1[selectedOption]; + + const initalState: ChangeCompareToolState = { + ...initialChangeCompareToolState, + }; + + return { + ...initalState, + ...changeCompareToolData, + selectedOption, + fullPixelValuesRange, + selectedRange: + changeCompareToolData?.selectedRange || fullPixelValuesRange, + }; +}; + +const getPreloadedTrendToolState = (): TrendToolState => { + // const maskToolData = getMaskToolDataFromHashParams(); + const trendToolData = getTemporalProfileToolDataFromHashParams(); + + return { + ...initialTrendToolState, + ...trendToolData, + selectedIndex: trendToolData?.selectedIndex || ('water' as RadarIndex), + }; +}; + +const getPreloadedMaskToolState = (): MaskToolState => { + const pixelValueRangeDataForSentinel1Explorer: MaskToolPixelValueRangeBySpectralIndex = + { + ...DefaultPixelValueRangeBySelectedIndex, + water: { + selectedRange: [0.25, 1], + }, + 'water anomaly': { + selectedRange: [-1, 0], + }, + ship: { + selectedRange: [0.3, 1], + }, + urban: { + selectedRange: [0.15, 1], + }, + }; + + const maskToolData = getMaskToolDataFromHashParams( + pixelValueRangeDataForSentinel1Explorer + ); + + if (!maskToolData) { + return { + ...initialMaskToolState, + pixelValueRangeBySelectedIndex: + pixelValueRangeDataForSentinel1Explorer, + }; + } + + return { + ...initialMaskToolState, + ...maskToolData, + }; +}; + +const getPreloadedSentinel1State = (): Sentinel1State => { + // const maskToolData = getMaskToolDataFromHashParams(); + const sentinel1State = getSentinel1StateFromHashParams(); + + return { + ...initialSentinel1State, + orbitDirection: sentinel1State?.orbitDirection || 'Ascending', + polarizationFilter: sentinel1State?.polarizationFilter || 'VV', + } as Sentinel1State; +}; + +export const getPreloadedState = async (): Promise => { + // get default raster function and location and pass to the getPreloadedMapState, getPreloadedUIState and getPreloadedImageryScenesState + + return { + Map: getPreloadedMapState(), + UI: getPreloadedUIState(), + ImageryScenes: getPreloadedImageryScenesState(), + TemporalCompositeTool: getPreloadedTemporalCompositeToolState(), + ChangeCompareTool: getPreloadedChangeCompareToolState(), + TrendTool: getPreloadedTrendToolState(), + MaskTool: getPreloadedMaskToolState(), + Sentinel1: getPreloadedSentinel1State(), + } as PartialRootState; +}; diff --git a/src/sentinel-1-explorer/store/index.ts b/src/sentinel-1-explorer/store/index.ts new file mode 100644 index 00000000..724a3ebe --- /dev/null +++ b/src/sentinel-1-explorer/store/index.ts @@ -0,0 +1,22 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import configureAppStore from '@shared/store/configureStore'; +import { getPreloadedState } from './getPreloadedState4Sentinel1Explorer'; + +export const getSentinel1ExplorerStore = async () => { + const preloadedState = await getPreloadedState(); + return configureAppStore(preloadedState); +}; diff --git a/src/shared/components/AnalysisToolSelector/AnalysisToolSelector.tsx b/src/shared/components/AnalysisToolSelector/AnalysisToolSelector.tsx index 85f6b799..3320fe59 100644 --- a/src/shared/components/AnalysisToolSelector/AnalysisToolSelector.tsx +++ b/src/shared/components/AnalysisToolSelector/AnalysisToolSelector.tsx @@ -17,46 +17,22 @@ import React, { FC } from 'react'; import { Button } from '../Button'; import classNames from 'classnames'; import { AnalysisTool } from '@shared/store/ImageryScene/reducer'; - -const AnalysisTools: { - tool: AnalysisTool; - title: string; - subtitle: string; -}[] = [ - { - tool: 'mask', - title: 'Index', - subtitle: 'mask', - }, - { - tool: 'trend', - title: 'Temporal', - subtitle: 'profile', - }, - { - tool: 'spectral', - title: 'Spectral', - subtitle: 'profile', - }, - { - tool: 'change', - title: 'Change', - subtitle: 'detection', - }, -]; +import { AnalyzeToolSelectorData } from './AnalysisToolSelectorContainer'; type Props = { + data: AnalyzeToolSelectorData[]; selectedTool: AnalysisTool; onChange: (tool: AnalysisTool) => void; }; export const AnalysisToolSelector: FC = ({ + data, selectedTool, onChange, }: Props) => { return ( <> - {AnalysisTools.map(({ tool, title, subtitle }) => ( + {data.map(({ tool, title, subtitle }) => (
); }; diff --git a/src/shared/components/Calendar/Calendar.tsx b/src/shared/components/Calendar/Calendar.tsx index aef29f8e..d8525988 100644 --- a/src/shared/components/Calendar/Calendar.tsx +++ b/src/shared/components/Calendar/Calendar.tsx @@ -42,17 +42,17 @@ export type FormattedImageryScene = { */ formattedAcquisitionDate: string; /** - * if true, this date should be rendered using the style of cloudy day + * name of the satellite (e.g., `Landsat-7`) */ - isCloudy: boolean; + satellite: string; /** - * percent of cloud coverage of the selected Imagery Scene acquired on this day + * Flag indicating if the imagery scene does not meet all user-selected criteria */ - cloudCover: number; + doesNotMeetCriteria: boolean; /** - * name of the satellite (e.g., `Landsat-7`) + * custom text to be displayed in the calendar component */ - satellite: string; + customTooltipText?: string[]; }; type CalendarProps = { @@ -104,6 +104,7 @@ const MonthGrid: FC = ({ days, selectedAcquisitionDate, availableScenes, + // shouldHideCloudCoverInfo, onSelect, }: MonthGridProps) => { const dataOfImagerySceneByAcquisitionDate = useMemo(() => { @@ -165,15 +166,15 @@ const MonthGrid: FC = ({ 'border-custom-calendar-border-available': isSelected === false && hasAvailableData && - dataOfImageryScene?.isCloudy === true, + dataOfImageryScene?.doesNotMeetCriteria === true, 'bg-custom-calendar-background-available': isSelected === false && hasAvailableData && - dataOfImageryScene?.isCloudy === false, + dataOfImageryScene?.doesNotMeetCriteria === false, 'border-custom-calendar-background-available': isSelected === false && hasAvailableData && - dataOfImageryScene?.isCloudy === false, + dataOfImageryScene?.doesNotMeetCriteria === false, })} style={{ // why do not use drop-shadow? It seems the drop shadow get applied to child elements, @@ -216,7 +217,17 @@ const MonthGrid: FC = ({ )}
- {dataOfImageryScene.cloudCover}% Cloudy + {dataOfImageryScene?.customTooltipText + ? dataOfImageryScene?.customTooltipText.map( + (text) => { + return ( +
+ {text} +
+ ); + } + ) + : null}
)}
@@ -245,6 +256,7 @@ const Calendar: FC = ({ dateRange, selectedAcquisitionDate, availableScenes, + // shouldHideCloudCoverInfo, onSelect, }: CalendarProps) => { const { startDate, endDate } = dateRange; @@ -272,6 +284,7 @@ const Calendar: FC = ({ days={getNumberOfDays(year, month)} selectedAcquisitionDate={selectedAcquisitionDate} availableScenes={availableScenes} + // shouldHideCloudCoverInfo={shouldHideCloudCoverInfo} onSelect={onSelect} /> ); @@ -290,6 +303,7 @@ const Calendar: FC = ({ days={getNumberOfDays(startYear, month)} selectedAcquisitionDate={selectedAcquisitionDate} availableScenes={availableScenes} + // shouldHideCloudCoverInfo={shouldHideCloudCoverInfo} onSelect={onSelect} /> ); @@ -305,6 +319,7 @@ const Calendar: FC = ({ days={getNumberOfDays(endYear, month)} selectedAcquisitionDate={selectedAcquisitionDate} availableScenes={availableScenes} + // shouldHideCloudCoverInfo={shouldHideCloudCoverInfo} onSelect={onSelect} /> ); diff --git a/src/shared/components/Calendar/CalendarContainer.tsx b/src/shared/components/Calendar/CalendarContainer.tsx index 9931a0ec..f7af52cb 100644 --- a/src/shared/components/Calendar/CalendarContainer.tsx +++ b/src/shared/components/Calendar/CalendarContainer.tsx @@ -34,15 +34,18 @@ import { // updateCloudCover, } from '@shared/store/ImageryScene/thunks'; import classNames from 'classnames'; -import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; -import { CloudFilter } from '@shared/components/CloudFilter'; -import { - // acquisitionYearChanged, - cloudCoverChanged, -} from '@shared/store/ImageryScene/reducer'; +// import { +// // selectIsAnimationPlaying, +// selectShouldHideCloudCoverInfo, +// } from '@shared/store/UI/selectors'; +// import { CloudFilter } from '@shared/components/CloudFilter'; +// import { +// // acquisitionYearChanged, +// cloudCoverChanged, +// } from '@shared/store/ImageryScene/reducer'; // import { LandsatMissionFilter } from '../LandsatMissionFilter'; -import { APP_NAME } from '@shared/config'; -import { useFindSelectedSceneByDate } from './useFindSelectedSceneByDate'; +// import { APP_NAME } from '@shared/config'; +// import { useFindSelectedSceneByDate } from './useFindSelectedSceneByDate'; import { useAcquisitionDateFromSelectedScene } from './useAcquisitionDateFromSelectedScene'; import { useFormattedScenes } from './useFormattedScenes'; import { useShouldDisableCalendar } from './useShouldDisableCalendar'; @@ -52,6 +55,7 @@ import { } from '@shared/utils/date-time/getTimeRange'; import { useAcquisitionYear } from './useAcquisitionYear'; import { batch } from 'react-redux'; +import { useFindSelectedSceneByDate } from '@shared/hooks/useFindSelectedSceneByDate'; // import { useUpdateAcquisitionYear } from './useUpdateAcquisitionYear'; type Props = { @@ -63,13 +67,13 @@ const CalendarContainer: FC = ({ children }: Props) => { const queryParams = useSelector(selectQueryParams4SceneInSelectedMode); - const isAnimationPlaying = useSelector(selectIsAnimationPlaying); + // const isAnimationPlaying = useSelector(selectIsAnimationPlaying); const acquisitionDate = queryParams?.acquisitionDate; const acquisitionDateRange = queryParams?.acquisitionDateRange; - const cloudCoverThreshold = useSelector(selectCloudCover); + // const cloudCoverThreshold = useSelector(selectCloudCover); const acquisitionYear = useAcquisitionYear(); @@ -116,9 +120,9 @@ const CalendarContainer: FC = ({ children }: Props) => { // */ // useUpdateAcquisitionYear(); - const shouldShowCloudFilter = useMemo(() => { - return APP_NAME === 'landsat' || APP_NAME === 'landsat-surface-temp'; - }, []); + // const shouldShowCloudFilter = useMemo(() => { + // return APP_NAME === 'landsat' || APP_NAME === 'landsat-surface-temp'; + // }, []); return (
= ({ children }: Props) => { {/* {APP_NAME === 'landsat' && } */} {children} - - {shouldShowCloudFilter && ( - { - dispatch(cloudCoverChanged(newValue)); - }} - /> - )}
= ({ children }: Props) => { dateRange={acquisitionDateRange || getDateRangeForPast12Month()} selectedAcquisitionDate={selectedAcquisitionDate} availableScenes={formattedScenes} + // shouldHideCloudCoverInfo={shouldHideCloudCoverInfo} onSelect={(formattedAcquisitionDate) => { // console.log(formattedAcquisitionDate) diff --git a/src/shared/components/Calendar/useAvailableScenes.tsx b/src/shared/components/Calendar/useAvailableScenes.tsx deleted file mode 100644 index e1e929ba..00000000 --- a/src/shared/components/Calendar/useAvailableScenes.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// import React, { useEffect } from 'react'; -// import { useSelector } from 'react-redux'; -// import { selectMapCenter } from '@shared/store/Map/selectors'; -// import { useDispatch } from 'react-redux'; -// // import { updateObjectIdOfSelectedScene } from '@shared/store/ImageryScene/thunks'; -// import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; -// import { selectLandsatMissionsToBeExcluded } from '@shared/store/Landsat/selectors'; -// import { queryAvailableScenes } from '@shared/store/Landsat/thunks'; -// import { selectAcquisitionYear } from '@shared/store/ImageryScene/selectors'; - -// /** -// * This custom hook queries the landsat service and find landsat scenes -// * that were acquired within the selected year and intersect with the center of the map screen -// * @returns -// */ -// export const useQueryAvailableLandsatScenes = (): void => { -// const dispatch = useDispatch(); - -// const acquisitionYear = useSelector(selectAcquisitionYear); - -// const isAnimationPlaying = useSelector(selectIsAnimationPlaying); - -// const missionsToBeExcluded = useSelector(selectLandsatMissionsToBeExcluded); - -// /** -// * current map center -// */ -// const center = useSelector(selectMapCenter); - -// useEffect(() => { -// if (!center || !acquisitionYear) { -// return; -// } - -// if (isAnimationPlaying) { -// return; -// } - -// dispatch(queryAvailableScenes(acquisitionYear)); -// }, [center, acquisitionYear, isAnimationPlaying, missionsToBeExcluded]); - -// return null; -// }; diff --git a/src/shared/components/Calendar/useFormattedScenes.tsx b/src/shared/components/Calendar/useFormattedScenes.tsx index 7c7e7037..e643786b 100644 --- a/src/shared/components/Calendar/useFormattedScenes.tsx +++ b/src/shared/components/Calendar/useFormattedScenes.tsx @@ -53,15 +53,21 @@ export const useFormattedScenes = (): FormattedImageryScene[] => { // isCloudy, cloudCover, satellite, + doesNotMeetCriteria, + customTooltipText, } = scene; + const doestNotMeetCloudTreshold = cloudCover > cloudCoverThreshold; + return { formattedAcquisitionDate, acquisitionDate, - isCloudy: cloudCover > cloudCoverThreshold, - cloudCover: Math.ceil(cloudCover * 100), + // isCloudy: cloudCover > cloudCoverThreshold, + doesNotMeetCriteria: + doesNotMeetCriteria || doestNotMeetCloudTreshold, satellite, - }; + customTooltipText, + } as FormattedImageryScene; }); }, [isAnimationPlaying, availableScenes, cloudCoverThreshold]); diff --git a/src/shared/components/Calendar/useShouldDisableCalendar.tsx b/src/shared/components/Calendar/useShouldDisableCalendar.tsx index f2a97b19..7b4d2bac 100644 --- a/src/shared/components/Calendar/useShouldDisableCalendar.tsx +++ b/src/shared/components/Calendar/useShouldDisableCalendar.tsx @@ -23,6 +23,7 @@ import { } from '@shared/store/ImageryScene/selectors'; import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; import { selectChangeCompareLayerIsOn } from '@shared/store/ChangeCompareTool/selectors'; +import { selectIsTemporalCompositeLayerOn } from '@shared/store/TemporalCompositeTool/selectors'; /** * This custom hook returns a boolean value that indicates if the Calendar component should be disabled. @@ -39,6 +40,10 @@ export const useShouldDisableCalendar = () => { const isChangeCompareLayerOn = useSelector(selectChangeCompareLayerIsOn); + const isTemporalCompositeLayerOn = useSelector( + selectIsTemporalCompositeLayerOn + ); + const shouldBeDisabled = useMemo(() => { if (!queryParams || isAnimationPlaying) { return true; @@ -49,6 +54,11 @@ export const useShouldDisableCalendar = () => { return isChangeCompareLayerOn; } + // calendar should be disabled when user is viewing the combined result of temporal composite layer + if (mode === 'analysis' && analysisTool === 'temporal composite') { + return isTemporalCompositeLayerOn; + } + return false; }, [ queryParams, @@ -56,6 +66,7 @@ export const useShouldDisableCalendar = () => { mode, analysisTool, isChangeCompareLayerOn, + isTemporalCompositeLayerOn, ]); return shouldBeDisabled; diff --git a/src/shared/components/Calendar/useUpdateAcquisitionYear.tsx b/src/shared/components/Calendar/useUpdateAcquisitionYear.tsx deleted file mode 100644 index e06762ba..00000000 --- a/src/shared/components/Calendar/useUpdateAcquisitionYear.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// import { acquisitionYearChanged } from '@shared/store/ImageryScene/reducer'; -// import { -// selectAppMode, -// selectQueryParams4SceneInSelectedMode, -// } from '@shared/store/ImageryScene/selectors'; -// import { getYearFromFormattedDateString } from '@shared/utils/date-time/formatDateString'; -// import { getCurrentYear } from '@shared/utils/date-time/getCurrentDateTime'; -// import React, { useEffect } from 'react'; -// import { useDispatch } from 'react-redux'; -// import { useSelector } from 'react-redux'; - -// /** -// * This custom hook is triggered whenever the user-selected acquisition date changes. -// * It updates the user-selected year based on the year from the selected acquisition date. -// * In animation mode, the newly added frame utilizes the year inherited from the previous animation frame, if available. -// * -// * Why is this necessary? For instance, in Swipe Mode, when the user has two scenes selected— -// * one acquired in 1990 and another in 2020, switching between these scenes should update the calendar -// * to display scenes available from the year of the selected scene. Without invoking this hook, -// * the acquisition year won't update when users switch scenes. The same logic applies to animation mode. -// * In other words, the calendar should always reflect available scenes from the acquisition year based on the user-selected scenes. -// */ -// export const useUpdateAcquisitionYear = (): void => { -// const dispatch = useDispatch(); - -// const mode = useSelector(selectAppMode); - -// const queryParams = useSelector(selectQueryParams4SceneInSelectedMode); - -// const acquisitionDate = queryParams?.acquisitionDate; - -// useEffect(() => { -// let year = getCurrentYear(); - -// // If acquisition date exists, use the year from the date -// if (acquisitionDate) { -// // try to use the year from acquisition date first -// year = getYearFromFormattedDateString(acquisitionDate); -// } else if ( -// mode === 'animate' && -// queryParams?.inheritedAcquisitionYear -// ) { -// // In animation mode, use the inherited acquisition year from the previous frame -// year = queryParams.inheritedAcquisitionYear; -// } - -// dispatch(acquisitionYearChanged(year)); -// }, [acquisitionDate]); -// }; diff --git a/src/shared/components/ChangeCompareLayer/index.ts b/src/shared/components/ChangeCompareLayer/index.ts new file mode 100644 index 00000000..32a2046a --- /dev/null +++ b/src/shared/components/ChangeCompareLayer/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { useChangeCompareLayerVisibility } from './useChangeCompareLayerVisibility'; diff --git a/src/shared/components/ChangeCompareLayer/useChangeCompareLayerVisibility.tsx b/src/shared/components/ChangeCompareLayer/useChangeCompareLayerVisibility.tsx new file mode 100644 index 00000000..aaa39729 --- /dev/null +++ b/src/shared/components/ChangeCompareLayer/useChangeCompareLayerVisibility.tsx @@ -0,0 +1,68 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectQueryParams4MainScene, + selectQueryParams4SecondaryScene, +} from '@shared/store/ImageryScene/selectors'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { + selectChangeCompareLayerIsOn, + selectSelectedOption4ChangeCompareTool, + selectUserSelectedRangeInChangeCompareTool, +} from '@shared/store/ChangeCompareTool/selectors'; + +export const useChangeCompareLayerVisibility = () => { + const mode = useSelector(selectAppMode); + + const changeCompareLayerIsOn = useSelector(selectChangeCompareLayerIsOn); + + const queryParams4SceneA = useSelector(selectQueryParams4MainScene); + + const queryParams4SceneB = useSelector(selectQueryParams4SecondaryScene); + + const anailysisTool = useSelector(selectActiveAnalysisTool); + + const isVisible = useMemo(() => { + if (mode !== 'analysis') { + return false; + } + + if (anailysisTool !== 'change') { + return false; + } + + if ( + !queryParams4SceneA?.objectIdOfSelectedScene || + !queryParams4SceneB?.objectIdOfSelectedScene + ) { + return false; + } + + return changeCompareLayerIsOn; + }, [ + mode, + anailysisTool, + changeCompareLayerIsOn, + queryParams4SceneA, + queryParams4SceneB, + ]); + + return isVisible; +}; diff --git a/src/shared/components/ChangeCompareTool/ChangeCompareToolControls.tsx b/src/shared/components/ChangeCompareTool/ChangeCompareToolControls.tsx new file mode 100644 index 00000000..026f0a97 --- /dev/null +++ b/src/shared/components/ChangeCompareTool/ChangeCompareToolControls.tsx @@ -0,0 +1,192 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +import { PixelRangeSlider } from '@shared/components/PixelRangeSlider'; +import { + selectedRangeUpdated, + selectedOption4ChangeCompareToolChanged, +} from '@shared/store/ChangeCompareTool/reducer'; +import { + selectChangeCompareLayerIsOn, + selectFullPixelValuesRangeInChangeCompareTool, + selectSelectedOption4ChangeCompareTool, + selectUserSelectedRangeInChangeCompareTool, +} from '@shared/store/ChangeCompareTool/selectors'; +import { selectActiveAnalysisTool } from '@shared/store/ImageryScene/selectors'; +import { SpectralIndex } from '@typing/imagery-service'; +import classNames from 'classnames'; +import React, { FC } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { getChangeCompareLayerColorrampAsCSSGradient } from './helpers'; +import { TotalVisibleAreaInfo } from '../TotalAreaInfo/TotalAreaInfo'; + +type Props = { + /** + * the label text to be placed at the bottom of the pixel range selector. e.g. `['decrease', 'no change', 'increase']` + */ + legendLabelText?: string[]; + /** + * The selected parameter for comparison, such as 'Water Index' or 'Log Difference'. + * It will be displayed along with the Estimated area info. + */ + comparisonTopic?: string; + /** + * The preselection dialogue text message will be displayed when the change layer is not turned on. + * This text provide user instruction about how to use this tool + */ + preselectionText?: string; +}; + +export const ChangeCompareToolControls: FC = ({ + legendLabelText = [], + comparisonTopic, + preselectionText, +}: Props) => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const selectedRange = useSelector( + selectUserSelectedRangeInChangeCompareTool + ); + + const fullPixelValueRange = useSelector( + selectFullPixelValuesRangeInChangeCompareTool + ); + + const isChangeLayerOn = useSelector(selectChangeCompareLayerIsOn); + + const getPixelRangeSlider = () => { + const [min, max] = fullPixelValueRange; + + // const fullRange = Math.abs(max - min); + + // const countOfTicks = fullRange / 0.25 + 1; + + // const oneFourthOfFullRange = fullRange / 4; + + // const tickLabels: number[] = [ + // min, + // min + oneFourthOfFullRange, + // min + oneFourthOfFullRange * 2, + // min + oneFourthOfFullRange * 3, + // max, + // ]; + + // console.log(min, max, fullRange, countOfTicks, tickLabels); + + return ( + { + dispatch(selectedRangeUpdated(vals)); + }} + min={min} + max={max} + steps={0.01} + showSliderTooltip={true} + /> + ); + }; + + const getLegendLabelText = () => { + if (!legendLabelText?.length) { + return null; + } + + let content: JSX.Element = null; + + if (legendLabelText.length === 2) { + content = ( +
+
+ {legendLabelText[0] || 'decrease'} +
+ +
+ {legendLabelText[1] || 'increase'} +
+
+ ); + } + + if (legendLabelText.length === 3) { + content = ( +
+
+ {legendLabelText[0] || 'decrease'} +
+
+ {legendLabelText[1] || 'no change'} +
+
+ {legendLabelText[2] || 'increase'} +
+
+ ); + } + + if (!content) { + return null; + } + + return
{content}
; + }; + + if (tool !== 'change') { + return null; + } + + if (!isChangeLayerOn) { + return ( +
+

+ {preselectionText || + 'Select two scenes, SCENE A and SCENE B, and then click VIEW CHANGE.'} +

+
+ ); + } + + return ( +
+
+ {/* {legendTitle} */} + +
+ +
+
+
+ + {getPixelRangeSlider()} + {getLegendLabelText()} +
+ ); +}; diff --git a/src/shared/components/ChangeCompareTool/ChangeCompareToolHeader.tsx b/src/shared/components/ChangeCompareTool/ChangeCompareToolHeader.tsx new file mode 100644 index 00000000..ab012a6e --- /dev/null +++ b/src/shared/components/ChangeCompareTool/ChangeCompareToolHeader.tsx @@ -0,0 +1,80 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +import { PixelRangeSlider } from '@shared/components/PixelRangeSlider'; +import { + selectedRangeUpdated, + selectedOption4ChangeCompareToolChanged, +} from '@shared/store/ChangeCompareTool/reducer'; +import { + selectChangeCompareLayerIsOn, + selectSelectedOption4ChangeCompareTool, + selectUserSelectedRangeInChangeCompareTool, +} from '@shared/store/ChangeCompareTool/selectors'; +import { selectActiveAnalysisTool } from '@shared/store/ImageryScene/selectors'; +import { SpectralIndex } from '@typing/imagery-service'; +import classNames from 'classnames'; +import React, { FC } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; + +type Props = { + /** + * list of options that will be available in the dropdown menu + */ + options: { + value: SpectralIndex | string; + label: string; + }[]; + /** + * tooltip text for the info icon of the Change Compare tool + */ + tooltipText?: string; +}; + +export const ChangeCompareToolHeader: FC = ({ + options, + tooltipText, +}: Props) => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const selectedOption = useSelector(selectSelectedOption4ChangeCompareTool); + + if (tool !== 'change') { + return null; + } + + return ( + { + dispatch( + selectedOption4ChangeCompareToolChanged( + val as SpectralIndex + ) + ); + }} + /> + ); +}; diff --git a/src/landsat-explorer/components/ChangeLayer/helpers.ts b/src/shared/components/ChangeCompareTool/helpers.ts similarity index 58% rename from src/landsat-explorer/components/ChangeLayer/helpers.ts rename to src/shared/components/ChangeCompareTool/helpers.ts index 162e9d2a..93623b6f 100644 --- a/src/landsat-explorer/components/ChangeLayer/helpers.ts +++ b/src/shared/components/ChangeCompareTool/helpers.ts @@ -13,6 +13,8 @@ * limitations under the License. */ +import { QueryParams4ImageryScene } from '@shared/store/ImageryScene/reducer'; +import { formattedDateString2Unixtimestamp } from '@shared/utils/date-time/formatDateString'; import { hexToRgb } from '@shared/utils/snippets/hex2rgb'; // const ColorRamps = ['#511C02', '#A93A03', '#FFE599', '#0084A8', '#004053']; @@ -58,12 +60,23 @@ export const getChangeCompareLayerColorrampAsCSSGradient = () => { * Get the RGB color corresponding to a given value within a predefined pixel value range. * * @param value - The numeric value to map to a color within the range. + * @param pixelValueRange - the full pixel value range. * @returns An array representing the RGB color value as three integers in the range [0, 255]. */ -export const getPixelColor = (value: number): number[] => { - // the minimum and maximum values for the pixel value range. - const MIN = -2; - const MAX = 2; +export const getPixelColor4ChangeCompareLayer = ( + value: number, + pixelValueRange: number[] +): number[] => { + // the minimum and maximum values for the full pixel value range. + const [MIN, MAX] = pixelValueRange; + + if (value <= MIN) { + return ColorRampsInRGB[0]; + } + + if (value >= MAX) { + return ColorRampsInRGB[ColorRampsInRGB.length - 1]; + } const RANGE = MAX - MIN; @@ -72,5 +85,34 @@ export const getPixelColor = (value: number): number[] => { const index = Math.floor((value / RANGE) * (ColorRampsInRGB.length - 1)); + // console.log(value, pixelValueRange) + return ColorRampsInRGB[index]; }; + +/** + * Sort query parameters by acquisition date in ascending order. + * @param sceneA + * @param sceneB + * @returns + */ +export const sortQueryParams4ImagerySceneByAcquisitionDate = ( + sceneA: QueryParams4ImageryScene, + sceneB: QueryParams4ImageryScene +): QueryParams4ImageryScene[] => { + // Sort query parameters by acquisition date in ascending order. + const [ + queryParams4SceneAcquiredInEarlierDate, + queryParams4SceneAcquiredInLaterDate, + ] = [sceneA, sceneB].sort((a, b) => { + return ( + formattedDateString2Unixtimestamp(a.acquisitionDate) - + formattedDateString2Unixtimestamp(b.acquisitionDate) + ); + }); + + return [ + queryParams4SceneAcquiredInEarlierDate, + queryParams4SceneAcquiredInLaterDate, + ]; +}; diff --git a/src/shared/components/ChangeCompareTool/index.ts b/src/shared/components/ChangeCompareTool/index.ts new file mode 100644 index 00000000..3c32e48e --- /dev/null +++ b/src/shared/components/ChangeCompareTool/index.ts @@ -0,0 +1,17 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ChangeCompareToolControls } from './ChangeCompareToolControls'; +export { ChangeCompareToolHeader } from './ChangeCompareToolHeader'; diff --git a/src/shared/components/CloseButton/CloseButton.css b/src/shared/components/CloseButton/CloseButton.css index c8516e47..0e624248 100644 --- a/src/shared/components/CloseButton/CloseButton.css +++ b/src/shared/components/CloseButton/CloseButton.css @@ -1,5 +1,5 @@ .close-button::before { - @apply absolute top-0 right-0 w-52 h-52 pointer-events-none z-0; + @apply fixed top-0 right-0 w-52 h-52 pointer-events-none z-0; content: ' '; background: linear-gradient(215deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 40%); } \ No newline at end of file diff --git a/src/shared/components/CloseButton/CloseButton.tsx b/src/shared/components/CloseButton/CloseButton.tsx index f1d4d690..0758687b 100644 --- a/src/shared/components/CloseButton/CloseButton.tsx +++ b/src/shared/components/CloseButton/CloseButton.tsx @@ -29,7 +29,7 @@ export const CloseButton: FC = ({ onClick }: Props) => { viewBox="0 0 32 32" height="64" width="64" - className="absolute top-1 right-1 cursor-pointer with-drop-shadow" + className="fixed top-1 right-1 cursor-pointer with-drop-shadow" onClick={onClick} > { + const dispatch = useDispatch(); + + const cloudCoverThreshold = useSelector(selectCloudCover); + + const isAnimationPlaying = useSelector(selectIsAnimationPlaying); + + return ( + { + dispatch(cloudCoverChanged(newValue)); + }} + /> + ); +}; diff --git a/src/shared/components/CloudFilter/index.ts b/src/shared/components/CloudFilter/index.ts index 06a4ba10..ea4aef2d 100644 --- a/src/shared/components/CloudFilter/index.ts +++ b/src/shared/components/CloudFilter/index.ts @@ -13,4 +13,4 @@ * limitations under the License. */ -export { CloudFilter } from './CloudFilter'; +export { CloudFilterContainer as CloudFilter } from './CloudFilterContainer'; diff --git a/src/shared/components/CustomMapArrtribution/CustomMapArrtribution.tsx b/src/shared/components/CustomMapArrtribution/CustomMapArrtribution.tsx index 19f83bfa..c56bc932 100644 --- a/src/shared/components/CustomMapArrtribution/CustomMapArrtribution.tsx +++ b/src/shared/components/CustomMapArrtribution/CustomMapArrtribution.tsx @@ -13,8 +13,15 @@ * limitations under the License. */ +import { useSelector } from 'react-redux'; import './style.css'; -import React, { FC } from 'react'; +import React, { FC, useEffect, useState } from 'react'; +import { + selectMapResolution, + selectMapScale, +} from '@shared/store/Map/selectors'; +import { numberWithCommas } from 'helper-toolkit-ts'; +import classNames from 'classnames'; type Props = { /** @@ -30,24 +37,55 @@ type Props = { * @returns */ const CustomMapArrtribution: FC = ({ atrribution }) => { - const toggleEsriAttribution = () => { + const mapScale = useSelector(selectMapScale); + const resolution = useSelector(selectMapResolution); + + const [shouldShowEsriAttribution, setShouldShowEsriAttribution] = + useState(false); + + // const toggleEsriAttribution = () => { + // const element = document.querySelector('.esri-attribution'); + // element.classList.toggle('show'); + // }; + + useEffect(() => { const element = document.querySelector('.esri-attribution'); - element.classList.toggle('show'); - }; + element.classList.toggle('show', shouldShowEsriAttribution); + }, [shouldShowEsriAttribution]); return (
-
- - Powered by Esri - {' | '} - {atrribution} - +
+
+ + Powered by Esri + {' | '} + {atrribution} + +
+ + {mapScale !== null && resolution !== null && ( +
+
+ + 1:{numberWithCommas(+mapScale.toFixed(0))} | 1px:{' '} + {numberWithCommas(+resolution.toFixed(0))}m + +
+
+ )}
); }; diff --git a/src/shared/components/CustomMapArrtribution/style.css b/src/shared/components/CustomMapArrtribution/style.css index a65925d5..bc62f665 100644 --- a/src/shared/components/CustomMapArrtribution/style.css +++ b/src/shared/components/CustomMapArrtribution/style.css @@ -1,12 +1,13 @@ .custom-attribution-text { + position: relative; font-size: 12px; line-height: 16px; padding: 0 5px; background-color: rgba(36,36,36,.8); - display: flex; + /* display: flex; flex-flow: row nowrap; justify-content: space-between; - align-items: center; + align-items: center; */ color: rgba(255,255,255,.7); } diff --git a/src/shared/components/DocPanel/DocPanel.tsx b/src/shared/components/DocPanel/DocPanel.tsx new file mode 100644 index 00000000..dff1b07c --- /dev/null +++ b/src/shared/components/DocPanel/DocPanel.tsx @@ -0,0 +1,59 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + selectIsAnimationPlaying, + selectShouldShowDocPanel, +} from '@shared/store/UI/selectors'; +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; +import { CloseButton } from '../CloseButton'; +import { showDocPanelToggled } from '@shared/store/UI/reducer'; + +type Props = { + children?: React.ReactNode; +}; + +export const DocPanel: FC = ({ children }) => { + const dispatch = useDispatch(); + + const show = useSelector(selectShouldShowDocPanel); + + const isAnimationPlaying = useSelector(selectIsAnimationPlaying); + + if (!show || isAnimationPlaying) { + return null; + } + + return ( +
+ { + dispatch(showDocPanelToggled()); + }} + /> + +
+ {children} +
+
+ ); +}; diff --git a/src/shared/components/DynamicModeInfo/DynamicModeInfo.tsx b/src/shared/components/DynamicModeInfo/DynamicModeInfo.tsx index 768daffe..58abadb4 100644 --- a/src/shared/components/DynamicModeInfo/DynamicModeInfo.tsx +++ b/src/shared/components/DynamicModeInfo/DynamicModeInfo.tsx @@ -13,18 +13,43 @@ * limitations under the License. */ -import { APP_NAME } from '@shared/config'; -import React from 'react'; -import LandsatInfo from './LandsatInfo'; +// import { APP_NAME } from '@shared/config'; +import React, { FC } from 'react'; +import { IS_MOBILE_DEVICE } from '@shared/constants/UI'; +import { modeChanged } from '@shared/store/ImageryScene/reducer'; +import { useDispatch } from 'react-redux'; + +type Props = { + content: string; +}; + +export const DynamicModeInfo: FC = ({ content }) => { + const dispatch = useDispatch(); + + const openFindASceneMode = () => { + dispatch(modeChanged('find a scene')); + }; -export const DynamicModeInfo = () => { return (
Dynamic View
- {APP_NAME === 'landsat' && } +

{content}

+ + {IS_MOBILE_DEVICE === false ? ( +

+ To select an individual scene for a specific date, try the{' '} + + FIND A SCENE + {' '} + mode. +

+ ) : null}
); }; diff --git a/src/shared/components/DynamicModeInfo/LandsatInfo.tsx b/src/shared/components/DynamicModeInfo/LandsatInfo.tsx deleted file mode 100644 index b37f2d0d..00000000 --- a/src/shared/components/DynamicModeInfo/LandsatInfo.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { IS_MOBILE_DEVICE } from '@shared/constants/UI'; -import { modeChanged } from '@shared/store/ImageryScene/reducer'; -import React from 'react'; -import { useDispatch } from 'react-redux'; - -const LandsatInfo = () => { - const dispatch = useDispatch(); - - const openFindASceneMode = () => { - dispatch(modeChanged('find a scene')); - }; - return ( - <> -

- In the current map display, the most recent and most cloud free - scenes from the Landsat archive are prioritized and dynamically - fused into a single mosaicked image layer. As you explore, the - map continues to dynamically fetch and render the best available - scenes. -

- - {IS_MOBILE_DEVICE === false ? ( -

- To select a scene for a specific date, try the{' '} - - FIND A SCENE - {' '} - mode. -

- ) : null} - - ); -}; - -export default LandsatInfo; diff --git a/src/shared/components/GirdCard/GirdCard.tsx b/src/shared/components/GirdCard/GirdCard.tsx index c03591e3..bffc667c 100644 --- a/src/shared/components/GirdCard/GirdCard.tsx +++ b/src/shared/components/GirdCard/GirdCard.tsx @@ -53,7 +53,12 @@ export const GirdCard: FC = ({ }) => { return (
= ({ className={classNames('absolute top-0 left-0 w-full h-full', { 'border-2': selected, 'border-custom-light-blue': selected, - 'drop-shadow-custom-light-blue': selected, + // 'drop-shadow-custom-light-blue': selected, })} style={{ background: `linear-gradient(0deg, rgba(2,28,36,1) 0%, rgba(2,28,36,0.6) 30%, rgba(2,28,36,0) 50%, rgba(2,28,36,0) 100%)`, diff --git a/src/shared/components/ImageryLayer/ImageryLayerByObjectID.tsx b/src/shared/components/ImageryLayer/ImageryLayerByObjectID.tsx new file mode 100644 index 00000000..d7b0aa50 --- /dev/null +++ b/src/shared/components/ImageryLayer/ImageryLayerByObjectID.tsx @@ -0,0 +1,124 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import React, { FC, useEffect } from 'react'; +import { useImageryLayerByObjectId } from './useImageLayer'; +import { useSelector } from 'react-redux'; +import { + selectQueryParams4SceneInSelectedMode, + selectAppMode, + selectActiveAnalysisTool, +} from '@shared/store/ImageryScene/selectors'; +import { selectAnimationStatus } from '@shared/store/UI/selectors'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { selectChangeCompareLayerIsOn } from '@shared/store/ChangeCompareTool/selectors'; +import { selectIsTemporalCompositeLayerOn } from '@shared/store/TemporalCompositeTool/selectors'; +import MosaicRule from '@arcgis/core/layers/support/MosaicRule'; + +type Props = { + serviceUrl: string; + mapView?: MapView; + groupLayer?: GroupLayer; + /** + * the mosaic rule that will be used for the imagery layer in Dynamic mode + */ + defaultMosaicRule: MosaicRule; +}; + +const ImageryLayerByObjectID: FC = ({ + serviceUrl, + mapView, + groupLayer, + defaultMosaicRule, +}: Props) => { + const mode = useSelector(selectAppMode); + + const { rasterFunctionName, objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + const animationStatus = useSelector(selectAnimationStatus); + + const analysisTool = useSelector(selectActiveAnalysisTool); + + const changeCompareLayerIsOn = useSelector(selectChangeCompareLayerIsOn); + + const isTemporalCompositeLayerOn = useSelector( + selectIsTemporalCompositeLayerOn + ); + + const getVisibility = () => { + if (mode === 'dynamic') { + return true; + } + + if (mode === 'find a scene' || mode === 'spectral sampling') { + return objectIdOfSelectedScene !== null; + } + + if (mode === 'analysis') { + // no need to show imagery layer when user is viewing change layer in the change compare tool + if (analysisTool === 'change' && changeCompareLayerIsOn) { + return false; + } + + // no need to show imagery layer when user is using the 'temporal composite' tool + if (analysisTool === 'temporal composite') { + return false; + } + + return objectIdOfSelectedScene !== null; + } + + // when in animate mode, only need to show landsat layer if animation is not playing + if ( + mode === 'animate' && + objectIdOfSelectedScene && + animationStatus === null + ) { + return true; + } + + return false; + }; + + const getObjectId = () => { + // should ignore the object id of selected scene if in dynamic mode, + if (mode === 'dynamic') { + return null; + } + + return objectIdOfSelectedScene; + }; + + const layer = useImageryLayerByObjectId({ + url: serviceUrl, + visible: getVisibility(), + rasterFunction: rasterFunctionName, + objectId: getObjectId(), + defaultMosaicRule, + }); + + useEffect(() => { + if (groupLayer && layer) { + groupLayer.add(layer); + groupLayer.reorder(layer, 0); + } + }, [groupLayer, layer]); + + return null; +}; + +export default ImageryLayerByObjectID; diff --git a/src/shared/components/ImageryLayer/useImageLayer.tsx b/src/shared/components/ImageryLayer/useImageLayer.tsx new file mode 100644 index 00000000..688c2825 --- /dev/null +++ b/src/shared/components/ImageryLayer/useImageLayer.tsx @@ -0,0 +1,145 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import ImageryLayer from '@arcgis/core/layers/ImageryLayer'; +import MosaicRule from '@arcgis/core/layers/support/MosaicRule'; + +type Props = { + /** + * service url + */ + url: string; + /** + * name of selected raster function that will be used to render the imagery layer + */ + rasterFunction: string; + /** + * object id of the selected scene + */ + objectId?: number; + /** + * visibility of the imagery layer + */ + visible?: boolean; + /** + * the mosaic rule that will be used to render the imagery layer in Dynamic mode + */ + defaultMosaicRule?: MosaicRule; +}; + +/** + * Get the mosaic rule that will be used to define how the Imagery images should be mosaicked. + * @param objectId - object id of the selected Imagery scene + * @returns A Promise that resolves to an IMosaicRule object representing the mosaic rule. + * + * @see https://developers.arcgis.com/javascript/latest/api-reference/esri-layers-support-MosaicRule.html + */ +export const getLockRasterMosaicRule = (objectId: number): MosaicRule => { + if (!objectId) { + return null; + } + + // {"mosaicMethod":"esriMosaicLockRaster","where":"objectid in (2969545)","ascending":false,"lockRasterIds":[2969545]} + return new MosaicRule({ + method: 'lock-raster', + ascending: false, + where: `objectid in (${objectId})`, + lockRasterIds: [objectId], + }); +}; + +/** + * A custom React hook that returns an Imagery Layer instance . + * The hook also updates the Imagery Layer when the input parameters are changed. + * + * @returns {IImageryLayer} - The Imagery Layer. + */ +export const useImageryLayerByObjectId = ({ + url, + visible, + rasterFunction, + objectId, + defaultMosaicRule, +}: Props) => { + const layerRef = useRef(); + + const [layer, setLayer] = useState(); + + /** + * initialize imagery layer using mosaic created using the input year + */ + const init = async () => { + const mosaicRule = objectId + ? getLockRasterMosaicRule(objectId) + : defaultMosaicRule; + + layerRef.current = new ImageryLayer({ + // URL to the imagery service + url, + mosaicRule, + rasterFunction: { + functionName: rasterFunction, + }, + visible, + // blendMode: 'multiply' + }); + + setLayer(layerRef.current); + }; + + useEffect(() => { + if (!layerRef.current) { + init(); + } else { + // layerRef.current.mosaicRule = createMosaicRuleByYear( + // year, + // aquisitionMonth + // ) as any; + } + }, []); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.rasterFunction = { + functionName: rasterFunction, + } as any; + }, [rasterFunction]); + + useEffect(() => { + (async () => { + if (!layerRef.current) { + return; + } + + layerRef.current.mosaicRule = objectId + ? getLockRasterMosaicRule(objectId) + : defaultMosaicRule; + })(); + }, [objectId]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.visible = visible; + }, [visible]); + + return layer; +}; diff --git a/src/shared/components/ImageryLayerWithPixelFilter/ImageryLayerWithPixelFilter.tsx b/src/shared/components/ImageryLayerWithPixelFilter/ImageryLayerWithPixelFilter.tsx new file mode 100644 index 00000000..9a4a068b --- /dev/null +++ b/src/shared/components/ImageryLayerWithPixelFilter/ImageryLayerWithPixelFilter.tsx @@ -0,0 +1,371 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect, useRef, useState } from 'react'; +import ImageryLayer from '@arcgis/core/layers/ImageryLayer'; +import MapView from '@arcgis/core/views/MapView'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; +import PixelBlock from '@arcgis/core/layers/support/PixelBlock'; +import GroupLayer from '@arcgis/core/layers/GroupLayer'; +import { getLockRasterMosaicRule } from '../ImageryLayer/useImageLayer'; +import { BlendMode } from '@typing/argis-sdk-for-javascript'; + +type Props = { + mapView?: MapView; + groupLayer?: GroupLayer; + /** + * url of the imagery service + */ + serviceURL: string; + /** + * object id of selected imagery scene + */ + objectId?: number; + /** + * raster function for the imagery layer + */ + rasterFunction: RasterFunction; + /** + * visibility of the imagery layer + */ + visible: boolean; + /** + * user selected pixel value range + */ + selectedPixelValueRange: number[]; + /** + * user selected pixel value range for band 2. + */ + selectedPixelValueRange4Band2?: number[]; + /** + * full pixel value range + */ + fullPixelValueRange: number[]; + /** + * blend mode to be used by this layer + */ + blendMode?: BlendMode; + /** + * opacity of the layer + */ + opacity?: number; + /** + * the color to render the pixels + */ + pixelColor?: number[]; + /** + * Get the RGB color corresponding to a given value within a predefined pixel value range + * @param val - The pixel value + * @param pixelValueRange - The range of pixel values + * @returns The RGB color as an array of numbers + */ + getPixelColor?: (val: number, pixelValueRange: number[]) => number[]; + /** + * Emits when percent of visible pixels changes + * @param totalPixels total number of pixels of the mask layer + * @param visiblePixels total number of visible pixels (within the user selected pixel value range) of the mask layer + * @returns + */ + countOfPixelsOnChange?: ( + totalPixels: number, + visiblePixels: number + ) => void; +}; + +type PixelData = { + pixelBlock: PixelBlock; +}; + +export const ImageryLayerWithPixelFilter: FC = ({ + mapView, + groupLayer, + serviceURL, + objectId, + rasterFunction, + visible, + selectedPixelValueRange, + selectedPixelValueRange4Band2, + fullPixelValueRange, + blendMode, + opacity, + pixelColor, + getPixelColor, + countOfPixelsOnChange, +}) => { + const layerRef = useRef(); + + /** + * user selected pixel value range for band 1 + */ + const selectedRangeRef = useRef(); + + /** + * user selected pixel value range for band 2 + */ + const selectedRange4Band2Ref = useRef(); + + /** + * full pixel value range + */ + const fullPixelValueRangeRef = useRef(); + + const pixelColorRef = useRef(); + + /** + * initialize landsat layer using mosaic created using the input year + */ + const init = () => { + layerRef.current = new ImageryLayer({ + // URL to the imagery service + url: serviceURL, + mosaicRule: objectId ? getLockRasterMosaicRule(objectId) : null, + format: 'lerc', + rasterFunction, + visible, + pixelFilter: pixelFilterFunction, + blendMode: blendMode || null, + opacity: opacity || 1, + effect: 'drop-shadow(2px, 2px, 3px, #000)', + }); + + groupLayer.add(layerRef.current); + }; + + /** + * Pixel filter function to process and filter pixel data based on the provided ranges and colors + * @param pixelData - The pixel data to filter + */ + const pixelFilterFunction = (pixelData: PixelData) => { + // const color = colorRef.current || [255, 255, 255]; + + const { pixelBlock } = pixelData || {}; + + if (!pixelBlock) { + return; + } + + const { pixels, width, height } = pixelBlock; + // console.log(pixelBlock) + + if (!pixels) { + return; + } + + const band1 = pixels[0]; + const band2 = pixels[1]; + // console.log(band2) + + const n = pixels[0].length; + + // total number of pixels with in the selected scenes + let totalPixels = n; + // total number of visible pixels that with in the selected pixel range + let visiblePixels = n; + + let containsPixelsOutsideOfSelectedScene = true; + + // Initialize the mask if it doesn't exist, indicating all pixels are within the selected scene. + if (!pixelBlock.mask) { + pixelBlock.mask = new Uint8Array(n); + containsPixelsOutsideOfSelectedScene = false; + } + + const pr = new Uint8Array(n); + const pg = new Uint8Array(n); + const pb = new Uint8Array(n); + + const numPixels = width * height; + + const [minValSelectedPixelRange4Band1, maxValSelectedPixelRange4Band1] = + selectedRangeRef.current || [0, 0]; + // console.log(min, max) + + const [minValSelectedPixelRange4Band2, maxValSelectedPixelRange4Band2] = + selectedRange4Band2Ref.current || [undefined, undefined]; + + const [minValOfFullRange, maxValOfFulRange] = + fullPixelValueRangeRef.current || [0, 0]; + + for (let i = 0; i < numPixels; i++) { + // Adjust the pixel value to ensure it fits within the full pixel value range. + // If the pixel value is less than the minimum value of the full range, set it to the minimum value. + // If the pixel value is greater than the maximum value of the full range, set it to the maximum value. + let adjustedPixelValueFromBand1 = band1[i]; + adjustedPixelValueFromBand1 = Math.max( + adjustedPixelValueFromBand1, + minValOfFullRange + ); + adjustedPixelValueFromBand1 = Math.min( + adjustedPixelValueFromBand1, + maxValOfFulRange + ); + + // Decrease the total pixel count if the pixel is outside the selected scene. + if ( + containsPixelsOutsideOfSelectedScene && + pixelBlock.mask[i] === 0 + ) { + totalPixels--; + } + + // If the adjusted pixel value is outside the selected range, or if the original pixel value is 0, hide this pixel. + // A pixel value of 0 typically indicates it is outside the extent of the selected imagery scene. + if ( + adjustedPixelValueFromBand1 < minValSelectedPixelRange4Band1 || + adjustedPixelValueFromBand1 > maxValSelectedPixelRange4Band1 || + band1[i] === 0 + ) { + pixelBlock.mask[i] = 0; + visiblePixels--; + continue; + } + + // If band 2 exists, adjust its pixel value to ensure it fits within the full pixel value range. + let adjustedPixelValueFromBand2 = band2 ? band2[i] : undefined; + + if (adjustedPixelValueFromBand2 !== undefined) { + adjustedPixelValueFromBand2 = Math.max( + adjustedPixelValueFromBand2, + minValOfFullRange + ); + adjustedPixelValueFromBand2 = Math.min( + adjustedPixelValueFromBand2, + maxValOfFulRange + ); + } + + // If band 2 exists and a pixel range for band 2 is provided, + // filter the pixels to retain only those within the selected range for band 2. + if ( + adjustedPixelValueFromBand2 !== undefined && + minValSelectedPixelRange4Band2 !== undefined && + maxValSelectedPixelRange4Band2 !== undefined && + (adjustedPixelValueFromBand2 < minValSelectedPixelRange4Band2 || + adjustedPixelValueFromBand2 > + maxValSelectedPixelRange4Band2) + ) { + pixelBlock.mask[i] = 0; + visiblePixels--; + continue; + } + + // use the color provided by the user, + // or call the getPixelColor function to determine the color that will be used to render this pixel + const color = + pixelColorRef.current || + getPixelColor(band1[i], fullPixelValueRangeRef.current); + + // should not render this pixel if the color is undefined. + if (!color) { + pixelBlock.mask[i] = 0; + continue; + } + + pixelBlock.mask[i] = 1; + + pr[i] = color[0]; + pg[i] = color[1]; + pb[i] = color[2]; + } + + pixelBlock.pixels = [pr, pg, pb]; + + pixelBlock.pixelType = 'u8'; + + if (countOfPixelsOnChange) { + // const pctOfVisiblePixels = visiblePixels / totalPixels; + // console.log(pctVisiblePixels) + countOfPixelsOnChange(totalPixels, visiblePixels); + } + }; + + useEffect(() => { + if (groupLayer && !layerRef.current) { + init(); + } + }, [groupLayer]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.rasterFunction = rasterFunction; + }, [rasterFunction]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.mosaicRule = objectId + ? getLockRasterMosaicRule(objectId) + : null; + }, [objectId]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.opacity = opacity; + }, [opacity]); + + // useEffect(() => { + // if (!layerRef.current) { + // return; + // } + + // layerRef.current.visible = visible; + + // // if (visible) { + // // // reorder it to make sure it is the top most layer on the map + // // groupLayer.reorder(layerRef.current, mapView.map.layers.length - 1); + // // } + // }, [visible]); + + useEffect(() => { + selectedRangeRef.current = selectedPixelValueRange; + selectedRange4Band2Ref.current = selectedPixelValueRange4Band2; + fullPixelValueRangeRef.current = fullPixelValueRange; + pixelColorRef.current = pixelColor; + + if (!layerRef.current) { + return; + } + + layerRef.current.visible = visible; + + if (visible) { + layerRef.current.redraw(); + } + }, [ + selectedPixelValueRange, + selectedPixelValueRange4Band2, + fullPixelValueRange, + pixelColor, + visible, + ]); + + useEffect(() => { + if (!layerRef.current) { + return; + } + + layerRef.current.blendMode = blendMode || 'normal'; + }, [blendMode]); + + return null; +}; diff --git a/src/shared/components/ImageryLayerWithPixelFilter/index.ts b/src/shared/components/ImageryLayerWithPixelFilter/index.ts new file mode 100644 index 00000000..928411af --- /dev/null +++ b/src/shared/components/ImageryLayerWithPixelFilter/index.ts @@ -0,0 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ImageryLayerWithPixelFilter } from './ImageryLayerWithPixelFilter'; diff --git a/src/shared/components/InterestingPlaces/InterestingPlaces.tsx b/src/shared/components/InterestingPlaces/InterestingPlaces.tsx index 80a7dff4..2b680881 100644 --- a/src/shared/components/InterestingPlaces/InterestingPlaces.tsx +++ b/src/shared/components/InterestingPlaces/InterestingPlaces.tsx @@ -23,6 +23,10 @@ import { InterestingPlaceData } from '@typing/shared'; type Props = { data: InterestingPlaceData[]; nameOfSelectedPlace: string; + /** + * if true, use 3 columns grid instead of 4 columns + */ + isThreeColumnGrid?: boolean; onChange: (name: string) => void; /** * Emits when user move mouse over/out an interesting place card @@ -35,6 +39,7 @@ type Props = { export const InterestingPlaces: FC = ({ data, nameOfSelectedPlace, + isThreeColumnGrid, onChange, onHover, }) => { @@ -56,8 +61,10 @@ export const InterestingPlaces: FC = ({
{data.map((d) => { diff --git a/src/shared/components/InterestingPlaces/InterestingPlacesContainer.tsx b/src/shared/components/InterestingPlaces/InterestingPlacesContainer.tsx index e93722f6..c01a76f8 100644 --- a/src/shared/components/InterestingPlaces/InterestingPlacesContainer.tsx +++ b/src/shared/components/InterestingPlaces/InterestingPlacesContainer.tsx @@ -32,9 +32,16 @@ type Props = { * list of interesting place data */ data: InterestingPlaceData[]; + /** + * if true, use 3 columns grid instead of 4 columns + */ + isThreeColumnGrid?: boolean; }; -export const InterestingPlacesContainer: FC = ({ data }) => { +export const InterestingPlacesContainer: FC = ({ + data, + isThreeColumnGrid, +}) => { const dispatch = useDispatch(); const nameOfSelectedInterestingPlace = useSelector( @@ -74,6 +81,7 @@ export const InterestingPlacesContainer: FC = ({ data }) => { { dispatch(nameOfSelectedInterestingPlaceChanged(name)); }} diff --git a/src/shared/components/MapActionButton/index.ts b/src/shared/components/MapActionButton/index.ts index 1a47324a..284857ef 100644 --- a/src/shared/components/MapActionButton/index.ts +++ b/src/shared/components/MapActionButton/index.ts @@ -1 +1,16 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { MapActionButtonsGroup } from './MapActionButtonsGroup'; diff --git a/src/shared/components/MapPopup/MapPopup.tsx b/src/shared/components/MapPopup/MapPopup.tsx index 10a17ffb..d72e9166 100644 --- a/src/shared/components/MapPopup/MapPopup.tsx +++ b/src/shared/components/MapPopup/MapPopup.tsx @@ -39,9 +39,9 @@ export type MapPopupData = { */ title: string; /** - * html string of the popup content + * popup content */ - content: string; + content: string | HTMLDivElement; /** * point of the popup anchor location */ @@ -78,7 +78,7 @@ export const MapPopup: FC = ({ data, mapView, onOpen }: Props) => { const openPopupRef = useRef(); const closePopUp = (message: string) => { - console.log('calling closePopUp', message); + // console.log('calling closePopUp', message); mapView.closePopup(); @@ -124,8 +124,11 @@ export const MapPopup: FC = ({ data, mapView, onOpen }: Props) => { // behavior in order to display your own popup mapView.popupEnabled = false; mapView.popup.dockEnabled = false; - mapView.popup.collapseEnabled = false; + // mapView.popup.collapseEnabled = false; mapView.popup.alignment = 'bottom-right'; + mapView.popup.visibleElements = { + collapseButton: false, + }; mapView.on('click', (evt) => { openPopupRef.current(evt.mapPoint, evt.x); @@ -183,7 +186,7 @@ export const MapPopup: FC = ({ data, mapView, onOpen }: Props) => { if (error) { console.error( - 'failed to open popup for landsat scene', + 'failed to open popup for imagery scene', error.message ); diff --git a/src/shared/components/MapPopup/helper.ts b/src/shared/components/MapPopup/helper.ts index ebf2e9bf..f16d8941 100644 --- a/src/shared/components/MapPopup/helper.ts +++ b/src/shared/components/MapPopup/helper.ts @@ -1,3 +1,20 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from '@arcgis/core/geometry'; + /** * Check and see if user clicked on the left side of the swipe widget * @param swipePosition position of the swipe handler, value should be bewteen 0 - 100 @@ -19,3 +36,51 @@ export const getLoadingIndicator = () => { popupDiv.innerHTML = ``; return popupDiv; }; + +/** + * Get HTML Div Element that will be used as content of Map Popup. + * The popup location information will be added to the bottom of the this Div Element. + * @param mapPoint + * @param popupContent + * @returns + */ +export const getPopUpContentWithLocationInfo = ( + mapPoint: Point, + popupContent?: string +): HTMLDivElement => { + const lat = Math.round(mapPoint.latitude * 1000) / 1000; + const lon = Math.round(mapPoint.longitude * 1000) / 1000; + + const popupDiv = document.createElement('div'); + + popupContent = popupContent || ''; + + popupDiv.innerHTML = + popupContent + + ` + + `; + + const locationInfo = popupDiv.querySelector('.popup-location-info'); + + if (locationInfo) { + locationInfo.addEventListener('click', async () => { + // console.log('clicked on popup location info') + await navigator.clipboard.writeText( + `x: ${mapPoint.longitude.toFixed( + 5 + )} y: ${mapPoint.latitude.toFixed(5)}` + ); + }); + } + + return popupDiv; +}; diff --git a/src/shared/components/MapView/CustomMapPopupStyle.css b/src/shared/components/MapView/CustomMapPopupStyle.css index 88fbd8ec..ace67727 100644 --- a/src/shared/components/MapView/CustomMapPopupStyle.css +++ b/src/shared/components/MapView/CustomMapPopupStyle.css @@ -1,5 +1,8 @@ .esri-popup__main-container { --calcite-ui-foreground-1: var(--custom-background); + --calcite-color-foreground-1: var(--custom-background); + --calcite-color-border-3: var(--custom-light-blue-50); + --calcite-color-text-2: var(--custom-light-blue); } .esri-popup__main-container .esri-features__content-feature{ @@ -19,7 +22,7 @@ .esri-widget__heading { font-size: 12px !important; - color: var(--custom-light-blue);; + color: var(--custom-light-blue); } .esri-popup__main-container calcite-action[title="Dock"] { diff --git a/src/shared/components/MapView/EventHandlers.tsx b/src/shared/components/MapView/EventHandlers.tsx index 22a6ee76..50d1e24c 100644 --- a/src/shared/components/MapView/EventHandlers.tsx +++ b/src/shared/components/MapView/EventHandlers.tsx @@ -26,7 +26,13 @@ type Props = { * @param zoom * @returns */ - onStationary?: (center: Point, zoom: number, extent: Extent) => void; + onStationary?: ( + center: Point, + zoom: number, + extent: Extent, + resolution: number, + scale: number + ) => void; /** * fires when user clicks on the map * @param point @@ -56,7 +62,9 @@ const EventHandlers: FC = ({ onStationary( mapView.center, mapView.zoom, - mapView.extent + mapView.extent, + mapView.resolution, + mapView.scale ); } } diff --git a/src/shared/components/MapView/MapView.tsx b/src/shared/components/MapView/MapView.tsx index 51069fa1..bfc7bd56 100644 --- a/src/shared/components/MapView/MapView.tsx +++ b/src/shared/components/MapView/MapView.tsx @@ -66,9 +66,7 @@ const MapView: React.FC = ({ lods: TileInfo.create().lods, snapToZoom: false, }, - popup: { - autoOpenEnabled: false, - }, + popupEnabled: false, }); mapViewRef.current.when(() => { diff --git a/src/shared/components/MapView/MapViewContainer.tsx b/src/shared/components/MapView/MapViewContainer.tsx index 8dee9095..0a6307d0 100644 --- a/src/shared/components/MapView/MapViewContainer.tsx +++ b/src/shared/components/MapView/MapViewContainer.tsx @@ -32,7 +32,13 @@ import { import EventHandlers from './EventHandlers'; import { useDispatch } from 'react-redux'; import { batch } from 'react-redux'; -import { centerChanged, zoomChanged } from '../../store/Map/reducer'; +import { + centerChanged, + isUpdatingChanged, + resolutionUpdated, + scaleUpdated, + zoomChanged, +} from '../../store/Map/reducer'; import { saveMapCenterToHashParams } from '../../utils/url-hash-params'; import { MapLoadingIndicator } from './MapLoadingIndicator'; // import { queryLocation4TrendToolChanged } from '@shared/store/TrendTool/reducer'; @@ -112,6 +118,10 @@ const MapViewContainer: FC = ({ mapOnClick, children }) => { document.body.classList.toggle('hide-map-control', isAnimationPlaying); }, [isAnimationPlaying]); + useEffect(() => { + dispatch(isUpdatingChanged(isUpdating)); + }, [isUpdating]); + return (
= ({ mapOnClick, children }) => { {children} { + onStationary={(center, zoom, extent, resolution, scale) => { // console.log('map view is stationary', center, zoom, extent); batch(() => { @@ -134,6 +144,8 @@ const MapViewContainer: FC = ({ mapOnClick, children }) => { ]) ); dispatch(zoomChanged(zoom)); + dispatch(resolutionUpdated(resolution)); + dispatch(scaleUpdated(scale)); }); }} onClickHandler={(point) => { diff --git a/src/shared/components/MaskTool/RenderingControlsContainer.tsx b/src/shared/components/MaskTool/RenderingControlsContainer.tsx index f8a844f5..f7b5049c 100644 --- a/src/shared/components/MaskTool/RenderingControlsContainer.tsx +++ b/src/shared/components/MaskTool/RenderingControlsContainer.tsx @@ -21,7 +21,8 @@ import { } from '@shared/store/MaskTool/reducer'; import { selectMaskLayerOpcity, - selectMaskOptions, + selectMaskLayerPixelColor, + selectMaskLayerPixelValueRange, selectShouldClipMaskLayer, } from '@shared/store/MaskTool/selectors'; import { updateMaskColor } from '@shared/store/MaskTool/thunks'; @@ -31,10 +32,7 @@ import { useDispatch } from 'react-redux'; export const RenderingControlsContainer = () => { const dispatch = useDispatch(); - /** - * options for selected spectral index - */ - const maskOptions = useSelector(selectMaskOptions); + const pixelColor = useSelector(selectMaskLayerPixelColor); /** * opacity of the mask layer @@ -50,7 +48,7 @@ export const RenderingControlsContainer = () => { { dispatch(updateMaskColor(color)); }} diff --git a/src/shared/components/MaskTool/WarningMessage.tsx b/src/shared/components/MaskTool/WarningMessage.tsx new file mode 100644 index 00000000..f99b7911 --- /dev/null +++ b/src/shared/components/MaskTool/WarningMessage.tsx @@ -0,0 +1,28 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +export const WarningMessage = () => { + return ( +
+

Select a scene to calculate a mask for the selected index.

+
+ ); +}; diff --git a/src/shared/components/MaskTool/index.ts b/src/shared/components/MaskTool/index.ts index 35f8c995..4726803c 100644 --- a/src/shared/components/MaskTool/index.ts +++ b/src/shared/components/MaskTool/index.ts @@ -14,3 +14,5 @@ */ export { RenderingControlsContainer as MaskLayerRenderingControls } from './RenderingControlsContainer'; +export { WarningMessage as MaskToolWarnigMessage } from './WarningMessage'; +// export { MaskLayerVisibleAreaInfo } from './MaskLayerVisibleAreaInfo'; diff --git a/src/shared/components/RasterFunctionSelector/RasterFunctionSelector.tsx b/src/shared/components/RasterFunctionSelector/RasterFunctionSelector.tsx index ba0c6581..77c81d52 100644 --- a/src/shared/components/RasterFunctionSelector/RasterFunctionSelector.tsx +++ b/src/shared/components/RasterFunctionSelector/RasterFunctionSelector.tsx @@ -38,6 +38,10 @@ type Props = { * if true, Raster Function selector should be disabled */ disabled: boolean; + /** + * The width of header tooltip container in px. The default width is 240px and this value can be used to override that value + */ + widthOfTooltipContainer?: number; /** * Fires when user selects a new raster function * @param name name of new raster function @@ -55,6 +59,7 @@ export const RasterFunctionSelector: FC = ({ nameOfSelectedRasterFunction, rasterFunctionInfo, disabled, + widthOfTooltipContainer, onChange, itemOnHover, }) => { @@ -70,14 +75,17 @@ export const RasterFunctionSelector: FC = ({ ref={containerRef} >
- + Renderer
-
+
{rasterFunctionInfo.map((d) => { const { name, thumbnail, label } = d; diff --git a/src/shared/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx b/src/shared/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx index 1b8fc9de..deb646c3 100644 --- a/src/shared/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx +++ b/src/shared/components/RasterFunctionSelector/RasterFunctionSelectorContainer.tsx @@ -27,12 +27,17 @@ import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; import { updateTooltipData } from '@shared/store/UI/thunks'; import { RasterFunctionInfo } from '@typing/imagery-service'; import { selectChangeCompareLayerIsOn } from '@shared/store/ChangeCompareTool/selectors'; +import { selectIsTemporalCompositeLayerOn } from '@shared/store/TemporalCompositeTool/selectors'; type Props = { /** * tooltip text that will be displayed when user hovers the info icon next to the header */ headerTooltip: string; + /** + * The width of header tooltip container in px. The default width is 240px and this value can be used to override that value + */ + widthOfTooltipContainer?: number; /** * list of raster functions of the imagery service */ @@ -41,6 +46,7 @@ type Props = { export const RasterFunctionSelectorContainer: FC = ({ headerTooltip, + widthOfTooltipContainer, data, }) => { const dispatch = useDispatch(); @@ -58,6 +64,14 @@ export const RasterFunctionSelectorContainer: FC = ({ const { rasterFunctionName, objectIdOfSelectedScene } = useSelector(selectQueryParams4SceneInSelectedMode) || {}; + const shouldHide = useMemo(() => { + if (mode === 'analysis' && analysisTool === 'temporal composite') { + return true; + } + + return false; + }, [mode, analysisTool]); + const shouldDisable = () => { if (mode === 'dynamic') { return false; @@ -82,7 +96,7 @@ export const RasterFunctionSelectorContainer: FC = ({ return false; }; - if (!data || !data.length) { + if (!data || !data.length || shouldHide) { return null; } @@ -96,6 +110,7 @@ export const RasterFunctionSelectorContainer: FC = ({ rasterFunctionInfo={data} nameOfSelectedRasterFunction={rasterFunctionName} disabled={shouldDisable()} + widthOfTooltipContainer={widthOfTooltipContainer} onChange={(rasterFunctionName) => { dispatch(updateRasterFunctionName(rasterFunctionName)); }} diff --git a/src/shared/components/SceneInfoTable/SceneInfoTable.tsx b/src/shared/components/SceneInfoTable/SceneInfoTable.tsx index 7a1b8ac5..0f98a71d 100644 --- a/src/shared/components/SceneInfoTable/SceneInfoTable.tsx +++ b/src/shared/components/SceneInfoTable/SceneInfoTable.tsx @@ -13,8 +13,11 @@ * limitations under the License. */ +import { delay } from '@shared/utils/snippets/delay'; import classNames from 'classnames'; -import React, { FC } from 'react'; +import { generateUID } from 'helper-toolkit-ts'; +import React, { FC, useState } from 'react'; +import { Tooltip } from '../Tooltip'; /** * data for a single row in Scene Info Table @@ -28,21 +31,89 @@ export type SceneInfoTableData = { * value of the field */ value: string; + /** + * if true, user can click to copy this value + */ + clickToCopy?: boolean; }; type Props = { data: SceneInfoTableData[]; }; -const SceneInfoRow: FC = ({ name, value }) => { +const SceneInfoRow: FC = ({ name, value, clickToCopy }) => { + const [hasCopied2Clipboard, setHasCopied2Clipboard] = + useState(false); + + const valueOnClickHandler = async () => { + if (!clickToCopy) { + return; + } + + await navigator.clipboard.writeText(value); + + setHasCopied2Clipboard(true); + + await delay(2000); + + setHasCopied2Clipboard(false); + }; + + const getContentOfValueField = () => { + const valueField = ( + + {value} + + ); + + if (!clickToCopy) { + return valueField; + } + + const tooltipContent = ` +

${value}

+

+ ${ + hasCopied2Clipboard + ? `Copied to clipboard` + : 'Click to copy to clipboard.' + } +

+ `; + + return ( + + {valueField} + + ); + }; + return ( <> -
+
{name}
-
- {value} +
+ {getContentOfValueField()}
); @@ -71,13 +142,7 @@ export const SceneInfoTable: FC = ({ data }: Props) => { data-element="scene-info-table" // this [data-element] attribute will be used to monitor the health of the app > {data.map((d: SceneInfoTableData) => { - return ( - - ); + return ; })}
); diff --git a/src/shared/components/Slider/PixelRangeSlider.tsx b/src/shared/components/Slider/PixelRangeSlider.tsx index 7e820aba..43e4df98 100644 --- a/src/shared/components/Slider/PixelRangeSlider.tsx +++ b/src/shared/components/Slider/PixelRangeSlider.tsx @@ -19,6 +19,7 @@ import SliderWidget from '@arcgis/core/widgets/Slider'; // import './PixelRangeSlider.css'; import './Slider.css'; import classNames from 'classnames'; +import { nanoid } from 'nanoid'; type Props = { /** @@ -57,6 +58,32 @@ type Props = { valuesOnChange: (vals: number[]) => void; }; +const getTickLabels = (min: number, max: number) => { + const fullRange = Math.abs(max - min); + + const oneFourthOfFullRange = fullRange / 4; + + const tickLabels: number[] = [ + min, + min + oneFourthOfFullRange, + min + oneFourthOfFullRange * 2, + min + oneFourthOfFullRange * 3, + max, + ]; + + return tickLabels; +}; + +const getCountOfTicks = (min: number, max: number) => { + const fullRange = Math.abs(max - min); + + const countOfTicks = fullRange / 0.25 + 1; + + // console.log(fullRange, countOfTicks); + + return countOfTicks; +}; + /** * A slider component to select fixel range of the mask layer for selected Imagery scene * @param param0 @@ -67,8 +94,8 @@ export const PixelRangeSlider: FC = ({ min = -1, max = 1, steps = 0.05, - countOfTicks = 0, - tickLabels = [], + countOfTicks, + tickLabels, showSliderTooltip, valuesOnChange, }) => { @@ -76,8 +103,28 @@ export const PixelRangeSlider: FC = ({ const sliderRef = useRef(); + const getTickConfigs = (min: number, max: number) => { + return [ + { + mode: 'count', + values: + countOfTicks !== undefined + ? countOfTicks + : getCountOfTicks(min, max), + }, + { + mode: 'position', + values: + tickLabels && tickLabels.length + ? tickLabels + : getTickLabels(min, max), //[-1, -0.5, 0, 0.5, 1], + labelsVisible: true, + }, + ] as __esri.TickConfig[]; + }; + const init = async () => { - sliderRef.current = new SliderWidget({ + const widget = new SliderWidget({ container: containerRef.current, min, max, @@ -88,20 +135,12 @@ export const PixelRangeSlider: FC = ({ labels: false, rangeLabels: false, }, - tickConfigs: [ - { - mode: 'count', - values: countOfTicks, - }, - { - mode: 'position', - values: tickLabels, //[-1, -0.5, 0, 0.5, 1], - labelsVisible: true, - }, - ], + tickConfigs: getTickConfigs(min, max), // layout: 'vertical', }); + sliderRef.current = widget; + sliderRef.current.on('thumb-drag', (evt) => { // const { value, index } = evt; // valOnChange(index, value); @@ -182,6 +221,20 @@ export const PixelRangeSlider: FC = ({ addTooltipTextAttribute(); }, [values]); + useEffect(() => { + if (!sliderRef.current) { + return; + } + + const tickConfigs = getTickConfigs(min, max); + + sliderRef.current.min = min; + sliderRef.current.max = max; + sliderRef.current.values = values; + sliderRef.current.steps = steps; + sliderRef.current.tickConfigs = tickConfigs; + }, [min, max, values, steps, countOfTicks, tickLabels]); + return (
= ({ mapView }: Props) => { +export const SwipeWidget4ImageryLayers: FC = ({ + serviceUrl, + mapView, +}: Props) => { const dispatch = useDispatch(); const isSwipeWidgetVisible = useSelector(selectIsSwipeModeOn); @@ -40,9 +51,8 @@ export const SwipeWidgetContainer: FC = ({ mapView }: Props) => { const queryParams4RightSide = useSelector(selectQueryParams4SecondaryScene); - // const isSwipeWidgetVisible = appMode === 'swipe'; - - const leadingLayer = useLandsatLayer({ + const leadingLayer = useImageryLayerByObjectId({ + url: serviceUrl, visible: isSwipeWidgetVisible && queryParams4LeftSide?.objectIdOfSelectedScene !== null, @@ -50,7 +60,8 @@ export const SwipeWidgetContainer: FC = ({ mapView }: Props) => { objectId: queryParams4LeftSide?.objectIdOfSelectedScene, }); - const trailingLayer = useLandsatLayer({ + const trailingLayer = useImageryLayerByObjectId({ + url: serviceUrl, visible: isSwipeWidgetVisible && queryParams4RightSide?.objectIdOfSelectedScene !== null, diff --git a/src/landsat-explorer/components/TrendTool/TrendChart.tsx b/src/shared/components/TemporalProfileChart/TemporalProfileChart.tsx similarity index 66% rename from src/landsat-explorer/components/TrendTool/TrendChart.tsx rename to src/shared/components/TemporalProfileChart/TemporalProfileChart.tsx index 098c999f..802a9740 100644 --- a/src/landsat-explorer/components/TrendTool/TrendChart.tsx +++ b/src/shared/components/TemporalProfileChart/TemporalProfileChart.tsx @@ -14,9 +14,9 @@ */ import React, { FC, useMemo } from 'react'; -import { useSelector } from 'react-redux'; +// import { useSelector } from 'react-redux'; import { LineChartBasic } from '@vannizhang/react-d3-charts'; -import { selectQueryParams4SceneInSelectedMode } from '@shared/store/ImageryScene/selectors'; +// import { selectQueryParams4SceneInSelectedMode } from '@shared/store/ImageryScene/selectors'; import { formattedDateString2Unixtimestamp, getMonthFromFormattedDateString, @@ -27,13 +27,13 @@ import { DATE_FORMAT } from '@shared/constants/UI'; import { TemporalProfileData } from '@typing/imagery-service'; import { SpectralIndex } from '@typing/imagery-service'; import { LineChartDataItem } from '@vannizhang/react-d3-charts/dist/LineChart/types'; -import { - LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS, - LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT, - LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS, - LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT, -} from '@shared/services/landsat-level-2/config'; -import { calcSpectralIndex } from '@shared/services/landsat-level-2/helpers'; +// import { +// LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS, +// LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT, +// LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS, +// LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT, +// } from '@shared/services/landsat-level-2/config'; +// import { calcSpectralIndex } from '@shared/services/landsat-level-2/helpers'; // import { selectTrendToolOption } from '@shared/store/TrendTool/selectors'; // import { getMonthAbbreviation } from '@shared/utils/date-time/getMonthName'; import { TrendToolOption } from '@shared/store/TrendTool/reducer'; @@ -43,13 +43,13 @@ import { formatInUTCTimeZone } from '@shared/utils/date-time/formatInUTCTimeZone type Props = { /** - * data that will be used to plot the trend chart + * data that will be used to plot the Line chart for the Temporal Profile Tool */ - data: TemporalProfileData[]; + chartData: LineChartDataItem[]; /** - * user selected spectral index + * custom domain for the Y-Scale of the Line chart */ - spectralIndex: SpectralIndex; + customDomain4YScale: number[]; /** * user selected trend tool option */ @@ -66,68 +66,9 @@ type Props = { onClickHandler: (index: number) => void; }; -/** - * Converts Landsat temporal profile data to chart data. - * @param temporalProfileData - Array of temporal profile data. - * @param spectralIndex - Spectral index to calculate the value for each data point. - * @param month2month - if true, user is trying to plot month to month trend line for a selected year. - * @returns An array of QuickD3ChartDataItem objects representing the chart data. - * - */ -export const convertLandsatTemporalProfileData2ChartData = ( - temporalProfileData: TemporalProfileData[], - spectralIndex: SpectralIndex, - month2month?: boolean -): LineChartDataItem[] => { - const data = temporalProfileData.map((d) => { - const { acquisitionDate, values } = d; - - // calculate the spectral index that will be used as the y value for each chart vertex - let y = calcSpectralIndex(spectralIndex, values); - - let yMin = -1; - let yMax = 1; - - // justify the y value for surface temperature index to make it not go below the hardcoded y min - if ( - spectralIndex === 'temperature farhenheit' || - spectralIndex === 'temperature celcius' - ) { - yMin = - spectralIndex === 'temperature farhenheit' - ? LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT - : LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS; - - yMax = - spectralIndex === 'temperature farhenheit' - ? LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT - : LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS; - } - - // y should not go below y min - y = Math.max(y, yMin); - - // y should not go beyond y max - y = Math.min(y, yMax); - - const tooltip = `${formatInUTCTimeZone( - acquisitionDate, - 'LLL yyyy' - )}: ${y.toFixed(2)}`; - - return { - x: month2month ? d.acquisitionMonth : d.acquisitionDate, - y, - tooltip, - }; - }); - - return data; -}; - export const TemporalProfileChart: FC = ({ - data, - spectralIndex, + chartData, + customDomain4YScale, trendToolOption, acquisitionYear, selectedAcquisitionDate, @@ -138,12 +79,6 @@ export const TemporalProfileChart: FC = ({ // const queryParams4SelectedScene = // useSelector(selectQueryParams4SceneInSelectedMode) || {}; - const chartData = convertLandsatTemporalProfileData2ChartData( - data, - spectralIndex, - trendToolOption === 'month-to-month' - ); - const customDomain4XScale = useMemo(() => { if (!chartData.length) { return null; @@ -173,38 +108,6 @@ export const TemporalProfileChart: FC = ({ return [xMin, xMax]; }, [chartData, selectedAcquisitionDate, trendToolOption]); - const customDomain4YScale = useMemo(() => { - const yValues = chartData.map((d) => d.y); - - // boundary of y axis, for spectral index, the boundary should be -1 and 1 - let yUpperLimit = 1; - let yLowerLimit = -1; - - // temperature is handled differently as we display the actual values in the chart - if (spectralIndex === 'temperature farhenheit') { - yLowerLimit = LANDSAT_SURFACE_TEMPERATURE_MIN_FAHRENHEIT; - yUpperLimit = LANDSAT_SURFACE_TEMPERATURE_MAX_FAHRENHEIT; - } - - if (spectralIndex === 'temperature celcius') { - yLowerLimit = LANDSAT_SURFACE_TEMPERATURE_MIN_CELSIUS; - yUpperLimit = LANDSAT_SURFACE_TEMPERATURE_MAX_CELSIUS; - } - - // get min and max from the data - let ymin = Math.min(...yValues); - let ymax = Math.max(...yValues); - - // get range between min and max from the data - const yRange = ymax - ymin; - - // adjust ymin and ymax to add 10% buffer to it, but also need to make sure it fits in the upper and lower limit - ymin = Math.max(yLowerLimit, ymin - yRange * 0.1); - ymax = Math.min(yUpperLimit, ymax + yRange * 0.1); - - return [ymin, ymax]; - }, [chartData]); - const trendLineData = useMemo(() => { if (!chartData || !chartData.length) { return []; diff --git a/src/landsat-explorer/components/TrendTool/TrendChartContainer.tsx b/src/shared/components/TemporalProfileChart/TemporalProfileChartContainer.tsx similarity index 78% rename from src/landsat-explorer/components/TrendTool/TrendChartContainer.tsx rename to src/shared/components/TemporalProfileChart/TemporalProfileChartContainer.tsx index af97e549..96b5654a 100644 --- a/src/landsat-explorer/components/TrendTool/TrendChartContainer.tsx +++ b/src/shared/components/TemporalProfileChart/TemporalProfileChartContainer.tsx @@ -16,15 +16,15 @@ import { selectTrendToolData, selectQueryLocation4TrendTool, - selectSpectralIndex4TrendTool, selectAcquisitionYear4TrendTool, selectTrendToolOption, selectIsLoadingData4TrendingTool, + selectError4TemporalProfileTool, } from '@shared/store/TrendTool/selectors'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useSelector } from 'react-redux'; -import { TemporalProfileChart } from './TrendChart'; +import { TemporalProfileChart } from './TemporalProfileChart'; import { updateAcquisitionDate, updateObjectIdOfSelectedScene, @@ -33,8 +33,23 @@ import { import { centerChanged } from '@shared/store/Map/reducer'; import { batch } from 'react-redux'; import { selectQueryParams4MainScene } from '@shared/store/ImageryScene/selectors'; +import { LineChartDataItem } from '@vannizhang/react-d3-charts/dist/LineChart/types'; + +type Props = { + /** + * data that will be used to plot the Line chart for the Temporal Profile Tool + */ + chartData: LineChartDataItem[]; + /** + * custom domain for the Y-Scale of the Line chart + */ + customDomain4YScale: number[]; +}; -export const TrendChartContainer = () => { +export const TemporalProfileChartContainer: FC = ({ + chartData, + customDomain4YScale, +}) => { const dispatch = useDispatch(); const queryLocation = useSelector(selectQueryLocation4TrendTool); @@ -43,14 +58,14 @@ export const TrendChartContainer = () => { const temporalProfileData = useSelector(selectTrendToolData); - const spectralIndex = useSelector(selectSpectralIndex4TrendTool); - const queryParams4MainScene = useSelector(selectQueryParams4MainScene); const trendToolOption = useSelector(selectTrendToolOption); const isLoading = useSelector(selectIsLoadingData4TrendingTool); + const error = useSelector(selectError4TemporalProfileTool); + const message = useMemo(() => { if (isLoading) { return 'fetching temporal profile data'; @@ -72,10 +87,18 @@ export const TrendChartContainer = () => { ); } + if (error) { + return ( +
+ {error} +
+ ); + } + return ( void; }; -export const TrendToolControls = ({ +export const TemporalProfileToolControls = ({ acquisitionMonth, acquisitionYear, selectedTrendOption, diff --git a/src/shared/components/TemproalProfileTool/TemporalProfileToolControlsContainer.tsx b/src/shared/components/TemproalProfileTool/TemporalProfileToolControlsContainer.tsx new file mode 100644 index 00000000..0bf01271 --- /dev/null +++ b/src/shared/components/TemproalProfileTool/TemporalProfileToolControlsContainer.tsx @@ -0,0 +1,57 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AnalysisToolHeader } from '@shared/components/AnalysisToolHeader'; +// import { getProfileData } from '@shared/services/landsat-2/getProfileData'; +import { trendToolOptionChanged } from '@shared/store/TrendTool/reducer'; +import { + selectAcquisitionMonth4TrendTool, + selectQueryLocation4TrendTool, + selectAcquisitionYear4TrendTool, + selectTrendToolOption, +} from '@shared/store/TrendTool/selectors'; +import { resetTrendToolData } from '@shared/store/TrendTool/thunks'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; + +import { TemporalProfileToolControls } from './TemporalProfileToolControls'; + +export const TemporalProfileToolControlsContainer = () => { + const dispatch = useDispatch(); + + const queryLocation = useSelector(selectQueryLocation4TrendTool); + + const acquisitionMonth = useSelector(selectAcquisitionMonth4TrendTool); + + const acquisitionYear = useSelector(selectAcquisitionYear4TrendTool); + + const selectedTrendToolOption = useSelector(selectTrendToolOption); + + return ( + { + dispatch(trendToolOptionChanged(data)); + }} + closeButtonOnClick={() => { + dispatch(resetTrendToolData()); + }} + /> + ); +}; diff --git a/src/shared/components/TemproalProfileTool/TemporalProfileToolHeader.tsx b/src/shared/components/TemproalProfileTool/TemporalProfileToolHeader.tsx new file mode 100644 index 00000000..c4ac8180 --- /dev/null +++ b/src/shared/components/TemproalProfileTool/TemporalProfileToolHeader.tsx @@ -0,0 +1,51 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { selectSelectedIndex4TrendTool } from '@shared/store/TrendTool/selectors'; +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; +import { AnalysisToolHeader } from '../AnalysisToolHeader'; +import { RadarIndex, SpectralIndex } from '@typing/imagery-service'; +import { useDispatch } from 'react-redux'; +import { selectedIndex4TrendToolChanged } from '@shared/store/TrendTool/reducer'; + +type Props = { + options: { + value: SpectralIndex | RadarIndex; + label: string; + }[]; + tooltipText: string; +}; + +export const TemporalProfileToolHeader: FC = ({ + options, + tooltipText, +}) => { + const dispatch = useDispatch(); + + const spectralIndex = useSelector(selectSelectedIndex4TrendTool); + + return ( + { + dispatch(selectedIndex4TrendToolChanged(val as SpectralIndex)); + }} + tooltipText={tooltipText} + /> + ); +}; diff --git a/src/shared/components/TemproalProfileTool/index.ts b/src/shared/components/TemproalProfileTool/index.ts new file mode 100644 index 00000000..f5846d1f --- /dev/null +++ b/src/shared/components/TemproalProfileTool/index.ts @@ -0,0 +1,17 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { TemporalProfileToolControlsContainer as TemporalProfileToolControls } from './TemporalProfileToolControlsContainer'; +export { TemporalProfileToolHeader } from './TemporalProfileToolHeader'; diff --git a/src/shared/components/TrendToolControls/useMonthOptions.tsx b/src/shared/components/TemproalProfileTool/useMonthOptions.tsx similarity index 100% rename from src/shared/components/TrendToolControls/useMonthOptions.tsx rename to src/shared/components/TemproalProfileTool/useMonthOptions.tsx diff --git a/src/shared/components/TemproalProfileTool/useSyncSelectedYearAndMonth.tsx b/src/shared/components/TemproalProfileTool/useSyncSelectedYearAndMonth.tsx new file mode 100644 index 00000000..81474a40 --- /dev/null +++ b/src/shared/components/TemproalProfileTool/useSyncSelectedYearAndMonth.tsx @@ -0,0 +1,63 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { batch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { + // getFormatedDateString, + getMonthFromFormattedDateString, + getYearFromFormattedDateString, +} from '@shared/utils/date-time/formatDateString'; +import { selectQueryParams4SceneInSelectedMode } from '@shared/store/ImageryScene/selectors'; +import { + acquisitionMonth4TrendToolChanged, + // samplingTemporalResolutionChanged, + // trendToolDataUpdated, + // selectedIndex4TrendToolChanged, + // queryLocation4TrendToolChanged, + // trendToolOptionChanged, + acquisitionYear4TrendToolChanged, +} from '@shared/store/TrendTool/reducer'; +import { updateQueryLocation4TrendTool } from '@shared/store/TrendTool/thunks'; + +/** + * This custom hook update the `acquisitionMonth` and `acquisitionMonth` property of the Trend Tool State + * to keep it in sync with the acquisition date of selected imagery scene + */ +export const useSyncSelectedYearAndMonth4TemporalProfileTool = () => { + const dispatch = useDispatch(); + + const { rasterFunctionName, acquisitionDate, objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + useEffect(() => { + // remove query location when selected acquisition date is removed + if (!acquisitionDate) { + dispatch(updateQueryLocation4TrendTool(null)); + return; + } + + const month = getMonthFromFormattedDateString(acquisitionDate); + + const year = getYearFromFormattedDateString(acquisitionDate); + + batch(() => { + dispatch(acquisitionMonth4TrendToolChanged(month)); + dispatch(acquisitionYear4TrendToolChanged(year)); + }); + }, [acquisitionDate]); +}; diff --git a/src/shared/components/TrendToolControls/useTrendOptions.tsx b/src/shared/components/TemproalProfileTool/useTrendOptions.tsx similarity index 100% rename from src/shared/components/TrendToolControls/useTrendOptions.tsx rename to src/shared/components/TemproalProfileTool/useTrendOptions.tsx diff --git a/src/shared/components/TemproalProfileTool/useUpdateTemporalProfileToolData.tsx b/src/shared/components/TemproalProfileTool/useUpdateTemporalProfileToolData.tsx new file mode 100644 index 00000000..760583b8 --- /dev/null +++ b/src/shared/components/TemproalProfileTool/useUpdateTemporalProfileToolData.tsx @@ -0,0 +1,120 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + selectAcquisitionMonth4TrendTool, + selectQueryLocation4TrendTool, + // selectSelectedIndex4TrendTool, + selectAcquisitionYear4TrendTool, + selectTrendToolOption, +} from '@shared/store/TrendTool/selectors'; +import { + IntersectWithImagerySceneFunc, + FetchTemporalProfileDataFunc, + updateTemporalProfileToolData, +} from '@shared/store/TrendTool/thunks'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectQueryParams4SceneInSelectedMode, +} from '@shared/store/ImageryScene/selectors'; +// import { SpectralIndex } from '@typing/imagery-service'; +import { selectLandsatMissionsToBeExcluded } from '@shared/store/Landsat/selectors'; +import { debounce } from '@shared/utils/snippets/debounce'; +import { useDispatch } from 'react-redux'; + +/** + * This custom hook triggers `updateTemporalProfileToolData` thunk function to get temporal profile data + * when query location, acquisition date or other options are changed. + * @param fetchTemporalProfileDataFunc - async function that retrieves the Temporal Profile data. This function will be invoked by the `updateTemporalProfileToolData` thunk function. + * @param intersectWithImagerySceneFunc - async function that determines if the query location intersects with the imagery scene specified by the input object ID. This function will be invoked by the `updateTemporalProfileToolData` thunk function. + */ +export const useUpdateTemporalProfileToolData = ( + fetchTemporalProfileDataFunc: FetchTemporalProfileDataFunc, + intersectWithImagerySceneFunc: IntersectWithImagerySceneFunc +) => { + const dispatch = useDispatch(); + + const tool = useSelector(selectActiveAnalysisTool); + + const queryLocation = useSelector(selectQueryLocation4TrendTool); + + const acquisitionMonth = useSelector(selectAcquisitionMonth4TrendTool); + + const acquisitionYear = useSelector(selectAcquisitionYear4TrendTool); + + const selectedTrendToolOption = useSelector(selectTrendToolOption); + + const missionsToBeExcluded = useSelector(selectLandsatMissionsToBeExcluded); + + const { objectIdOfSelectedScene } = + useSelector(selectQueryParams4SceneInSelectedMode) || {}; + + const updateTrendToolDataDebounced = useCallback( + debounce(() => { + dispatch( + updateTemporalProfileToolData( + fetchTemporalProfileDataFunc, + intersectWithImagerySceneFunc + ) + ); + }, 50), + [fetchTemporalProfileDataFunc, intersectWithImagerySceneFunc] + ); + + // triggered when user selects a new acquisition month that will be used to draw the "year-to-year" trend data + useEffect(() => { + (async () => { + if (tool !== 'trend') { + return; + } + + if (selectedTrendToolOption !== 'year-to-year') { + return; + } + + updateTrendToolDataDebounced(); + })(); + }, [ + acquisitionMonth, + queryLocation, + tool, + selectedTrendToolOption, + missionsToBeExcluded, + // objectIdOfSelectedScene, + ]); + + // triggered when user selects a new acquisition year that will be used to draw the "month-to-month" trend data + useEffect(() => { + (async () => { + if (tool !== 'trend') { + return; + } + + if (selectedTrendToolOption !== 'month-to-month') { + return; + } + + updateTrendToolDataDebounced(); + })(); + }, [ + acquisitionYear, + queryLocation, + tool, + selectedTrendToolOption, + missionsToBeExcluded, + // objectIdOfSelectedScene, + ]); +}; diff --git a/src/shared/components/TotalAreaInfo/TotalAreaInfo.tsx b/src/shared/components/TotalAreaInfo/TotalAreaInfo.tsx new file mode 100644 index 00000000..03e049b2 --- /dev/null +++ b/src/shared/components/TotalAreaInfo/TotalAreaInfo.tsx @@ -0,0 +1,65 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { selectIsMapUpdating } from '@shared/store/Map/selectors'; +import { selectTotalVisibleArea } from '@shared/store/Map/selectors'; +import { numberWithCommas } from 'helper-toolkit-ts'; +import React, { FC } from 'react'; +import { useSelector } from 'react-redux'; + +type Props = { + /** + * label text to be place next to the number of total area + * that provide some context about what this total area is for (e.g. 'Estimated Mask Area') + */ + label: string; +}; + +export const TotalVisibleAreaInfo: FC = ({ label }: Props) => { + const totalArea = useSelector(selectTotalVisibleArea); + + const isMapUpdating = useSelector(selectIsMapUpdating); + + if (totalArea === null) { + return null; + } + + const getFormattedArea = () => { + if (!totalArea) { + return 0; + } + + if (totalArea > 100) { + return numberWithCommas(Math.floor(totalArea)); + } + + return totalArea.toFixed(2); + }; + + return ( +
+ {isMapUpdating ? ( +
+ + Loading... +
+ ) : ( +

+ {label}: {getFormattedArea()} km² +

+ )} +
+ ); +}; diff --git a/src/landsat-explorer/components/ZoomToExtent/ZoomToExtentContainer.tsx b/src/shared/components/ZoomToExtent/ZoomToExtentContainer.tsx similarity index 81% rename from src/landsat-explorer/components/ZoomToExtent/ZoomToExtentContainer.tsx rename to src/shared/components/ZoomToExtent/ZoomToExtentContainer.tsx index bde27c55..f76fae41 100644 --- a/src/landsat-explorer/components/ZoomToExtent/ZoomToExtentContainer.tsx +++ b/src/shared/components/ZoomToExtent/ZoomToExtentContainer.tsx @@ -20,21 +20,26 @@ import { } from '@shared/store/UI/selectors'; import MapView from '@arcgis/core/views/MapView'; import { useSelector } from 'react-redux'; -import { ZoomToExtent } from '@shared/components/ZoomToExtent/ZoomToExtent'; +import { ZoomToExtent } from './ZoomToExtent'; import { selectAppMode, selectQueryParams4SceneInSelectedMode, } from '@shared/store/ImageryScene/selectors'; -import { - getExtentOfLandsatSceneByObjectId, - getLandsatFeatureByObjectId, -} from '@shared/services/landsat-level-2/getLandsatScenes'; +// import { +// getExtentOfLandsatSceneByObjectId, +// // getLandsatFeatureByObjectId, +// } from '@shared/services/landsat-level-2/getLandsatScenes'; +import { getExtentByObjectId } from '@shared/services/helpers/getExtentById'; type Props = { + /** + * URL of the imagery service that will be used to get the extent by object id of selected scene + */ + serviceUrl: string; mapView?: MapView; }; -export const ZoomToExtentContainer: FC = ({ mapView }) => { +export const ZoomToExtentContainer: FC = ({ serviceUrl, mapView }) => { // const animationStatus = useSelector(selectAnimationStatus); const isAnimationPlaying = useSelector(selectIsAnimationPlaying); @@ -67,7 +72,8 @@ export const ZoomToExtentContainer: FC = ({ mapView }) => { setIsLoadingExtent(true); try { - const extent = await getExtentOfLandsatSceneByObjectId( + const extent = await getExtentByObjectId( + serviceUrl, objectIdOfSelectedScene ); mapView.extent = extent as any; diff --git a/src/landsat-explorer/components/ZoomToExtent/index.ts b/src/shared/components/ZoomToExtent/index.ts similarity index 100% rename from src/landsat-explorer/components/ZoomToExtent/index.ts rename to src/shared/components/ZoomToExtent/index.ts diff --git a/src/shared/hooks/useCalculatePixelArea.tsx b/src/shared/hooks/useCalculatePixelArea.tsx new file mode 100644 index 00000000..aa3be3cb --- /dev/null +++ b/src/shared/hooks/useCalculatePixelArea.tsx @@ -0,0 +1,67 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { calculatePixelArea } from '@shared/services/helpers/calculatePixelArea'; +import { getCentroidByObjectId } from '@shared/services/helpers/getCentroidByObjectId'; +import React, { useEffect, useState } from 'react'; + +/** + * Custom hook to calculate the approximate area a pixel covers, adjusted by the latitude of the centroid point of + * the imagery scene to which this pixel belongs. + * + * For example, if the pixel size is 10 meters at the equator, it covers 100 square meters. However, at latitude 40, + * it only covers approximately 76.6 square meters. + * + * @param params - An object containing the pixel dimensions, service URL, and object ID. + * @param params.pixelWidth - The width of the pixel in meter. + * @param params.pixelHeigh - The height of the pixel in meter. + * @param params.serviceURL - The URL of the imagery service. + * @param params.objectId - The unique identifier of the feature. + * @returns The area of the pixel in square meters. + */ +export const useCalculatePixelArea = ({ + pixelWidth, + pixelHeigh, + serviceURL, + objectId, +}: { + pixelWidth: number; + pixelHeigh: number; + serviceURL: string; + objectId: number; +}) => { + const [pixelAreaInSqMeter, setPixelAreaInSqMeter] = useState( + pixelWidth * pixelHeigh + ); + + useEffect(() => { + (async () => { + if (!objectId) { + return; + } + + const [lon, lat] = await getCentroidByObjectId( + serviceURL, + objectId + ); + + const area = calculatePixelArea(pixelWidth, lat); + + setPixelAreaInSqMeter(area); + })(); + }, [objectId, pixelWidth, pixelHeigh]); + + return pixelAreaInSqMeter; +}; diff --git a/src/shared/hooks/useCalculateTotalAreaByPixelsCount.tsx b/src/shared/hooks/useCalculateTotalAreaByPixelsCount.tsx new file mode 100644 index 00000000..c5778a30 --- /dev/null +++ b/src/shared/hooks/useCalculateTotalAreaByPixelsCount.tsx @@ -0,0 +1,66 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import MapView from '@arcgis/core/views/MapView'; +import { useCalculatePixelArea } from '@shared/hooks/useCalculatePixelArea'; +// import { SENTINEL_1_SERVICE_URL } from '@shared/services/sentinel-1/config'; +import { totalVisibleAreaInSqKmChanged } from '@shared/store/Map/reducer'; +import { selectCountOfVisiblePixels } from '@shared/store/Map/selectors'; +// import { debounce } from '@shared/utils/snippets/debounce'; +import React, { useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; + +/** + * Custom hook that calculates the total visible area based on + * the count of visible pixels of the imagery layer in Mask Index or Change Compare tools. + * + * @param {Object} params - The parameters object. + * @param {number} params.objectId - The object ID of the imagery scene. + * @param {string} params.serviceURL - The service URL for the imagery layer. + * @param {pixelSize} params.pixelSize - Represents the size of one pixel in map units. + */ +export const useCalculateTotalAreaByPixelsCount = ({ + objectId, + serviceURL, + pixelSize, +}: { + objectId: number; + serviceURL: string; + pixelSize: number; +}) => { + const dispatach = useDispatch(); + + const countOfVisiblePixels = useSelector(selectCountOfVisiblePixels); + + // calculate the approximate area a pixel covers, adjusted by the latitude of the centroid point of the imagery scene to which this pixel belongs + const pixelAreaInSqMeter = useCalculatePixelArea({ + pixelHeigh: pixelSize, + pixelWidth: pixelSize, + serviceURL: serviceURL, + objectId: objectId, + }); + + const clacAreaByNumOfPixels = (visiblePixels: number) => { + const areaSqMeter = pixelAreaInSqMeter * visiblePixels; + const areaSqKM = areaSqMeter / 1000000; + + dispatach(totalVisibleAreaInSqKmChanged(areaSqKM)); + }; + + useEffect(() => { + clacAreaByNumOfPixels(countOfVisiblePixels); + }, [countOfVisiblePixels, pixelAreaInSqMeter]); +}; diff --git a/src/shared/hooks/useCurrenPageBecomesVisible.tsx b/src/shared/hooks/useCurrenPageBecomesVisible.tsx index 9039ad10..791dc43e 100644 --- a/src/shared/hooks/useCurrenPageBecomesVisible.tsx +++ b/src/shared/hooks/useCurrenPageBecomesVisible.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { useMemo } from 'react'; import { usePrevious } from './usePrevious'; import useVisibilityState from './useVisibilityState'; diff --git a/src/shared/components/Calendar/useFindSelectedSceneByDate.tsx b/src/shared/hooks/useFindSelectedSceneByDate.tsx similarity index 79% rename from src/shared/components/Calendar/useFindSelectedSceneByDate.tsx rename to src/shared/hooks/useFindSelectedSceneByDate.tsx index 8fd5a68f..6f2f35ad 100644 --- a/src/shared/components/Calendar/useFindSelectedSceneByDate.tsx +++ b/src/shared/hooks/useFindSelectedSceneByDate.tsx @@ -14,14 +14,16 @@ */ import React, { useEffect } from 'react'; -import { useSelector } from 'react-redux'; +import { useSelector, batch } from 'react-redux'; import { selectAvailableScenes, selectQueryParams4SceneInSelectedMode, + selectShouldForceSceneReselection, } from '@shared/store/ImageryScene/selectors'; import { useDispatch } from 'react-redux'; import { updateObjectIdOfSelectedScene } from '@shared/store/ImageryScene/thunks'; import { selectIsAnimationPlaying } from '@shared/store/UI/selectors'; +import { shouldForceSceneReselectionUpdated } from '@shared/store/ImageryScene/reducer'; /** * This custom hooks tries to find the selected scene that was acquired from the selected acquisition date @@ -40,6 +42,10 @@ export const useFindSelectedSceneByDate = (): void => { */ const availableScenes = useSelector(selectAvailableScenes); + const shouldForceSceneReselection = useSelector( + selectShouldForceSceneReselection + ); + useEffect(() => { // It is unnecessary to update the object ID of the selected scene while the animation is playing. // This is because the available scenes associated with each animation frame do not get updated during animation playback. @@ -55,10 +61,10 @@ export const useFindSelectedSceneByDate = (): void => { return; } - // We want to maintain the selected imagery scene as locked, in other words, - // once a scene is chosen, it will remain locked until the user explicitly removes the + // We want to maintain the selected imagery scene as locked as long as the `shouldForceSceneReselection` flag is set to false, + // in other words, once a scene is chosen, it will remain locked until the user explicitly removes the // selected date from the calendar or removes the object Id of the selected scene. - if (objectIdOfSelectedScene) { + if (objectIdOfSelectedScene && shouldForceSceneReselection === false) { return; } @@ -68,8 +74,12 @@ export const useFindSelectedSceneByDate = (): void => { (d) => d.formattedAcquisitionDate === acquisitionDate ); - dispatch( - updateObjectIdOfSelectedScene(selectedScene?.objectId || null) - ); + batch(() => { + dispatch( + updateObjectIdOfSelectedScene(selectedScene?.objectId || null) + ); + + dispatch(shouldForceSceneReselectionUpdated(false)); + }); }, [availableScenes, acquisitionDate, objectIdOfSelectedScene]); }; diff --git a/src/shared/hooks/useRevalidateToken.tsx b/src/shared/hooks/useRevalidateToken.tsx index 7cabafe7..29a3243e 100644 --- a/src/shared/hooks/useRevalidateToken.tsx +++ b/src/shared/hooks/useRevalidateToken.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { FC, useEffect } from 'react'; import useCurrenPageBecomesVisible from './useCurrenPageBecomesVisible'; import { revalidateToken } from '@shared/utils/esri-oauth'; diff --git a/src/shared/hooks/useSaveAppState2HashParams.tsx b/src/shared/hooks/useSaveAppState2HashParams.tsx index 080cf43c..f854c330 100644 --- a/src/shared/hooks/useSaveAppState2HashParams.tsx +++ b/src/shared/hooks/useSaveAppState2HashParams.tsx @@ -42,12 +42,15 @@ import { saveAnimationSpeedToHashParams, saveSpectralProfileToolStateToHashParams, saveChangeCompareToolStateToHashParams, + saveTemporalCompositeToolStateToHashParams, } from '@shared/utils/url-hash-params'; import React, { FC, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { selectSpectralProfileToolState } from '@shared/store/SpectralProfileTool/selectors'; import { QueryParams4ImageryScene } from '@shared/store/ImageryScene/reducer'; import { selectChangeCompareToolState } from '@shared/store/ChangeCompareTool/selectors'; +import { saveListOfQueryParamsToHashParams } from '@shared/utils/url-hash-params/queryParams4ImageryScene'; +import { selectTemporalCompositeToolState } from '@shared/store/TemporalCompositeTool/selectors'; export const useSaveAppState2HashParams = () => { const mode = useSelector(selectAppMode); @@ -66,9 +69,7 @@ export const useSaveAppState2HashParams = () => { const spectralToolState = useSelector(selectSpectralProfileToolState); - const queryParams4ScenesInAnimationMode = useSelector( - selectListOfQueryParams - ); + const listOfQueryParams = useSelector(selectListOfQueryParams); const animationStatus = useSelector(selectAnimationStatus); @@ -82,6 +83,10 @@ export const useSaveAppState2HashParams = () => { const changeCompareToolState = useSelector(selectChangeCompareToolState); + const temporalCompositeToolState = useSelector( + selectTemporalCompositeToolState + ); + useEffect(() => { updateHashParams('mode', mode); @@ -100,7 +105,7 @@ export const useSaveAppState2HashParams = () => { } saveQueryParams4SecondarySceneToHashParams(queryParams); - }, [mode, queryParams4SecondaryScene]); + }, [mode, analysisTool, queryParams4SecondaryScene]); useEffect(() => { saveMaskToolToHashParams( @@ -140,9 +145,25 @@ export const useSaveAppState2HashParams = () => { useEffect(() => { saveQueryParams4ScenesInAnimationToHashParams( - mode === 'animate' ? queryParams4ScenesInAnimationMode : null + mode === 'animate' ? listOfQueryParams : null + ); + }, [mode, listOfQueryParams]); + + useEffect(() => { + saveListOfQueryParamsToHashParams( + mode === 'analysis' && analysisTool === 'temporal composite' + ? listOfQueryParams + : null + ); + }, [mode, analysisTool, listOfQueryParams]); + + useEffect(() => { + saveTemporalCompositeToolStateToHashParams( + mode === 'analysis' && analysisTool === 'temporal composite' + ? temporalCompositeToolState + : null ); - }, [mode, queryParams4ScenesInAnimationMode]); + }, [mode, analysisTool, temporalCompositeToolState]); useEffect(() => { saveAnimationSpeedToHashParams( diff --git a/src/shared/hooks/useShouldShowSecondaryControls.tsx b/src/shared/hooks/useShouldShowSecondaryControls.tsx new file mode 100644 index 00000000..79d0f3ff --- /dev/null +++ b/src/shared/hooks/useShouldShowSecondaryControls.tsx @@ -0,0 +1,31 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { selectAppMode } from '@shared/store/ImageryScene/selectors'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +/** + * This custom hook returns a boolean value indicates if the secondary controls should be shown + * @returns {boolean} If true, show secondary controls next to mode selectors + */ +export const useShouldShowSecondaryControls = () => { + const mode = useSelector(selectAppMode); + + const shouldShowSecondaryControls = + mode === 'swipe' || mode === 'animate' || mode === 'analysis'; + + return shouldShowSecondaryControls; +}; diff --git a/src/shared/hooks/useSyncRenderers.tsx b/src/shared/hooks/useSyncRenderers.tsx new file mode 100644 index 00000000..72bb8561 --- /dev/null +++ b/src/shared/hooks/useSyncRenderers.tsx @@ -0,0 +1,90 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import React, { FC, useEffect, useMemo } from 'react'; +import SwipeWidget from '@shared/components/SwipeWidget/SwipeWidget'; +import { useSelector } from 'react-redux'; +import { + selectActiveAnalysisTool, + selectAppMode, + selectIsSecondarySceneActive, + selectIsSwipeModeOn, + selectQueryParams4MainScene, + selectQueryParams4SecondaryScene, +} from '@shared/store/ImageryScene/selectors'; +import { useDispatch } from 'react-redux'; +import { + QueryParams4ImageryScene, + queryParams4SecondarySceneChanged, +} from '@shared/store/ImageryScene/reducer'; + +/** + * Custom hook to synchronize the renderer of the secondary imagery scene with the main scene. + * + * This hook ensures that the renderer of the secondary scene matches the main scene's renderer + * when the user has not explicitly updated it. This is especially useful in Swipe Mode and the + * Change Compare tool to provide a consistent user experience. + */ +export const useSyncRenderers = () => { + const dispatch = useDispatch(); + + const mode = useSelector(selectAppMode); + + const analyzeTool = useSelector(selectActiveAnalysisTool); + + const isSwipeWidgetVisible = useSelector(selectIsSwipeModeOn); + + const queryParams4MainScene = useSelector(selectQueryParams4MainScene); + + const queryParams4SecondaryScene = useSelector( + selectQueryParams4SecondaryScene + ); + + const isSecondarySceneActive = useSelector(selectIsSecondarySceneActive); + + /** + * Determines whether the renderer should be synchronized based on the current mode + * and visibility of the swipe widget. + */ + const shouldSync = useMemo(() => { + // only sync it when secondary scene is active + if (!isSecondarySceneActive) { + return false; + } + + return ( + isSwipeWidgetVisible || + (mode === 'analysis' && analyzeTool === 'change') + ); + }, [isSwipeWidgetVisible, mode, analyzeTool, isSecondarySceneActive]); + + useEffect(() => { + if (!shouldSync) { + return; + } + + // If a raster function is not explicitly selected for the secondary scene, + // inherit the raster function from the main scene. + if (!queryParams4SecondaryScene?.rasterFunctionName) { + const updatedQueryParams: QueryParams4ImageryScene = { + ...queryParams4SecondaryScene, + rasterFunctionName: queryParams4MainScene.rasterFunctionName, + }; + + dispatch(queryParams4SecondarySceneChanged(updatedQueryParams)); + } + }, [queryParams4SecondaryScene?.rasterFunctionName, shouldSync]); +}; diff --git a/src/shared/hooks/useVisibilityState.tsx b/src/shared/hooks/useVisibilityState.tsx index 91a24ee9..65472757 100644 --- a/src/shared/hooks/useVisibilityState.tsx +++ b/src/shared/hooks/useVisibilityState.tsx @@ -1,3 +1,18 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { useEffect, useState } from 'react'; /** diff --git a/src/shared/services/helpers/calculatePixelArea.ts b/src/shared/services/helpers/calculatePixelArea.ts new file mode 100644 index 00000000..5dc1bdea --- /dev/null +++ b/src/shared/services/helpers/calculatePixelArea.ts @@ -0,0 +1,31 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Calculate the area of a pixel in Web Mercator projection + * @param {number} pixelSize - The size of the pixel in map units (meters) + * @param {number} latitude - The latitude at the center of the pixel in degrees + * @returns {number} The area of the pixel in square meters + */ +export const calculatePixelArea = (pixelSize: number, latitude: number) => { + // Convert latitude from degrees to radians + const latitudeInRadians = (latitude * Math.PI) / 180; + + // Correct the size of pixel using the cosine of the latitude + const correctedPixelSize = pixelSize * Math.cos(latitudeInRadians); + + // return the Calculated pixel area + return correctedPixelSize ** 2; +}; diff --git a/src/shared/services/helpers/deduplicateListOfScenes.test.ts b/src/shared/services/helpers/deduplicateListOfScenes.test.ts new file mode 100644 index 00000000..1587a681 --- /dev/null +++ b/src/shared/services/helpers/deduplicateListOfScenes.test.ts @@ -0,0 +1,124 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { deduplicateListOfImageryScenes } from './deduplicateListOfScenes'; + +describe('test deduplicateListOfImageryScenes', () => { + const data = [ + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: false, + objectId: 1, + }, + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: false, + objectId: 2, + }, + { + formattedAcquisitionDate: '01-02', + acquisitionDate: 20240102, + doesNotMeetCriteria: false, + objectId: 3, + }, + { + formattedAcquisitionDate: '01-02', + acquisitionDate: 20240102, + doesNotMeetCriteria: false, + objectId: 4, + }, + ] as any; + + it('should retain one scene per day', () => { + const output = deduplicateListOfImageryScenes(data, null); + + expect(output.length).toBe(2); + }); + + it('should retain the scene acquired later when there are two scenes acquired on the same day', () => { + const output = deduplicateListOfImageryScenes(data, null); + + expect(output.find((d) => d.objectId === 2)).toBeDefined(); + expect(output.find((d) => d.objectId === 4)).toBeDefined(); + }); + + it('should retain the currently selected scene (even if acquired earlier) when there are two scenes acquired on the same day', () => { + const output = deduplicateListOfImageryScenes( + [ + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: false, + objectId: 1, + }, + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: false, + objectId: 2, + }, + ] as any, + 1 + ); + + expect(output.find((d) => d.objectId === 1)).toBeDefined(); + }); + + it('should prioritize retaining scenes that meet all filter criteria', () => { + const output = deduplicateListOfImageryScenes( + [ + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: false, + objectId: 1, + }, + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: true, + objectId: 2, + }, + ] as any, + null + ); + + expect(output.find((d) => d.objectId === 1)).toBeDefined(); + }); + + it('should prioritize retaining scenes that meet all filter criteria even if there is a selected scene that was acquired on the same day', () => { + const output = deduplicateListOfImageryScenes( + [ + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: true, + objectId: 1, + }, + { + formattedAcquisitionDate: '01-01', + acquisitionDate: 20240101, + doesNotMeetCriteria: false, + objectId: 2, + }, + ] as any, + 1 + ); + + expect(output.find((d) => d.objectId === 2)).toBeDefined(); + }); +}); diff --git a/src/shared/services/helpers/deduplicateListOfScenes.ts b/src/shared/services/helpers/deduplicateListOfScenes.ts new file mode 100644 index 00000000..1fc55aa9 --- /dev/null +++ b/src/shared/services/helpers/deduplicateListOfScenes.ts @@ -0,0 +1,82 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ImageryScene } from '@shared/store/ImageryScene/reducer'; + +/** + * Deduplicates a list of imagery scenes based on acquisition date, keeping only one scene per day. + * When there are multiple scenes acquired on the same day, the function prioritizes keeping the currently + * selected scene or the one acquired later. + * + * @param scenes An array of ImageryScene objects to be deduplicated. + * @param objectIdOfSelectedScene The object ID of the scene that should be prioritized if there are multiple + * scenes acquired on the same day. + * @returns An array of deduplicated ImageryScene objects. + */ +export const deduplicateListOfImageryScenes = ( + scenes: ImageryScene[], + objectIdOfSelectedScene: number +): ImageryScene[] => { + // sort scenes uing acquisition date in an ascending order + // which is necessary for us to select between two overlapping scenes in step below + const sorted = [...scenes].sort( + (a, b) => a.acquisitionDate - b.acquisitionDate + ); + + const output: ImageryScene[] = []; + + for (const currScene of sorted) { + // Get the last imagery scene in the array + const prevScene = output[output.length - 1]; + + // Check if there is a previous scene and if its acquisition date matches the current scene. + // We aim to keep only one imagery scene for each day. When there are two scenes acquired on the same date, + // we prioritize keeping the scene that meets all filter criteria, the currently selected scene, or the one acquired later. + if ( + prevScene && + prevScene.formattedAcquisitionDate === + currScene.formattedAcquisitionDate + ) { + // Prioritize retaining scenes that meet all filter criteria + // even if there is a selected scene acquired on the same day + if ( + prevScene.doesNotMeetCriteria !== currScene.doesNotMeetCriteria + ) { + // Remove the previous scene and use the current scene if the previous one does not meet all filter criteria + // even if it is currently selected + if (prevScene.doesNotMeetCriteria) { + output.pop(); + output.push(currScene); + } + + // If the previous scene meets all filter criteria, keep it and skip the current scene + continue; + } + + // Check if the previous scene is the currently selected scene + // Skip the current iteration if the previous scene is the selected scene + if (prevScene.objectId === objectIdOfSelectedScene) { + continue; + } + + // Remove the previous scene from output as it was acquired before the current scene + output.pop(); + } + + output.push(currScene); + } + + return output; +}; diff --git a/src/shared/services/helpers/exportImage.ts b/src/shared/services/helpers/exportImage.ts index d91b80b8..3addbca9 100644 --- a/src/shared/services/helpers/exportImage.ts +++ b/src/shared/services/helpers/exportImage.ts @@ -14,7 +14,7 @@ */ import IExtent from '@arcgis/core/geometry/Extent'; -import { getMosaicRuleByObjectId } from './getMosaicRuleByObjectId'; +import { getLockRasterMosaicRule } from './getMosaicRules'; type ExportImageParams = { /** @@ -62,7 +62,7 @@ export const exportImage = async ({ imageSR: '102100', format: 'jpgpng', size: `${width},${height}`, - mosaicRule: JSON.stringify(getMosaicRuleByObjectId(objectId)), + mosaicRule: JSON.stringify(getLockRasterMosaicRule([objectId])), renderingRule: JSON.stringify({ rasterFunction: rasterFunctionName }), }); diff --git a/src/shared/services/helpers/getCentroidByObjectId.ts b/src/shared/services/helpers/getCentroidByObjectId.ts new file mode 100644 index 00000000..5c627139 --- /dev/null +++ b/src/shared/services/helpers/getCentroidByObjectId.ts @@ -0,0 +1,43 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getExtentByObjectId } from './getExtentById'; + +/** + * Retrieves the centroid point of a feature from an imagery service using the object ID as a key. + * @param serviceUrl The URL of the imagery service. + * @param objectId The unique identifier of the feature. + * @returns A promise that resolves to an array containing the [longitude, latitude] of the centroid of the feature's extent. + */ +export const getCentroidByObjectId = async ( + serviceUrl: string, + objectId: number +): Promise => { + const extent = await getExtentByObjectId(serviceUrl, objectId, 4326); + + const { ymax, ymin } = extent; + let { xmax, xmin } = extent; + + // Normalize the x values in case the extent crosses the International Date Line + if (xmin < 0 && xmax > 0) { + xmin = xmax; + xmax = 180; + } + + const centerX = (xmax - xmin) / 2 + xmin; + const centerY = (ymax - ymin) / 2 + ymin; + + return [centerX, centerY]; +}; diff --git a/src/shared/services/helpers/getExtentById.ts b/src/shared/services/helpers/getExtentById.ts new file mode 100644 index 00000000..d3cafeff --- /dev/null +++ b/src/shared/services/helpers/getExtentById.ts @@ -0,0 +1,55 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IExtent } from '@esri/arcgis-rest-feature-service'; + +/** + * Get the extent of a feature from a imagery service using the object Id as key. + * @param objectId The unique identifier of the feature + * @returns IExtent The extent of the feature from the input service + */ +export const getExtentByObjectId = async ( + serviceUrl: string, + objectId: number, + outputSpatialReference?: number +): Promise => { + const queryParams = new URLSearchParams({ + f: 'json', + returnExtentOnly: 'true', + objectIds: objectId.toString(), + }); + + if (outputSpatialReference) { + queryParams.append('outSR', outputSpatialReference.toString()); + } + + const res = await fetch(`${serviceUrl}/query?${queryParams.toString()}`); + + if (!res.ok) { + throw new Error('failed to query ' + serviceUrl); + } + + const data = await res.json(); + + if (data.error) { + throw data.error; + } + + if (!data?.extent) { + return null; + } + + return data?.extent as IExtent; +}; diff --git a/src/shared/services/helpers/getFeatureById.ts b/src/shared/services/helpers/getFeatureById.ts new file mode 100644 index 00000000..e88615da --- /dev/null +++ b/src/shared/services/helpers/getFeatureById.ts @@ -0,0 +1,55 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IFeature } from '@esri/arcgis-rest-feature-service'; + +/** + * Query Imagery Service to get a feature by ObjectID + * @param serviceUrl URL of the Imagery Service + * @param objectId object id of the feature + * @returns + */ +export const getFeatureByObjectId = async ( + serviceUrl: string, + objectId: number, + abortController?: AbortController +): Promise => { + const queryParams = new URLSearchParams({ + f: 'json', + returnGeometry: 'true', + objectIds: objectId.toString(), + outFields: '*', + }); + + const res = await fetch(`${serviceUrl}/query?${queryParams.toString()}`, { + signal: abortController?.signal, + }); + + if (!res.ok) { + throw new Error('failed to query ' + serviceUrl); + } + + const data = await res.json(); + + if (data.error) { + throw data.error; + } + + if (!data?.features || !data.features.length) { + return null; + } + + return data?.features[0] as IFeature; +}; diff --git a/src/shared/services/helpers/getMosaicRules.ts b/src/shared/services/helpers/getMosaicRules.ts new file mode 100644 index 00000000..33e164ba --- /dev/null +++ b/src/shared/services/helpers/getMosaicRules.ts @@ -0,0 +1,59 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// export const getMosaicRuleByObjectId = (objectId: number) => { +// return { +// ascending: false, +// lockRasterIds: [objectId], +// mosaicMethod: 'esriMosaicLockRaster', +// where: `objectid in (${objectId})`, +// }; +// }; + +/** + * Get mosaic rule that only displays the selected rasters. + * @param objectIds + * @returns + * + * @see https://developers.arcgis.com/rest/services-reference/enterprise/mosaic-rules/#lockraster + */ +export const getLockRasterMosaicRule = (objectIds: number[]) => { + return { + ascending: false, + lockRasterIds: objectIds, + mosaicMethod: 'esriMosaicLockRaster', + where: `objectid in (${objectIds.join(',')})`, + }; +}; + +/** + * Orders rasters based on the absolute distance between their values of an attribute and a base value. + * @param objectIds + * @returns + * + * @see https://developers.arcgis.com/rest/services-reference/enterprise/mosaic-rules/#lockraster + */ +export const getByAttributeMosaicRule = ( + sortField: string, + sortValue: string +) => { + return { + ascending: true, + mosaicMethod: 'esriMosaicAttribute', + mosaicOperation: 'MT_FIRST', + sortField, + sortValue, + }; +}; diff --git a/src/shared/services/helpers/getPixelValues.ts b/src/shared/services/helpers/getPixelValues.ts new file mode 100644 index 00000000..3168aaa6 --- /dev/null +++ b/src/shared/services/helpers/getPixelValues.ts @@ -0,0 +1,128 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from '@arcgis/core/geometry'; +import { splitObjectIdsToSeparateGroups } from './splitObjectIdsToSeparateGroups'; +import { IdentifyTaskResponse, identify } from './identify'; +import { canBeConvertedToNumber } from '@shared/utils/snippets/canBeConvertedToNumber'; + +/** + * Parameters for the Get Pixel Values + */ +type GetPixelValuesParams = { + /** + * URL of the imagery service + */ + serviceURL: string; + /** + * Point geometry to be used to query the pixel + */ + point: Point; + /** + * Array of Object IDs to be used to find imagery scenes to get the pixel values from + */ + objectIds?: number[]; + /** + * Abort controller to be used to cancel the identify task + */ + abortController: AbortController; +}; + +/** + * Values of pixel at a given location from a imagery scene by object id + */ +export type PixelValuesData = { + /** + * object Id of the assoicate imagery scene + */ + objectId: number; + /** + * array of pixel values + */ + values: number[]; +}; + +/** + * Retrieves pixel values that intersect with the specified point from imagery scenes by the input object ID. + * The function sends parallel identify requests for groups of object IDs to the specified service URL. + * + * @param param0 - An object containing the following properties: + * @param serviceURL - The URL of the imagery service. + * @param point - The geographic point used to intersect with the imagery scenes. + * @param objectIds - An array of object IDs to fetch pixel values for. + * @param abortController - An AbortController to handle request cancellation. + * @returns A promise that resolves to an array of objects, each containing an object ID and its corresponding pixel values. + */ +export const getPixelValues = async ({ + serviceURL, + point, + objectIds, + abortController, +}: GetPixelValuesParams): Promise => { + // divide object IDs into separate groups for parallel fetching. + const objectsIdsInSeparateGroups = + splitObjectIdsToSeparateGroups(objectIds); + // console.log(objectsIdsInSeparateGroups) + + // send identify requests in parallel for each group of object IDs. + const identifyResponseInSeparateGroups: IdentifyTaskResponse[] = + await Promise.all( + objectsIdsInSeparateGroups.map((oids) => + identify({ + serviceURL, + point, + objectIds: oids, + abortController, + }) + ) + ); + // console.log(identifyResponseInSeparateGroups) + + const pixelValuesByObjectId: { + [key: number]: number[]; + } = {}; + + for (const res of identifyResponseInSeparateGroups) { + const { catalogItems, properties } = res; + + const { features } = catalogItems; + const { Values } = properties; + + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const value = Values[i]; + + const objectId = feature.attributes.objectid; + const values = value.split(' ').map((d) => { + if (canBeConvertedToNumber(d) === false) { + return null; + } + + return +d; + }); + + pixelValuesByObjectId[objectId] = values; + } + } + + return objectIds + .filter((objectId) => pixelValuesByObjectId[objectId] !== undefined) + .map((objectId) => { + return { + objectId, + values: pixelValuesByObjectId[objectId], + }; + }); +}; diff --git a/src/shared/services/helpers/getPixelValuesFromIdentifyTaskResponse.ts b/src/shared/services/helpers/getPixelValuesFromIdentifyTaskResponse.ts new file mode 100644 index 00000000..9b6a87e3 --- /dev/null +++ b/src/shared/services/helpers/getPixelValuesFromIdentifyTaskResponse.ts @@ -0,0 +1,43 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { canBeConvertedToNumber } from '@shared/utils/snippets/canBeConvertedToNumber'; +import { IdentifyTaskResponse } from './identify'; + +/** + * Get pixel values from Identify Task Response + * @param res + * @returns + */ +export const getPixelValuesFromIdentifyTaskResponse = ( + res: IdentifyTaskResponse +): number[] => { + let bandValues: number[] = null; + + if (res?.value && res?.value !== 'NoData') { + // get pixel values from the value property first + bandValues = res?.value.split(', ').map((d) => +d); + } else if (res?.properties?.Values[0]) { + bandValues = res?.properties?.Values[0].split(' ').map((d) => { + if (canBeConvertedToNumber(d) === false) { + return null; + } + + return +d; + }); + } + + return bandValues; +}; diff --git a/src/shared/services/helpers/getRasterFunctionLabelText.ts b/src/shared/services/helpers/getRasterFunctionLabelText.ts deleted file mode 100644 index 63d8030e..00000000 --- a/src/shared/services/helpers/getRasterFunctionLabelText.ts +++ /dev/null @@ -1,43 +0,0 @@ -// /* Copyright 2024 Esri -// * -// * Licensed under the Apache License Version 2.0 (the "License"); -// * you may not use this file except in compliance with the License. -// * You may obtain a copy of the License at -// * -// * http://www.apache.org/licenses/LICENSE-2.0 -// * -// * Unless required by applicable law or agreed to in writing, software -// * distributed under the License is distributed on an "AS IS" BASIS, -// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// * See the License for the specific language governing permissions and -// * limitations under the License. -// */ - -// import { LANDSAT_RASTER_FUNCTION_INFOS } from '../landsat-level-2/config'; - -// let rasterFunctionLabelMap: Map = null; - -// const initRasterFunctionLabelMap = () => { -// rasterFunctionLabelMap = new Map(); - -// const infos = [...LANDSAT_RASTER_FUNCTION_INFOS]; - -// for (const { name, label } of infos) { -// rasterFunctionLabelMap.set(name, label); -// } -// }; - -// /** -// * Get label text for a raster function for diplaying purpose. -// * @param rasterFunctionName name of the raster function -// * @returns {string} label associated with the raster function -// */ -// export const getRasterFunctionLabelText = ( -// rasterFunctionName: string -// ): string => { -// if (!rasterFunctionLabelMap) { -// initRasterFunctionLabelMap(); -// } - -// return rasterFunctionLabelMap.get(rasterFunctionName) || rasterFunctionName; -// }; diff --git a/src/shared/services/helpers/identify.ts b/src/shared/services/helpers/identify.ts new file mode 100644 index 00000000..a09a6f2d --- /dev/null +++ b/src/shared/services/helpers/identify.ts @@ -0,0 +1,153 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Geometry, Point } from '@arcgis/core/geometry'; +import { IFeature } from '@esri/arcgis-rest-feature-service'; +import { getLockRasterMosaicRule } from './getMosaicRules'; +import RasterFunction from '@arcgis/core/layers/support/RasterFunction'; + +/** + * Parameters for the Identify Task + */ +export type IdentifyTaskParams = { + /** + * URL of the imagery service + */ + serviceURL: string; + /** + * Point geometry to be used in the identify task + */ + point: Point; + /** + * Object IDs of the imagery scenes that will be used to create a Lock Raster mosaic rule + */ + objectIds?: number[]; + /** + * A custom mosaic rule to be used when object Ids are not provided + */ + customMosaicRule?: any; + /** + * Raster Function that will be used as rendering rule + */ + rasterFunction?: RasterFunction; + /** + * the resoultion of the map view, it is the size of one pixel in map units. + * The value of resolution can be found by dividing the extent width by the view's width. + */ + resolution?: number; + /** + * specify the number of catalog items that will be returned + */ + maxItemCount?: number; + /** + * Abort controller to be used to cancel the identify task + */ + abortController: AbortController; +}; + +/** + * Run identify task on an imagery service to fetch pixel values for the input point location + * @param param0 - IdentifyTaskParams object containing parameters for the identify task + * @returns Promise of IdentifyTaskResponse containing the result of the identify task + */ +export type IdentifyTaskResponse = { + catalogItems: { + features: IFeature[]; + geometryType: string; + objectIdFieldName: string; + }; + location: Geometry; + value: string; + name: string; + properties: { + Values: string[]; + }; +}; + +/** + * Run identify task on an imagery service to fetch pixel values for the input point location + * @param param0 + * @returns + */ +export const identify = async ({ + serviceURL, + point, + objectIds, + customMosaicRule, + rasterFunction, + resolution, + maxItemCount, + abortController, +}: IdentifyTaskParams): Promise => { + const mosaicRule = + objectIds && objectIds.length + ? getLockRasterMosaicRule(objectIds) + : customMosaicRule; + + const params = new URLSearchParams({ + f: 'json', + // maxItemCount: '1', + returnGeometry: 'false', + returnCatalogItems: 'true', + geometryType: 'esriGeometryPoint', + geometry: JSON.stringify({ + spatialReference: { + wkid: 4326, + }, + x: point.longitude, + y: point.latitude, + }), + // mosaicRule: JSON.stringify(mosaicRule), + }); + + if (rasterFunction) { + const renderingRule = rasterFunction.toJSON(); + + params.append('renderingRules', JSON.stringify(renderingRule)); + } + + if (resolution) { + const pixelSize = JSON.stringify({ + x: resolution, + y: resolution, + spatialReference: { + wkid: 102100, + latestWkid: 3857, + }, + }); + + params.append('pixelSize', JSON.stringify(pixelSize)); + } + + if (maxItemCount) { + params.append('maxItemCount', maxItemCount.toString()); + } + + if (mosaicRule) { + params.append('mosaicRule', JSON.stringify(mosaicRule)); + } + + const requestURL = `${serviceURL}/identify?${params.toString()}`; + + const res = await fetch(requestURL, { signal: abortController.signal }); + + const data = await res.json(); + + if (data.error) { + throw data.error; + } + + return data as IdentifyTaskResponse; +}; diff --git a/src/shared/services/helpers/intersectWithImageryScene.ts b/src/shared/services/helpers/intersectWithImageryScene.ts new file mode 100644 index 00000000..38036505 --- /dev/null +++ b/src/shared/services/helpers/intersectWithImageryScene.ts @@ -0,0 +1,67 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from '@arcgis/core/geometry'; + +export type IntersectWithImagerySceneParams = { + serviceUrl: string; + objectId: number; + point: Point; + + abortController?: AbortController; +}; + +/** + * Check if the input point intersects with a Imagery scene specified by the input object ID. + * @param point Point geometry representing the location to check for intersection. + * @param objectId Object ID of the imagery scene to check intersection with. + * @returns {boolean} Returns true if the input point intersects with the specified Imagery scene, otherwise false. + */ +export const intersectWithImageryScene = async ({ + serviceUrl, + objectId, + point, + abortController, +}: IntersectWithImagerySceneParams): Promise => { + const geometry = JSON.stringify({ + spatialReference: { + wkid: 4326, + }, + x: point.longitude, + y: point.latitude, + }); + + const queryParams = new URLSearchParams({ + f: 'json', + returnCountOnly: 'true', + returnGeometry: 'false', + objectIds: objectId.toString(), + geometry, + spatialRel: 'esriSpatialRelIntersects', + geometryType: 'esriGeometryPoint', + }); + + const res = await fetch(`${serviceUrl}/query?${queryParams.toString()}`, { + signal: abortController.signal, + }); + + if (!res.ok) { + throw new Error('failed to query service' + serviceUrl); + } + + const data = await res.json(); + + return data?.count && data?.count > 0; +}; diff --git a/src/shared/services/helpers/splitObjectIdsToSeparateGroups.ts b/src/shared/services/helpers/splitObjectIdsToSeparateGroups.ts new file mode 100644 index 00000000..a15f5705 --- /dev/null +++ b/src/shared/services/helpers/splitObjectIdsToSeparateGroups.ts @@ -0,0 +1,45 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Splits an array of object IDs into separate groups, each containing a maximum of 20 object IDs. + * This is useful when making requests with an upper limit on the number of object IDs. + * + * @param {number[]} objectIds - An array of object IDs to be split into groups. + * @returns {number[][]} An array of arrays, each representing a separate group of object IDs. + */ +export const splitObjectIdsToSeparateGroups = ( + objectIds: number[] +): number[][] => { + // Define the maximum number of items per group + const ItemsPerGroup = 20; + + // number of groups needed based on the total number of object IDs + const numOfGroups = Math.ceil(objectIds.length / ItemsPerGroup); + + // Initialize an array of empty arrays to hold the separate groups of object IDs + const objectsIdsInSeparateGroups: number[][] = [ + ...new Array(numOfGroups), + ].map(() => []); + + for (let i = 0; i < numOfGroups; i++) { + // Calculate the start and end indices for the current group + const startIdx = i * ItemsPerGroup; + const endIdx = Math.min(startIdx + ItemsPerGroup, objectIds.length); + objectsIdsInSeparateGroups[i] = objectIds.slice(startIdx, endIdx); + } + + return objectsIdsInSeparateGroups; +}; diff --git a/src/shared/services/landsat-level-2/config.ts b/src/shared/services/landsat-level-2/config.ts index ebfb883d..28942f8b 100644 --- a/src/shared/services/landsat-level-2/config.ts +++ b/src/shared/services/landsat-level-2/config.ts @@ -18,6 +18,14 @@ import { TIER, getServiceConfig } from '@shared/config'; import { celsius2fahrenheit } from '@shared/utils/temperature-conversion'; const serviceConfig = getServiceConfig('landsat-level-2'); +// console.log('landsat-level-2 service config', serviceConfig); +// +// const serviceUrls = { +// development: +// 'https://utility.arcgis.com/usrsvcs/servers/f89d8adb0d5141a7a5820e8a6375480e/rest/services/LandsatC2L2/ImageServer', +// production: +// 'https://utility.arcgis.com/usrsvcs/servers/125204cf060644659af558f4f6719b0f/rest/services/LandsatC2L2/ImageServer', +// }; /** * Landsat 8 and 9 multispectral and multitemporal atmospherically corrected imagery with on-the-fly renderings and indices for visualization and analysis. @@ -40,13 +48,13 @@ const LANDSAT_LEVEL_2_ORIGINAL_SERVICE_URL = * Service URL to be used in PROD enviroment */ export const LANDSAT_LEVEL_2_SERVICE_URL_PROD = - serviceConfig?.production || LANDSAT_LEVEL_2_ORIGINAL_SERVICE_URL; + serviceConfig.production || LANDSAT_LEVEL_2_ORIGINAL_SERVICE_URL; /** * Service URL to be used in DEV enviroment */ export const LANDSAT_LEVEL_2_SERVICE_URL_DEV = - serviceConfig?.development || LANDSAT_LEVEL_2_ORIGINAL_SERVICE_URL; + serviceConfig.development || LANDSAT_LEVEL_2_ORIGINAL_SERVICE_URL; /** * A proxy imagery service which has embedded credential that points to the actual Landsat Level-2 imagery service @@ -330,3 +338,7 @@ export const LANDSAT_BAND_NAMES = [ 'Surface Temperature (Kelvin)', 'Surface Temperature QA', ]; + +export const LANDSAT_LEVEL_2_SERVICE_SORT_FIELD = 'best'; + +export const LANDSAT_LEVEL_2_SERVICE_SORT_VALUE = '0'; diff --git a/src/shared/services/landsat-level-2/exportImage.ts b/src/shared/services/landsat-level-2/exportImage.ts deleted file mode 100644 index 6ff14ef1..00000000 --- a/src/shared/services/landsat-level-2/exportImage.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import IExtent from '@arcgis/core/geometry/Extent'; -import { LANDSAT_LEVEL_2_SERVICE_URL } from './config'; -import { getMosaicRuleByObjectId } from './helpers'; - -type ExportImageParams = { - /** - * Map Extent - */ - extent: Pick; - /** - * width of map container - */ - width: number; - /** - * height of map container - */ - height: number; - /** - * raster function name that will be used in the rendering rule - */ - rasterFunctionName: string; - /** - * object Id of the Landsat scene - */ - objectId: number; - abortController: AbortController; -}; - -export const exportImage = async ({ - extent, - width, - height, - rasterFunctionName, - objectId, - abortController, -}: ExportImageParams) => { - const { xmin, xmax, ymin, ymax } = extent; - - const params = new URLSearchParams({ - f: 'image', - bbox: `${xmin},${ymin},${xmax},${ymax}`, - bboxSR: '102100', - imageSR: '102100', - format: 'jpgpng', - size: `${width},${height}`, - mosaicRule: JSON.stringify(getMosaicRuleByObjectId(objectId)), - renderingRule: JSON.stringify({ rasterFunction: rasterFunctionName }), - }); - - const requestURL = `${LANDSAT_LEVEL_2_SERVICE_URL}/exportImage?${params.toString()}`; - - const res = await fetch(requestURL, { signal: abortController.signal }); - - const blob = await res.blob(); - - return blob; -}; diff --git a/src/shared/services/landsat-level-2/getLandsatPixelValues.ts b/src/shared/services/landsat-level-2/getLandsatPixelValues.ts new file mode 100644 index 00000000..9946ab2a --- /dev/null +++ b/src/shared/services/landsat-level-2/getLandsatPixelValues.ts @@ -0,0 +1,79 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from '@arcgis/core/geometry'; +import { LANDSAT_LEVEL_2_SERVICE_URL } from './config'; +import { getPixelValues } from '../helpers/getPixelValues'; + +type GetPixelValuesParams = { + point: Point; + objectIds: number[]; + abortController: AbortController; +}; + +/** + * Run identify task to get values of the pixel that intersects with the input point from the scene with input object id. + * @param param0 + * @returns + */ +export const getLandsatPixelValues = async ({ + point, + objectIds, + abortController, +}: GetPixelValuesParams): Promise => { + // const res = await identify({ + // serviceURL: LANDSAT_LEVEL_2_SERVICE_URL, + // point, + // objectIds, + // abortController, + // }); + + // if ( + // res?.catalogItems?.features && + // res?.catalogItems?.features.length === 0 + // ) { + // throw new Error( + // 'Failed to fetch pixel values. Please select a location inside of the selected landsat scene.' + // ); + // } + + // const bandValues = getPixelValuesFromIdentifyTaskResponse(res); + + // if (!bandValues) { + // throw new Error('Identify task does not return band values'); + // } + + // return bandValues; + + const res = await getPixelValues({ + serviceURL: LANDSAT_LEVEL_2_SERVICE_URL, + point, + objectIds, + abortController, + }); + // console.log(res) + + if (!res.length) { + throw new Error( + 'Failed to fetch pixel values. Please select a location inside of the selected landsat scene.' + ); + } + + if (!res[0]?.values) { + throw new Error('Identify task does not return band values'); + } + + return res[0].values; +}; diff --git a/src/shared/services/landsat-level-2/getLandsatScenes.ts b/src/shared/services/landsat-level-2/getLandsatScenes.ts index cea91159..e333d775 100644 --- a/src/shared/services/landsat-level-2/getLandsatScenes.ts +++ b/src/shared/services/landsat-level-2/getLandsatScenes.ts @@ -21,6 +21,9 @@ import { getFormatedDateString } from '@shared/utils/date-time/formatDateString' import { LandsatScene } from '@typing/imagery-service'; import { DateRange } from '@typing/shared'; import { Point } from '@arcgis/core/geometry'; +import { getFeatureByObjectId } from '../helpers/getFeatureById'; +import { getExtentByObjectId } from '../helpers/getExtentById'; +import { intersectWithImageryScene } from '../helpers/intersectWithImageryScene'; type GetLandsatScenesParams = { /** @@ -300,32 +303,38 @@ export const getLandsatSceneByObjectId = async ( export const getLandsatFeatureByObjectId = async ( objectId: number ): Promise => { - const queryParams = new URLSearchParams({ - f: 'json', - returnGeometry: 'true', - objectIds: objectId.toString(), - outFields: '*', - }); + // const queryParams = new URLSearchParams({ + // f: 'json', + // returnGeometry: 'true', + // objectIds: objectId.toString(), + // outFields: '*', + // }); + + // const res = await fetch( + // `${LANDSAT_LEVEL_2_SERVICE_URL}/query?${queryParams.toString()}` + // ); + + // if (!res.ok) { + // throw new Error('failed to query Landsat-2 service'); + // } - const res = await fetch( - `${LANDSAT_LEVEL_2_SERVICE_URL}/query?${queryParams.toString()}` - ); + // const data = await res.json(); - if (!res.ok) { - throw new Error('failed to query Landsat-2 service'); - } - - const data = await res.json(); + // if (data.error) { + // throw data.error; + // } - if (data.error) { - throw data.error; - } + // if (!data?.features || !data.features.length) { + // return null; + // } - if (!data?.features || !data.features.length) { - return null; - } + // return data?.features[0] as IFeature; - return data?.features[0] as IFeature; + const feature = await getFeatureByObjectId( + LANDSAT_LEVEL_2_SERVICE_URL, + objectId + ); + return feature; }; /** @@ -336,31 +345,37 @@ export const getLandsatFeatureByObjectId = async ( export const getExtentOfLandsatSceneByObjectId = async ( objectId: number ): Promise => { - const queryParams = new URLSearchParams({ - f: 'json', - returnExtentOnly: 'true', - objectIds: objectId.toString(), - }); - - const res = await fetch( - `${LANDSAT_LEVEL_2_SERVICE_URL}/query?${queryParams.toString()}` - ); + // const queryParams = new URLSearchParams({ + // f: 'json', + // returnExtentOnly: 'true', + // objectIds: objectId.toString(), + // }); + + // const res = await fetch( + // `${LANDSAT_LEVEL_2_SERVICE_URL}/query?${queryParams.toString()}` + // ); + + // if (!res.ok) { + // throw new Error('failed to query Landsat-2 service'); + // } - if (!res.ok) { - throw new Error('failed to query Landsat-2 service'); - } + // const data = await res.json(); - const data = await res.json(); + // if (data.error) { + // throw data.error; + // } - if (data.error) { - throw data.error; - } + // if (!data?.extent) { + // return null; + // } - if (!data?.extent) { - return null; - } + // return data?.extent as IExtent; - return data?.extent as IExtent; + const extent = await getExtentByObjectId( + LANDSAT_LEVEL_2_SERVICE_URL, + objectId + ); + return extent; }; /** @@ -374,36 +389,45 @@ export const intersectWithLandsatScene = async ( objectId: number, abortController?: AbortController ) => { - const geometry = JSON.stringify({ - spatialReference: { - wkid: 4326, - }, - x: point.longitude, - y: point.latitude, - }); - - const queryParams = new URLSearchParams({ - f: 'json', - returnCountOnly: 'true', - returnGeometry: 'false', - objectIds: objectId.toString(), - geometry, - spatialRel: 'esriSpatialRelIntersects', - geometryType: 'esriGeometryPoint', - }); + // const geometry = JSON.stringify({ + // spatialReference: { + // wkid: 4326, + // }, + // x: point.longitude, + // y: point.latitude, + // }); + + // const queryParams = new URLSearchParams({ + // f: 'json', + // returnCountOnly: 'true', + // returnGeometry: 'false', + // objectIds: objectId.toString(), + // geometry, + // spatialRel: 'esriSpatialRelIntersects', + // geometryType: 'esriGeometryPoint', + // }); + + // const res = await fetch( + // `${LANDSAT_LEVEL_2_SERVICE_URL}/query?${queryParams.toString()}`, + // { + // signal: abortController.signal, + // } + // ); + + // if (!res.ok) { + // throw new Error('failed to query Landsat-2 service'); + // } - const res = await fetch( - `${LANDSAT_LEVEL_2_SERVICE_URL}/query?${queryParams.toString()}`, - { - signal: abortController.signal, - } - ); + // const data = await res.json(); - if (!res.ok) { - throw new Error('failed to query Landsat-2 service'); - } + // return data?.count && data?.count > 0; - const data = await res.json(); + const res = await intersectWithImageryScene({ + serviceUrl: LANDSAT_LEVEL_2_SERVICE_URL, + objectId, + point, + abortController, + }); - return data?.count && data?.count > 0; + return res; }; diff --git a/src/shared/services/landsat-level-2/getSamples.ts b/src/shared/services/landsat-level-2/getSamples.ts deleted file mode 100644 index c0e9ebab..00000000 --- a/src/shared/services/landsat-level-2/getSamples.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Point } from '@arcgis/core/geometry'; -import { LANDSAT_LEVEL_2_SERVICE_URL } from './config'; - -export type LandsatSampleData = { - locationId: number; - /** - * object id of the landsat imagery scene - */ - rasterId: number; - resolution: number; - /** - * space separated band values returned by the server - */ - value: string; - /** - * array of band values as numerical values - */ - values: number[]; -}; - -export const getSamples = async ( - queryLocation: Point, - objectIds: number[], - controller: AbortController -): Promise => { - // const { x, y, spatialReference } = queryLocation; - - const params = new URLSearchParams({ - f: 'json', - geometry: JSON.stringify(queryLocation), - geometryType: 'esriGeometryPoint', - mosaicRule: JSON.stringify({ - mosaicMethod: 'esriMosaicLockRaster', - ascending: true, - mosaicOperation: 'MT_FIRST', - lockRasterIds: objectIds, - method: 'esriMosaicLockRaster', - operation: 'MT_FIRST', - multidimensionalDefinition: [], - }), - returnFirstValueOnly: 'false', - returnGeometry: 'false', - }); - - const res = await fetch( - `${LANDSAT_LEVEL_2_SERVICE_URL}/getSamples?${params.toString()}`, - { - signal: controller.signal, - } - ); - - if (!res.ok) { - throw new Error('failed to get samples'); - } - - const data = await res.json(); - - if (data.error) { - throw data.error; - } - - const samples: LandsatSampleData[] = data?.samples - ? data.samples.map((d: LandsatSampleData) => { - return { - ...d, - values: d.value.split(' ').map((d) => +d), - }; - }) - : []; - - return samples; -}; diff --git a/src/shared/services/landsat-level-2/getTemporalProfileData.ts b/src/shared/services/landsat-level-2/getTemporalProfileData.ts index 8cf0dc53..e050eb42 100644 --- a/src/shared/services/landsat-level-2/getTemporalProfileData.ts +++ b/src/shared/services/landsat-level-2/getTemporalProfileData.ts @@ -17,9 +17,11 @@ import { Point } from '@arcgis/core/geometry'; import { getLandsatScenes } from './getLandsatScenes'; import { TemporalProfileData, LandsatScene } from '@typing/imagery-service'; import { LANDSAT_LEVEL_2_SERVICE_URL } from './config'; -import { getSamples, LandsatSampleData } from './getSamples'; +// import { getSamples, LandsatSampleData } from './getSamples'; import { checkClearFlagInQABand } from './helpers'; import { getDateRangeForYear } from '@shared/utils/date-time/getTimeRange'; +// import { splitObjectIdsToSeparateGroups } from '../helpers/splitObjectIdsToSeparateGroups'; +import { PixelValuesData, getPixelValues } from '../helpers/getPixelValues'; type GetProfileDataOptions = { queryLocation: Point; @@ -43,35 +45,6 @@ type GetProfileDataOptions = { // let controller: AbortController = null; -/** - * Splits an array of object IDs into separate groups, each containing a maximum of 20 object IDs. - * This is useful when making requests with an upper limit on the number of object IDs. - * - * @param {number[]} objectIds - An array of object IDs to be split into groups. - * @returns {number[][]} An array of arrays, each representing a separate group of object IDs. - */ -const splitObjectIdsToSeparateGroups = (objectIds: number[]): number[][] => { - // Define the maximum number of items per group - const ItemsPerGroup = 20; - - // number of groups needed based on the total number of object IDs - const numOfGroups = Math.ceil(objectIds.length / ItemsPerGroup); - - // Initialize an array of empty arrays to hold the separate groups of object IDs - const objectsIdsInSeparateGroups: number[][] = [ - ...new Array(numOfGroups), - ].map(() => []); - - for (let i = 0; i < numOfGroups; i++) { - // Calculate the start and end indices for the current group - const startIdx = i * ItemsPerGroup; - const endIdx = Math.min(startIdx + ItemsPerGroup, objectIds.length); - objectsIdsInSeparateGroups[i] = objectIds.slice(startIdx, endIdx); - } - - return objectsIdsInSeparateGroups; -}; - /** * Retrieves data for the Trend (Temporal Profile) Tool based on specific criteria. * @@ -122,30 +95,37 @@ export const getDataForTrendTool = async ({ // extract object IDs from refined Landsat scenes. const objectIds = landsatScenesToSample.map((d) => d.objectId); - // divide object IDs into separate groups for parallel fetching. - const objectsIdsInSeparateGroups = - splitObjectIdsToSeparateGroups(objectIds); - // console.log(objectsIdsInSeparateGroups) - - // fetch samples data in parallel for each group of object IDs. - const samplesDataInSeparateGroups: LandsatSampleData[][] = - await Promise.all( - objectsIdsInSeparateGroups.map((oids) => - getSamples(queryLocation, oids, abortController) - ) - ); - - // combine samples data from different groups into a single array. - const samplesData: LandsatSampleData[] = samplesDataInSeparateGroups.reduce( - (combined, subsetOfSamplesData) => { - return [...combined, ...subsetOfSamplesData]; - }, - [] - ); - // console.log(samplesData); + // // divide object IDs into separate groups for parallel fetching. + // const objectsIdsInSeparateGroups = + // splitObjectIdsToSeparateGroups(objectIds); + // // console.log(objectsIdsInSeparateGroups) + + // // fetch samples data in parallel for each group of object IDs. + // const samplesDataInSeparateGroups: LandsatSampleData[][] = + // await Promise.all( + // objectsIdsInSeparateGroups.map((oids) => + // getSamples(queryLocation, oids, abortController) + // ) + // ); + + // // combine samples data from different groups into a single array. + // const samplesData: LandsatSampleData[] = samplesDataInSeparateGroups.reduce( + // (combined, subsetOfSamplesData) => { + // return [...combined, ...subsetOfSamplesData]; + // }, + // [] + // ); + // // console.log(samplesData); + + const pixelValues = await getPixelValues({ + serviceURL: LANDSAT_LEVEL_2_SERVICE_URL, + point: queryLocation, + objectIds, + abortController, + }); const temporalProfileData = formatAsTemporalProfileData( - samplesData, + pixelValues, landsatScenesToSample ); @@ -167,7 +147,7 @@ export const getDataForTrendTool = async ({ * @returns */ const formatAsTemporalProfileData = ( - samples: LandsatSampleData[], + pixelValues: PixelValuesData[], scenes: LandsatScene[] ): TemporalProfileData[] => { const output: TemporalProfileData[] = []; @@ -178,22 +158,21 @@ const formatAsTemporalProfileData = ( sceneByObjectId.set(scene.objectId, scene); } - for (let i = 0; i < samples.length; i++) { - const sampleData = samples[i]; - const { rasterId, values } = sampleData; + for (let i = 0; i < pixelValues.length; i++) { + const sampleData = pixelValues[i]; + const { objectId, values } = sampleData; - if (sceneByObjectId.has(rasterId) === false) { + if (sceneByObjectId.has(objectId) === false) { continue; } // const scene = scenes[i]; const { - objectId, acquisitionDate, acquisitionMonth, acquisitionYear, formattedAcquisitionDate, - } = sceneByObjectId.get(rasterId); + } = sceneByObjectId.get(objectId); output.push({ objectId, diff --git a/src/shared/services/landsat-level-2/helpers.ts b/src/shared/services/landsat-level-2/helpers.ts index 839bbbe9..b5698fd2 100644 --- a/src/shared/services/landsat-level-2/helpers.ts +++ b/src/shared/services/landsat-level-2/helpers.ts @@ -243,14 +243,14 @@ export const parseLandsatInfo = (productId: string): LandsatProductInfo => { }; }; -export const getMosaicRuleByObjectId = (objectId: number) => { - return { - ascending: false, - lockRasterIds: [objectId], - mosaicMethod: 'esriMosaicLockRaster', - where: `objectid in (${objectId})`, - }; -}; +// export const getMosaicRuleByObjectId = (objectId: number) => { +// return { +// ascending: false, +// lockRasterIds: [objectId], +// mosaicMethod: 'esriMosaicLockRaster', +// where: `objectid in (${objectId})`, +// }; +// }; export const getBandIndexesBySpectralIndex = ( spectralIndex: SpectralIndex diff --git a/src/shared/services/landsat-level-2/identify.ts b/src/shared/services/landsat-level-2/identify.ts deleted file mode 100644 index 0146cf58..00000000 --- a/src/shared/services/landsat-level-2/identify.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Geometry, Point } from '@arcgis/core/geometry'; -import { LANDSAT_LEVEL_2_SERVICE_URL } from './config'; -import { IFeature } from '@esri/arcgis-rest-feature-service'; -import { getMosaicRuleByObjectId } from './helpers'; -import { canBeConvertedToNumber } from '@shared/utils/snippets/canBeConvertedToNumber'; - -type IdentifyTaskParams = { - point: Point; - abortController: AbortController; - objectId?: number; -}; - -type IdentifyTaskResponse = { - catalogItems: { - features: IFeature[]; - geometryType: string; - objectIdFieldName: string; - }; - location: Geometry; - value: string; - name: string; - properties: { - Values: string[]; - }; -}; - -/** - * The identify operation is performed on Landsat-2 image service resource. - * It identifies the content of an image service for a given location, mosaic rule, and rendering rule or rules. - * - * @param param0 - * @returns - * - * @see https://developers.arcgis.com/rest/services-reference/enterprise/identify-image-service-.htm - */ -export const identify = async ({ - point, - objectId, - abortController, -}: IdentifyTaskParams): Promise => { - const mosaicRule = objectId - ? getMosaicRuleByObjectId(objectId) - : { - ascending: true, - mosaicMethod: 'esriMosaicAttribute', - mosaicOperation: 'MT_FIRST', - sortField: 'best', - sortValue: '0', - }; - - const params = new URLSearchParams({ - f: 'json', - maxItemCount: '1', - returnGeometry: 'false', - returnCatalogItems: 'true', - geometryType: 'esriGeometryPoint', - geometry: JSON.stringify({ - spatialReference: { - wkid: 4326, - }, - x: point.longitude, - y: point.latitude, - }), - mosaicRule: JSON.stringify(mosaicRule), - }); - - const requestURL = `${LANDSAT_LEVEL_2_SERVICE_URL}/identify?${params.toString()}`; - - const res = await fetch(requestURL, { signal: abortController.signal }); - - const data = await res.json(); - - if (data.error) { - throw data.error; - } - - return data as IdentifyTaskResponse; -}; - -/** - * Get pixel values from Identify Task Response - * @param res - * @returns - */ -export const getPixelValuesFromIdentifyTaskResponse = ( - res: IdentifyTaskResponse -): number[] => { - let bandValues: number[] = null; - - if (res?.value && res?.value !== 'NoData') { - // get pixel values from the value property first - bandValues = res?.value.split(', ').map((d) => +d); - } else if (res?.properties?.Values[0]) { - bandValues = res?.properties?.Values[0].split(' ').map((d) => { - if (canBeConvertedToNumber(d) === false) { - return null; - } - - return +d; - }); - } - - return bandValues; -}; - -/** - * Run identify task to get values of the pixel that intersects with the input point from the scene with input object id. - * @param param0 - * @returns - */ -export const getPixelValues = async ({ - point, - objectId, - abortController, -}: IdentifyTaskParams): Promise => { - const res = await identify({ - point, - objectId, - abortController, - }); - - if ( - res?.catalogItems?.features && - res?.catalogItems?.features.length === 0 - ) { - throw new Error( - 'Failed to fetch pixel values. Please select a location inside of the selected landsat scene.' - ); - } - - const bandValues = getPixelValuesFromIdentifyTaskResponse(res); - - if (!bandValues) { - throw new Error('Identify task does not return band values'); - } - - return bandValues; -}; diff --git a/src/shared/services/sentinel-1/config.ts b/src/shared/services/sentinel-1/config.ts new file mode 100644 index 00000000..c74e6099 --- /dev/null +++ b/src/shared/services/sentinel-1/config.ts @@ -0,0 +1,188 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import { TIER } from '@shared/constants'; +import { TIER, getServiceConfig } from '@shared/config'; + +const serviceConfig = getServiceConfig('sentinel-1'); +// console.log('sentinel-1 service config', serviceConfig); + +// const serviceUrls = { +// development: +// 'https://utility.arcgis.com/usrsvcs/servers/c5f3f9cddbcb45e6b2434dd8eeef8083/rest/services/Sentinel1RTC/ImageServer', +// production: +// 'https://utility.arcgis.com/usrsvcs/servers/c5f3f9cddbcb45e6b2434dd8eeef8083/rest/services/Sentinel1RTC/ImageServer', +// }; + +/** + * Sentinel-1 RTC 10-meter C-band synthetic aperture radar (SAR) imagery in single and dual V-polarization with on-the-fly functions for visualization and unit conversions for analysis. + * This imagery layer is updated daily with the latest available imagery from the Microsoft Planetary Computer data catalog. + * @see https://www.arcgis.com/home/item.html?id=ca91605a3261409aa984f01f7d065fbc + */ +export const SENTINEL_1_ITEM_ID = `ca91605a3261409aa984f01f7d065fbc`; + +/** + * URL of the Sentinel-1 Item on ArcGIS Online + */ +export const SENTINEL_1_ITEM_URL = `https://www.arcgis.com/home/item.html?id=${SENTINEL_1_ITEM_ID}`; + +/** + * This is the original service URL, which will prompt user to sign in by default as it requires subscription + */ +const SENTINEL_1_ORIGINAL_SERVICE_URL = + 'https://sentinel1.imagery1.arcgis.com/arcgis/rest/services/Sentinel1RTC/ImageServer'; + +/** + * Service URL to be used in PROD enviroment + */ +export const SENTINEL_1_SERVICE_URL_PROD = + serviceConfig.production || SENTINEL_1_ORIGINAL_SERVICE_URL; + +/** + * Service URL to be used in DEV enviroment + * + * @see https://sentinel1dev.imagery1.arcgis.com/arcgis/rest/services/Sentinel1RTC/ImageServer/ + */ +export const SENTINEL_1_SERVICE_URL_DEV = + serviceConfig.development || SENTINEL_1_ORIGINAL_SERVICE_URL; + +/** + * A proxy imagery service which has embedded credential that points to the actual Landsat Level-2 imagery service + * @see https://landsat.imagery1.arcgis.com/arcgis/rest/services/LandsatC2L2/ImageServer + */ +export const SENTINEL_1_SERVICE_URL = + TIER === 'development' + ? SENTINEL_1_SERVICE_URL_DEV + : SENTINEL_1_SERVICE_URL_PROD; + +/** + * Field Names Look-up table for Sentinel1RTC (ImageServer) + * @see https://sentinel1.imagery1.arcgis.com/arcgis/rest/services/Sentinel1RTC/ImageServer + */ +export const FIELD_NAMES = { + OBJECTID: 'objectid', + NAME: 'name', + CENTER_X: 'centerx', + CENTER_Y: 'centery', + POLARIZATION_TYPE: 'polarizationtype', + SENSOR: 'sensor', + ORBIT_DIRECTION: 'orbitdirection', + ACQUISITION_DATE: 'acquisitiondate', + ABSOLUTE_ORBIT: 'absoluteorbit', + RELATIVE_ORBIT: 'relativeorbit', +}; + +/** + * List of Raster Functions for the Sentinel-1 service + */ +const SENTINEL1_RASTER_FUNCTIONS = [ + 'False Color dB with DRA', + // 'Sentinel-1 RGB dB', + 'VV dB Colorized', + 'VH dB Colorized', + 'SWI Colorized', + // 'Sentinel-1 DpRVIc Raw', + 'Water Anomaly Index Colorized', + 'SWI Raw', + 'Water Anomaly Index Raw', + 'VV Amplitude with Despeckle', + 'VH Amplitude with Despeckle', + // This RFT creates a two-band raster including VV and VH polarization in power scale, with a despeckling filter applied + 'VV and VH Power with Despeckle', +] as const; + +export type Sentinel1FunctionName = (typeof SENTINEL1_RASTER_FUNCTIONS)[number]; + +/** + * Sentinel-1 Raster Function Infos + * @see https://sentinel1.imagery1.arcgis.com/arcgis/rest/services/Sentinel1RTC/ImageServer/rasterFunctionInfos + */ +export const SENTINEL1_RASTER_FUNCTION_INFOS: { + name: Sentinel1FunctionName; + description: string; + label: string; +}[] = [ + { + name: 'False Color dB with DRA', + description: + 'RGB false color composite of VV, VH, VV/VH in dB scale with a dynamic stretch applied for visualization only.', + label: 'False Color', + }, + { + name: 'VV dB Colorized', + description: + 'VV data in dB scale with a 7x7 Refined Lee despeckling filter and a dynamic stretch applied for visualization only.', + label: 'Colorized VV', + }, + { + name: 'VH dB Colorized', + description: + 'VH data in dB scale with a 7x7 Refined Lee despeckling filter and a dynamic stretch applied for visualization only.', + label: 'Colorized VH', + }, + { + name: 'SWI Colorized', + description: + 'Sentinel-1 Water Index with a color map. Wetlands and moist areas range from light green to dark blue. Computed as (0.1747 * dB_vv) + (0.0082 * dB_vh * dB_vv) + (0.0023 * dB_vv ^ 2) - (0.0015 * dB_vh ^ 2) + 0.1904.', + label: 'Water Index ', + }, + { + name: 'Water Anomaly Index Colorized', + description: + 'Water Anomaly Index with a color map. Increased water anomalies are indicated by bright yellow, orange and red colors. Computed as Ln (0.01 / (0.01 + VV * 2)).', + label: 'Water Anomaly', + }, + // { + // name: 'SWI Raw', + // description: + // 'Sentinel-1 Water Index with a 7x7 Refined Lee despeckling filter for extracting water bodies and monitoring droughts. Computed as (0.1747 * dB_vv) + (0.0082 * dB_vh * dB_vv) + (0.0023 * dB_vv ^ 2) - (0.0015 * dB_vh ^ 2) + 0.1904.', + // label: 'SWI', + // }, + // { + // name: 'Water Anomaly Index Raw', + // description: + // 'Water Anomaly Index with a 7x7 Refined Lee despeckling filter for detecting water pollutants and natural phenomena. For example oil, industrial pollutants, sewage, red ocean tides, seaweed blobs, turbidity, and more. Computed as Ln (0.01 / (0.01 + VV * 2)).', + // label: 'Water Anomaly', + // }, + // { + // name: 'VV Amplitude with Despeckle', + // description: 'VV data in Amplitude scale for computational analysis', + // label: 'VV Amplitude', + // }, + // { + // name: 'VH Amplitude with Despeckle', + // description: 'VH data in Amplitude scale for computational analysis', + // label: 'VH Amplitude', + // }, +]; + +/** + * For the Water Index (SWI), we have selected a input range of -0.3 to 1, though it may need adjustment. + */ +export const SENTINEL1_WATER_INDEX_PIXEL_RANGE: number[] = [-0.5, 1]; + +/** + * For Water Anomaly Index, we can use a input range of -2 to 0. Typically, oil appears within the range of -1 to 0. + */ +export const SENTINEL1_WATER_ANOMALY_INDEX_PIXEL_RANGE: number[] = [-2, 0]; + +/** + * For both ship and urban detection, we can use range of 0 to 1 for the threshold slider + */ +export const SENTINEL1_SHIP_AND_URBAN_INDEX_PIXEL_RANGE: number[] = [0, 1]; + +export const SENTINEL1_SERVICE_SORT_FIELD = 'best'; + +export const SENTINEL1_SERVICE_SORT_VALUE = '-99999999'; diff --git a/src/shared/services/sentinel-1/covert2ImageryScenes.ts b/src/shared/services/sentinel-1/covert2ImageryScenes.ts new file mode 100644 index 00000000..227b2320 --- /dev/null +++ b/src/shared/services/sentinel-1/covert2ImageryScenes.ts @@ -0,0 +1,60 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ImageryScene } from '@shared/store/ImageryScene/reducer'; +import { + Sentinel1OrbitDirection, + Sentinel1Scene, +} from '@typing/imagery-service'; + +export const convert2ImageryScenes = ( + scenes: Sentinel1Scene[], + userSelectedOrbitDirection: Sentinel1OrbitDirection +): ImageryScene[] => { + // convert list of Landsat scenes to list of imagery scenes + const imageryScenes: ImageryScene[] = scenes.map( + (scene: Sentinel1Scene) => { + const { + objectId, + name, + formattedAcquisitionDate, + acquisitionDate, + acquisitionYear, + acquisitionMonth, + orbitDirection, + } = scene; + + const doesNotMeetCriteria = + userSelectedOrbitDirection !== orbitDirection; + + const imageryScene: ImageryScene = { + objectId, + sceneId: name, + formattedAcquisitionDate, + acquisitionDate, + acquisitionYear, + acquisitionMonth, + cloudCover: 0, + doesNotMeetCriteria, + satellite: 'Sentinel-1', + customTooltipText: [orbitDirection], + }; + + return imageryScene; + } + ); + + return imageryScenes; +}; diff --git a/src/shared/services/sentinel-1/getSentinel1Scenes.ts b/src/shared/services/sentinel-1/getSentinel1Scenes.ts new file mode 100644 index 00000000..0eae4222 --- /dev/null +++ b/src/shared/services/sentinel-1/getSentinel1Scenes.ts @@ -0,0 +1,301 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FIELD_NAMES } from './config'; +import { SENTINEL_1_SERVICE_URL } from './config'; +import { IExtent, IFeature } from '@esri/arcgis-rest-feature-service'; +import { getFormatedDateString } from '@shared/utils/date-time/formatDateString'; +import { + Sentinel1OrbitDirection, + Sentinel1Scene, +} from '@typing/imagery-service'; +import { DateRange } from '@typing/shared'; +import { Point } from '@arcgis/core/geometry'; +import { getFeatureByObjectId } from '../helpers/getFeatureById'; +import { getExtentByObjectId } from '../helpers/getExtentById'; +import { intersectWithImageryScene } from '../helpers/intersectWithImageryScene'; + +type GetSentinel1ScenesParams = { + /** + * longitude and latitude (e.g. [-105, 40]) + */ + mapPoint: number[]; + /** + * orbit direction + */ + orbitDirection?: Sentinel1OrbitDirection; + /** + * the relative orbits of sentinel-1 scenes + */ + relativeOrbit?: string; + /** + * acquisition date range. + * + * @example + * ``` + * { + * startDate: '2023-01-01', + * endDate: '2023-12-31' + * } + * ``` + */ + acquisitionDateRange?: DateRange; + /** + * acquisition month + */ + acquisitionMonth?: number; + /** + * acquisition date in formate of `YYYY-MM-DD` (e.g. `2023-05-26`) + */ + acquisitionDate?: string; + /** + * abortController that will be used to cancel the unfinished requests + */ + abortController: AbortController; +}; + +// let controller:AbortController = null; + +const { + OBJECTID, + ACQUISITION_DATE, + NAME, + SENSOR, + ORBIT_DIRECTION, + POLARIZATION_TYPE, + ABSOLUTE_ORBIT, + RELATIVE_ORBIT, +} = FIELD_NAMES; + +/** + * A Map that will be used to retrieve Sentinel-1 Scene data using the object Id as key + */ +const sentinel1SceneByObjectId: Map = new Map(); + +/** + * Formats the features from Sentinel-1 service and returns an array of Sentinel-1 objects. + * @param features - An array of IFeature objects from Sentinel-1 service. + * @returns An array of Sentinel1Scene objects containing the acquisition date, formatted acquisition date, and other attributes. + */ +export const getFormattedSentinel1Scenes = ( + features: IFeature[] +): Sentinel1Scene[] => { + return features.map((feature) => { + const { attributes } = feature; + + const acquisitionDate = attributes[ACQUISITION_DATE]; + + const name = attributes[NAME]; + + /** + * formatted aquisition date should be like `2023-05-01` + */ + const formattedAcquisitionDate = getFormatedDateString({ + date: +acquisitionDate, + }); //format(acquisitionDate, 'yyyy-MM-dd'); + + const [acquisitionYear, acquisitionMonth] = formattedAcquisitionDate + .split('-') + .map((d) => +d); + + const formattedScene: Sentinel1Scene = { + objectId: attributes[OBJECTID], + name, + sensor: attributes[SENSOR], + orbitDirection: attributes[ORBIT_DIRECTION], + polarizationType: attributes[POLARIZATION_TYPE], + absoluteOrbit: attributes[ABSOLUTE_ORBIT], + relativeOrbit: attributes[RELATIVE_ORBIT], + acquisitionDate, + formattedAcquisitionDate, + acquisitionYear, + acquisitionMonth, + }; + + return formattedScene; + }); +}; + +/** + * Query the Sentinel-1 imagery service to find a list of scenes for available Sentinel-1 data that + * intersect with the input map point or map extent and were acquired during the input year and month. + * + * @param {number} params - The params object that will be used query Sentinel-1 data + * @returns {Promise} A promise that resolves to an array of Sentinel1Scene objects. + * + */ +export const getSentinel1Scenes = async ({ + mapPoint, + // acquisitionYear, + acquisitionDateRange, + orbitDirection, + relativeOrbit, + acquisitionMonth, + acquisitionDate, + abortController, +}: GetSentinel1ScenesParams): Promise => { + const whereClauses = [ + // `(${CATEGORY} = 1)` + ]; + + if (acquisitionDateRange) { + whereClauses.push( + `(${ACQUISITION_DATE} BETWEEN timestamp '${acquisitionDateRange.startDate} 00:00:00' AND timestamp '${acquisitionDateRange.endDate} 23:59:59')` + ); + } else if (acquisitionDate) { + // if acquisitionDate is provided, only query scenes that are acquired on this date, + // otherwise, query scenes that were acquired within the acquisitionYear year + whereClauses.push( + `(${ACQUISITION_DATE} BETWEEN timestamp '${acquisitionDate} 00:00:00' AND timestamp '${acquisitionDate} 23:59:59')` + ); + } + + // if (acquisitionMonth) { + // whereClauses.push(`(${MONTH} = ${acquisitionMonth})`); + // } + + if (orbitDirection) { + whereClauses.push(`(${ORBIT_DIRECTION} = '${orbitDirection}')`); + } else { + whereClauses.push(`(${ORBIT_DIRECTION} is NOT NULL)`); + } + + if (relativeOrbit) { + whereClauses.push(`(${RELATIVE_ORBIT} = ${relativeOrbit})`); + } + + // we should only include scenes with dual polarization + whereClauses.push(`(${POLARIZATION_TYPE} = 'Dual')`); + + const [longitude, latitude] = mapPoint; + + const geometry = JSON.stringify({ + spatialReference: { + wkid: 4326, + }, + x: longitude, + y: latitude, + }); + + const params = new URLSearchParams({ + f: 'json', + spatialRel: 'esriSpatialRelIntersects', + // geometryType: 'esriGeometryEnvelope', + geometryType: 'esriGeometryPoint', + outFields: '*', + orderByFields: ACQUISITION_DATE, + resultOffset: '0', + returnGeometry: 'false', + resultRecordCount: '1000', + geometry, + where: whereClauses.join(` AND `), + }); + + const res = await fetch( + `${SENTINEL_1_SERVICE_URL}/query?${params.toString()}`, + { + signal: abortController.signal, + } + ); + + if (!res.ok) { + throw new Error('failed to query Sentinel-1 Imagery service'); + } + + const data = await res.json(); + + if (data.error) { + throw data.error; + } + + const sentinel1Scenes: Sentinel1Scene[] = getFormattedSentinel1Scenes( + data?.features || [] + ); + + // save the sentinel-1 scenes to `sentinel1SceneByObjectId` map + for (const sentinel1Scene of sentinel1Scenes) { + sentinel1SceneByObjectId.set(sentinel1Scene.objectId, sentinel1Scene); + } + + return sentinel1Scenes; +}; + +/** + * Query a feature from Sentinel-1 service using the input object Id, + * and return the feature as formatted Sentinel Scene. + * @param objectId The unique identifier of the feature + * @returns The formatted Sentinel-11 Scene corresponding to the objectId + */ +export const getSentinel1SceneByObjectId = async ( + objectId: number, + abortController?: AbortController +): Promise => { + // Check if the Sentinel-1 scene already exists in the cache + if (sentinel1SceneByObjectId.has(objectId)) { + return sentinel1SceneByObjectId.get(objectId); + } + + const feature = await getFeatureByObjectId( + SENTINEL_1_SERVICE_URL, + objectId, + abortController + ); + + if (!feature) { + return null; + } + + const sentinel1Scene: Sentinel1Scene = getFormattedSentinel1Scenes([ + feature, + ])[0]; + + sentinel1SceneByObjectId.set(objectId, sentinel1Scene); + + return sentinel1Scene; +}; + +/** + * Get the extent of a feature from Sentinel-1 service using the object Id as key. + * @param objectId The unique identifier of the feature + * @returns IExtent The extent of the feature from Sentinel-1 service + */ +export const getExtentOfSentinel1SceneByObjectId = async ( + objectId: number +): Promise => { + const extent = await getExtentByObjectId(SENTINEL_1_SERVICE_URL, objectId); + + return extent; +}; + +/** + * Check if the input point intersects with the Sentinel-1 scene specified by the input object ID. + * @param point Point geometry representing the location to check for intersection. + * @param objectId Object ID of the Sentinel-1 scene to check intersection with. + * @returns {boolean} Returns true if the input point intersects with the specified Sentinel-1 scene, otherwise false. + */ +export const intersectWithSentinel1Scene = async ( + point: Point, + objectId: number, + abortController?: AbortController +) => { + const res = await intersectWithImageryScene({ + serviceUrl: SENTINEL_1_SERVICE_URL, + objectId, + point, + abortController, + }); + + return res; +}; diff --git a/src/shared/services/sentinel-1/getTemporalProfileData.ts b/src/shared/services/sentinel-1/getTemporalProfileData.ts new file mode 100644 index 00000000..86f9dcc5 --- /dev/null +++ b/src/shared/services/sentinel-1/getTemporalProfileData.ts @@ -0,0 +1,229 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Point } from '@arcgis/core/geometry'; +import { + Sentinel1OrbitDirection, + Sentinel1Scene, + TemporalProfileData, +} from '@typing/imagery-service'; +import { + getSentinel1SceneByObjectId, + getSentinel1Scenes, +} from './getSentinel1Scenes'; +import { getDateRangeForYear } from '@shared/utils/date-time/getTimeRange'; +// import { splitObjectIdsToSeparateGroups } from '../helpers/splitObjectIdsToSeparateGroups'; +// import { identify } from '../helpers/identify'; +import { SENTINEL_1_SERVICE_URL } from './config'; +import { PixelValuesData, getPixelValues } from '../helpers/getPixelValues'; + +type GetSentinel1TemporalProfileDataOptions = { + queryLocation: Point; + /** + * acquisition month to be used to fetch temporal trend data for a given month (Year to Year) + */ + acquisitionMonth: number; + /** + * acquisition year to be used to fetch temporal trend data for a given year (Month to Month) + */ + acquisitionYear: number; + /** + * object id of selected sentinel-1 scene + */ + objectId: number; + // /** + // * orbit direction + // */ + // orbitDirection: Sentinel1OrbitDirection; + /** + * abortController that will be used to cancel the pending requests + */ + abortController: AbortController; +}; + +export const getSentinel1TemporalProfileData = async ({ + queryLocation, + // orbitDirection, + objectId, + acquisitionMonth, + acquisitionYear, + abortController, +}: GetSentinel1TemporalProfileDataOptions): Promise => { + const { x, y } = queryLocation; + + let sentinel1Scenes: Sentinel1Scene[] = []; + + // get data of selected sentinel-1 scene and use the orbit direction of this scene to query temporal profile data + const selectedScene = await getSentinel1SceneByObjectId(objectId); + const orbitDirection = selectedScene.orbitDirection; + + if (acquisitionMonth) { + // query Sentinel-1 scenes based on input location and acquisition month to show "year-to-year" trend + sentinel1Scenes = await getSentinel1Scenes({ + mapPoint: [x, y], + // dualPolarizationOnly: true, + orbitDirection, + acquisitionMonth, + abortController, + }); + } else if (acquisitionYear) { + // query Sentinel-1 scenes based on input location and acquisition year to show "month-to-month" trend + sentinel1Scenes = await getSentinel1Scenes({ + mapPoint: [x, y], + // dualPolarizationOnly: true, + orbitDirection, + acquisitionDateRange: getDateRangeForYear(acquisitionYear), + abortController, + }); + } + + if (!sentinel1Scenes.length) { + return []; + } + + // refine Sentinel1 scenes and only keep one scene for each month + const sentinel1ScenesToSample = getSentinel1ScenesToSample( + sentinel1Scenes, + acquisitionMonth + ); + // console.log(sentinel1ScenesToSample) + + // extract object IDs from refined Sentinel-1 scenes. + const objectIds = sentinel1ScenesToSample.map((d) => d.objectId); + // console.log(objectIds) + + const pixelValues = await getPixelValues({ + serviceURL: SENTINEL_1_SERVICE_URL, + point: queryLocation, + objectIds, + abortController, + }); + // console.log(pixelValues); + + const temporalProfileData = formatAsTemporalProfileData( + pixelValues, + sentinel1ScenesToSample + ); + // console.log(temporalProfileData); + + return temporalProfileData; +}; + +/** + * Filters the input Sentinel1 scenes to retain only one scene per month. + * If a specific acquisition month is provided, only scenes from that month are considered. + * The selected scene for each month is the first scene of that month in the sorted list. + * + * @param scenes - An array of Sentinel1 scenes. + * @param acquisitionMonth - An optional user-selected month to filter scenes by. + * If provided, only scenes from this month will be kept. + * @returns An array of Sentinel1 scenes, with only one scene per month. + */ +const getSentinel1ScenesToSample = ( + scenes: Sentinel1Scene[], + acquisitionMonth?: number +): Sentinel1Scene[] => { + if (!scenes.length) { + return []; + } + + scenes.sort((a, b) => a.acquisitionDate - b.acquisitionDate); + // console.log(scenes); + + const candidates: Sentinel1Scene[] = [scenes[0]]; + + const { relativeOrbit, orbitDirection } = scenes[0]; + + for (let i = 1; i < scenes.length; i++) { + const prevScene = candidates[candidates.length - 1]; + + const currentScene = scenes[i]; + + // skip if the relative orbit and orbit direction of the current scene + // is not the same as the first scene + if ( + currentScene.relativeOrbit !== relativeOrbit || + currentScene.orbitDirection !== orbitDirection + ) { + continue; + } + + // If an acquisition month is provided, only keep scenes from that month. + if ( + acquisitionMonth && + currentScene.acquisitionMonth !== acquisitionMonth + ) { + continue; + } + + // add current scene to candidates if it was acquired from a different year or month + if ( + prevScene?.acquisitionYear !== currentScene.acquisitionYear || + prevScene?.acquisitionMonth !== currentScene.acquisitionMonth + ) { + candidates.push(currentScene); + continue; + } + } + + return candidates; +}; + +/** + * Create Temporal Profiles Data by combining pixel values data and imagery scene data + * @param samples + * @param scenes Sentinel-1 Imagery Scenes + * @returns + */ +const formatAsTemporalProfileData = ( + pixelValues: PixelValuesData[], + scenes: Sentinel1Scene[] +): TemporalProfileData[] => { + const output: TemporalProfileData[] = []; + + const sceneByObjectId = new Map(); + + for (const scene of scenes) { + sceneByObjectId.set(scene.objectId, scene); + } + + for (let i = 0; i < pixelValues.length; i++) { + const d = pixelValues[i]; + const { objectId, values } = d; + + if (sceneByObjectId.has(objectId) === false) { + continue; + } + + // const scene = scenes[i]; + const { + acquisitionDate, + acquisitionMonth, + acquisitionYear, + formattedAcquisitionDate, + } = sceneByObjectId.get(objectId); + + output.push({ + objectId, + acquisitionDate, + acquisitionMonth, + acquisitionYear, + formattedAcquisitionDate, + values, + }); + } + + return output; +}; diff --git a/src/shared/services/sentinel-1/getTimeExtent.ts b/src/shared/services/sentinel-1/getTimeExtent.ts new file mode 100644 index 00000000..3e682d63 --- /dev/null +++ b/src/shared/services/sentinel-1/getTimeExtent.ts @@ -0,0 +1,49 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ImageryServiceTimeExtentData } from '@typing/imagery-service'; +import { SENTINEL_1_SERVICE_URL } from './config'; +import { getTimeExtent } from '../helpers/getTimeExtent'; + +let timeExtentData: ImageryServiceTimeExtentData = null; + +/** + * Get Landsat layer's time extent + * @returns TimeExtentData + * + * @example + * Usage + * ``` + * getTimeExtent() + * ``` + * + * Returns + * ``` + * { + * start: 1363622294000, + * end: 1683500585000 + * } + * ``` + */ +export const getTimeExtentOfSentinel1Service = + async (): Promise => { + if (timeExtentData) { + return timeExtentData; + } + + const data = await getTimeExtent(SENTINEL_1_SERVICE_URL); + + return (timeExtentData = data); + }; diff --git a/src/shared/services/sentinel-1/helper.ts b/src/shared/services/sentinel-1/helper.ts new file mode 100644 index 00000000..5e251fe5 --- /dev/null +++ b/src/shared/services/sentinel-1/helper.ts @@ -0,0 +1,61 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RadarIndex } from '@typing/imagery-service'; + +export const calcRadarIndex = ( + indexName: RadarIndex, + bandValues: number[] +): number => { + let [VV, VH] = bandValues; + + let value = 0; + + // The raw pixel values are in Power Scale already + // For SWI, we will need to convert the Power Scale to dB and then calculate the index + // @see https://github.com/vannizhang/imagery-explorer-apps-private/issues/28 + if (indexName === 'water') { + VV = 10 * Math.log10(VV); + VH = 10 * Math.log10(VH); + } + + // Calculate the value based on the input radar index + if (indexName === 'water') { + value = + 0.1747 * VV + + 0.0082 * VH * VV + + ((0.0023 * VV) ^ 2) - + ((0.0015 * VH) ^ 2) + + 0.1904; + } else if (indexName === 'water anomaly') { + value = 0.01 / (0.01 + VV * 2); + } + + return value; +}; + +export const getSentinel1PixelValueRangeByRadarIndex = ( + indexName: RadarIndex +) => { + if (indexName === 'water anomaly') { + return [-2, 0]; + } + + if (indexName === 'water') { + return [-0.5, 1]; + } + + return [0, 0]; +}; diff --git a/src/shared/store/ChangeCompareTool/reducer.ts b/src/shared/store/ChangeCompareTool/reducer.ts index a80b62f9..7a2b1ce0 100644 --- a/src/shared/store/ChangeCompareTool/reducer.ts +++ b/src/shared/store/ChangeCompareTool/reducer.ts @@ -19,13 +19,13 @@ import { PayloadAction, // createAsyncThunk } from '@reduxjs/toolkit'; -import { SpectralIndex } from '@typing/imagery-service'; +import { RadarIndex, SpectralIndex } from '@typing/imagery-service'; export type ChangeCompareToolState = { /** - * use selected spectral index + * user selected option that will be used to create raster function to compare change between two imagery scenes */ - spectralIndex: SpectralIndex; + selectedOption: SpectralIndex | RadarIndex | string; /** * if true, the change compare layer is visible in the map */ @@ -34,23 +34,28 @@ export type ChangeCompareToolState = { * user selected pixel value range, the full range of pixel values of change compare layer should be between -2 and 2 */ selectedRange: number[]; + /** + * the full range of pixel values + */ + fullPixelValuesRange: number[]; }; export const initialChangeCompareToolState: ChangeCompareToolState = { - spectralIndex: 'vegetation', + selectedOption: 'vegetation', changeCompareLayerIsOn: false, selectedRange: [-2, 2], + fullPixelValuesRange: [-2, 2], }; const slice = createSlice({ name: 'ChangeCompareTool', initialState: initialChangeCompareToolState, reducers: { - spectralIndex4ChangeCompareToolChanged: ( + selectedOption4ChangeCompareToolChanged: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.spectralIndex = action.payload; + state.selectedOption = action.payload; }, changeCompareLayerIsOnUpdated: ( state, @@ -61,15 +66,22 @@ const slice = createSlice({ selectedRangeUpdated: (state, action: PayloadAction) => { state.selectedRange = action.payload; }, + fullPixelValuesRangeUpdated: ( + state, + action: PayloadAction + ) => { + state.fullPixelValuesRange = action.payload; + }, }, }); const { reducer } = slice; export const { - spectralIndex4ChangeCompareToolChanged, + selectedOption4ChangeCompareToolChanged, changeCompareLayerIsOnUpdated, selectedRangeUpdated, + fullPixelValuesRangeUpdated, } = slice.actions; export default reducer; diff --git a/src/shared/store/ChangeCompareTool/selectors.ts b/src/shared/store/ChangeCompareTool/selectors.ts index 5155ad2d..b4da98e3 100644 --- a/src/shared/store/ChangeCompareTool/selectors.ts +++ b/src/shared/store/ChangeCompareTool/selectors.ts @@ -16,9 +16,9 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '../configureStore'; -export const selectSpectralIndex4ChangeCompareTool = createSelector( - (state: RootState) => state.ChangeCompareTool.spectralIndex, - (spectralIndex) => spectralIndex +export const selectSelectedOption4ChangeCompareTool = createSelector( + (state: RootState) => state.ChangeCompareTool.selectedOption, + (selectedOption) => selectedOption ); export const selectChangeCompareLayerIsOn = createSelector( @@ -31,6 +31,11 @@ export const selectUserSelectedRangeInChangeCompareTool = createSelector( (selectedRange) => selectedRange ); +export const selectFullPixelValuesRangeInChangeCompareTool = createSelector( + (state: RootState) => state.ChangeCompareTool.fullPixelValuesRange, + (fullPixelValuesRange) => fullPixelValuesRange +); + export const selectChangeCompareToolState = createSelector( (state: RootState) => state.ChangeCompareTool, (ChangeCompareTool) => ChangeCompareTool diff --git a/src/shared/store/ChangeCompareTool/thunks.ts b/src/shared/store/ChangeCompareTool/thunks.ts index 30b8f2ae..46e4ab4e 100644 --- a/src/shared/store/ChangeCompareTool/thunks.ts +++ b/src/shared/store/ChangeCompareTool/thunks.ts @@ -13,13 +13,83 @@ * limitations under the License. */ +import { DateRange } from '@typing/shared'; +import { StoreDispatch, StoreGetState } from '../configureStore'; +import { useSelector } from 'react-redux'; +import { + selectQueryParams4MainScene, + selectQueryParams4SecondaryScene, +} from '../ImageryScene/selectors'; +import { getDateRangeForPast12Month } from '@shared/utils/date-time/getTimeRange'; +import { + queryParams4MainSceneChanged, + queryParams4SecondarySceneChanged, +} from '../ImageryScene/reducer'; +import { batch } from 'react-redux'; + // import Point from '@arcgis/core/geometry/Point'; // import { RootState, StoreDispatch, StoreGetState } from '../configureStore'; // let abortController: AbortController = null; -// export const thunkFunction = -// (point: Point) => -// async (dispatch: StoreDispatch, getState: StoreGetState) => { +const DATE_RANGE_OF_PAST_12_MONTH = getDateRangeForPast12Month(); + +/** + * This thunk function updates the query parameters of Imagery Scenes for the Change Compare Tool + * to sync with the default acquisition date range (past 12 months) using the updated date range + * provided by the user. + * + * This is done to prevent the calendar from jumping between the "past 12 months" and a selected year, + * which might cause confusion for users. In other words, we want imagery scenes used by Change Compare Tool that don't have a + * user-selected acquisition date range to inherit the date range that the user selected for other imagery scenes. + * + * @param updatedDateRange - The new date range to apply to imagery scenes with the default date range. + */ +export const syncImageryScenesDateRangeForChangeCompareTool = + (updatedDateRange: DateRange) => + async (dispatch: StoreDispatch, getState: StoreGetState) => { + const storeState = getState(); + + const queryParams4MainScene = selectQueryParams4MainScene(storeState); + const queryParams4SecondaryScene = + selectQueryParams4SecondaryScene(storeState); + + const updatedQueryParams4MainScene = { ...queryParams4MainScene }; + const updatedQueryParams4SecondaryScene = { + ...queryParams4SecondaryScene, + }; + + // If the acquisition date range matches the default "past 12 months" date range, + // update it to the new date range provided by the user + if ( + updatedQueryParams4MainScene.acquisitionDateRange.startDate === + DATE_RANGE_OF_PAST_12_MONTH.startDate && + updatedQueryParams4MainScene.acquisitionDateRange.endDate === + DATE_RANGE_OF_PAST_12_MONTH.endDate + ) { + updatedQueryParams4MainScene.acquisitionDateRange = + updatedDateRange; + } + + if ( + updatedQueryParams4SecondaryScene.acquisitionDateRange.startDate === + DATE_RANGE_OF_PAST_12_MONTH.startDate && + updatedQueryParams4SecondaryScene.acquisitionDateRange.endDate === + DATE_RANGE_OF_PAST_12_MONTH.endDate + ) { + updatedQueryParams4SecondaryScene.acquisitionDateRange = + updatedDateRange; + } + + batch(() => { + dispatch( + queryParams4MainSceneChanged(updatedQueryParams4MainScene) + ); -// }; + dispatch( + queryParams4SecondarySceneChanged( + updatedQueryParams4SecondaryScene + ) + ); + }); + }; diff --git a/src/shared/store/ImageryScene/reducer.ts b/src/shared/store/ImageryScene/reducer.ts index d4153d1d..fd39c7bd 100644 --- a/src/shared/store/ImageryScene/reducer.ts +++ b/src/shared/store/ImageryScene/reducer.ts @@ -19,10 +19,10 @@ import { PayloadAction, // createAsyncThunk } from '@reduxjs/toolkit'; -import { getCurrentYear } from '@shared/utils/date-time/getCurrentDateTime'; +// import { getCurrentYear } from '@shared/utils/date-time/getCurrentDateTime'; import { getDateRangeForPast12Month, - getDateRangeForYear, + // getDateRangeForYear, } from '@shared/utils/date-time/getTimeRange'; import { DateRange } from '@typing/shared'; @@ -40,7 +40,12 @@ export type AppMode = /** * the imagery explorer app supports these analysis tools */ -export type AnalysisTool = 'mask' | 'trend' | 'spectral' | 'change'; +export type AnalysisTool = + | 'mask' + | 'trend' + | 'spectral' + | 'change' + | 'temporal composite'; /** * Query Params and Rendering Options for a Imagery Scene (e.g. Landsat or Sentinel-2) @@ -67,14 +72,6 @@ export type QueryParams4ImageryScene = { * unique id of the query params that is associated with this Imagery Scene */ uniqueId?: string; - // /** - // * This property represents the acquisition year inherited from the previously selected item in the listOfQueryParams. - // * - // * Normally, the current year is used as the default acquisition year for new query parameters. - // * However, to enhance the user experience in animation mode, we retain the acquisition year from the previous frame. - // * This ensures a seamless workflow, allowing users to seamlessly continue their work on the same year as the prior animation frame. - // */ - // inheritedAcquisitionYear?: number; /** * User selected acquisition date range that will be used to query available imagery scenes. */ @@ -112,6 +109,14 @@ export type ImageryScene = { * percent of cloud cover, the value ranges from 0 - 1 */ cloudCover: number; + /** + * Flag indicating if the imagery scene does not meet all user-selected criteria + */ + doesNotMeetCriteria?: boolean; + /** + * custom text to be displayed in the calendar component + */ + customTooltipText?: string[]; }; export type ImageryScenesState = { @@ -163,10 +168,12 @@ export type ImageryScenesState = { * user selected cloud coverage threshold, the value ranges from 0 to 1 */ cloudCover: number; - // /** - // * user selected acquisiton year that will be used to find list of available imagery scenes - // */ - // acquisitionYear: number; + /** + * Flag indicating whether the scene should be forcibly reselected. + * If true, the default logic (inside of `useFindSelectedSceneByDate` custom hook) of keeping with the currently selected scene + * will be overridden, and the scene will be reselected from the new list of scenes. + */ + shouldForceSceneReselection: boolean; }; export const DefaultQueryParams4ImageryScene: QueryParams4ImageryScene = { @@ -197,7 +204,7 @@ export const initialImagerySceneState: ImageryScenesState = { objectIds: [], }, cloudCover: 0.5, - // acquisitionYear: getCurrentYear(), + shouldForceSceneReselection: false, }; const slice = createSlice({ @@ -303,6 +310,12 @@ const slice = createSlice({ byObjectId, }; }, + shouldForceSceneReselectionUpdated: ( + state, + action: PayloadAction + ) => { + state.shouldForceSceneReselection = action.payload; + }, }, }); @@ -320,7 +333,7 @@ export const { cloudCoverChanged, activeAnalysisToolChanged, availableImageryScenesUpdated, - // acquisitionYearChanged, + shouldForceSceneReselectionUpdated, } = slice.actions; export default reducer; diff --git a/src/shared/store/ImageryScene/selectors.ts b/src/shared/store/ImageryScene/selectors.ts index 8a892a9e..04220f33 100644 --- a/src/shared/store/ImageryScene/selectors.ts +++ b/src/shared/store/ImageryScene/selectors.ts @@ -38,14 +38,21 @@ export const selectQueryParams4SceneInSelectedMode = createSelector( } if (mode === 'analysis') { - if (activeAnalysisTool !== 'change') { - return queryParams4MainScene; + // when in 'change compare' tool, we need to find the query params based on selected scene + if (activeAnalysisTool === 'change') { + return isSecondarySceneActive + ? queryParams4SecondaryScene + : queryParams4MainScene; } - // when in 'change compare' tool, we need to find the query params based on selected scene - return isSecondarySceneActive - ? queryParams4SecondaryScene - : queryParams4MainScene; + // For the 'temporal composite' tool, there are three items in queryParamsList that represent the imagery scenes + // to be used for the red, green, and blue bands in that respective order. + // The queryParamsList and selectedItemID are configured by the `initiateImageryScenes4TemporalCompositeTool` thunk function. + if (activeAnalysisTool === 'temporal composite') { + return queryParamsList.byId[selectedItemID] || null; + } + + return queryParams4MainScene; } if (mode === 'swipe') { @@ -131,3 +138,8 @@ export const selectAvailableScenes = createSelector( return objectIds.map((objectId) => byObjectId[objectId]); } ); + +export const selectShouldForceSceneReselection = createSelector( + (state: RootState) => state.ImageryScenes.shouldForceSceneReselection, + (shouldForceSceneReselection) => shouldForceSceneReselection +); diff --git a/src/shared/store/ImageryScene/thunks.ts b/src/shared/store/ImageryScene/thunks.ts index bdfec4c8..f854d290 100644 --- a/src/shared/store/ImageryScene/thunks.ts +++ b/src/shared/store/ImageryScene/thunks.ts @@ -57,15 +57,31 @@ export const updateQueryParams4SceneInSelectedMode = return; } - if (mode === 'analysis' && analysisTool !== 'change') { + if (mode === 'analysis') { + if (analysisTool === 'change') { + if (isSecondarySceneActive) { + dispatch( + queryParams4SecondarySceneChanged(updatedQueryParams) + ); + } else { + dispatch(queryParams4MainSceneChanged(updatedQueryParams)); + } + + return; + } + + if (analysisTool === 'temporal composite') { + dispatch( + queryParams4SelectedItemInListChanged(updatedQueryParams) + ); + return; + } + dispatch(queryParams4MainSceneChanged(updatedQueryParams)); return; } - if ( - mode === 'swipe' || - (mode === 'analysis' && analysisTool === 'change') - ) { + if (mode === 'swipe') { if (isSecondarySceneActive) { dispatch(queryParams4SecondarySceneChanged(updatedQueryParams)); } else { @@ -77,6 +93,7 @@ export const updateQueryParams4SceneInSelectedMode = if (mode === 'animate' || mode === 'spectral sampling') { dispatch(queryParams4SelectedItemInListChanged(updatedQueryParams)); + return; } }; diff --git a/src/shared/store/Landsat/thunks.ts b/src/shared/store/Landsat/thunks.ts index 81f2bd15..d3f2099b 100644 --- a/src/shared/store/Landsat/thunks.ts +++ b/src/shared/store/Landsat/thunks.ts @@ -14,11 +14,7 @@ */ import { batch } from 'react-redux'; -import { - // getLandsatFeatureByObjectId, - // getLandsatSceneByObjectId, - getLandsatScenes, -} from '@shared/services/landsat-level-2/getLandsatScenes'; +import { getLandsatScenes } from '@shared/services/landsat-level-2/getLandsatScenes'; import { selectMapCenter } from '../Map/selectors'; import { RootState, StoreDispatch, StoreGetState } from '../configureStore'; import { landsatScenesUpdated } from './reducer'; @@ -35,6 +31,7 @@ import { } from '../ImageryScene/reducer'; import { DateRange } from '@typing/shared'; import { selectQueryParams4SceneInSelectedMode } from '../ImageryScene/selectors'; +import { deduplicateListOfImageryScenes } from '@shared/services/helpers/deduplicateListOfScenes'; let abortController: AbortController = null; /** @@ -66,7 +63,7 @@ export const queryAvailableScenes = ); // get scenes that were acquired within the acquisition year - const scenes = await getLandsatScenes({ + const landsatScenes = await getLandsatScenes({ acquisitionDateRange, mapPoint: center, abortController, @@ -122,39 +119,39 @@ export const queryAvailableScenes = // } // } - // sort scenes uing acquisition date in an ascending order - // which is necessary for us to select between two overlapping scenes in step below - scenes.sort((a, b) => a.acquisitionDate - b.acquisitionDate); - - const landsatScenes: LandsatScene[] = []; - - for (const currScene of scenes) { - // Get the last Landsat scene in the 'landsatScenes' array - const prevScene = landsatScenes[landsatScenes.length - 1]; - - // Check if there is a previous scene and its acquisition date matches the current scene. - // We aim to keep only one Landsat scene for each day. When there are two scenes acquired on the same date, - // we prioritize keeping the currently selected scene or the one acquired later. - if ( - prevScene && - prevScene.formattedAcquisitionDate === - currScene.formattedAcquisitionDate - ) { - // Check if the previous scene is the currently selected scene - // Skip the current iteration if the previous scene is the selected scene - if (prevScene.objectId === objectIdOfSelectedScene) { - continue; - } - - // Remove the previous scene from 'landsatScenes' as it was acquired before the current scene - landsatScenes.pop(); - } + // // sort scenes uing acquisition date in an ascending order + // // which is necessary for us to select between two overlapping scenes in step below + // scenes.sort((a, b) => a.acquisitionDate - b.acquisitionDate); + + // const landsatScenes: LandsatScene[] = []; + + // for (const currScene of scenes) { + // // Get the last Landsat scene in the 'landsatScenes' array + // const prevScene = landsatScenes[landsatScenes.length - 1]; + + // // Check if there is a previous scene and its acquisition date matches the current scene. + // // We aim to keep only one Landsat scene for each day. When there are two scenes acquired on the same date, + // // we prioritize keeping the currently selected scene or the one acquired later. + // if ( + // prevScene && + // prevScene.formattedAcquisitionDate === + // currScene.formattedAcquisitionDate + // ) { + // // Check if the previous scene is the currently selected scene + // // Skip the current iteration if the previous scene is the selected scene + // if (prevScene.objectId === objectIdOfSelectedScene) { + // continue; + // } + + // // Remove the previous scene from 'landsatScenes' as it was acquired before the current scene + // landsatScenes.pop(); + // } - landsatScenes.push(currScene); - } + // landsatScenes.push(currScene); + // } // convert list of Landsat scenes to list of imagery scenes - const imageryScenes: ImageryScene[] = landsatScenes.map( + let imageryScenes: ImageryScene[] = landsatScenes.map( (landsatScene: LandsatScene) => { const { objectId, @@ -176,12 +173,20 @@ export const queryAvailableScenes = acquisitionMonth, cloudCover, satellite, + customTooltipText: [ + `${Math.ceil(cloudCover * 100)}% Cloudy`, + ], }; return imageryScene; } ); + imageryScenes = deduplicateListOfImageryScenes( + imageryScenes, + objectIdOfSelectedScene + ); + batch(() => { dispatch(landsatScenesUpdated(landsatScenes)); dispatch(availableImageryScenesUpdated(imageryScenes)); diff --git a/src/shared/store/Map/reducer.ts b/src/shared/store/Map/reducer.ts index dfd9b440..11dbfaad 100644 --- a/src/shared/store/Map/reducer.ts +++ b/src/shared/store/Map/reducer.ts @@ -38,6 +38,10 @@ export type MapState = { * zoom level */ zoom: number; + /** + * map scale + */ + scale: number; /** * Represents the size of one pixel in map units. * The value of resolution can be found by dividing the extent width by the view's width. @@ -67,6 +71,18 @@ export type MapState = { * anchor location of the map popup windown */ popupAnchorLocation: Point; + /** + * Indicates whether the map is being updated by additional data requests to the network, or by processing received data. + */ + isUpadting: boolean; + /** + * total visible area of the Imagery layer (with pixel filters) in square kilometers + */ + totalVisibleAreaInSqKm: number; + /** + * total number of visible pixels of the Imagery layer (with pixel filters) + */ + countOfVisiblePixels: number; }; export const initialMapState: MapState = { @@ -74,12 +90,16 @@ export const initialMapState: MapState = { center: MAP_CENTER, zoom: MAP_ZOOM, resolution: null, + scale: null, extent: null, showMapLabel: true, showTerrain: true, showBasemap: true, swipeWidgetHanlderPosition: 50, popupAnchorLocation: null, + isUpadting: false, + totalVisibleAreaInSqKm: null, + countOfVisiblePixels: 0, }; const slice = createSlice({ @@ -98,6 +118,9 @@ const slice = createSlice({ resolutionUpdated: (state, action: PayloadAction) => { state.resolution = action.payload; }, + scaleUpdated: (state, action: PayloadAction) => { + state.scale = action.payload; + }, extentUpdated: (state, action: PayloadAction) => { state.extent = action.payload; }, @@ -119,6 +142,18 @@ const slice = createSlice({ popupAnchorLocationChanged: (state, action: PayloadAction) => { state.popupAnchorLocation = action.payload; }, + isUpdatingChanged: (state, action: PayloadAction) => { + state.isUpadting = action.payload; + }, + totalVisibleAreaInSqKmChanged: ( + state, + action: PayloadAction + ) => { + state.totalVisibleAreaInSqKm = action.payload; + }, + countOfVisiblePixelsChanged: (state, action: PayloadAction) => { + state.countOfVisiblePixels = action.payload; + }, }, }); @@ -129,12 +164,16 @@ export const { centerChanged, zoomChanged, resolutionUpdated, + scaleUpdated, extentUpdated, showMapLabelToggled, showTerrainToggled, showBasemapToggled, swipeWidgetHanlderPositionChanged, popupAnchorLocationChanged, + isUpdatingChanged, + totalVisibleAreaInSqKmChanged, + countOfVisiblePixelsChanged, } = slice.actions; export default reducer; diff --git a/src/shared/store/Map/selectors.ts b/src/shared/store/Map/selectors.ts index 8d8f0850..7b666ba3 100644 --- a/src/shared/store/Map/selectors.ts +++ b/src/shared/store/Map/selectors.ts @@ -41,6 +41,11 @@ export const selectMapResolution = createSelector( (resolution) => resolution ); +export const selectMapScale = createSelector( + (state: RootState) => state.Map.scale, + (scale) => scale +); + export const selectShowMapLabel = createSelector( (state: RootState) => state.Map.showMapLabel, (showMapLabel) => showMapLabel @@ -65,3 +70,18 @@ export const selectMapPopupAnchorLocation = createSelector( (state: RootState) => state.Map.popupAnchorLocation, (popupAnchorLocation) => popupAnchorLocation ); + +export const selectIsMapUpdating = createSelector( + (state: RootState) => state.Map.isUpadting, + (isUpadting) => isUpadting +); + +export const selectTotalVisibleArea = createSelector( + (state: RootState) => state.Map.totalVisibleAreaInSqKm, + (totalVisibleAreaInSqKm) => totalVisibleAreaInSqKm +); + +export const selectCountOfVisiblePixels = createSelector( + (state: RootState) => state.Map.countOfVisiblePixels, + (countOfVisiblePixels) => countOfVisiblePixels +); diff --git a/src/shared/store/MaskTool/reducer.ts b/src/shared/store/MaskTool/reducer.ts index 0474b0ee..5950344d 100644 --- a/src/shared/store/MaskTool/reducer.ts +++ b/src/shared/store/MaskTool/reducer.ts @@ -19,27 +19,32 @@ import { PayloadAction, // createAsyncThunk } from '@reduxjs/toolkit'; -import { SpectralIndex } from '@typing/imagery-service'; +import { RadarIndex, SpectralIndex } from '@typing/imagery-service'; -export type MaskOptions = { +export type MaskLayerPixelValueRangeData = { selectedRange: number[]; - /** - * color array in RGB format - */ - color: number[]; }; -type MaskOptionsBySpectralIndex = Record; +export type MaskToolPixelValueRangeBySpectralIndex = Record< + SpectralIndex | RadarIndex, + MaskLayerPixelValueRangeData +>; + +type PixelColorBySpectralIndex = Record; export type MaskToolState = { /** - * user selected spectral index to be used in the mask tool + * user selected spectral/radar index to be used in the mask tool */ - spectralIndex: SpectralIndex; + selectedIndex: SpectralIndex | RadarIndex; /** - * maks tool options by spectral index name + * Mask Layer Pixel Value range by index name */ - maskOptionsBySpectralIndex: MaskOptionsBySpectralIndex; + pixelValueRangeBySelectedIndex: MaskToolPixelValueRangeBySpectralIndex; + /** + * Maksk Layer pixel color by index name + */ + pixelColorBySelectedIndex: PixelColorBySpectralIndex; /** * opacity of the mask layer */ @@ -48,52 +53,95 @@ export type MaskToolState = { * if true, mask layer should be used to clip the imagery scene */ shouldClipMaskLayer: boolean; + // /** + // * total visible area of the Mask layer in square kilometers + // */ + // totalVisibleAreaInSqKm: number; + // /** + // * total number of visible pixels + // */ + // countOfVisiblePixels: number; }; -export const initialMaskToolState: MaskToolState = { - spectralIndex: 'water', - maskLayerOpacity: 1, - shouldClipMaskLayer: false, - maskOptionsBySpectralIndex: { +export const DefaultPixelValueRangeBySelectedIndex: MaskToolPixelValueRangeBySpectralIndex = + { moisture: { selectedRange: [0, 1], - color: [89, 255, 252], + // color: [89, 255, 252], }, vegetation: { selectedRange: [0, 1], - color: [115, 255, 132], + // color: [115, 255, 132], }, water: { selectedRange: [0, 1], - color: [89, 214, 255], + // color: [89, 214, 255], }, 'temperature farhenheit': { // selectedRange: [30, 140], // the mask layer throws error when using farhenheit as input unit, // therefore we will just use celsius degrees in the selectedRange selectedRange: [0, 60], - color: [251, 182, 100], + // color: [251, 182, 100], }, 'temperature celcius': { selectedRange: [0, 60], // default range should be between 0-60 celcius degrees - color: [251, 182, 100], + // color: [251, 182, 100], + }, + 'water anomaly': { + selectedRange: [-1, 0], + }, + ship: { + selectedRange: [0, 1], }, + urban: { + selectedRange: [0, 1], + }, + }; + +export const initialMaskToolState: MaskToolState = { + selectedIndex: 'water', + maskLayerOpacity: 1, + shouldClipMaskLayer: false, + pixelValueRangeBySelectedIndex: DefaultPixelValueRangeBySelectedIndex, + pixelColorBySelectedIndex: { + moisture: [89, 255, 252], + vegetation: [115, 255, 132], + water: [89, 214, 255], + 'temperature farhenheit': [251, 182, 100], + 'temperature celcius': [251, 182, 100], + 'water anomaly': [255, 214, 102], + ship: [255, 0, 21], + urban: [255, 0, 21], }, + // totalVisibleAreaInSqKm: null, + // countOfVisiblePixels: 0, }; const slice = createSlice({ name: 'MaskTool', initialState: initialMaskToolState, reducers: { - spectralIndex4MaskToolChanged: ( + selectedIndex4MaskToolChanged: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.spectralIndex = action.payload; + state.selectedIndex = action.payload; }, - maskOptionsChanged: (state, action: PayloadAction) => { - const spectralIndex = state.spectralIndex; - state.maskOptionsBySpectralIndex[spectralIndex] = action.payload; + pixelValueRangeChanged: ( + state, + action: PayloadAction + ) => { + const selectedIndex = state.selectedIndex; + state.pixelValueRangeBySelectedIndex[selectedIndex] = + action.payload; + }, + maskLayerPixelColorChanged: ( + state, + action: PayloadAction + ) => { + const selectedIndex = state.selectedIndex; + state.pixelColorBySelectedIndex[selectedIndex] = action.payload; }, maskLayerOpacityChanged: (state, action: PayloadAction) => { state.maskLayerOpacity = action.payload; @@ -101,6 +149,15 @@ const slice = createSlice({ shouldClipMaskLayerToggled: (state, action: PayloadAction) => { state.shouldClipMaskLayer = !state.shouldClipMaskLayer; }, + // totalVisibleAreaInSqKmChanged: ( + // state, + // action: PayloadAction + // ) => { + // state.totalVisibleAreaInSqKm = action.payload; + // }, + // countOfVisiblePixelsChanged: (state, action: PayloadAction) => { + // state.countOfVisiblePixels = action.payload; + // }, }, }); @@ -108,10 +165,13 @@ const { reducer } = slice; export const { // activeAnalysisToolChanged, - spectralIndex4MaskToolChanged, - maskOptionsChanged, + selectedIndex4MaskToolChanged, + pixelValueRangeChanged, maskLayerOpacityChanged, shouldClipMaskLayerToggled, + maskLayerPixelColorChanged, + // totalVisibleAreaInSqKmChanged, + // countOfVisiblePixelsChanged, } = slice.actions; export default reducer; diff --git a/src/shared/store/MaskTool/selectors.ts b/src/shared/store/MaskTool/selectors.ts index 26345b05..ba1b362f 100644 --- a/src/shared/store/MaskTool/selectors.ts +++ b/src/shared/store/MaskTool/selectors.ts @@ -16,16 +16,23 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '../configureStore'; -export const selectSpectralIndex4MaskTool = createSelector( - (state: RootState) => state.MaskTool.spectralIndex, - (spectralIndex) => spectralIndex +export const selectSelectedIndex4MaskTool = createSelector( + (state: RootState) => state.MaskTool.selectedIndex, + (selectedIndex) => selectedIndex ); -export const selectMaskOptions = createSelector( - (state: RootState) => state.MaskTool.spectralIndex, - (state: RootState) => state.MaskTool.maskOptionsBySpectralIndex, - (spectralIndex, maskOptionsBySpectralIndex) => - maskOptionsBySpectralIndex[spectralIndex] +export const selectMaskLayerPixelValueRange = createSelector( + (state: RootState) => state.MaskTool.selectedIndex, + (state: RootState) => state.MaskTool.pixelValueRangeBySelectedIndex, + (selectedIndex, pixelValueRangeBySelectedIndex) => + pixelValueRangeBySelectedIndex[selectedIndex] +); + +export const selectMaskLayerPixelColor = createSelector( + (state: RootState) => state.MaskTool.selectedIndex, + (state: RootState) => state.MaskTool.pixelColorBySelectedIndex, + (selectedIndex, pixelColorBySelectedIndex) => + pixelColorBySelectedIndex[selectedIndex] || [255, 255, 255] ); export const selectMaskLayerOpcity = createSelector( @@ -42,3 +49,13 @@ export const selectMaskToolState = createSelector( (state: RootState) => state.MaskTool, (maskTool) => maskTool ); + +// export const selectMaskLayerVisibleArea = createSelector( +// (state: RootState) => state.MaskTool.totalVisibleAreaInSqKm, +// (totalVisibleAreaInSqKm) => totalVisibleAreaInSqKm +// ); + +// export const selectCountOfVisiblePixels = createSelector( +// (state: RootState) => state.MaskTool.countOfVisiblePixels, +// (countOfVisiblePixels) => countOfVisiblePixels +// ); diff --git a/src/shared/store/MaskTool/thunks.ts b/src/shared/store/MaskTool/thunks.ts index 99839177..45f6a55a 100644 --- a/src/shared/store/MaskTool/thunks.ts +++ b/src/shared/store/MaskTool/thunks.ts @@ -15,10 +15,10 @@ import { Point } from '@arcgis/core/geometry'; import { RootState, StoreDispatch, StoreGetState } from '../configureStore'; -import { MaskOptions, maskOptionsChanged } from './reducer'; +import { maskLayerPixelColorChanged, pixelValueRangeChanged } from './reducer'; import { // selectActiveAnalysisTool, - selectMaskOptions, + selectMaskLayerPixelValueRange, // selectSamplingTemporalResolution, } from './selectors'; @@ -27,30 +27,23 @@ import { * @param values updated range of the mask layer * @returns void */ -export const updateSelectedRange = +export const updateMaskLayerSelectedRange = (values: number[]) => async (dispatch: StoreDispatch, getState: StoreGetState) => { - const maskOptions = selectMaskOptions(getState()); + const pixelValueRangeData = selectMaskLayerPixelValueRange(getState()); const selectedRange = [...values]; - const updatedMaskOptions = { - ...maskOptions, + const updatedPixelValueRange = { + ...pixelValueRangeData, selectedRange, }; - dispatch(maskOptionsChanged(updatedMaskOptions)); + dispatch(pixelValueRangeChanged(updatedPixelValueRange)); }; export const updateMaskColor = (color: number[]) => async (dispatch: StoreDispatch, getState: StoreGetState) => { - const maskOptions = selectMaskOptions(getState()); - - const updatedMaskOptions = { - ...maskOptions, - color, - }; - - dispatch(maskOptionsChanged(updatedMaskOptions)); + dispatch(maskLayerPixelColorChanged(color)); }; diff --git a/src/shared/store/Sentinel1/reducer.ts b/src/shared/store/Sentinel1/reducer.ts new file mode 100644 index 00000000..e68406e5 --- /dev/null +++ b/src/shared/store/Sentinel1/reducer.ts @@ -0,0 +1,121 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createSlice, + // createSelector, + PayloadAction, + // createAsyncThunk +} from '@reduxjs/toolkit'; +import { + Sentinel1OrbitDirection, + Sentinel1Scene, +} from '@typing/imagery-service'; + +export type Sentinel1PolarizationFilter = 'VV' | 'VH'; + +export type LockedRelativeOrbitInfo = { + lockedRelativeOrbit: string; + objectIdOfSceneWithLockedRelativeOrbit: number; +}; + +export type Sentinel1State = { + /** + * Sentinel-1 scenes that intersect with center point of map view and were acquired during the input year. + */ + sentinel1Scenes?: { + byObjectId?: { + [key: number]: Sentinel1Scene; + }; + objectIds?: number[]; + }; + orbitDirection: Sentinel1OrbitDirection; + /** + * The polarization filter that allows user to switch between VV and VH + */ + polarizationFilter: Sentinel1PolarizationFilter; + /** + * the relative orbit that will be used to lock the scene selection to make sure the Analyze tools like Change Compare or Temporal Composite always use scenes have the same relative orbit + */ + lockedRelativeOrbitInfo: LockedRelativeOrbitInfo; +}; + +export const initialSentinel1State: Sentinel1State = { + sentinel1Scenes: { + byObjectId: {}, + objectIds: [], + }, + orbitDirection: 'Ascending', + polarizationFilter: 'VV', + lockedRelativeOrbitInfo: null, +}; + +const slice = createSlice({ + name: 'Sentinel1', + initialState: initialSentinel1State, + reducers: { + sentinel1ScenesUpdated: ( + state, + action: PayloadAction + ) => { + const objectIds: number[] = []; + + const byObjectId: { + [key: number]: Sentinel1Scene; + } = {}; + + for (const scene of action.payload) { + const { objectId } = scene; + + objectIds.push(objectId); + byObjectId[objectId] = scene; + } + + state.sentinel1Scenes = { + objectIds, + byObjectId, + }; + }, + orbitDirectionChanged: ( + state, + action: PayloadAction + ) => { + state.orbitDirection = action.payload; + }, + polarizationFilterChanged: ( + state, + action: PayloadAction + ) => { + state.polarizationFilter = action.payload; + }, + lockedRelativeOrbitInfoChanged: ( + state, + action: PayloadAction + ) => { + state.lockedRelativeOrbitInfo = action.payload; + }, + }, +}); + +const { reducer } = slice; + +export const { + sentinel1ScenesUpdated, + orbitDirectionChanged, + polarizationFilterChanged, + lockedRelativeOrbitInfoChanged, +} = slice.actions; + +export default reducer; diff --git a/src/shared/store/Sentinel1/selectors.ts b/src/shared/store/Sentinel1/selectors.ts new file mode 100644 index 00000000..2a1cda9d --- /dev/null +++ b/src/shared/store/Sentinel1/selectors.ts @@ -0,0 +1,42 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createSelector } from '@reduxjs/toolkit'; +import { RootState } from '../configureStore'; + +export const selectAvailableScenesByObjectId = createSelector( + (state: RootState) => state.Sentinel1.sentinel1Scenes.byObjectId, + (byObjectId) => byObjectId +); + +export const selectSentinel1OrbitDirection = createSelector( + (state: RootState) => state.Sentinel1.orbitDirection, + (orbitDirection) => orbitDirection +); + +export const selectPolarizationFilter = createSelector( + (state: RootState) => state.Sentinel1.polarizationFilter, + (polarizationFilter) => polarizationFilter +); + +export const selectSentinel1State = createSelector( + (state: RootState) => state.Sentinel1, + (Sentinel1) => Sentinel1 +); + +export const selectLockedRelativeOrbit = createSelector( + (state: RootState) => state.Sentinel1.lockedRelativeOrbitInfo, + (lockedRelativeOrbitInfo) => lockedRelativeOrbitInfo +); diff --git a/src/shared/store/Sentinel1/thunks.ts b/src/shared/store/Sentinel1/thunks.ts new file mode 100644 index 00000000..90c6cd09 --- /dev/null +++ b/src/shared/store/Sentinel1/thunks.ts @@ -0,0 +1,108 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { batch } from 'react-redux'; +import { selectMapCenter } from '../Map/selectors'; +import { RootState, StoreDispatch, StoreGetState } from '../configureStore'; +import { sentinel1ScenesUpdated } from './reducer'; +import { + Sentinel1OrbitDirection, + Sentinel1Scene, +} from '@typing/imagery-service'; +import { + ImageryScene, + availableImageryScenesUpdated, +} from '../ImageryScene/reducer'; +import { DateRange } from '@typing/shared'; +import { selectQueryParams4SceneInSelectedMode } from '../ImageryScene/selectors'; +import { getSentinel1Scenes } from '@shared/services/sentinel-1/getSentinel1Scenes'; +import { convert2ImageryScenes } from '@shared/services/sentinel-1/covert2ImageryScenes'; +import { deduplicateListOfImageryScenes } from '@shared/services/helpers/deduplicateListOfScenes'; +import { selectSentinel1OrbitDirection } from './selectors'; + +type QueryAvailableSentinel1ScenesParams = { + /** + * user selected date range to query sentinel-1 scenes + */ + acquisitionDateRange: DateRange; + /** + * relative orbit of the sentinel-1 scenes to query + */ + relativeOrbit?: string; +}; + +let abortController: AbortController = null; + +/** + * Query Sentinel-1 Scenes that intersect with center point of map view that were acquired within the user selected acquisition year. + * @param acquisitionDateRange user selected acquisition date range + * @param relativeOrbit relative orbit of sentinel-1 scenes + * @returns + */ +export const queryAvailableSentinel1Scenes = + ({ + acquisitionDateRange, + relativeOrbit, + }: QueryAvailableSentinel1ScenesParams) => + async (dispatch: StoreDispatch, getState: StoreGetState) => { + if (!acquisitionDateRange) { + return; + } + + if (abortController) { + abortController.abort(); + } + + abortController = new AbortController(); + + const storeState = getState(); + + try { + const { objectIdOfSelectedScene, acquisitionDate } = + selectQueryParams4SceneInSelectedMode(storeState) || {}; + + const orbitDirection = selectSentinel1OrbitDirection(storeState); + + const center = selectMapCenter(getState()); + + // get scenes that were acquired within the acquisition year + const scenes = await getSentinel1Scenes({ + acquisitionDateRange, + // orbitDirection, + relativeOrbit, + mapPoint: center, + abortController, + }); + + // convert list of Sentinel-1 scenes to list of imagery scenes + let imageryScenes: ImageryScene[] = convert2ImageryScenes( + scenes, + orbitDirection + ); + + // deduplicates the list based on acquisition date, keeping only one scene per day + imageryScenes = deduplicateListOfImageryScenes( + imageryScenes, + objectIdOfSelectedScene + ); + + batch(() => { + dispatch(sentinel1ScenesUpdated(scenes)); + dispatch(availableImageryScenesUpdated(imageryScenes)); + }); + } catch (err) { + console.error(err.message); + } + }; diff --git a/src/shared/store/SpectralProfileTool/thunks.ts b/src/shared/store/SpectralProfileTool/thunks.ts index 16455f49..375ca725 100644 --- a/src/shared/store/SpectralProfileTool/thunks.ts +++ b/src/shared/store/SpectralProfileTool/thunks.ts @@ -26,7 +26,7 @@ import { selectActiveAnalysisTool, selectQueryParams4MainScene, } from '../ImageryScene/selectors'; -import { getPixelValues } from '@shared/services/landsat-level-2/identify'; +import { getLandsatPixelValues } from '@shared/services/landsat-level-2/getLandsatPixelValues'; let abortController: AbortController = null; @@ -69,9 +69,9 @@ export const updateSpectralProfileData = dispatch(errorChanged(null)); try { - const bandValues = await getPixelValues({ + const bandValues = await getLandsatPixelValues({ point: queryLocation, - objectId: objectIdOfSelectedScene, + objectIds: [objectIdOfSelectedScene], abortController, }); diff --git a/src/shared/store/SpectralSamplingTool/thunks.ts b/src/shared/store/SpectralSamplingTool/thunks.ts index 31efe6be..5eb54699 100644 --- a/src/shared/store/SpectralSamplingTool/thunks.ts +++ b/src/shared/store/SpectralSamplingTool/thunks.ts @@ -29,7 +29,7 @@ import { selectSelectedSpectralSamplingPointData, selectSpectralSamplingPointsData, } from './selectors'; -import { getPixelValues } from '@shared/services/landsat-level-2/identify'; +import { getLandsatPixelValues } from '@shared/services/landsat-level-2/getLandsatPixelValues'; import { queryParamsListChanged } from '../ImageryScene/reducer'; let abortController: AbortController = null; @@ -139,10 +139,11 @@ export const updateLocationOfSpectralSamplingPoint = abortController = new AbortController(); try { - const bandValues = await getPixelValues({ + const bandValues = await getLandsatPixelValues({ point, - objectId: + objectIds: [ queryParamsOfSelectedSpectralSamplingPoint.objectIdOfSelectedScene, + ], abortController, }); diff --git a/src/shared/store/TemporalCompositeTool/reducer.ts b/src/shared/store/TemporalCompositeTool/reducer.ts new file mode 100644 index 00000000..a3ba9a5b --- /dev/null +++ b/src/shared/store/TemporalCompositeTool/reducer.ts @@ -0,0 +1,65 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createSlice, + // createSelector, + PayloadAction, + // createAsyncThunk +} from '@reduxjs/toolkit'; + +export type TemporalCompositeToolState = { + /** + * if true, the temporal composite layer is visible in the map + */ + isTemporalCompositeLayerOn: boolean; + /** + * name of the raster function to be used for the temporal composite tool + */ + rasterFunction: string; +}; + +export const initialState4TemporalCompositeTool: TemporalCompositeToolState = { + isTemporalCompositeLayerOn: false, + rasterFunction: '', +}; + +const slice = createSlice({ + name: 'TemporalCompositeTool', + initialState: initialState4TemporalCompositeTool, + reducers: { + isTemporalCompositeLayerOnUpdated: ( + state, + action: PayloadAction + ) => { + state.isTemporalCompositeLayerOn = action.payload; + }, + rasterFunction4TemporalCompositeToolChanged: ( + state, + action: PayloadAction + ) => { + state.rasterFunction = action.payload; + }, + }, +}); + +const { reducer } = slice; + +export const { + isTemporalCompositeLayerOnUpdated, + rasterFunction4TemporalCompositeToolChanged, +} = slice.actions; + +export default reducer; diff --git a/src/shared/store/TemporalCompositeTool/selectors.ts b/src/shared/store/TemporalCompositeTool/selectors.ts new file mode 100644 index 00000000..e2ccf53d --- /dev/null +++ b/src/shared/store/TemporalCompositeTool/selectors.ts @@ -0,0 +1,33 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createSelector } from '@reduxjs/toolkit'; +import { RootState } from '../configureStore'; + +export const selectIsTemporalCompositeLayerOn = createSelector( + (state: RootState) => + state.TemporalCompositeTool.isTemporalCompositeLayerOn, + (isTemporalCompositeLayerOn) => isTemporalCompositeLayerOn +); + +export const selectRasterFunction4TemporalCompositeTool = createSelector( + (state: RootState) => state.TemporalCompositeTool.rasterFunction, + (rasterFunction) => rasterFunction +); + +export const selectTemporalCompositeToolState = createSelector( + (state: RootState) => state.TemporalCompositeTool, + (TemporalCompositeTool) => TemporalCompositeTool +); diff --git a/src/shared/store/TemporalCompositeTool/thunks.ts b/src/shared/store/TemporalCompositeTool/thunks.ts new file mode 100644 index 00000000..704ab346 --- /dev/null +++ b/src/shared/store/TemporalCompositeTool/thunks.ts @@ -0,0 +1,173 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// import Point from '@arcgis/core/geometry/Point'; +import { nanoid } from 'nanoid'; +import { + DefaultQueryParams4ImageryScene, + QueryParams4ImageryScene, + queryParamsListChanged, +} from '../ImageryScene/reducer'; +import { + selectIdOfSelectedItemInListOfQueryParams, + selectListOfQueryParams, + selectQueryParams4MainScene, + selectQueryParams4SecondaryScene, + selectSelectedItemFromListOfQueryParams, +} from '../ImageryScene/selectors'; +import { RootState, StoreDispatch, StoreGetState } from '../configureStore'; +// import { Sentinel1FunctionName } from '@shared/services/sentinel-1/config'; +import { DateRange } from '@typing/shared'; +import { getDateRangeForPast12Month } from '@shared/utils/date-time/getTimeRange'; + +const DATE_RANGE_OF_PAST_12_MONTH = getDateRangeForPast12Month(); + +// let abortController: AbortController = null; + +/** + * Initializes a new list of query parameters for use with the Temporal Composite Tool. + * @param shouldInheritScenesFromCurrentList If true, attempts to inherit query parameters from the existing list. If false, creates new query parameters using `DefaultQueryParams4ImageryScene`. + */ +export const initiateImageryScenes4TemporalCompositeTool = + (shouldInheritScenesFromCurrentList: boolean) => + async (dispatch: StoreDispatch, getState: StoreGetState) => { + const storeState = getState(); + + // const queryParams4MainScene = selectQueryParams4MainScene(storeState); + // const queryParams4SecondaryScene = selectQueryParams4SecondaryScene(storeState); + + // Get the current list of query parameters from the store + const listOfQueryParams = selectListOfQueryParams(storeState); + + const queryParams4RedBand: QueryParams4ImageryScene = + shouldInheritScenesFromCurrentList && listOfQueryParams[0] + ? listOfQueryParams[0] + : { + ...DefaultQueryParams4ImageryScene, + rasterFunctionName: '', + uniqueId: nanoid(5), + }; + + const queryParams4GreenBand: QueryParams4ImageryScene = + shouldInheritScenesFromCurrentList && listOfQueryParams[1] + ? listOfQueryParams[1] + : { + ...DefaultQueryParams4ImageryScene, + rasterFunctionName: '', + uniqueId: nanoid(5), + }; + + const queryParams4BlueBand: QueryParams4ImageryScene = + shouldInheritScenesFromCurrentList && listOfQueryParams[2] + ? listOfQueryParams[2] + : { + ...DefaultQueryParams4ImageryScene, + rasterFunctionName: '', + uniqueId: nanoid(5), + }; + + const updatedListOfQueryParams: QueryParams4ImageryScene[] = [ + queryParams4RedBand, + queryParams4GreenBand, + queryParams4BlueBand, + ]; + + dispatch( + queryParamsListChanged({ + queryParams: updatedListOfQueryParams, + selectedItemID: queryParams4RedBand.uniqueId, + }) + ); + }; + +export const swapImageryScenesInTemporalCompositeTool = + (indexOfSceneA: number, indexOfSceneB: number) => + async (dispatch: StoreDispatch, getState: StoreGetState) => { + const storeState = getState(); + + const queryParams = selectListOfQueryParams(storeState); + const queryParams4SecondaryScene = + selectSelectedItemFromListOfQueryParams(storeState); + + const sceneA = queryParams[indexOfSceneA]; + const sceneB = queryParams[indexOfSceneB]; + + if (!sceneA || !sceneB) { + return; + } + + queryParams[indexOfSceneA] = sceneB; + queryParams[indexOfSceneB] = sceneA; + + dispatch( + queryParamsListChanged({ + queryParams, + selectedItemID: queryParams4SecondaryScene.uniqueId, + }) + ); + }; + +/** + * This thunk function updates the query parameters of Imagery Scenes for the Temporal Composite Tool + * to sync with the default acquisition date range (past 12 months) using the updated date range + * provided by the user. + * + * This is done to prevent the calendar from jumping between the "past 12 months" and a selected year, + * which might cause confusion for users. In other words, we want imagery scenes used by Temporal Composite Tool that don't have a + * user-selected acquisition date range to inherit the date range that the user selected for other imagery scenes. + * + * @param updatedDateRange - The new date range to apply to imagery scenes with the default date range. + */ +export const syncImageryScenesDateRangeForTemporalCompositeTool = + (updatedDateRange: DateRange) => + async (dispatch: StoreDispatch, getState: StoreGetState) => { + const storeState = getState(); + + // Get the current list of query parameters from the store + const listOfQueryParams = selectListOfQueryParams(storeState); + + const selectedItemID = + selectIdOfSelectedItemInListOfQueryParams(storeState); + + // Initialize an array to hold the updated list of query parameters + const updatedListOfQueryParams: QueryParams4ImageryScene[] = []; + + for (const queryParams of listOfQueryParams) { + // Create a copy of the current query parameters + const updatedQueryParams = { ...queryParams }; + + const { acquisitionDateRange } = updatedQueryParams; + + // If the acquisition date range matches the default "past 12 months" date range, + // update it to the new date range provided by the user + if ( + acquisitionDateRange.startDate === + DATE_RANGE_OF_PAST_12_MONTH.startDate && + acquisitionDateRange.endDate === + DATE_RANGE_OF_PAST_12_MONTH.endDate + ) { + updatedQueryParams.acquisitionDateRange = updatedDateRange; + } + + updatedListOfQueryParams.push(updatedQueryParams); + } + + dispatch( + queryParamsListChanged({ + queryParams: updatedListOfQueryParams, + selectedItemID, + }) + ); + }; diff --git a/src/shared/store/TrendTool/reducer.ts b/src/shared/store/TrendTool/reducer.ts index 19e698a2..b566b4bc 100644 --- a/src/shared/store/TrendTool/reducer.ts +++ b/src/shared/store/TrendTool/reducer.ts @@ -23,7 +23,11 @@ import { getCurrentMonth, getCurrentYear, } from '@shared/utils/date-time/getCurrentDateTime'; -import { TemporalProfileData, SpectralIndex } from '@typing/imagery-service'; +import { + TemporalProfileData, + SpectralIndex, + RadarIndex, +} from '@typing/imagery-service'; import { Point } from '@arcgis/core/geometry'; /** @@ -52,9 +56,9 @@ export type TrendToolState = { */ acquisitionYear: number; /** - * user selected spectral index to be used in the Temporal trend tool + * user selected spectral index or radar index that will be used to fetch/create Trend Profile data that will be used in the Temporal trend tool */ - spectralIndex: SpectralIndex; + selectedIndex: SpectralIndex | RadarIndex; /** * imagery temporal trend data */ @@ -68,6 +72,10 @@ export type TrendToolState = { * if ture, it is in process of loading data to render trend tool */ loading: boolean; + /** + * message from the error that was caught while fetch the temporal profile data + */ + error: string; }; export const initialTrendToolState: TrendToolState = { @@ -75,12 +83,13 @@ export const initialTrendToolState: TrendToolState = { queryLocation: null, acquisitionMonth: getCurrentMonth(), acquisitionYear: getCurrentYear(), - spectralIndex: 'moisture', + selectedIndex: 'moisture', temporalProfileData: { byObjectId: {}, objectIds: [], }, loading: false, + error: null, }; const slice = createSlice({ @@ -125,11 +134,11 @@ const slice = createSlice({ byObjectId, }; }, - spectralIndex4TrendToolChanged: ( + selectedIndex4TrendToolChanged: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.spectralIndex = action.payload; + state.selectedIndex = action.payload; }, trendToolOptionChanged: ( state, @@ -140,6 +149,9 @@ const slice = createSlice({ trendToolIsLoadingChanged: (state, action: PayloadAction) => { state.loading = action.payload; }, + errorChanged: (state, action: PayloadAction) => { + state.error = action.payload; + }, }, }); @@ -150,9 +162,10 @@ export const { acquisitionMonth4TrendToolChanged, acquisitionYear4TrendToolChanged, trendToolDataUpdated, - spectralIndex4TrendToolChanged, + selectedIndex4TrendToolChanged, trendToolOptionChanged, trendToolIsLoadingChanged, + errorChanged, } = slice.actions; export default reducer; diff --git a/src/shared/store/TrendTool/selectors.ts b/src/shared/store/TrendTool/selectors.ts index a3178add..2841d49b 100644 --- a/src/shared/store/TrendTool/selectors.ts +++ b/src/shared/store/TrendTool/selectors.ts @@ -39,9 +39,9 @@ export const selectTrendToolData = createSelector( } ); -export const selectSpectralIndex4TrendTool = createSelector( - (state: RootState) => state.TrendTool.spectralIndex, - (spectralIndex) => spectralIndex +export const selectSelectedIndex4TrendTool = createSelector( + (state: RootState) => state.TrendTool.selectedIndex, + (selectedOption) => selectedOption ); export const selectTrendToolOption = createSelector( @@ -58,3 +58,8 @@ export const selectIsLoadingData4TrendingTool = createSelector( (state: RootState) => state.TrendTool.loading, (loading) => loading ); + +export const selectError4TemporalProfileTool = createSelector( + (state: RootState) => state.TrendTool.error, + (error) => error +); diff --git a/src/shared/store/TrendTool/thunks.ts b/src/shared/store/TrendTool/thunks.ts index 97968e86..cce30ab4 100644 --- a/src/shared/store/TrendTool/thunks.ts +++ b/src/shared/store/TrendTool/thunks.ts @@ -19,6 +19,7 @@ import { trendToolDataUpdated, queryLocation4TrendToolChanged, trendToolIsLoadingChanged, + errorChanged, } from './reducer'; import { selectAcquisitionMonth4TrendTool, @@ -26,47 +27,50 @@ import { selectQueryLocation4TrendTool, selectTrendToolOption, } from './selectors'; -import { getDataForTrendTool } from '@shared/services/landsat-level-2/getTemporalProfileData'; import { selectActiveAnalysisTool, selectAppMode, selectQueryParams4SceneInSelectedMode, } from '../ImageryScene/selectors'; import { TemporalProfileData } from '@typing/imagery-service'; -import { selectLandsatMissionsToBeExcluded } from '../Landsat/selectors'; -import { intersectWithLandsatScene } from '@shared/services/landsat-level-2/getLandsatScenes'; -// import { delay } from '@shared/utils/snippets/delay'; -export const updateQueryLocation4TrendTool = - (point: Point) => +/** + * Type definition for a function that determines if the query location intersects with the imagery scene specified by the input object ID. + */ +export type IntersectWithImagerySceneFunc = ( + queryLocation: Point, + objectId: number, + abortController: AbortController +) => Promise; + +/** + * Type definition for a function that retrieves the Temporal Profile data. + */ +export type FetchTemporalProfileDataFunc = ( + queryLocation: Point, + acquisitionMonth: number, + acquisitionYear: number, + abortController: AbortController +) => Promise; + +/** + * This thunk function updates the temporal profile tool data by invoking the provided `fetchTemporalProfileDataFunc` + * to retrieve the temporal profile data for the selected imagery service. It also calls `intersectWithImagerySceneFunc` + * to verify if the query location is within the extent of the selected imagery scene. + * + * Why pass `fetchTemporalProfileDataFunc` and `intersectWithImagerySceneFunc` as parameters? This approach decouples this + * thunk function from any specific imagery service, allowing wrapper components to determine how and where to fetch the temporal profile data. + * + * @param fetchTemporalProfileDataFunc - An async function that retrieves the temporal profile data. + * @param intersectWithImagerySceneFunc - An async function that checks if the query location intersects with the specified imagery scene using the input object ID. + * @returns void + */ +export const updateTemporalProfileToolData = + ( + fetchTemporalProfileDataFunc: FetchTemporalProfileDataFunc, + intersectWithImagerySceneFunc: IntersectWithImagerySceneFunc + ) => async (dispatch: StoreDispatch, getState: StoreGetState) => { - const mode = selectAppMode(getState()); - - const tool = selectActiveAnalysisTool(getState()); - - if (mode !== 'analysis' || tool !== 'trend') { - return; - } - - dispatch(queryLocation4TrendToolChanged(point)); - }; - -let abortController: AbortController = null; - -export const resetTrendToolData = - () => (dispatch: StoreDispatch, getState: StoreGetState) => { - // cancel pending requests triggered by `updateTrendToolData` thunk function - if (abortController) { - abortController.abort(); - } - - dispatch(trendToolDataUpdated([])); - dispatch(queryLocation4TrendToolChanged(null)); - dispatch(trendToolIsLoadingChanged(false)); - }; - -export const updateTrendToolData = - () => async (dispatch: StoreDispatch, getState: StoreGetState) => { const rootState = getState(); const queryLocation = selectQueryLocation4TrendTool(rootState); @@ -80,12 +84,10 @@ export const updateTrendToolData = const trendToolOption = selectTrendToolOption(rootState); - const missionsToBeExcluded = - selectLandsatMissionsToBeExcluded(rootState); + // remove any existing error from previous request + dispatch(errorChanged(null)); if (!queryLocation || !objectIdOfSelectedScene) { - // dispatch(trendToolDataUpdated([])); - // dispatch(queryLocation4TrendToolChanged(null)); return dispatch(resetTrendToolData()); } @@ -98,7 +100,7 @@ export const updateTrendToolData = dispatch(trendToolIsLoadingChanged(true)); try { - const isIntersected = await intersectWithLandsatScene( + const isIntersected = await intersectWithImagerySceneFunc( queryLocation, objectIdOfSelectedScene, abortController @@ -110,20 +112,17 @@ export const updateTrendToolData = ); } - const data: TemporalProfileData[] = await getDataForTrendTool({ - queryLocation, - acquisitionMonth: + const data: TemporalProfileData[] = + await fetchTemporalProfileDataFunc( + queryLocation, trendToolOption === 'year-to-year' ? acquisitionMonth : null, - acquisitionYear: trendToolOption === 'month-to-month' ? acquisitionYear : null, - // samplingTemporalResolution, - missionsToBeExcluded, - abortController, - }); + abortController + ); dispatch(trendToolDataUpdated(data)); @@ -136,7 +135,42 @@ export const updateTrendToolData = } // console.log('failed to fetch temporal profile data'); + dispatch( + errorChanged( + err?.message || + 'failed to fetch data for temporal profile tool' + ) + ); + dispatch(trendToolIsLoadingChanged(false)); - throw err; + // throw err; + } + }; + +export const updateQueryLocation4TrendTool = + (point: Point) => + async (dispatch: StoreDispatch, getState: StoreGetState) => { + const mode = selectAppMode(getState()); + + const tool = selectActiveAnalysisTool(getState()); + + if (mode !== 'analysis' || tool !== 'trend') { + return; } + + dispatch(queryLocation4TrendToolChanged(point)); + }; + +let abortController: AbortController = null; + +export const resetTrendToolData = + () => (dispatch: StoreDispatch, getState: StoreGetState) => { + // cancel pending requests triggered by `updateTrendToolData` thunk function + if (abortController) { + abortController.abort(); + } + + dispatch(trendToolDataUpdated([])); + dispatch(queryLocation4TrendToolChanged(null)); + dispatch(trendToolIsLoadingChanged(false)); }; diff --git a/src/shared/store/UI/reducer.ts b/src/shared/store/UI/reducer.ts index 451483db..5ba7b0f2 100644 --- a/src/shared/store/UI/reducer.ts +++ b/src/shared/store/UI/reducer.ts @@ -84,6 +84,10 @@ export type UIState = { * if true, show Save Webmap Panel */ showSaveWebMapPanel?: boolean; + /** + * if true, show Documentation Panel + */ + showDocPanel?: boolean; }; export const initialUIState: UIState = { @@ -98,6 +102,7 @@ export const initialUIState: UIState = { nameOfSelectedInterestingPlace: '', showDownloadPanel: false, showSaveWebMapPanel: false, + showDocPanel: false, }; const slice = createSlice({ @@ -153,6 +158,9 @@ const slice = createSlice({ showSaveWebMapPanelToggled: (state) => { state.showSaveWebMapPanel = !state.showSaveWebMapPanel; }, + showDocPanelToggled: (state) => { + state.showDocPanel = !state.showDocPanel; + }, }, }); @@ -171,6 +179,7 @@ export const { nameOfSelectedInterestingPlaceChanged, showDownloadPanelToggled, showSaveWebMapPanelToggled, + showDocPanelToggled, } = slice.actions; export default reducer; diff --git a/src/shared/store/UI/selectors.ts b/src/shared/store/UI/selectors.ts index 00f6ece4..34eacaf7 100644 --- a/src/shared/store/UI/selectors.ts +++ b/src/shared/store/UI/selectors.ts @@ -75,3 +75,8 @@ export const selectAnimationLinkIsCopied = createSelector( (state: RootState) => state.UI.animationLinkIsCopied, (animationLinkIsCopied) => animationLinkIsCopied ); + +export const selectShouldShowDocPanel = createSelector( + (state: RootState) => state.UI.showDocPanel, + (showDocPanel) => showDocPanel +); diff --git a/src/shared/store/rootReducer.ts b/src/shared/store/rootReducer.ts index 4e51ef89..964b8787 100644 --- a/src/shared/store/rootReducer.ts +++ b/src/shared/store/rootReducer.ts @@ -24,7 +24,9 @@ import SpectralProfileTool from './SpectralProfileTool/reducer'; import ChangeCompareTool from './ChangeCompareTool/reducer'; import Landsat from './Landsat/reducer'; import SpectralSamplingTool from './SpectralSamplingTool/reducer'; +import TemporalCompositeTool from './TemporalCompositeTool/reducer'; import LandcoverExplorer from './LandcoverExplorer/reducer'; +import Sentinel1 from './Sentinel1/reducer'; const reducers = combineReducers({ Map, @@ -32,12 +34,14 @@ const reducers = combineReducers({ ImageryScenes, Sentinel2, Landsat, + LandcoverExplorer, + Sentinel1, TrendTool, MaskTool, SpectralProfileTool, ChangeCompareTool, SpectralSamplingTool, - LandcoverExplorer, + TemporalCompositeTool, }); export default reducers; diff --git a/src/shared/styles/index.css b/src/shared/styles/index.css index c99c5109..302ef77b 100644 --- a/src/shared/styles/index.css +++ b/src/shared/styles/index.css @@ -33,6 +33,10 @@ .analyze-tool-and-scene-info-container { @apply w-analysis-tool-container-width mx-10 3xl:mx-16; } + + .analyze-tool-without-scene-info-container { + @apply w-[520px] mx-10 3xl:mx-16; + } } #root .esri-view-root { diff --git a/src/shared/utils/url-hash-params/changeCompareTool.ts b/src/shared/utils/url-hash-params/changeCompareTool.ts index 12ba55e2..ef5b07cb 100644 --- a/src/shared/utils/url-hash-params/changeCompareTool.ts +++ b/src/shared/utils/url-hash-params/changeCompareTool.ts @@ -25,9 +25,9 @@ export const encodeChangeCompareToolData = ( return null; } - const { spectralIndex, changeCompareLayerIsOn, selectedRange } = data; + const { selectedOption, changeCompareLayerIsOn, selectedRange } = data; - return [spectralIndex, changeCompareLayerIsOn, selectedRange].join('|'); + return [selectedOption, changeCompareLayerIsOn, selectedRange].join('|'); }; export const decodeChangeCompareToolData = ( @@ -37,12 +37,12 @@ export const decodeChangeCompareToolData = ( return null; } - const [spectralIndex, changeCompareLayerIsOn, selectedRange] = + const [selectedOption, changeCompareLayerIsOn, selectedRange] = val.split('|'); return { ...initialChangeCompareToolState, - spectralIndex, + selectedOption, changeCompareLayerIsOn: changeCompareLayerIsOn === 'true', selectedRange: selectedRange.split(',').map((d) => +d), } as ChangeCompareToolState; diff --git a/src/shared/utils/url-hash-params/index.ts b/src/shared/utils/url-hash-params/index.ts index cf416a27..0d4ddd33 100644 --- a/src/shared/utils/url-hash-params/index.ts +++ b/src/shared/utils/url-hash-params/index.ts @@ -33,7 +33,7 @@ export { saveQueryParams4ScenesInAnimationToHashParams, getQueryParams4MainSceneFromHashParams, getQueryParams4SecondarySceneFromHashParams, - getQueryParams4ScenesInAnimationFromHashParams, + getListOfQueryParamsFromHashParams, } from './queryParams4ImageryScene'; export { @@ -51,11 +51,17 @@ export { getSpectralProfileToolDataFromHashParams, } from './spectralTool'; +export { + saveTemporalCompositeToolStateToHashParams, + getTemporalCompositeToolDataFromHashParams, +} from './temporalCompositeTool'; + export type UrlHashParamKey = | 'mapCenter' // hash params for map center | 'mode' // hash params for app mode | 'mainScene' // hash params for query params of the main scene | 'secondaryScene' // hash params for query params of the secondary scene + | 'listOfScenes' // hash params for query params in list of imagery scene | 'animationScenes' // hash params for query params of scenes in the animation mode | 'animation' // hash params for animation mode | 'animationWindow' // hash params for animation window info that includes map extent and size @@ -64,10 +70,12 @@ export type UrlHashParamKey = | 'trend' // hash params for trend tool | 'spectral' // hash params for spectral profile tool | 'change' // hash params for spectral profile tool + | 'composite' // hash params for temporal composite tool | 'hideTerrain' // hash params for terrain layer | 'hideMapLabels' // hash params for map labels layer | 'hideBasemap' // hash params for map labels layer - | 'tool'; // hash params for active analysis tool + | 'tool' // hash params for active analysis tool + | 'sentinel1'; // hash params for Sentinel-1 scenes const getHashParams = () => { return new URLSearchParams(window.location.hash.slice(1)); diff --git a/src/shared/utils/url-hash-params/map.ts b/src/shared/utils/url-hash-params/map.ts index 09ebf29d..b5d4643c 100644 --- a/src/shared/utils/url-hash-params/map.ts +++ b/src/shared/utils/url-hash-params/map.ts @@ -35,8 +35,8 @@ const decodeMapCenter = (value: string) => { export const encodeMapCenter = (center: number[], zoom: number) => { const [longitude, latitude] = center; - const value = `${longitude.toFixed(3)},${latitude.toFixed( - 3 + const value = `${longitude.toFixed(5)},${latitude.toFixed( + 5 )},${zoom.toFixed(3)}`; return value; diff --git a/src/shared/utils/url-hash-params/maskTool.ts b/src/shared/utils/url-hash-params/maskTool.ts index 14cca682..3c05e107 100644 --- a/src/shared/utils/url-hash-params/maskTool.ts +++ b/src/shared/utils/url-hash-params/maskTool.ts @@ -16,6 +16,8 @@ import { MaskToolState, initialMaskToolState, + MaskToolPixelValueRangeBySpectralIndex, + DefaultPixelValueRangeBySelectedIndex, } from '@shared/store/MaskTool/reducer'; import { SpectralIndex } from '@typing/imagery-service'; import { debounce } from '../snippets/debounce'; @@ -27,54 +29,76 @@ export const encodeMaskToolData = (data: MaskToolState): string => { } const { - spectralIndex, + selectedIndex, shouldClipMaskLayer, maskLayerOpacity, - maskOptionsBySpectralIndex, + pixelValueRangeBySelectedIndex, + pixelColorBySelectedIndex, } = data; - const maskOptions = maskOptionsBySpectralIndex[spectralIndex]; + const pixelValueRange = pixelValueRangeBySelectedIndex[selectedIndex]; + + const pixelColor = pixelColorBySelectedIndex[selectedIndex]; return [ - spectralIndex, + selectedIndex, shouldClipMaskLayer, maskLayerOpacity, - maskOptions?.color, - maskOptions?.selectedRange, + pixelColor, + pixelValueRange?.selectedRange, ].join('|'); }; -export const decodeMaskToolData = (val: string): MaskToolState => { +/** + * Decode mask tool data from URL hash parameters + * @param val - A string from URL hash parameters + * @param pixelValueRangeData - Optional custom pixel value range data to override the default values + * @returns MaskToolState to be used by the Redux store, or null if the input value is empty + */ +export const decodeMaskToolData = ( + val: string, + pixelValueRangeData?: MaskToolPixelValueRangeBySpectralIndex +): MaskToolState => { if (!val) { return null; } const [ - spectralIndex, + selectedIndex, shouldClipMaskLayer, maskLayerOpacity, color, selectedRange, ] = val.split('|'); - const maskOptionForSelectedSpectralIndex = - color && selectedRange - ? { - color: color.split(',').map((d) => +d), - selectedRange: selectedRange.split(',').map((d) => +d), - } - : initialMaskToolState.maskOptionsBySpectralIndex[ - spectralIndex as SpectralIndex - ]; + const pixelValueRangeBySelectedIndex = pixelValueRangeData + ? { ...pixelValueRangeData } + : { ...DefaultPixelValueRangeBySelectedIndex }; + + if (selectedRange) { + pixelValueRangeBySelectedIndex[selectedIndex as SpectralIndex] = { + selectedRange: selectedRange.split(',').map((d) => +d), + }; + } + + const pixelColorBySelectedIndex = { + ...initialMaskToolState.pixelColorBySelectedIndex, + }; + + if (color) { + pixelColorBySelectedIndex[selectedIndex as SpectralIndex] = color + .split(',') + .map((d) => +d); + } return { - spectralIndex: spectralIndex as SpectralIndex, + selectedIndex: selectedIndex as SpectralIndex, shouldClipMaskLayer: shouldClipMaskLayer === 'true', maskLayerOpacity: +maskLayerOpacity, - maskOptionsBySpectralIndex: { - ...initialMaskToolState.maskOptionsBySpectralIndex, - [spectralIndex]: maskOptionForSelectedSpectralIndex, - }, + pixelValueRangeBySelectedIndex, + pixelColorBySelectedIndex, + // totalVisibleAreaInSqKm: 0, + // countOfVisiblePixels: 0, }; }; @@ -82,7 +106,14 @@ export const saveMaskToolToHashParams = debounce((data: MaskToolState) => { updateHashParams('mask', encodeMaskToolData(data)); }, 500); -export const getMaskToolDataFromHashParams = (): MaskToolState => { +/** + * + * @param pixelValueRangeData custom pixel value range data to override the default values + * @returns + */ +export const getMaskToolDataFromHashParams = ( + pixelValueRangeData?: MaskToolPixelValueRangeBySpectralIndex +): MaskToolState => { const value = getHashParamValueByKey('mask'); - return decodeMaskToolData(value); + return decodeMaskToolData(value, pixelValueRangeData); }; diff --git a/src/shared/utils/url-hash-params/queryParams4ImageryScene.ts b/src/shared/utils/url-hash-params/queryParams4ImageryScene.ts index 90ff3af6..c31769c8 100644 --- a/src/shared/utils/url-hash-params/queryParams4ImageryScene.ts +++ b/src/shared/utils/url-hash-params/queryParams4ImageryScene.ts @@ -82,6 +82,16 @@ export const saveQueryParams4ScenesInAnimationToHashParams = ( updateHashParams('animationScenes', encodedData); }; +export const saveListOfQueryParamsToHashParams = ( + data: QueryParams4ImageryScene[] +) => { + const encodedData = + data && data.length + ? data.map((d) => encodeQueryParams4ImageryScene(d)).join(',') + : null; + updateHashParams('listOfScenes', encodedData); +}; + export const getQueryParams4MainSceneFromHashParams = () => { const value = getHashParamValueByKey('mainScene'); return decodeQueryParams4ImageryScene(value); @@ -92,9 +102,11 @@ export const getQueryParams4SecondarySceneFromHashParams = () => { return decodeQueryParams4ImageryScene(value); }; -export const getQueryParams4ScenesInAnimationFromHashParams = +export const getListOfQueryParamsFromHashParams = (): QueryParams4ImageryScene[] => { - const value = getHashParamValueByKey('animationScenes'); + const value = + getHashParamValueByKey('animationScenes') || + getHashParamValueByKey('listOfScenes'); if (!value) { return null; diff --git a/src/shared/utils/url-hash-params/sentinel1.ts b/src/shared/utils/url-hash-params/sentinel1.ts new file mode 100644 index 00000000..61088e43 --- /dev/null +++ b/src/shared/utils/url-hash-params/sentinel1.ts @@ -0,0 +1,55 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Sentinel1PolarizationFilter, + Sentinel1State, + initialSentinel1State, +} from '@shared/store/Sentinel1/reducer'; +import { Sentinel1OrbitDirection } from '@typing/imagery-service'; +import { getHashParamValueByKey, updateHashParams } from '.'; + +const encodeSentinel1Data = (data: Sentinel1State): string => { + if (!data) { + return null; + } + + const { orbitDirection, polarizationFilter } = data; + + return [orbitDirection, polarizationFilter].join('|'); +}; + +const decodeSentinel1Data = (val: string): Sentinel1State => { + if (!val) { + return null; + } + + const [orbitDirection, polarizationFilter] = val.split('|'); + + return { + ...initialSentinel1State, + orbitDirection: orbitDirection as Sentinel1OrbitDirection, + polarizationFilter: polarizationFilter as Sentinel1PolarizationFilter, + }; +}; + +export const saveSentinel1StateToHashParams = (state: Sentinel1State) => { + updateHashParams('sentinel1', encodeSentinel1Data(state)); +}; + +export const getSentinel1StateFromHashParams = () => { + const value = getHashParamValueByKey('sentinel1'); + return decodeSentinel1Data(value); +}; diff --git a/src/shared/utils/url-hash-params/temporalCompositeTool.ts b/src/shared/utils/url-hash-params/temporalCompositeTool.ts new file mode 100644 index 00000000..d8cd156c --- /dev/null +++ b/src/shared/utils/url-hash-params/temporalCompositeTool.ts @@ -0,0 +1,59 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + TemporalCompositeToolState, + initialState4TemporalCompositeTool, +} from '@shared/store/TemporalCompositeTool/reducer'; +import { getHashParamValueByKey, updateHashParams } from '.'; + +const encodeTemporalCompositeTool = ( + data: TemporalCompositeToolState +): string => { + if (!data) { + return null; + } + + const { isTemporalCompositeLayerOn, rasterFunction } = data; + + return [isTemporalCompositeLayerOn, rasterFunction].join('|'); +}; + +const decodeTemporalCompositeTool = ( + val: string +): TemporalCompositeToolState => { + if (!val) { + return null; + } + + const [isTemporalCompositeLayerOn, rasterFunction] = val.split('|'); + + return { + isTemporalCompositeLayerOn: isTemporalCompositeLayerOn === 'true', + rasterFunction: rasterFunction || '', + } as TemporalCompositeToolState; +}; + +export const saveTemporalCompositeToolStateToHashParams = ( + data: TemporalCompositeToolState +) => { + updateHashParams('composite', encodeTemporalCompositeTool(data)); +}; + +export const getTemporalCompositeToolDataFromHashParams = + (): TemporalCompositeToolState => { + const value = getHashParamValueByKey('composite'); + return decodeTemporalCompositeTool(value); + }; diff --git a/src/shared/utils/url-hash-params/trendTool.ts b/src/shared/utils/url-hash-params/trendTool.ts index dea723f9..afa9c4af 100644 --- a/src/shared/utils/url-hash-params/trendTool.ts +++ b/src/shared/utils/url-hash-params/trendTool.ts @@ -30,7 +30,7 @@ const encodeTemporalProfileToolData = (data: TrendToolState): string => { } const { - spectralIndex, + selectedIndex, acquisitionMonth, queryLocation, acquisitionYear, @@ -42,7 +42,7 @@ const encodeTemporalProfileToolData = (data: TrendToolState): string => { } return [ - spectralIndex, + selectedIndex, acquisitionMonth, // samplingTemporalResolution, encodeQueryLocation(queryLocation), @@ -57,7 +57,7 @@ const decodeTemporalProfileToolData = (val: string): TrendToolState => { } const [ - spectralIndex, + selectedOption, acquisitionMonth, // samplingTemporalResolution, queryLocation, @@ -67,7 +67,7 @@ const decodeTemporalProfileToolData = (val: string): TrendToolState => { return { ...initialTrendToolState, - spectralIndex: spectralIndex as SpectralIndex, + selectedIndex: selectedOption as SpectralIndex, acquisitionMonth: +acquisitionMonth, acquisitionYear: acquisitionYear ? +acquisitionYear : getCurrentYear(), option: option ? (option as TrendToolOption) : 'year-to-year', diff --git a/src/types/argis-sdk-for-javascript.d.ts b/src/types/argis-sdk-for-javascript.d.ts new file mode 100644 index 00000000..e0957ace --- /dev/null +++ b/src/types/argis-sdk-for-javascript.d.ts @@ -0,0 +1,47 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type BlendMode = + | 'average' + | 'color-burn' + | 'color-dodge' + | 'color' + | 'darken' + | 'destination-atop' + | 'destination-in' + | 'destination-out' + | 'destination-over' + | 'difference' + | 'exclusion' + | 'hard-light' + | 'hue' + | 'invert' + | 'lighten' + | 'lighter' + | 'luminosity' + | 'minus' + | 'multiply' + | 'normal' + | 'overlay' + | 'plus' + | 'reflect' + | 'saturation' + | 'screen' + | 'soft-light' + | 'source-atop' + | 'source-in' + | 'source-out' + | 'vivid-light' + | 'xor'; diff --git a/src/types/imagery-service.d.ts b/src/types/imagery-service.d.ts index 06f2df6b..e69db7d7 100644 --- a/src/types/imagery-service.d.ts +++ b/src/types/imagery-service.d.ts @@ -51,6 +51,12 @@ export type SpectralIndex = | 'temperature farhenheit' | 'temperature celcius'; +/** + * Name of Radar Index for SAR image (e.g. Sentinel-1) + */ +export type RadarIndex = 'water' | 'water anomaly' | 'ship' | 'urban'; +// | 'vegetation' + export type LandsatScene = { objectId: number; /** @@ -168,3 +174,46 @@ type ImageryServiceTimeExtentData = { start: number; end: number; }; + +export type Sentinel1OrbitDirection = 'Ascending' | 'Descending'; + +export type Sentinel1Scene = { + objectId: number; + /** + * product name + * @example S1A_IW_GRDH_1SDV_20141003T040550_20141003T040619_002660_002F64_EC04 + */ + name: string; + /** + * name of the sensor + */ + sensor: string; + /** + * orbit direction of the sentinel-1 imagery scene + */ + orbitDirection: Sentinel1OrbitDirection; + /** + * single polarisation (HH or VV) or dual polarisation (HH+HV or VV+VH) + */ + polarizationType: string; + + absoluteOrbit: string; + + relativeOrbit: string; + /** + * acquisitionDate as a string in ISO format (YYYY-MM-DD). + */ + formattedAcquisitionDate: string; + /** + * acquisitionDate in unix timestamp + */ + acquisitionDate: number; + /** + * year when this scene was acquired + */ + acquisitionYear: number; + /** + * month when this scene was acquired + */ + acquisitionMonth: number; +}; diff --git a/tailwind.config.js b/tailwind.config.js index 0a063222..fda2329c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,15 @@ const colors = require('tailwindcss/colors') + +/** + * suppress the warning of deprecated colors. + * @see https://github.com/tailwindlabs/tailwindcss/issues/4690#issuecomment-1046087220 + */ +delete colors['lightBlue']; +delete colors['warmGray']; +delete colors['trueGray']; +delete colors['coolGray']; +delete colors['blueGray']; + module.exports = { content: [ './src/**/*.html', diff --git a/webpack.config.js b/webpack.config.js index 9631ac8a..2c5d8de7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,6 @@ +require('dotenv').config({ path: './.env' }); + const path = require('path'); -const os = require('os'); const package = require('./package.json'); const HtmlWebpackPlugin = require("html-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); @@ -10,16 +11,6 @@ const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const { DefinePlugin } = require('webpack'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const computerName = os.hostname(); - -/** - * the App ID and some of the proxy service URLs of the app only works with `arcgis.com` domain, - * therefore we need to run the webpack dev server using the host name below `${computerName}.arcgis.com` instead of `localhost`. - */ -const hostname = computerName.includes('Esri') - ? `${computerName}.arcgis.com` - : 'localhost'; - const config = require('./src/config.json'); module.exports = (env, options)=> { @@ -67,7 +58,7 @@ module.exports = (env, options)=> { mode: options.mode, devServer: { server: 'https', - host: hostname, + host: process.env.WEBPACK_DEV_SERVER_HOSTNAME || 'localhost', allowedHosts: "all", port: 8080 }, @@ -214,7 +205,14 @@ module.exports = (env, options)=> { } }), new CssMinimizerPlugin() - ] + ], + /** + * Encountered the `Uncaught ReferenceError: x is not defined` error during the production build. + * One suggestion that we found is disabling the `optimization.innerGraph` option is the best way to prevent this issue. + * + * @see https://img.ly/docs/pesdk/web/faq/webpack_reference_error/ + */ + innerGraph: false, }, }