diff --git a/.editorconfig b/.editorconfig index 901c218f5..0b8d76715 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,11 +1,11 @@ -root = true - -[*.js] -indent_style = space -indent_size = 2 -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false +root = true + +[{*.js,*.ts}] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index fd5994a62..025b4f2d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ package-lock.json -diff +* -text \ No newline at end of file diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml index db6a883f3..9f2516659 100644 --- a/.github/workflows/publish-edge.yml +++ b/.github/workflows/publish-edge.yml @@ -1,132 +1,132 @@ -name: Publish Edge - -on: - push: - branches: - - "develop" - - workflow_dispatch: - inputs: - version: - type: string - description: | - Version number (e.g. 1.2.3-dev1). - Leave empty to determine the next version automatically. - required: false - default: "" - is-edge: - type: boolean - description: "Tag the commit and published image with `edge`." - default: true - -permissions: write-all - -env: - IS_EDGE: ${{ github.event_name == 'push' || github.event.inputs.is-edge == 'true' }} - -jobs: - determine_version: - name: "determine version" - runs-on: ubuntu-latest - outputs: - version: ${{ steps.find_version.outputs.result || github.event.inputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - if: ${{ github.event.inputs.version == '' }} - - name: Get tags of edge commit - id: get_edge_tags - if: ${{ github.event.inputs.version == '' }} - run: | - git fetch --tags - EDGE_COMMIT=$(git rev-list -n 1 edge) - EDGE_TAGS=$(printf "%s," $(git tag --contains $EDGE_COMMIT)) - EDGE_TAGS=${EDGE_TAGS%,} - echo "edge_tags=$EDGE_TAGS" >> "$GITHUB_OUTPUT" - - name: Find next version - id: find_version - if: ${{ github.event.inputs.version == '' }} - uses: actions/github-script@v7 - env: - EDGE_TAGS: ${{ steps.get_edge_tags.outputs.edge_tags }} - with: - result-encoding: string - script: | - const { findNextVersion } = require('./.github/scripts/find-version.js'); - const tags = process.env.EDGE_TAGS.split(','); - const targetBranch = context.payload.ref.replace('refs/heads/', ''); - - const pullRequests = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'closed', - base: targetBranch, - sort: 'updated', - direction: 'desc' - }); - - const mergedPullRequest = pullRequests.data.find(pr => pr.merge_commit_sha === context.payload.after); - const sourceBranch = mergedPullRequest == null - ? targetBranch - : mergedPullRequest.head.ref.replace('refs/heads/', '') - - const version = findNextVersion(tags, sourceBranch); - return `${version.major}.${version.minor}.${version.patch}-dev${version.preRelease}`; - - build_and_push_api: - name: "build and push api" - needs: - - determine_version - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Create image - uses: ./.github/actions/create-image - with: - IMAGE_NAME: ${{ vars.BASE_IMAGE_NAME }}-api - TAG: ${{ env.IS_EDGE == 'true' && 'edge' || '' }} - VERSION: ${{ needs.determine_version.outputs.version }} - DOCKERFILE: ./api/Dockerfile - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CONTEXT: ./api - - build_and_push_ui: - name: "build and push ui" - needs: - - determine_version - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Create image - uses: ./.github/actions/create-image - with: - IMAGE_NAME: ${{ vars.BASE_IMAGE_NAME }}-ui - TAG: ${{ env.IS_EDGE == 'true' && 'edge' || '' }} - VERSION: ${{ needs.determine_version.outputs.version }} - DOCKERFILE: ./ui/Dockerfile - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CONTEXT: ./ui - - tag_commit: - name: "tag commit" - needs: - - determine_version - - build_and_push_api - - build_and_push_ui - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: tag edge - if: ${{ env.IS_EDGE == 'true' }} - uses: ./.github/actions/tag-commit - with: - TAG_NAME: edge - SHA: ${{ github.sha }} - - name: tag version - uses: ./.github/actions/tag-commit - with: - TAG_NAME: ${{ needs.determine_version.outputs.version }} - SHA: ${{ github.sha }} +name: Publish Edge + +on: + push: + branches: + - "develop" + + workflow_dispatch: + inputs: + version: + type: string + description: | + Version number (e.g. 1.2.3-dev1). + Leave empty to determine the next version automatically. + required: false + default: "" + is-edge: + type: boolean + description: "Tag the commit and published image with `edge`." + default: true + +permissions: write-all + +env: + IS_EDGE: ${{ github.event_name == 'push' || github.event.inputs.is-edge == 'true' }} + +jobs: + determine_version: + name: "determine version" + runs-on: ubuntu-latest + outputs: + version: ${{ steps.find_version.outputs.result || github.event.inputs.version }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + if: ${{ github.event.inputs.version == '' }} + - name: Get tags of edge commit + id: get_edge_tags + if: ${{ github.event.inputs.version == '' }} + run: | + git fetch --tags + EDGE_COMMIT=$(git rev-list -n 1 edge) + EDGE_TAGS=$(printf "%s," $(git tag --contains $EDGE_COMMIT)) + EDGE_TAGS=${EDGE_TAGS%,} + echo "edge_tags=$EDGE_TAGS" >> "$GITHUB_OUTPUT" + - name: Find next version + id: find_version + if: ${{ github.event.inputs.version == '' }} + uses: actions/github-script@v7 + env: + EDGE_TAGS: ${{ steps.get_edge_tags.outputs.edge_tags }} + with: + result-encoding: string + script: | + const { findNextVersion } = require('./.github/scripts/find-version.js'); + const tags = process.env.EDGE_TAGS.split(','); + const targetBranch = context.payload.ref.replace('refs/heads/', ''); + + const pullRequests = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + base: targetBranch, + sort: 'updated', + direction: 'desc' + }); + + const mergedPullRequest = pullRequests.data.find(pr => pr.merge_commit_sha === context.payload.after); + const sourceBranch = mergedPullRequest == null + ? targetBranch + : mergedPullRequest.head.ref.replace('refs/heads/', '') + + const version = findNextVersion(tags, sourceBranch); + return `${version.major}.${version.minor}.${version.patch}-dev${version.preRelease}`; + + build_and_push_api: + name: "build and push api" + needs: + - determine_version + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Create image + uses: ./.github/actions/create-image + with: + IMAGE_NAME: ${{ vars.BASE_IMAGE_NAME }}-api + TAG: ${{ env.IS_EDGE == 'true' && 'edge' || '' }} + VERSION: ${{ needs.determine_version.outputs.version }} + DOCKERFILE: ./api/Dockerfile + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONTEXT: ./api + + build_and_push_ui: + name: "build and push ui" + needs: + - determine_version + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Create image + uses: ./.github/actions/create-image + with: + IMAGE_NAME: ${{ vars.BASE_IMAGE_NAME }}-ui + TAG: ${{ env.IS_EDGE == 'true' && 'edge' || '' }} + VERSION: ${{ needs.determine_version.outputs.version }} + DOCKERFILE: ./ui/Dockerfile + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONTEXT: ./ui + + tag_commit: + name: "tag commit" + needs: + - determine_version + - build_and_push_api + - build_and_push_ui + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: tag edge + if: ${{ env.IS_EDGE == 'true' }} + uses: ./.github/actions/tag-commit + with: + TAG_NAME: edge + SHA: ${{ github.sha }} + - name: tag version + uses: ./.github/actions/tag-commit + with: + TAG_NAME: ${{ needs.determine_version.outputs.version }} + SHA: ${{ github.sha }} diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..b58b603fe --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..03d9549ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..c05da7201 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/swissgeol-viewer-app.iml b/.idea/swissgeol-viewer-app.iml new file mode 100644 index 000000000..91901028e --- /dev/null +++ b/.idea/swissgeol-viewer-app.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..c8397c94c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/webResources.xml b/.idea/webResources.xml new file mode 100644 index 000000000..0f264ac0a --- /dev/null +++ b/.idea/webResources.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index dde23eb81..de785d7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,6 @@ ### Changed +- Config is loaded from the frontend at runtime now. + ### Fixed diff --git a/api/.env b/api/.env index 3175b3888..d98fef5e1 100644 --- a/api/.env +++ b/api/.env @@ -7,6 +7,7 @@ # Application APP_PORT=3000 +ENV=dev # Database PGUSER=www-data @@ -29,4 +30,8 @@ S3_ENDPOINT=http://minio:9000 # Cognito COGNITO_AWS_REGION=eu-west-1 COGNITO_CLIENT_ID=10h1tga4i933buv25lelalmtrn +COGNITO_IDENTITY_POOL_ID=eu-west-1:aa0d145d-228e-40be-bb73-a9a2c83879df COGNITO_POOL_ID=eu-west-1_dbfEb2FuH + +# ION +ION_DEFAULT_ACCESS_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI0YjNhNmQ4My01OTdlLTRjNmQtYTllYS1lMjM0NmYxZTU5ZmUiLCJpZCI6MTg3NTIsInNjb3BlcyI6WyJhc2wiLCJhc3IiLCJhc3ciLCJnYyJdLCJpYXQiOjE1NzQ0MTAwNzV9.Cj3sxjA_x--bN6VATcN4KE9jBJNMftlzPuA8hawuZkY' \ No newline at end of file diff --git a/api/src/auth.rs b/api/src/auth.rs index fc4ec6f7c..ebb42b9b8 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -23,16 +23,19 @@ static AUD: OnceCell = OnceCell::new(); static ISS: OnceCell = OnceCell::new(); /// Configuration for AWS Cognito JWKS -#[derive(clap::Parser)] +#[derive(clap::Parser, Serialize)] pub struct Auth { /// The cognito client id - #[clap(env)] + #[clap(long, env)] pub cognito_client_id: String, - /// The identity pool id - #[clap(env)] + /// The user pool id + #[clap(long, env)] pub cognito_pool_id: String, + /// The identity pool id + #[clap(long, env)] + pub cognito_identity_pool_id: String, /// The AWS region - #[clap(env, default_value = "eu-west-1")] + #[clap(long, env, default_value = "eu-west-1")] pub cognito_aws_region: String, } diff --git a/api/src/config.rs b/api/src/config.rs index 0c5ce7539..3be5ec7a8 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -1,12 +1,24 @@ use crate::{auth::Auth, database::Database}; +use serde::Serialize; #[derive(clap::Parser)] pub struct Config { #[clap(flatten)] pub database: Database, - /// The application port - #[clap(env)] + #[clap(long, env)] pub app_port: u16, #[clap(flatten)] pub auth: Auth, + #[clap(long, env)] + pub env: String, +} + +#[derive(clap::Parser, Serialize)] +pub struct ClientConfig { + #[clap(long, env)] + pub env: String, + #[clap(long, env)] + pub ion_default_access_token: String, + #[clap(flatten)] + pub auth: Auth, } diff --git a/api/src/database.rs b/api/src/database.rs index e5c8d4487..c7b430be7 100644 --- a/api/src/database.rs +++ b/api/src/database.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, Connection, Executor, PgConnection, PgPool, @@ -6,19 +7,19 @@ use sqlx::{ #[derive(clap::Parser)] pub struct Database { /// The database username - #[clap(env)] + #[clap(long, env)] pub pguser: String, /// The database password - #[clap(env, hide_env_values = true)] + #[clap(long, env, hide_env_values = true)] pub pgpassword: String, /// The database host name - #[clap(env)] + #[clap(long, env)] pub pghost: String, /// The database port - #[clap(env)] + #[clap(long, env)] pub pgport: u16, /// The database name - #[clap(env)] + #[clap(long, env)] pub pgdatabase: String, } diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 5c5074723..7532dd47f 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -11,6 +11,8 @@ use uuid::Uuid; use crate::auth::Claims; use crate::{Error, Result}; use anyhow::Context; +use axum_macros::debug_handler; +use clap::Parser; use rand::{distributions::Alphanumeric, Rng}; use serde_json::Number; use std::collections::HashSet; @@ -138,6 +140,11 @@ pub struct UploadResponse { pub key: String, } +#[debug_handler] +pub async fn get_client_config() -> Json { + Json(crate::config::ClientConfig::parse()) +} + // Health check endpoint pub async fn health_check(Extension(pool): Extension) -> (StatusCode, String) { let version = format!("CARGO_PKG_VERSION: {}", env!("CARGO_PKG_VERSION")); diff --git a/api/src/lib.rs b/api/src/lib.rs index ad1206d3f..7b7201197 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,4 +1,3 @@ -use axum::response::IntoResponse; use axum::{ extract::{DefaultBodyLimit, Extension}, http::{HeaderValue, Method}, @@ -10,7 +9,7 @@ use axum::{ use clap::Parser; use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use sqlx::PgPool; -use tower::{ServiceBuilder, ServiceExt}; +use tower::ServiceBuilder; use tower_http::{cors::CorsLayer, trace::TraceLayer}; pub use config::Config; @@ -41,6 +40,7 @@ pub async fn app(pool: PgPool) -> Router { let aws_client = aws_config.create_client().await; Router::new() + .route("/api/client-config", get(handlers::get_client_config)) .route("/api/health_check", get(handlers::health_check)) .route( "/api/projects", diff --git a/ui/index.html b/ui/index.html index 668e82123..70527e6b9 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,48 +1,48 @@ - - - - - - - - - - - - swissgeol - - - - - - - - - - - - - - + + + + + + + + + + + + swissgeol + + + + + + + + + + + + + + diff --git a/ui/package-lock.json b/ui/package-lock.json index fd2374cbe..a11de8ea0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -13,6 +13,8 @@ "@fontsource/inter": "5.0.18", "@geoblocks/cesium-view-cube": "0.1.1", "@geoblocks/ga-search": "0.0.22", + "@lit/context": "^1.1.3", + "@lit/task": "^1.0.1", "cesium": "1.118.2", "d3-array": "3.2.4", "d3-axis": "3.0.0", @@ -4070,6 +4072,14 @@ "integrity": "sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==", "license": "BSD-3-Clause" }, + "node_modules/@lit/context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.3.tgz", + "integrity": "sha512-Auh37F4S0PZM93HTDfZWs97mmzaQ7M3vnTc9YvxAGyP3UItSK/8Fs0vTOGT+njuvOwbKio/l8Cx/zWL4vkutpQ==", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.0.0" + } + }, "node_modules/@lit/reactive-element": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", @@ -4079,6 +4089,14 @@ "@lit-labs/ssr-dom-shim": "^1.2.0" } }, + "node_modules/@lit/task": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lit/task/-/task-1.0.1.tgz", + "integrity": "sha512-fVLDtmwCau8NywnFIXaJxsCZjzaIxnVq+cFRKYC1Y4tA4/0rMTvF6DLZZ2JE51BwzOluaKtgJX8x1QDsQtAaIw==", + "dependencies": { + "@lit/reactive-element": "^1.0.0 || ^2.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/ui/package.json b/ui/package.json index 8222ce308..2ba846d6c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,8 @@ "@fontsource/inter": "5.0.18", "@geoblocks/cesium-view-cube": "0.1.1", "@geoblocks/ga-search": "0.0.22", + "@lit/context": "^1.1.3", + "@lit/task": "^1.0.1", "cesium": "1.118.2", "d3-array": "3.2.4", "d3-axis": "3.0.0", diff --git a/ui/src/AmazonS3Resource.js b/ui/src/AmazonS3Resource.js index c56d0647c..cd743b9fc 100644 --- a/ui/src/AmazonS3Resource.js +++ b/ui/src/AmazonS3Resource.js @@ -1,66 +1,66 @@ -import Auth from './auth'; -import {getSignedUrl} from '@aws-sdk/s3-request-presigner'; -import {S3Client, GetObjectCommand} from '@aws-sdk/client-s3'; -import {Resource, defer} from 'cesium'; - - -function keyFromUrl(val) { - try { - const url = new URL(val); - // remove the first '/' from the path - return url.pathname.slice(1); - } catch (err) { - return val; - } -} - - -export default class AmazonS3Resource extends Resource { - - constructor(options) { - super(options); - - this.bucket = options.bucket; - this.region = options.region || 'eu-west-1'; - } - - clone(result) { - if (!result) { - result = new AmazonS3Resource({ - url: this.url, - bucket: this.bucket, - }); - } - return result; - } - - getSignedUrl(credentials) { - const client = new S3Client({ - region: this.region, - credentials: credentials, - }); - const options = { - Bucket: this.bucket, - Key: keyFromUrl(this.url), - }; - const command = new GetObjectCommand(options); - return getSignedUrl(client, command); - } - - _makeRequest(options) { - const credentialsPromise = Auth.getCredentialsPromise(); - if (credentialsPromise) { - const deferred = defer(); - credentialsPromise.then(credentials => { - this.getSignedUrl(credentials).then(url => { - this.url = url; - const request = super._makeRequest(options); - if (request) { - request.then(value => deferred.resolve(value)); - } - }); - }); - return deferred.promise; - } - } -} +import {getSignedUrl} from '@aws-sdk/s3-request-presigner'; +import {S3Client, GetObjectCommand} from '@aws-sdk/client-s3'; +import {Resource, defer} from 'cesium'; + + +function keyFromUrl(val) { + try { + const url = new URL(val); + // remove the first '/' from the path + return url.pathname.slice(1); + } catch (err) { + return val; + } +} + +export default class AmazonS3Resource extends Resource { + bucket; + region; + + constructor(options, authService) { + super(options); + + this.bucket = options.bucket; + this.region = options.region || 'eu-west-1'; + } + + clone(result) { + if (!result) { + result = new AmazonS3Resource({ + url: this.url, + bucket: this.bucket, + }); + } + return result; + } + + getSignedUrl(credentials) { + const client = new S3Client({ + region: this.region, + credentials: credentials, + }); + const options = { + Bucket: this.bucket, + Key: keyFromUrl(this.url), + }; + const command = new GetObjectCommand(options); + return getSignedUrl(client, command); + } + + _makeRequest(options) { + const credentialsPromise = this.authService.getCredentialsPromise(); + if (credentialsPromise) { + const deferred = defer(); + credentialsPromise.then(credentials => { + this.getSignedUrl(credentials).then(url => { + this.url = url; + const request = super._makeRequest(options); + if (request) { + request.then(value => deferred.resolve(value)); + } + }); + }); + return deferred.promise; + } + } +} diff --git a/ui/src/api-client.ts b/ui/src/api/api-client.ts similarity index 88% rename from ui/src/api-client.ts rename to ui/src/api/api-client.ts index cbabcf4b8..29a7bc96a 100644 --- a/ui/src/api-client.ts +++ b/ui/src/api/api-client.ts @@ -1,21 +1,22 @@ -import Auth from './auth'; -import AuthStore from './store/auth'; -import {API_BY_PAGE_HOST} from './constants'; -import type {CreateProject, Project} from './elements/dashboard/ngm-dashboard'; +import AuthService from '../authService'; +import AuthStore from '../store/auth'; +import {API_BY_PAGE_HOST} from '../constants'; +import type {CreateProject, Project} from '../elements/dashboard/ngm-dashboard'; import {Subject} from 'rxjs'; -import {NgmGeometry} from './toolbox/interfaces'; +import {NgmGeometry} from '../toolbox/interfaces'; -class ApiClient { +export class ApiClient { projectsChange = new Subject(); - token = Auth.getAccessToken(); - private apiUrl: string; + token: string | null = null; + private readonly apiUrl: string; - constructor() { + constructor(private readonly authService: AuthService) { this.apiUrl = API_BY_PAGE_HOST[window.location.host]; + this.token = this.authService.getAccessToken(); AuthStore.user.subscribe(() => { - this.token = Auth.getAccessToken(); + this.token = this.authService.getAccessToken(); this.refreshProjects(); }); } @@ -166,5 +167,3 @@ function addAuthorization(headers: any, token: string|null) { headers['Authorization'] = `Bearer ${token}`; } } - -export const apiClient = new ApiClient(); diff --git a/ui/src/api/client-config.ts b/ui/src/api/client-config.ts new file mode 100644 index 000000000..4942f0ad7 --- /dev/null +++ b/ui/src/api/client-config.ts @@ -0,0 +1,10 @@ +export interface ClientConfig { + env: 'dev' | 'int' | 'prod', + ion_default_access_token: string, + auth: { + cognito_client_id: string, + cognito_pool_id: string, + cognito_identity_pool_id: string, + cognito_aws_region: string, + } +} \ No newline at end of file diff --git a/ui/src/api/config.service.ts b/ui/src/api/config.service.ts new file mode 100644 index 000000000..27c9fab95 --- /dev/null +++ b/ui/src/api/config.service.ts @@ -0,0 +1,22 @@ +import {ClientConfig} from './client-config'; +import {API_BY_PAGE_HOST} from '../constants'; + +export class ConfigService { + private readonly apiUrl: string; + + constructor() { + this.apiUrl = API_BY_PAGE_HOST[window.location.host]; + } + + async getConfig(): Promise { + try { + const response = await fetch(`${this.apiUrl}/client-config`, { + method: 'GET', + }); + return await response.json() as ClientConfig; + } catch (e) { + console.error(`Failed to update project: ${e}`); + return null; + } + } +} diff --git a/ui/src/auth.ts b/ui/src/authService.ts similarity index 73% rename from ui/src/auth.ts rename to ui/src/authService.ts index f0c57b1ef..cea5f1c91 100644 --- a/ui/src/auth.ts +++ b/ui/src/authService.ts @@ -1,138 +1,142 @@ -import type { - CognitoIdentityCredentialProvider, - CognitoIdentityCredentials} from '@aws-sdk/credential-provider-cognito-identity'; -import {fromCognitoIdentityPool} from '@aws-sdk/credential-provider-cognito-identity'; -import {CognitoIdentityClient} from '@aws-sdk/client-cognito-identity'; -import auth from './store/auth'; -import {COGNITO_VARIABLES} from './constants'; - -const cognitoState = 'cognito_state'; -const cognitoUser = 'cognito_user'; -const cognitoAccessToken = 'cognito_access_token'; - -export interface AuthUser { - username: string; - 'cognito:groups': string[]; - auth_time: number - client_id: string - exp: number - iat: number - iss: string - jti: string - scope: string - sub: string - token_use: string - -} - -export function getAccessToken() { - return Auth.getAccessToken(); -} - -let authTimeout = 0; - -let _AWSCredentials: CognitoIdentityCredentialProvider | null = null; -export default class Auth { - - static initialize(): void { - if (window.location.hash.startsWith('#')) { - // https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html - const response = window.location.hash.substring(1); - const params = new URLSearchParams(response); - - if (params.has('error')) { - throw new Error(`Auth error: ${params.get('error_description')}`); - } - - if (params.has('access_token') && params.has('id_token') && - params.get('token_type') === 'Bearer' && params.get('state') === this.state()) { - localStorage.setItem('rawCognitoResponse', response); - const token = params.get('access_token') || ''; - const payload = atob(token.split('.')[1]); - const claims = JSON.parse(payload); - this.setUser(claims); - this.setAccessToken(params.get('id_token') || ''); - } - } else if (this.getUser()) { - // this strange line sets up observable and autologout - this.setUser(this.getUser()); - } - - const accessToken = this.getAccessToken(); - if (accessToken) { - const {region, identityPoolId, userPoolId} = COGNITO_VARIABLES; - (window as any)['AWSCred'] = _AWSCredentials = fromCognitoIdentityPool({ - client: new CognitoIdentityClient({ - region: region - }), - identityPoolId: identityPoolId, - logins: { - [`cognito-idp.${region}.amazonaws.com/${userPoolId}`]: accessToken - } - }); - } - } - - static getCredentialsPromise(): Promise | undefined { - if (_AWSCredentials) { - return _AWSCredentials(); - } - return undefined; // FIXME: ugly - } - - static state(state?: string): string | null { - if (state !== undefined) { - localStorage.setItem(cognitoState, state); - } - if (localStorage.getItem(cognitoState) === null) { - localStorage.setItem(cognitoState, Math.random().toString(36).substring(2)); - } - return localStorage.getItem(cognitoState); - } - - static getUser(): AuthUser|null { - const value = localStorage.getItem(cognitoUser) as string; - return JSON.parse(value); - } - - static setUser(user: AuthUser|null): void { - if (authTimeout) { - window.clearTimeout(authTimeout); - authTimeout = 0; - } - if (user) { - const remaining = 1000 * user.exp - Date.now(); - authTimeout = window.setTimeout(() => { - Auth.logout(); - }, remaining); - } - auth.setUser(user); - const value = JSON.stringify(user); - localStorage.setItem(cognitoUser, value); - } - - static logout(): void { - localStorage.removeItem(cognitoUser); - localStorage.removeItem(cognitoState); - localStorage.removeItem(cognitoAccessToken); - localStorage.removeItem('rawCognitoResponse'); - auth.setUser(null); - _AWSCredentials = null; - } - - static getAccessToken(): string | null { - return localStorage.getItem(cognitoAccessToken); - } - - static setAccessToken(token: string): void { - localStorage.setItem(cognitoAccessToken, token); - } - - static async waitForAuthenticate(): Promise { - while (localStorage.getItem(cognitoUser) === null) { - await new Promise((resolve) => { - setTimeout(() => resolve(), 20); - }); - } - } -} +import type { + CognitoIdentityCredentialProvider, + CognitoIdentityCredentials +} from '@aws-sdk/credential-provider-cognito-identity'; +import {fromCognitoIdentityPool} from '@aws-sdk/credential-provider-cognito-identity'; +import {CognitoIdentityClient} from '@aws-sdk/client-cognito-identity'; +import auth from './store/auth'; +import {ClientConfig} from './api/client-config'; + +const cognitoState = 'cognito_state'; +const cognitoUser = 'cognito_user'; +const cognitoAccessToken = 'cognito_access_token'; + +export interface AuthUser { + username: string; + 'cognito:groups': string[]; + auth_time: number + client_id: string + exp: number + iat: number + iss: string + jti: string + scope: string + sub: string + token_use: string +} + +let authTimeout = 0; + +let _AWSCredentials: CognitoIdentityCredentialProvider | null = null; +export default class AuthService { + private _clientConfig?: ClientConfig; + public set clientConfig(value: ClientConfig) { + this._clientConfig = value; + } + + public initialize(): void { + if (window.location.hash.startsWith('#')) { + // https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html + const response = window.location.hash.substring(1); + const params = new URLSearchParams(response); + + if (params.has('error')) { + throw new Error(`Auth error: ${params.get('error_description')}`); + } + + if (params.has('access_token') && params.has('id_token') && + params.get('token_type') === 'Bearer' && params.get('state') === this.state()) { + localStorage.setItem('rawCognitoResponse', response); + const token = params.get('access_token') || ''; + const payload = atob(token.split('.')[1]); + const claims = JSON.parse(payload); + this.setUser(claims); + this.setAccessToken(params.get('id_token') || ''); + } + } else if (this.getUser()) { + // this strange line sets up observable and autologout + this.setUser(this.getUser()); + } + + const accessToken = this.getAccessToken(); + if (accessToken && this._clientConfig) { + const { + cognito_pool_id, + cognito_identity_pool_id, + cognito_aws_region + } = this._clientConfig.auth; + (window as any)['AWSCred'] = _AWSCredentials = fromCognitoIdentityPool({ + client: new CognitoIdentityClient({ + region: cognito_aws_region + }), + identityPoolId: cognito_identity_pool_id, + logins: { + [`cognito-idp.${cognito_aws_region}.amazonaws.com/${cognito_pool_id}`]: accessToken + } + }); + } + } + + getCredentialsPromise(): Promise | undefined { + if (_AWSCredentials) { + return _AWSCredentials(); + } + return undefined; // FIXME: ugly + } + + state(state?: string): string | null { + if (state !== undefined) { + localStorage.setItem(cognitoState, state); + } + if (localStorage.getItem(cognitoState) === null) { + localStorage.setItem(cognitoState, Math.random().toString(36).substring(2)); + } + return localStorage.getItem(cognitoState); + } + + getUser(): AuthUser | null { + const value = localStorage.getItem(cognitoUser) as string; + return JSON.parse(value); + } + + setUser(user: AuthUser | null): void { + if (authTimeout) { + window.clearTimeout(authTimeout); + authTimeout = 0; + } + if (user) { + const remaining = 1000 * user.exp - Date.now(); + authTimeout = window.setTimeout(() => { + this.logout(); + }, remaining); + } + auth.setUser(user); + const value = JSON.stringify(user); + localStorage.setItem(cognitoUser, value); + } + + logout(): void { + localStorage.removeItem(cognitoUser); + localStorage.removeItem(cognitoState); + localStorage.removeItem(cognitoAccessToken); + localStorage.removeItem('rawCognitoResponse'); + auth.setUser(null); + _AWSCredentials = null; + } + + getAccessToken(): string | null { + return localStorage.getItem(cognitoAccessToken); + } + + setAccessToken(token: string): void { + localStorage.setItem(cognitoAccessToken, token); + } + + async waitForAuthenticate(): Promise { + while (localStorage.getItem(cognitoUser) === null) { + await new Promise((resolve) => { + setTimeout(() => resolve(), 20); + }); + } + } +} diff --git a/ui/src/constants.ts b/ui/src/constants.ts index 88ef73e83..1a53b221e 100644 --- a/ui/src/constants.ts +++ b/ui/src/constants.ts @@ -149,27 +149,27 @@ export const API_BY_PAGE_HOST = { export const DEFAULT_UPLOADED_KML_COLOR = Color.fromCssColorString('#0056A4'); const _COGNITO_VARIABLES = { - dev: { - env: 'dev', - region: 'eu-west-1', - clientId: '10h1tga4i933buv25lelalmtrn', - identityPoolId: 'eu-west-1:aa0d145d-228e-40be-bb73-a9a2c83879df', - userPoolId: 'eu-west-1_dbfEb2FuH', - }, - int: { - env: 'int', - region: 'eu-west-1', - clientId: '35ld39a721f3fhbsgci6gekde2', - identityPoolId: 'eu-west-1:8dc87444-330a-4aa6-8e50-975c0aae97ae', - userPoolId: 'eu-west-1_HJ4hFAg2P', - }, - prod: { - env: 'prod', - region: 'eu-west-1', - clientId: '6brvjsufv7fdubr12r9u0gajnj', - identityPoolId: 'eu-west-1:8e7b48a6-9d3f-4a46-afa3-d05a78c46a90', - userPoolId: 'eu-west-1_1NcmOhPt4', - }, + dev: { + env: 'dev', + region: 'eu-west-1', + clientId: '10h1tga4i933buv25lelalmtrn', + identityPoolId: 'eu-west-1:aa0d145d-228e-40be-bb73-a9a2c83879df', + userPoolId: 'eu-west-1_dbfEb2FuH', + }, + int: { + env: 'int', + region: 'eu-west-1', + clientId: '35ld39a721f3fhbsgci6gekde2', + identityPoolId: 'eu-west-1:8dc87444-330a-4aa6-8e50-975c0aae97ae', + userPoolId: 'eu-west-1_HJ4hFAg2P', + }, + prod: { + env: 'prod', + region: 'eu-west-1', + clientId: '6brvjsufv7fdubr12r9u0gajnj', + identityPoolId: 'eu-west-1:8e7b48a6-9d3f-4a46-afa3-d05a78c46a90', + userPoolId: 'eu-west-1_1NcmOhPt4', + }, }; _COGNITO_VARIABLES['default_active_env'] = _COGNITO_VARIABLES['dev']; export const COGNITO_VARIABLES = _COGNITO_VARIABLES[(window as any).activeEnvironment]; diff --git a/ui/src/context/client-config.context.ts b/ui/src/context/client-config.context.ts new file mode 100644 index 000000000..d79ba5b30 --- /dev/null +++ b/ui/src/context/client-config.context.ts @@ -0,0 +1,8 @@ +import {createContext} from '@lit/context'; +import {ClientConfig} from '../api/client-config'; +import AuthService from '../authService'; +import {ApiClient} from '../api/api-client'; + +export const clientConfigContext = createContext('clientConfig'); +export const authServiceContext = createContext('authService'); +export const apiClientContext = createContext('apiClient'); diff --git a/ui/src/context/index.ts b/ui/src/context/index.ts new file mode 100644 index 000000000..5f3537871 --- /dev/null +++ b/ui/src/context/index.ts @@ -0,0 +1,2 @@ +export * from './client-config.context'; +export * from './register-context'; diff --git a/ui/src/context/register-context.ts b/ui/src/context/register-context.ts new file mode 100644 index 000000000..eb38edb2e --- /dev/null +++ b/ui/src/context/register-context.ts @@ -0,0 +1,22 @@ +import {LitElement} from 'lit'; +import {Context, ContextProvider} from '@lit/context'; +import {ClientConfig} from '../api/client-config'; +import {apiClientContext, authServiceContext, clientConfigContext} from './client-config.context'; +import {ApiClient} from '../api/api-client'; +import AuthService from '../authService'; + + +export const registerAppContext: (element: LitElement, clientConfig: ClientConfig) => ContextProvider, LitElement>[] + = (element: LitElement, clientConfig: ClientConfig) => { + + const authService = new AuthService(); + authService.clientConfig = clientConfig; + authService.initialize(); + const apiClient = new ApiClient(authService); + + return [ + new ContextProvider(element, {context: clientConfigContext, initialValue: clientConfig}), + new ContextProvider(element, {context: apiClientContext, initialValue: apiClient}), + new ContextProvider(element, {context: authServiceContext, initialValue: authService}), + ]; +}; diff --git a/ui/src/draw/CesiumDraw.ts b/ui/src/draw/CesiumDraw.ts index 0c75b8b11..6e5411c9f 100644 --- a/ui/src/draw/CesiumDraw.ts +++ b/ui/src/draw/CesiumDraw.ts @@ -1,860 +1,860 @@ -import { - CallbackProperty, - Cartesian2, - Cartesian3, - Cartographic, - ClassificationType, - Color, - ConstantPositionProperty, - ConstantProperty, - CustomDataSource, - Entity, - HeightReference, - Intersections2D, - JulianDate, - PolygonHierarchy, - ScreenSpaceEventHandler, - ScreenSpaceEventType, - Viewer -} from 'cesium'; -import {getDimensionLabel, rectanglify} from './helpers'; -import {getMeasurements, Measurements, updateHeightForCartesianPositions} from '../cesiumutils'; -import type {GeometryTypes} from '../toolbox/interfaces'; -import {cartesianToLv95} from '../projection'; - -type PointOptions = { - color?: Color, - virtualColor?: Color, - outlineWidth?: number, - outlineColor?: Color, - pixelSizeDefault?: number, - pixelSizeEdit?: number, - heightReference?: HeightReference, -} -export interface DrawOptions { - fillColor?: string | Color; - strokeColor?: string | Color; - strokeWidth?: number; - minPointsStop?: boolean; - pointOptions?: PointOptions; - lineClampToGround?: boolean; -} - -export type SegmentInfo = { - length: number, - eastingDiff: number, - northingDiff: number, - heightDiff: number -}; -export type DrawInfo = { - length: number, - numberOfSegments: number, - segments: SegmentInfo[], - type: GeometryTypes, - drawInProgress: boolean -} - -export type DrawEndDetails = { - positions: Cartesian3[], - type: GeometryTypes, - measurements: Measurements -} - -export class CesiumDraw extends EventTarget { - private viewer_: Viewer; - private strokeColor_: Color; - private strokeWidth_: number; - private fillColor_: Color; - private eventHandler_: ScreenSpaceEventHandler | undefined; - private activePoints_: Cartesian3[] = []; - private activePoint_: Cartesian3 | undefined; - private sketchPoint_: Entity | undefined; - private activeDistance_ = 0; - private activeDistances_: number[] = []; - private leftPressedPixel_: Cartesian2 | undefined; - private sketchPoints_: Entity[] = []; - private isDoubleClick = false; - private singleClickTimer: NodeJS.Timeout | null = null; - private segmentsInfo: SegmentInfo[] = []; - type: GeometryTypes | undefined; - julianDate = new JulianDate(); - drawingDataSource = new CustomDataSource('drawing'); - minPointsStop: boolean; - moveEntity = false; - entityForEdit: Entity | undefined; - ERROR_TYPES = {needMorePoints: 'need_more_points'}; - pointOptions: PointOptions; - // todo line options? - lineClampToGround: boolean = true; - - constructor(viewer: Viewer, options?: DrawOptions) { - super(); - // todo move default values to constants - this.viewer_ = viewer; - this.viewer_.dataSources.add(this.drawingDataSource); - this.strokeColor_ = options?.strokeColor instanceof Color ? - options.strokeColor : Color.fromCssColorString(options?.strokeColor || 'rgba(0, 153, 255, 0.75)'); - this.strokeWidth_ = options?.strokeWidth !== undefined ? options.strokeWidth : 4; - this.fillColor_ = options?.fillColor instanceof Color ? - options.fillColor : Color.fromCssColorString(options?.fillColor || 'rgba(0, 153, 255, 0.3)'); - this.minPointsStop = !!options?.minPointsStop; - this.lineClampToGround = typeof options?.lineClampToGround === 'boolean' ? options.lineClampToGround : true; - const pointOptions = options?.pointOptions; - const heightReference = pointOptions?.heightReference; - this.pointOptions = { - color: pointOptions?.color instanceof Color ? pointOptions.color : Color.WHITE, - virtualColor: pointOptions?.virtualColor instanceof Color ? pointOptions.virtualColor : Color.GREY, - outlineColor: pointOptions?.outlineColor instanceof Color ? pointOptions.outlineColor : Color.BLACK, - outlineWidth: typeof pointOptions?.outlineWidth === 'number' && !isNaN(pointOptions?.outlineWidth) ? pointOptions?.outlineWidth : 1, - pixelSizeDefault: typeof pointOptions?.pixelSizeDefault === 'number' && !isNaN(pointOptions?.pixelSizeDefault) ? pointOptions?.pixelSizeDefault : 5, - pixelSizeEdit: typeof pointOptions?.pixelSizeEdit === 'number' && !isNaN(pointOptions?.pixelSizeEdit) ? pointOptions?.pixelSizeEdit : 9, - heightReference: typeof heightReference === 'number' && !isNaN(heightReference) ? heightReference : HeightReference.CLAMP_TO_GROUND, - }; - } - - renderSceneIfTranslucent() { - // because calling render decreases performance, only call it when needed. - // see https://cesium.com/docs/cesiumjs-ref-doc/Scene.html#pickTranslucentDepth - if (this.viewer_.scene.globe.translucency.enabled) { - this.viewer_.scene.render(); - } - } - - /** - * - */ - get active() { - return this.eventHandler_ !== undefined; - } - - /** - * - */ - set active(value) { - // todo check for type - if (value && this.type) { - if (!this.eventHandler_) { - this.eventHandler_ = new ScreenSpaceEventHandler(this.viewer_.canvas); - if (this.entityForEdit) { - this.activateEditing(); - } else { - this.eventHandler_.setInputAction(this.onLeftClick.bind(this), ScreenSpaceEventType.LEFT_CLICK); - this.eventHandler_.setInputAction(this.onDoubleClick_.bind(this), ScreenSpaceEventType.LEFT_DOUBLE_CLICK); - } - this.eventHandler_.setInputAction(this.onMouseMove_.bind(this), ScreenSpaceEventType.MOUSE_MOVE); - } - this.dispatchEvent(new CustomEvent('drawinfo', { - detail: { - length: 0, - numberOfSegments: 0, - segments: [], - type: this.type, - drawInProgress: true - } - })); - } else { - if (this.eventHandler_) { - this.eventHandler_.destroy(); - } - this.eventHandler_ = undefined; - } - this.dispatchEvent(new CustomEvent('statechanged', {detail: {active: value && this.type}})); - } - - activateEditing() { - if (!this.eventHandler_ || !this.entityForEdit) return; - this.eventHandler_.setInputAction(event => this.onLeftDown_(event), ScreenSpaceEventType.LEFT_DOWN); - this.eventHandler_.setInputAction(event => this.onLeftUp_(event), ScreenSpaceEventType.LEFT_UP); - const position = this.entityForEdit.position?.getValue(this.julianDate); - let positions: Cartesian3[] = []; - let createVirtualSPs = false; - switch (this.type) { - case 'point': - this.entityForEdit.position = new CallbackProperty(() => this.activePoints_[0] || position, false); - break; - case 'line': - positions = [...this.entityForEdit.polyline!.positions!.getValue(this.julianDate)]; - this.entityForEdit.polyline!.positions = new CallbackProperty(() => this.activePoints_, false); - createVirtualSPs = true; - break; - case 'polygon': - positions = [...this.entityForEdit.polygon!.hierarchy!.getValue(this.julianDate).positions]; - this.entityForEdit.polygon!.hierarchy = new CallbackProperty(() => new PolygonHierarchy(this.activePoints_), false); - createVirtualSPs = true; - break; - case 'rectangle': - positions = [...this.entityForEdit.polygon!.hierarchy!.getValue(this.julianDate).positions]; - this.entityForEdit.polygon!.hierarchy = new CallbackProperty(() => new PolygonHierarchy(this.activePoints_), false); - this.drawingDataSource.entities.add({ - position: new CallbackProperty(() => { - positions = this.activePoints_.length ? this.activePoints_ : positions; - return Cartesian3.midpoint(positions[0], positions[1], new Cartesian3()); - }, false), - billboard: { - image: './images/rotate-icon.svg', - disableDepthTestDistance: Number.POSITIVE_INFINITY, - heightReference: HeightReference.CLAMP_TO_GROUND - }, - properties: { - type: 'rotate' - } - }); - break; - default: - break; - } - - positions.forEach((p, idx) => { - this.activePoints_.push(p); - const sketchPoint = this.createSketchPoint_(p, {edit: true, positionIndex: idx}); - sketchPoint.properties!.index = idx; - this.sketchPoints_.push(sketchPoint); - if (createVirtualSPs && (idx + 1) < positions.length) { - const p2 = this.halfwayPosition_(p, positions[idx + 1]); - const virtualSketchPoint = this.createSketchPoint_(p2, {edit: true, virtual: true}); - virtualSketchPoint.properties!.index = idx; - this.sketchPoints_.push(virtualSketchPoint); - } - }); - if (this.type === 'polygon' && positions.length > 2) { - // We need one more virtual sketchpoint for polygons - const lastIdx = positions.length - 1; - const p2 = this.halfwayPosition_(positions[lastIdx], positions[0]); - const virtualSketchPoint = this.createSketchPoint_(p2, {edit: true, virtual: true}); - virtualSketchPoint.properties!.index = lastIdx; - this.sketchPoints_.push(virtualSketchPoint); - } - this.viewer_.scene.requestRender(); - } - - finishDrawing() { - let positions = this.activePoints_; - if ((this.type === 'polygon' || this.type === 'rectangle') && positions.length < 3) { - this.dispatchEvent(new CustomEvent('drawerror', { - detail: { - error: this.ERROR_TYPES.needMorePoints - } - })); - return; - } - if (this.type === 'point') { - positions.push(this.activePoint_!); - this.drawShape_(this.activePoint_); - } else if (this.type === 'rectangle') { - positions = rectanglify(this.activePoints_); - this.drawShape_(positions); - } else { - if (this.type === 'polygon') { - const distance = Cartesian3.distance(this.activePoints_[this.activePoints_.length - 1], this.activePoints_[0]); - this.activeDistances_.push(distance / 1000); - } - this.drawShape_(this.activePoints_); - } - this.viewer_.scene.requestRender(); - - const measurements = getMeasurements(positions, this.type!); - const segments = this.getSegmentsInfo(); - this.dispatchEvent(new CustomEvent('drawinfo', { - detail: { - length: measurements.perimeter!, - numberOfSegments: segments.length, - segments: segments, - type: this.type!, - drawInProgress: false - } - })); - this.dispatchEvent(new CustomEvent('drawend', { - detail: { - positions: positions, - type: this.type!, - measurements: measurements - } - })); - - this.removeSketches(); - } - - removeSketches() { - this.drawingDataSource.entities.removeAll(); - - this.activePoints_ = []; - this.activePoint_ = undefined; - this.sketchPoint_ = undefined; - this.activeDistance_ = 0; - this.activeDistances_ = []; - this.entityForEdit = undefined; - this.leftPressedPixel_ = undefined; - this.moveEntity = false; - this.sketchPoints_ = []; - this.segmentsInfo = []; - } - - /** - * - */ - clear() { - this.removeSketches(); - } - - createSketchPoint_(position, options: { edit?: boolean, virtual?: boolean, positionIndex?: number, label?: boolean } = {}) { - const entity: Entity.ConstructorOptions = { - position: position, - point: { - color: options.virtual ? this.pointOptions.virtualColor : this.pointOptions.color, - outlineWidth: this.pointOptions.outlineWidth, - outlineColor: this.pointOptions.outlineColor, - pixelSize: options.edit ? this.pointOptions.pixelSizeEdit : this.pointOptions.pixelSizeDefault, - heightReference: this.pointOptions.heightReference, - }, - properties: {} - }; - if (options.edit) { - entity.point!.disableDepthTestDistance = Number.POSITIVE_INFINITY; - } - if (options.label && this.type) { - entity.label = getDimensionLabel(this.type, this.activeDistances_); - entity.label.heightReference = this.pointOptions.heightReference; - } - const pointEntity = this.drawingDataSource.entities.add(entity); - pointEntity.properties!.virtual = options.virtual; - return pointEntity; - } - - createSketchLine_(positions: Cartesian3[] | CallbackProperty) { - return this.drawingDataSource.entities.add({ - polyline: { - positions: positions, - clampToGround: this.lineClampToGround, - width: this.strokeWidth_, - material: this.strokeColor_, - classificationType: this.lineClampToGround ? ClassificationType.TERRAIN : ClassificationType.BOTH - } - }); - } - - drawShape_(positions: Cartesian3 | Cartesian3[] | undefined) { - if (!positions) return; - if (this.type === 'point' && !Array.isArray(positions)) { - this.drawingDataSource.entities.add({ - position: positions, - point: { - color: this.fillColor_, - outlineWidth: 2, - outlineColor: this.strokeColor_, - pixelSize: this.strokeWidth_, - heightReference: this.lineClampToGround ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE - } - }); - - } else if (this.type === 'line' && Array.isArray(positions)) { - this.drawingDataSource.entities.add({ - position: positions[positions.length - 1], - polyline: { - positions: positions, - clampToGround: this.lineClampToGround, - width: this.strokeWidth_, - material: this.strokeColor_, - classificationType: this.lineClampToGround ? ClassificationType.TERRAIN : ClassificationType.BOTH - }, - label: getDimensionLabel(this.type, this.activeDistances_) - }); - } else if ((this.type === 'polygon' || this.type === 'rectangle') && Array.isArray(positions)) { - this.drawingDataSource.entities.add({ - position: positions[positions.length - 1], - polygon: { - hierarchy: positions, - material: this.fillColor_, - classificationType: ClassificationType.TERRAIN - }, - label: getDimensionLabel(this.type, this.activeDistances_) - }); - } - } - - dynamicSketLinePositions() { - return new CallbackProperty(() => { - const activePoints: Cartesian3[] = [...this.activePoints_!, this.activePoint_!]; - const positions = this.type === 'rectangle' ? rectanglify(activePoints) : activePoints; - if (this.type === 'rectangle' && activePoints.length === 4) { // to avoid showing of confusing lines - return []; - } - if (positions.length >= 3 && this.type !== 'line') { - // close the polygon - // FIXME: better memory management - return [...positions, positions[0]]; - } else { - return positions; - } - }, false); - } - - updateSketchPoint() { - if (!this.sketchPoint_) return; - const activePoints: Cartesian3[] = [...this.activePoints_!, this.activePoint_!]; - const positions = this.type === 'rectangle' ? rectanglify(activePoints) : activePoints; - const pointsLength = positions.length; - if (pointsLength > 1) { - let distance; - if (this.type === 'rectangle' && pointsLength > 2) { - const b = positions[1]; //according to rectanglify - const bp = positions[2]; - distance = Cartesian3.distance(b, bp); - ( this.sketchPoint_.position).setValue(bp); - } else { - const lastPoint = positions[pointsLength - 1]; - distance = Cartesian3.distance(positions[pointsLength - 2], lastPoint); - ( this.sketchPoint_.position).setValue(lastPoint); - } - this.activeDistance_ = distance / 1000; - const value = `${this.activeDistance_.toFixed(3)}km`; - ( this.sketchPoint_.label!.text).setValue(value); - this.dispatchEvent(new CustomEvent('drawinfo', { - detail: { - length: this.activeDistance_, - numberOfSegments: this.activePoints_.length === 0 ? 0 : this.segmentsInfo.length + 1, - segments: this.segmentsInfo, - type: this.type!, - drawInProgress: true - } - })); - return; - } - ( this.sketchPoint_.label!.text).setValue('0km'); - this.dispatchEvent(new CustomEvent('drawinfo', { - detail: { - length: 0, - numberOfSegments: 0, - segments: [], - type: this.type!, - drawInProgress: true - } - })); - } - - onLeftClick(event) { - this.renderSceneIfTranslucent(); - if (!event?.position) return; - const pickedPosition = this.viewer_.scene.pickPosition(event.position); - if (pickedPosition) { - const position = Cartesian3.clone(pickedPosition); - if (!this.sketchPoint_) { - this.dispatchEvent(new CustomEvent('drawstart')); - this.sketchPoint_ = this.createSketchPoint_(position, {label: true}); - this.activePoint_ = position; - - this.createSketchLine_(this.dynamicSketLinePositions()); - this.viewer_.scene.requestRender(); - if (this.type === 'point') { - this.activePoints_.push(position); - this.finishDrawing(); - return; - } - } else if (!this.activeDistances_.includes(this.activeDistance_)) { - this.activeDistances_.push(this.activeDistance_); - } - this.activePoints_.push(Cartesian3.clone(this.activePoint_!)); - this.segmentsInfo = this.getSegmentsInfo(); - const forceFinish = this.minPointsStop && ( - (this.type === 'polygon' && this.activePoints_.length === 3) || - (this.type === 'line' && this.activePoints_.length === 2) - ); - if ((this.type === 'rectangle' && this.activePoints_.length === 3) || forceFinish) { - this.finishDrawing(); - } else if (this.type === 'line') { - if (!this.isDoubleClick) { - if (this.singleClickTimer) { - clearTimeout(this.singleClickTimer); - this.singleClickTimer = null; - } else { - this.singleClickTimer = setTimeout(() => { - this.isDoubleClick = false; - const prevPoint = Cartesian3.clone(this.activePoints_[this.activePoints_.length - 1]); - this.sketchPoints_.push(this.createSketchPoint_(prevPoint)); - this.singleClickTimer = null; - }, 250); - } - } - } - } - } - - updateRectCorner(corner, oppositePoint, midPoint, midPointPrev, midScale, negate) { - let midDiff = Cartesian3.subtract(corner, midPointPrev, new Cartesian3()); - midDiff = Cartesian3.multiplyByScalar(midDiff, midScale, new Cartesian3()); - const positionFromMid = Cartesian3.add(midPoint, midDiff, new Cartesian3()); - - const distancePrev = Cartesian3.distance(corner, oppositePoint); - const distanceCurrent = Cartesian3.distance(positionFromMid, oppositePoint); - const distanceScale = distanceCurrent / distancePrev; - let distanceDiff = Cartesian3.subtract(corner, oppositePoint, new Cartesian3()); - - distanceDiff = Cartesian3.multiplyByScalar(distanceDiff, distanceScale, new Cartesian3()); - let newCornerPosition = Cartesian3.add(oppositePoint, distanceDiff, new Cartesian3()); - if (negate) { - distanceDiff = Cartesian3.negate(distanceDiff, new Cartesian3()); - newCornerPosition = Cartesian3.add(oppositePoint, distanceDiff, new Cartesian3()); - } - return newCornerPosition; - } - - rotateRectangle(startPosition, endPosition) { - const positions = [...this.activePoints_]; - const center = Cartesian3.midpoint(positions[0], positions[2], new Cartesian3()); - const centerCart = Cartographic.fromCartesian(center); - const endCart = Cartographic.fromCartesian(endPosition); - const startCart = Cartographic.fromCartesian(startPosition); - const angleStart = Math.PI + Math.atan2(endCart.longitude - centerCart.longitude, endCart.latitude - centerCart.latitude); - const angleEnd = Math.PI + Math.atan2(startCart.longitude - centerCart.longitude, startCart.latitude - centerCart.latitude); - const angleDiff = angleEnd - angleStart; - - positions.forEach((pos, indx) => { - const point = Cartographic.fromCartesian(pos); - const cosTheta = Math.cos(angleDiff); - const sinTheta = Math.sin(angleDiff); - const vLon = (cosTheta * (point.longitude - centerCart.longitude) - sinTheta * (point.latitude - centerCart.latitude) / Math.abs(Math.cos(centerCart.latitude))); - const vLat = (sinTheta * (point.longitude - centerCart.longitude) * Math.abs(Math.cos(centerCart.latitude)) + cosTheta * (point.latitude - centerCart.latitude)); - const lon = centerCart.longitude + vLon; - const lat = centerCart.latitude + vLat; - - positions[indx] = Cartographic.toCartesian(new Cartographic(lon, lat)); - }); - this.sketchPoints_.forEach((sp, key) => { - sp.position = positions[key]; - this.activePoints_[key] = positions[key]; - }); - this.viewer_.scene.requestRender(); - } - - onMouseMove_(event) { - this.renderSceneIfTranslucent(); - if (!event?.endPosition) return; - const pickedPosition = this.viewer_.scene.pickPosition(event.endPosition); - if (!pickedPosition) return; - const position = Cartesian3.clone(pickedPosition); - if (this.entityForEdit && !!this.leftPressedPixel_) { - if (this.moveEntity) { - if (this.type === 'point') { - const cartographicPosition = Cartographic.fromCartesian(this.entityForEdit.position!.getValue(this.julianDate)!); - this.activePoints_[0] = position; - updateHeightForCartesianPositions(this.activePoints_, cartographicPosition.height, undefined, true); - } else { - const pointProperties = this.sketchPoint_!.properties!; - const index = pointProperties.index; - let prevPosition = new Cartesian3(); - if (typeof index === 'number') { - this.sketchPoint_!.position = position; - prevPosition = Cartesian3.clone(this.activePoints_[index]); - this.activePoints_[index] = position; - } - if (this.type === 'polygon') { - // move virtual SPs - const idx = this.sketchPoint_!.properties!.index; - const spLen = this.sketchPoints_.length; - const prevRealSPIndex = ((spLen + idx - 1) * 2) % spLen; - const prevRealSP = this.sketchPoints_[prevRealSPIndex]; - const prevVirtualPosition = this.halfwayPosition_(prevRealSP, this.sketchPoint_); - this.sketchPoints_[prevRealSPIndex + 1].position = prevVirtualPosition; - - const nextRealSPIndex = ((spLen + idx + 1) * 2) % spLen; - const nextRealSP = this.sketchPoints_[nextRealSPIndex]; - const nextVirtualPosition = this.halfwayPosition_(nextRealSP, this.sketchPoint_); - this.sketchPoints_[idx * 2 + 1].position = nextVirtualPosition; - } - if (this.type === 'line') { - // move virtual SPs - const idx = this.sketchPoint_!.properties!.index; - if (idx > 0) { - const prevRealSP = this.sketchPoints_[(idx - 1) * 2]; - const prevVirtualPosition = this.halfwayPosition_(prevRealSP, this.sketchPoint_); - this.sketchPoints_[(idx - 1) * 2 + 1].position = prevVirtualPosition; - } - if (idx < (this.activePoints_.length - 1)) { - const nextRealSP = this.sketchPoints_[(idx + 1) * 2]; - const nextVirtualPosition = this.halfwayPosition_(nextRealSP, this.sketchPoint_); - this.sketchPoints_[(idx + 1) * 2 - 1].position = nextVirtualPosition; - } - } else { - const positions = this.activePoints_; - if (this.type === 'rectangle') { - if (pointProperties.type && pointProperties.type.getValue() === 'rotate') { - const oldPosition = this.sketchPoint_!.position!.getValue(this.julianDate); - this.rotateRectangle(oldPosition, position); - return; - } - const oppositeIndex = index > 1 ? index - 2 : index + 2; - const leftIndex = index - 1 < 0 ? 3 : index - 1; - const rightIndex = index + 1 > 3 ? 0 : index + 1; - let draggedPoint = positions[index]; - const oppositePoint = positions[oppositeIndex]; - let leftPoint = positions[leftIndex]; - let rightPoint = positions[rightIndex]; - - const midPoint = Cartesian3.midpoint(draggedPoint, oppositePoint, new Cartesian3()); - const midPointPrev = Cartesian3.midpoint(prevPosition, oppositePoint, new Cartesian3()); - const midDist = Cartesian3.distance(draggedPoint, midPoint); - const midDistPrev = Cartesian3.distance(prevPosition, midPointPrev); - const midScale = midDist / midDistPrev; - - const negate = this.checkForNegateMove(draggedPoint, oppositePoint, leftPoint, rightPoint); - leftPoint = this.updateRectCorner(leftPoint, oppositePoint, midPoint, midPointPrev, midScale, negate.left); - rightPoint = this.updateRectCorner(rightPoint, oppositePoint, midPoint, midPointPrev, midScale, negate.right); - - draggedPoint = this.getCorrectRectCorner(draggedPoint, oppositePoint, leftPoint, rightPoint); - draggedPoint = this.getCorrectRectCorner(draggedPoint, oppositePoint, rightPoint, leftPoint); - - positions[index] = draggedPoint; - this.activePoints_[index] = draggedPoint; - positions[leftIndex] = leftPoint; - positions[rightIndex] = rightPoint; - this.sketchPoints_.forEach((sp, key) => { - sp.position = positions[key]; - }); - } - } - } - } - } else if (this.sketchPoint_) { - this.activePoint_ = position; - this.updateSketchPoint(); - } - this.viewer_.scene.requestRender(); - } - - onDoubleClick_() { - this.isDoubleClick = true; - if (this.singleClickTimer) { - clearTimeout(this.singleClickTimer); - } - if (!this.activeDistances_.includes(this.activeDistance_)) { - this.activeDistances_.push(this.activeDistance_); - } - this.activePoints_.pop(); - if (this.activeDistances_.length === this.activePoints_.length) { - this.activeDistances_.pop(); - } - this.finishDrawing(); - } - - /** - * Enables moving of point geometry or one of the sketch points for other geometries if left mouse button pressed on it - * @param event - * @private - */ - onLeftDown_(event) { - this.leftPressedPixel_ = Cartesian2.clone(event.position); - if (this.entityForEdit) { - const objects = this.viewer_.scene.drillPick(event.position, 5, 5, 5); - if (objects.length) { - const selectedPoint = objects.find(obj => !!obj.id.point || !!obj.id.billboard); - if (!selectedPoint) return; - const selectedEntity = selectedPoint.id; - this.sketchPoint_ = selectedEntity; - const properties = selectedEntity.properties; - // checks if picked entity is point geometry or one of the sketch points for other geometries - this.moveEntity = selectedEntity.id === this.entityForEdit.id || - this.sketchPoints_.some(sp => sp.id === selectedEntity.id) || - (properties && properties.type && properties.type.getValue() === 'rotate'); - if (this.moveEntity && this.sketchPoint_?.properties!.virtual) { - this.extendOrSplitLineOrPolygonPositions_(); - } - } - if (this.moveEntity) { - this.viewer_.scene.screenSpaceCameraController.enableInputs = false; - this.dispatchEvent(new CustomEvent('leftdown')); - } - } - } - - /** - * - * @param {*} a - * @param {*} b - * @return {Cartesian3} - */ - halfwayPosition_(a, b) { - a = a.position || a; - b = b.position || b; - a = a.getValue ? a.getValue(this.julianDate) : a; - b = b.getValue ? b.getValue(this.julianDate) : b; - const position = Cartesian3.add(a, b, new Cartesian3()); - Cartesian3.divideByScalar(position, 2, position); - return position; - } - - extendOrSplitLineOrPolygonPositions_() { - // Add new line vertex - // Create SPs, reuse the pressed virtual SP for first segment - const pressedVirtualSP = this.sketchPoint_!; - const pressedPosition = Cartesian3.clone(pressedVirtualSP.position!.getValue(this.julianDate)!); - const pressedIdx = pressedVirtualSP.properties!.index; - const realSP0 = this.sketchPoints_[pressedIdx * 2]; - const realSP2 = this.sketchPoints_[((pressedIdx + 1) * 2) % (this.sketchPoints_.length)]; - const virtualPosition0 = this.halfwayPosition_(realSP0, pressedPosition); - const virtualPosition1 = this.halfwayPosition_(pressedPosition, realSP2); - const realSP1 = this.createSketchPoint_(pressedPosition, {edit: true}); - const virtualSP1 = this.createSketchPoint_(virtualPosition1, {edit: true, virtual: true}); - const virtualSP0 = pressedVirtualSP; // the pressed SP is reused - virtualSP0.position = virtualPosition0; // but its position is changed - - this.insertVertexToPolylineOrPolygon_(pressedIdx + 1, pressedPosition.clone()); - this.sketchPoints_.splice((pressedIdx + 1) * 2, 0, realSP1, virtualSP1); - this.sketchPoints_.forEach((sp, idx) => sp.properties!.index = Math.floor(idx / 2)); - this.sketchPoint_ = realSP1; - this.viewer_.scene.requestRender(); - } - - insertVertexToPolylineOrPolygon_(idx, coordinates) { - this.activePoints_.splice(idx, 0, coordinates); - } - - /** - * @param event - */ - onLeftUp_(event) { - this.viewer_.scene.screenSpaceCameraController.enableInputs = true; - const wasAClick = Cartesian2.equalsEpsilon(event.position, this.leftPressedPixel_, 0, 2); - if (wasAClick) { - this.onLeftDownThenUp_(event); - } - if (this.moveEntity) - this.dispatchEvent(new CustomEvent('leftup')); - this.moveEntity = false; - this.leftPressedPixel_ = undefined; - this.sketchPoint_ = undefined; - } - - onLeftDownThenUp_(_event) { - const e = this.entityForEdit!; - if (this.sketchPoint_ && this.sketchPoint_.properties!.index !== undefined && !this.sketchPoint_.properties!.virtual) { - // remove clicked position from the edited geometry - let divider = 1; - switch (this.type) { - case 'polygon': { - const hierarchy = e.polygon!.hierarchy!.getValue(this.julianDate); - if (hierarchy.positions.length <= 3) { - return; - } - this.activePoints_.splice(this.sketchPoint_.properties!.index, 1); - divider = 2; - break; - } - case 'line': { - const pPositions = e.polyline!.positions!.getValue(this.julianDate); - if (pPositions.length <= 2) { - return; - } - this.activePoints_.splice(this.sketchPoint_.properties!.index, 1); - divider = 2; - break; - } - default: - break; - } - // a real sketch point was clicked => remove it - if (divider === 2) { - const pressedIdx = this.sketchPoint_.properties!.index; - const pressedIdx2 = pressedIdx * 2; - const isLine = this.type === 'line'; - const firstPointClicked = isLine && pressedIdx === 0; - const lastPointClicked = isLine && (pressedIdx2 === this.sketchPoints_.length - 1); - - if (!firstPointClicked && !lastPointClicked) { - // Move previous virtual SP in the middle of preRealSP and nextRealSP - const prevRealSPIndex2 = (this.sketchPoints_.length + pressedIdx2 - 2) % (this.sketchPoints_.length); - const nextRealSPIndex2 = (pressedIdx2 + 2) % (this.sketchPoints_.length); - const prevRealSP = this.sketchPoints_[prevRealSPIndex2]; - const prevVirtualSP = this.sketchPoints_[prevRealSPIndex2 + 1]; - const nextRealSP = this.sketchPoints_[nextRealSPIndex2]; - const newPosition = this.halfwayPosition_(prevRealSP, nextRealSP); - prevVirtualSP.position = newPosition; - } - - let removedSPs; - if (lastPointClicked) { - // remove 2 SPs backward - removedSPs = this.sketchPoints_.splice(pressedIdx2 - 1, 2); - } else { - // remove 2 SP forward - removedSPs = this.sketchPoints_.splice(pressedIdx2, 2); - } - this.sketchPoints_.forEach((s, index) => s.properties!.index = Math.floor(index / divider)); - removedSPs.forEach(s => this.drawingDataSource.entities.remove(s)); - } else if (this.type === 'polygon' || this.type === 'line') { - this.sketchPoints_.splice(this.sketchPoint_.properties!.index, 1); - this.sketchPoints_.forEach((sp, idx) => sp.properties!.index = idx); - this.drawingDataSource.entities.remove(this.sketchPoint_); - } - this.viewer_.scene.requestRender(); - } - } - - getCorrectRectCorner(corner, oppositePoint, checkPoint1, checkPoint2) { - const distance = Cartesian3.distance(checkPoint1, oppositePoint); - const newDistance = Cartesian3.distance(corner, checkPoint2); - const dScale = distance / newDistance; - let dDiff = Cartesian3.subtract(corner, checkPoint2, new Cartesian3()); - dDiff = Cartesian3.multiplyByScalar(dDiff, dScale, new Cartesian3()); - return Cartesian3.add(checkPoint2, dDiff, new Cartesian3()); - } - - checkForNegateMove(draggedPoint, oppositePoint, leftPoint, rightPoint) { - const draggedPoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(draggedPoint); - const rightPoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(rightPoint); - const leftPoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(leftPoint); - const oppositePoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(oppositePoint); - if (!draggedPoint2D || !rightPoint2D || !leftPoint2D || !oppositePoint2D) { - return { - right: false, - left: false - }; - } - return { - right: !!Intersections2D.computeLineSegmentLineSegmentIntersection( - draggedPoint2D.x, - draggedPoint2D.y, - rightPoint2D.x, - rightPoint2D.y, - leftPoint2D.x, - leftPoint2D.y, - oppositePoint2D.x, - oppositePoint2D.y - ), - left: !!Intersections2D.computeLineSegmentLineSegmentIntersection( - draggedPoint2D.x, - draggedPoint2D.y, - leftPoint2D.x, - leftPoint2D.y, - rightPoint2D.x, - rightPoint2D.y, - oppositePoint2D.x, - oppositePoint2D.y - ) - }; - } - - getSegmentsInfo(): SegmentInfo[] { - const positions = this.activePoints_; - return this.activeDistances_.map((dist, indx) => { - let easting = 0; - let northing = 0; - let height = 0; - if (positions[indx + 1]) { - const cartPosition1 = Cartographic.fromCartesian(positions[indx]); - const cartPosition2 = Cartographic.fromCartesian(positions[indx + 1]); - const lv95Position1 = cartesianToLv95(positions[indx]); - const lv95Position2 = cartesianToLv95(positions[indx + 1]); - easting = Math.abs(lv95Position2[0] - lv95Position1[0]) / 1000; - northing = Math.abs(lv95Position2[1] - lv95Position1[1]) / 1000; - height = Math.abs(cartPosition2.height - cartPosition1.height); - } - return { - length: dist, - eastingDiff: easting, - northingDiff: northing, - heightDiff: height, - }; - }); - } -} - - +import { + CallbackProperty, + Cartesian2, + Cartesian3, + Cartographic, + ClassificationType, + Color, + ConstantPositionProperty, + ConstantProperty, + CustomDataSource, + Entity, + HeightReference, + Intersections2D, + JulianDate, + PolygonHierarchy, + ScreenSpaceEventHandler, + ScreenSpaceEventType, + Viewer +} from 'cesium'; +import {getDimensionLabel, rectanglify} from './helpers'; +import {getMeasurements, Measurements, updateHeightForCartesianPositions} from '../cesiumutils'; +import type {GeometryTypes} from '../toolbox/interfaces'; +import {cartesianToLv95} from '../projection'; + +type PointOptions = { + color?: Color, + virtualColor?: Color, + outlineWidth?: number, + outlineColor?: Color, + pixelSizeDefault?: number, + pixelSizeEdit?: number, + heightReference?: HeightReference, +} +export interface DrawOptions { + fillColor?: string | Color; + strokeColor?: string | Color; + strokeWidth?: number; + minPointsStop?: boolean; + pointOptions?: PointOptions; + lineClampToGround?: boolean; +} + +export type SegmentInfo = { + length: number, + eastingDiff: number, + northingDiff: number, + heightDiff: number +}; +export type DrawInfo = { + length: number, + numberOfSegments: number, + segments: SegmentInfo[], + type: GeometryTypes, + drawInProgress: boolean +} + +export type DrawEndDetails = { + positions: Cartesian3[], + type: GeometryTypes, + measurements: Measurements +} + +export class CesiumDraw extends EventTarget { + private viewer_: Viewer; + private strokeColor_: Color; + private strokeWidth_: number; + private fillColor_: Color; + private eventHandler_: ScreenSpaceEventHandler | undefined; + private activePoints_: Cartesian3[] = []; + private activePoint_: Cartesian3 | undefined; + private sketchPoint_: Entity | undefined; + private activeDistance_ = 0; + private activeDistances_: number[] = []; + private leftPressedPixel_: Cartesian2 | undefined; + private sketchPoints_: Entity[] = []; + private isDoubleClick = false; + private singleClickTimer: NodeJS.Timeout | null = null; + private segmentsInfo: SegmentInfo[] = []; + type: GeometryTypes | undefined; + julianDate = new JulianDate(); + drawingDataSource = new CustomDataSource('drawing'); + minPointsStop: boolean; + moveEntity = false; + entityForEdit: Entity | undefined; + ERROR_TYPES = {needMorePoints: 'need_more_points'}; + pointOptions: PointOptions; + // todo line options? + lineClampToGround: boolean = true; + + constructor(viewer: Viewer, options?: DrawOptions) { + super(); + // todo move default values to constants + this.viewer_ = viewer; + this.viewer_.dataSources.add(this.drawingDataSource); + this.strokeColor_ = options?.strokeColor instanceof Color ? + options.strokeColor : Color.fromCssColorString(options?.strokeColor || 'rgba(0, 153, 255, 0.75)'); + this.strokeWidth_ = options?.strokeWidth !== undefined ? options.strokeWidth : 4; + this.fillColor_ = options?.fillColor instanceof Color ? + options.fillColor : Color.fromCssColorString(options?.fillColor || 'rgba(0, 153, 255, 0.3)'); + this.minPointsStop = !!options?.minPointsStop; + this.lineClampToGround = typeof options?.lineClampToGround === 'boolean' ? options.lineClampToGround : true; + const pointOptions = options?.pointOptions; + const heightReference = pointOptions?.heightReference; + this.pointOptions = { + color: pointOptions?.color instanceof Color ? pointOptions.color : Color.WHITE, + virtualColor: pointOptions?.virtualColor instanceof Color ? pointOptions.virtualColor : Color.GREY, + outlineColor: pointOptions?.outlineColor instanceof Color ? pointOptions.outlineColor : Color.BLACK, + outlineWidth: typeof pointOptions?.outlineWidth === 'number' && !isNaN(pointOptions?.outlineWidth) ? pointOptions?.outlineWidth : 1, + pixelSizeDefault: typeof pointOptions?.pixelSizeDefault === 'number' && !isNaN(pointOptions?.pixelSizeDefault) ? pointOptions?.pixelSizeDefault : 5, + pixelSizeEdit: typeof pointOptions?.pixelSizeEdit === 'number' && !isNaN(pointOptions?.pixelSizeEdit) ? pointOptions?.pixelSizeEdit : 9, + heightReference: typeof heightReference === 'number' && !isNaN(heightReference) ? heightReference : HeightReference.CLAMP_TO_GROUND, + }; + } + + renderSceneIfTranslucent() { + // because calling render decreases performance, only call it when needed. + // see https://cesium.com/docs/cesiumjs-ref-doc/Scene.html#pickTranslucentDepth + if (this.viewer_.scene.globe.translucency.enabled) { + this.viewer_.scene.render(); + } + } + + /** + * + */ + get active() { + return this.eventHandler_ !== undefined; + } + + /** + * + */ + set active(value) { + // todo check for type + if (value && this.type) { + if (!this.eventHandler_) { + this.eventHandler_ = new ScreenSpaceEventHandler(this.viewer_.canvas); + if (this.entityForEdit) { + this.activateEditing(); + } else { + this.eventHandler_.setInputAction(this.onLeftClick.bind(this), ScreenSpaceEventType.LEFT_CLICK); + this.eventHandler_.setInputAction(this.onDoubleClick_.bind(this), ScreenSpaceEventType.LEFT_DOUBLE_CLICK); + } + this.eventHandler_.setInputAction(this.onMouseMove_.bind(this), ScreenSpaceEventType.MOUSE_MOVE); + } + this.dispatchEvent(new CustomEvent('drawinfo', { + detail: { + length: 0, + numberOfSegments: 0, + segments: [], + type: this.type, + drawInProgress: true + } + })); + } else { + if (this.eventHandler_) { + this.eventHandler_.destroy(); + } + this.eventHandler_ = undefined; + } + this.dispatchEvent(new CustomEvent('statechanged', {detail: {active: value && this.type}})); + } + + activateEditing() { + if (!this.eventHandler_ || !this.entityForEdit) return; + this.eventHandler_.setInputAction(event => this.onLeftDown_(event), ScreenSpaceEventType.LEFT_DOWN); + this.eventHandler_.setInputAction(event => this.onLeftUp_(event), ScreenSpaceEventType.LEFT_UP); + const position = this.entityForEdit.position?.getValue(this.julianDate); + let positions: Cartesian3[] = []; + let createVirtualSPs = false; + switch (this.type) { + case 'point': + this.entityForEdit.position = new CallbackProperty(() => this.activePoints_[0] || position, false); + break; + case 'line': + positions = [...this.entityForEdit.polyline!.positions!.getValue(this.julianDate)]; + this.entityForEdit.polyline!.positions = new CallbackProperty(() => this.activePoints_, false); + createVirtualSPs = true; + break; + case 'polygon': + positions = [...this.entityForEdit.polygon!.hierarchy!.getValue(this.julianDate).positions]; + this.entityForEdit.polygon!.hierarchy = new CallbackProperty(() => new PolygonHierarchy(this.activePoints_), false); + createVirtualSPs = true; + break; + case 'rectangle': + positions = [...this.entityForEdit.polygon!.hierarchy!.getValue(this.julianDate).positions]; + this.entityForEdit.polygon!.hierarchy = new CallbackProperty(() => new PolygonHierarchy(this.activePoints_), false); + this.drawingDataSource.entities.add({ + position: new CallbackProperty(() => { + positions = this.activePoints_.length ? this.activePoints_ : positions; + return Cartesian3.midpoint(positions[0], positions[1], new Cartesian3()); + }, false), + billboard: { + image: './images/rotate-icon.svg', + disableDepthTestDistance: Number.POSITIVE_INFINITY, + heightReference: HeightReference.CLAMP_TO_GROUND + }, + properties: { + type: 'rotate' + } + }); + break; + default: + break; + } + + positions.forEach((p, idx) => { + this.activePoints_.push(p); + const sketchPoint = this.createSketchPoint_(p, {edit: true, positionIndex: idx}); + sketchPoint.properties!.index = idx; + this.sketchPoints_.push(sketchPoint); + if (createVirtualSPs && (idx + 1) < positions.length) { + const p2 = this.halfwayPosition_(p, positions[idx + 1]); + const virtualSketchPoint = this.createSketchPoint_(p2, {edit: true, virtual: true}); + virtualSketchPoint.properties!.index = idx; + this.sketchPoints_.push(virtualSketchPoint); + } + }); + if (this.type === 'polygon' && positions.length > 2) { + // We need one more virtual sketchpoint for polygons + const lastIdx = positions.length - 1; + const p2 = this.halfwayPosition_(positions[lastIdx], positions[0]); + const virtualSketchPoint = this.createSketchPoint_(p2, {edit: true, virtual: true}); + virtualSketchPoint.properties!.index = lastIdx; + this.sketchPoints_.push(virtualSketchPoint); + } + this.viewer_.scene.requestRender(); + } + + finishDrawing() { + let positions = this.activePoints_; + if ((this.type === 'polygon' || this.type === 'rectangle') && positions.length < 3) { + this.dispatchEvent(new CustomEvent('drawerror', { + detail: { + error: this.ERROR_TYPES.needMorePoints + } + })); + return; + } + if (this.type === 'point') { + positions.push(this.activePoint_!); + this.drawShape_(this.activePoint_); + } else if (this.type === 'rectangle') { + positions = rectanglify(this.activePoints_); + this.drawShape_(positions); + } else { + if (this.type === 'polygon') { + const distance = Cartesian3.distance(this.activePoints_[this.activePoints_.length - 1], this.activePoints_[0]); + this.activeDistances_.push(distance / 1000); + } + this.drawShape_(this.activePoints_); + } + this.viewer_.scene.requestRender(); + + const measurements = getMeasurements(positions, this.type!); + const segments = this.getSegmentsInfo(); + this.dispatchEvent(new CustomEvent('drawinfo', { + detail: { + length: measurements.perimeter!, + numberOfSegments: segments.length, + segments: segments, + type: this.type!, + drawInProgress: false + } + })); + this.dispatchEvent(new CustomEvent('drawend', { + detail: { + positions: positions, + type: this.type!, + measurements: measurements + } + })); + + this.removeSketches(); + } + + removeSketches() { + this.drawingDataSource.entities.removeAll(); + + this.activePoints_ = []; + this.activePoint_ = undefined; + this.sketchPoint_ = undefined; + this.activeDistance_ = 0; + this.activeDistances_ = []; + this.entityForEdit = undefined; + this.leftPressedPixel_ = undefined; + this.moveEntity = false; + this.sketchPoints_ = []; + this.segmentsInfo = []; + } + + /** + * + */ + clear() { + this.removeSketches(); + } + + createSketchPoint_(position, options: { edit?: boolean, virtual?: boolean, positionIndex?: number, label?: boolean } = {}) { + const entity: Entity.ConstructorOptions = { + position: position, + point: { + color: options.virtual ? this.pointOptions.virtualColor : this.pointOptions.color, + outlineWidth: this.pointOptions.outlineWidth, + outlineColor: this.pointOptions.outlineColor, + pixelSize: options.edit ? this.pointOptions.pixelSizeEdit : this.pointOptions.pixelSizeDefault, + heightReference: this.pointOptions.heightReference, + }, + properties: {} + }; + if (options.edit) { + entity.point!.disableDepthTestDistance = Number.POSITIVE_INFINITY; + } + if (options.label && this.type) { + entity.label = getDimensionLabel(this.type, this.activeDistances_); + entity.label.heightReference = this.pointOptions.heightReference; + } + const pointEntity = this.drawingDataSource.entities.add(entity); + pointEntity.properties!.virtual = options.virtual; + return pointEntity; + } + + createSketchLine_(positions: Cartesian3[] | CallbackProperty) { + return this.drawingDataSource.entities.add({ + polyline: { + positions: positions, + clampToGround: this.lineClampToGround, + width: this.strokeWidth_, + material: this.strokeColor_, + classificationType: this.lineClampToGround ? ClassificationType.TERRAIN : ClassificationType.BOTH + } + }); + } + + drawShape_(positions: Cartesian3 | Cartesian3[] | undefined) { + if (!positions) return; + if (this.type === 'point' && !Array.isArray(positions)) { + this.drawingDataSource.entities.add({ + position: positions, + point: { + color: this.fillColor_, + outlineWidth: 2, + outlineColor: this.strokeColor_, + pixelSize: this.strokeWidth_, + heightReference: this.lineClampToGround ? HeightReference.CLAMP_TO_GROUND : HeightReference.NONE + } + }); + + } else if (this.type === 'line' && Array.isArray(positions)) { + this.drawingDataSource.entities.add({ + position: positions[positions.length - 1], + polyline: { + positions: positions, + clampToGround: this.lineClampToGround, + width: this.strokeWidth_, + material: this.strokeColor_, + classificationType: this.lineClampToGround ? ClassificationType.TERRAIN : ClassificationType.BOTH + }, + label: getDimensionLabel(this.type, this.activeDistances_) + }); + } else if ((this.type === 'polygon' || this.type === 'rectangle') && Array.isArray(positions)) { + this.drawingDataSource.entities.add({ + position: positions[positions.length - 1], + polygon: { + hierarchy: positions, + material: this.fillColor_, + classificationType: ClassificationType.TERRAIN + }, + label: getDimensionLabel(this.type, this.activeDistances_) + }); + } + } + + dynamicSketLinePositions() { + return new CallbackProperty(() => { + const activePoints: Cartesian3[] = [...this.activePoints_!, this.activePoint_!]; + const positions = this.type === 'rectangle' ? rectanglify(activePoints) : activePoints; + if (this.type === 'rectangle' && activePoints.length === 4) { // to avoid showing of confusing lines + return []; + } + if (positions.length >= 3 && this.type !== 'line') { + // close the polygon + // FIXME: better memory management + return [...positions, positions[0]]; + } else { + return positions; + } + }, false); + } + + updateSketchPoint() { + if (!this.sketchPoint_) return; + const activePoints: Cartesian3[] = [...this.activePoints_!, this.activePoint_!]; + const positions = this.type === 'rectangle' ? rectanglify(activePoints) : activePoints; + const pointsLength = positions.length; + if (pointsLength > 1) { + let distance; + if (this.type === 'rectangle' && pointsLength > 2) { + const b = positions[1]; //according to rectanglify + const bp = positions[2]; + distance = Cartesian3.distance(b, bp); + ( this.sketchPoint_.position).setValue(bp); + } else { + const lastPoint = positions[pointsLength - 1]; + distance = Cartesian3.distance(positions[pointsLength - 2], lastPoint); + ( this.sketchPoint_.position).setValue(lastPoint); + } + this.activeDistance_ = distance / 1000; + const value = `${this.activeDistance_.toFixed(3)}km`; + ( this.sketchPoint_.label!.text).setValue(value); + this.dispatchEvent(new CustomEvent('drawinfo', { + detail: { + length: this.activeDistance_, + numberOfSegments: this.activePoints_.length === 0 ? 0 : this.segmentsInfo.length + 1, + segments: this.segmentsInfo, + type: this.type!, + drawInProgress: true + } + })); + return; + } + ( this.sketchPoint_.label!.text).setValue('0km'); + this.dispatchEvent(new CustomEvent('drawinfo', { + detail: { + length: 0, + numberOfSegments: 0, + segments: [], + type: this.type!, + drawInProgress: true + } + })); + } + + onLeftClick(event) { + this.renderSceneIfTranslucent(); + if (!event?.position) return; + const pickedPosition = this.viewer_.scene.pickPosition(event.position); + if (pickedPosition) { + const position = Cartesian3.clone(pickedPosition); + if (!this.sketchPoint_) { + this.dispatchEvent(new CustomEvent('drawstart')); + this.sketchPoint_ = this.createSketchPoint_(position, {label: true}); + this.activePoint_ = position; + + this.createSketchLine_(this.dynamicSketLinePositions()); + this.viewer_.scene.requestRender(); + if (this.type === 'point') { + this.activePoints_.push(position); + this.finishDrawing(); + return; + } + } else if (!this.activeDistances_.includes(this.activeDistance_)) { + this.activeDistances_.push(this.activeDistance_); + } + this.activePoints_.push(Cartesian3.clone(this.activePoint_!)); + this.segmentsInfo = this.getSegmentsInfo(); + const forceFinish = this.minPointsStop && ( + (this.type === 'polygon' && this.activePoints_.length === 3) || + (this.type === 'line' && this.activePoints_.length === 2) + ); + if ((this.type === 'rectangle' && this.activePoints_.length === 3) || forceFinish) { + this.finishDrawing(); + } else if (this.type === 'line') { + if (!this.isDoubleClick) { + if (this.singleClickTimer) { + clearTimeout(this.singleClickTimer); + this.singleClickTimer = null; + } else { + this.singleClickTimer = setTimeout(() => { + this.isDoubleClick = false; + const prevPoint = Cartesian3.clone(this.activePoints_[this.activePoints_.length - 1]); + this.sketchPoints_.push(this.createSketchPoint_(prevPoint)); + this.singleClickTimer = null; + }, 250); + } + } + } + } + } + + updateRectCorner(corner, oppositePoint, midPoint, midPointPrev, midScale, negate) { + let midDiff = Cartesian3.subtract(corner, midPointPrev, new Cartesian3()); + midDiff = Cartesian3.multiplyByScalar(midDiff, midScale, new Cartesian3()); + const positionFromMid = Cartesian3.add(midPoint, midDiff, new Cartesian3()); + + const distancePrev = Cartesian3.distance(corner, oppositePoint); + const distanceCurrent = Cartesian3.distance(positionFromMid, oppositePoint); + const distanceScale = distanceCurrent / distancePrev; + let distanceDiff = Cartesian3.subtract(corner, oppositePoint, new Cartesian3()); + + distanceDiff = Cartesian3.multiplyByScalar(distanceDiff, distanceScale, new Cartesian3()); + let newCornerPosition = Cartesian3.add(oppositePoint, distanceDiff, new Cartesian3()); + if (negate) { + distanceDiff = Cartesian3.negate(distanceDiff, new Cartesian3()); + newCornerPosition = Cartesian3.add(oppositePoint, distanceDiff, new Cartesian3()); + } + return newCornerPosition; + } + + rotateRectangle(startPosition, endPosition) { + const positions = [...this.activePoints_]; + const center = Cartesian3.midpoint(positions[0], positions[2], new Cartesian3()); + const centerCart = Cartographic.fromCartesian(center); + const endCart = Cartographic.fromCartesian(endPosition); + const startCart = Cartographic.fromCartesian(startPosition); + const angleStart = Math.PI + Math.atan2(endCart.longitude - centerCart.longitude, endCart.latitude - centerCart.latitude); + const angleEnd = Math.PI + Math.atan2(startCart.longitude - centerCart.longitude, startCart.latitude - centerCart.latitude); + const angleDiff = angleEnd - angleStart; + + positions.forEach((pos, indx) => { + const point = Cartographic.fromCartesian(pos); + const cosTheta = Math.cos(angleDiff); + const sinTheta = Math.sin(angleDiff); + const vLon = (cosTheta * (point.longitude - centerCart.longitude) - sinTheta * (point.latitude - centerCart.latitude) / Math.abs(Math.cos(centerCart.latitude))); + const vLat = (sinTheta * (point.longitude - centerCart.longitude) * Math.abs(Math.cos(centerCart.latitude)) + cosTheta * (point.latitude - centerCart.latitude)); + const lon = centerCart.longitude + vLon; + const lat = centerCart.latitude + vLat; + + positions[indx] = Cartographic.toCartesian(new Cartographic(lon, lat)); + }); + this.sketchPoints_.forEach((sp, key) => { + sp.position = positions[key]; + this.activePoints_[key] = positions[key]; + }); + this.viewer_.scene.requestRender(); + } + + onMouseMove_(event) { + this.renderSceneIfTranslucent(); + if (!event?.endPosition) return; + const pickedPosition = this.viewer_.scene.pickPosition(event.endPosition); + if (!pickedPosition) return; + const position = Cartesian3.clone(pickedPosition); + if (this.entityForEdit && !!this.leftPressedPixel_) { + if (this.moveEntity) { + if (this.type === 'point') { + const cartographicPosition = Cartographic.fromCartesian(this.entityForEdit.position!.getValue(this.julianDate)!); + this.activePoints_[0] = position; + updateHeightForCartesianPositions(this.activePoints_, cartographicPosition.height, undefined, true); + } else { + const pointProperties = this.sketchPoint_!.properties!; + const index = pointProperties.index; + let prevPosition = new Cartesian3(); + if (typeof index === 'number') { + this.sketchPoint_!.position = position; + prevPosition = Cartesian3.clone(this.activePoints_[index]); + this.activePoints_[index] = position; + } + if (this.type === 'polygon') { + // move virtual SPs + const idx = this.sketchPoint_!.properties!.index; + const spLen = this.sketchPoints_.length; + const prevRealSPIndex = ((spLen + idx - 1) * 2) % spLen; + const prevRealSP = this.sketchPoints_[prevRealSPIndex]; + const prevVirtualPosition = this.halfwayPosition_(prevRealSP, this.sketchPoint_); + this.sketchPoints_[prevRealSPIndex + 1].position = prevVirtualPosition; + + const nextRealSPIndex = ((spLen + idx + 1) * 2) % spLen; + const nextRealSP = this.sketchPoints_[nextRealSPIndex]; + const nextVirtualPosition = this.halfwayPosition_(nextRealSP, this.sketchPoint_); + this.sketchPoints_[idx * 2 + 1].position = nextVirtualPosition; + } + if (this.type === 'line') { + // move virtual SPs + const idx = this.sketchPoint_!.properties!.index; + if (idx > 0) { + const prevRealSP = this.sketchPoints_[(idx - 1) * 2]; + const prevVirtualPosition = this.halfwayPosition_(prevRealSP, this.sketchPoint_); + this.sketchPoints_[(idx - 1) * 2 + 1].position = prevVirtualPosition; + } + if (idx < (this.activePoints_.length - 1)) { + const nextRealSP = this.sketchPoints_[(idx + 1) * 2]; + const nextVirtualPosition = this.halfwayPosition_(nextRealSP, this.sketchPoint_); + this.sketchPoints_[(idx + 1) * 2 - 1].position = nextVirtualPosition; + } + } else { + const positions = this.activePoints_; + if (this.type === 'rectangle') { + if (pointProperties.type && pointProperties.type.getValue() === 'rotate') { + const oldPosition = this.sketchPoint_!.position!.getValue(this.julianDate); + this.rotateRectangle(oldPosition, position); + return; + } + const oppositeIndex = index > 1 ? index - 2 : index + 2; + const leftIndex = index - 1 < 0 ? 3 : index - 1; + const rightIndex = index + 1 > 3 ? 0 : index + 1; + let draggedPoint = positions[index]; + const oppositePoint = positions[oppositeIndex]; + let leftPoint = positions[leftIndex]; + let rightPoint = positions[rightIndex]; + + const midPoint = Cartesian3.midpoint(draggedPoint, oppositePoint, new Cartesian3()); + const midPointPrev = Cartesian3.midpoint(prevPosition, oppositePoint, new Cartesian3()); + const midDist = Cartesian3.distance(draggedPoint, midPoint); + const midDistPrev = Cartesian3.distance(prevPosition, midPointPrev); + const midScale = midDist / midDistPrev; + + const negate = this.checkForNegateMove(draggedPoint, oppositePoint, leftPoint, rightPoint); + leftPoint = this.updateRectCorner(leftPoint, oppositePoint, midPoint, midPointPrev, midScale, negate.left); + rightPoint = this.updateRectCorner(rightPoint, oppositePoint, midPoint, midPointPrev, midScale, negate.right); + + draggedPoint = this.getCorrectRectCorner(draggedPoint, oppositePoint, leftPoint, rightPoint); + draggedPoint = this.getCorrectRectCorner(draggedPoint, oppositePoint, rightPoint, leftPoint); + + positions[index] = draggedPoint; + this.activePoints_[index] = draggedPoint; + positions[leftIndex] = leftPoint; + positions[rightIndex] = rightPoint; + this.sketchPoints_.forEach((sp, key) => { + sp.position = positions[key]; + }); + } + } + } + } + } else if (this.sketchPoint_) { + this.activePoint_ = position; + this.updateSketchPoint(); + } + this.viewer_.scene.requestRender(); + } + + onDoubleClick_() { + this.isDoubleClick = true; + if (this.singleClickTimer) { + clearTimeout(this.singleClickTimer); + } + if (!this.activeDistances_.includes(this.activeDistance_)) { + this.activeDistances_.push(this.activeDistance_); + } + this.activePoints_.pop(); + if (this.activeDistances_.length === this.activePoints_.length) { + this.activeDistances_.pop(); + } + this.finishDrawing(); + } + + /** + * Enables moving of point geometry or one of the sketch points for other geometries if left mouse button pressed on it + * @param event + * @private + */ + onLeftDown_(event) { + this.leftPressedPixel_ = Cartesian2.clone(event.position); + if (this.entityForEdit) { + const objects = this.viewer_.scene.drillPick(event.position, 5, 5, 5); + if (objects.length) { + const selectedPoint = objects.find(obj => !!obj.id.point || !!obj.id.billboard); + if (!selectedPoint) return; + const selectedEntity = selectedPoint.id; + this.sketchPoint_ = selectedEntity; + const properties = selectedEntity.properties; + // checks if picked entity is point geometry or one of the sketch points for other geometries + this.moveEntity = selectedEntity.id === this.entityForEdit.id || + this.sketchPoints_.some(sp => sp.id === selectedEntity.id) || + (properties && properties.type && properties.type.getValue() === 'rotate'); + if (this.moveEntity && this.sketchPoint_?.properties!.virtual) { + this.extendOrSplitLineOrPolygonPositions_(); + } + } + if (this.moveEntity) { + this.viewer_.scene.screenSpaceCameraController.enableInputs = false; + this.dispatchEvent(new CustomEvent('leftdown')); + } + } + } + + /** + * + * @param {*} a + * @param {*} b + * @return {Cartesian3} + */ + halfwayPosition_(a, b) { + a = a.position || a; + b = b.position || b; + a = a.getValue ? a.getValue(this.julianDate) : a; + b = b.getValue ? b.getValue(this.julianDate) : b; + const position = Cartesian3.add(a, b, new Cartesian3()); + Cartesian3.divideByScalar(position, 2, position); + return position; + } + + extendOrSplitLineOrPolygonPositions_() { + // Add new line vertex + // Create SPs, reuse the pressed virtual SP for first segment + const pressedVirtualSP = this.sketchPoint_!; + const pressedPosition = Cartesian3.clone(pressedVirtualSP.position!.getValue(this.julianDate)!); + const pressedIdx = pressedVirtualSP.properties!.index; + const realSP0 = this.sketchPoints_[pressedIdx * 2]; + const realSP2 = this.sketchPoints_[((pressedIdx + 1) * 2) % (this.sketchPoints_.length)]; + const virtualPosition0 = this.halfwayPosition_(realSP0, pressedPosition); + const virtualPosition1 = this.halfwayPosition_(pressedPosition, realSP2); + const realSP1 = this.createSketchPoint_(pressedPosition, {edit: true}); + const virtualSP1 = this.createSketchPoint_(virtualPosition1, {edit: true, virtual: true}); + const virtualSP0 = pressedVirtualSP; // the pressed SP is reused + virtualSP0.position = virtualPosition0; // but its position is changed + + this.insertVertexToPolylineOrPolygon_(pressedIdx + 1, pressedPosition.clone()); + this.sketchPoints_.splice((pressedIdx + 1) * 2, 0, realSP1, virtualSP1); + this.sketchPoints_.forEach((sp, idx) => sp.properties!.index = Math.floor(idx / 2)); + this.sketchPoint_ = realSP1; + this.viewer_.scene.requestRender(); + } + + insertVertexToPolylineOrPolygon_(idx, coordinates) { + this.activePoints_.splice(idx, 0, coordinates); + } + + /** + * @param event + */ + onLeftUp_(event) { + this.viewer_.scene.screenSpaceCameraController.enableInputs = true; + const wasAClick = Cartesian2.equalsEpsilon(event.position, this.leftPressedPixel_, 0, 2); + if (wasAClick) { + this.onLeftDownThenUp_(event); + } + if (this.moveEntity) + this.dispatchEvent(new CustomEvent('leftup')); + this.moveEntity = false; + this.leftPressedPixel_ = undefined; + this.sketchPoint_ = undefined; + } + + onLeftDownThenUp_(_event) { + const e = this.entityForEdit!; + if (this.sketchPoint_ && this.sketchPoint_.properties!.index !== undefined && !this.sketchPoint_.properties!.virtual) { + // remove clicked position from the edited geometry + let divider = 1; + switch (this.type) { + case 'polygon': { + const hierarchy = e.polygon!.hierarchy!.getValue(this.julianDate); + if (hierarchy.positions.length <= 3) { + return; + } + this.activePoints_.splice(this.sketchPoint_.properties!.index, 1); + divider = 2; + break; + } + case 'line': { + const pPositions = e.polyline!.positions!.getValue(this.julianDate); + if (pPositions.length <= 2) { + return; + } + this.activePoints_.splice(this.sketchPoint_.properties!.index, 1); + divider = 2; + break; + } + default: + break; + } + // a real sketch point was clicked => remove it + if (divider === 2) { + const pressedIdx = this.sketchPoint_.properties!.index; + const pressedIdx2 = pressedIdx * 2; + const isLine = this.type === 'line'; + const firstPointClicked = isLine && pressedIdx === 0; + const lastPointClicked = isLine && (pressedIdx2 === this.sketchPoints_.length - 1); + + if (!firstPointClicked && !lastPointClicked) { + // Move previous virtual SP in the middle of preRealSP and nextRealSP + const prevRealSPIndex2 = (this.sketchPoints_.length + pressedIdx2 - 2) % (this.sketchPoints_.length); + const nextRealSPIndex2 = (pressedIdx2 + 2) % (this.sketchPoints_.length); + const prevRealSP = this.sketchPoints_[prevRealSPIndex2]; + const prevVirtualSP = this.sketchPoints_[prevRealSPIndex2 + 1]; + const nextRealSP = this.sketchPoints_[nextRealSPIndex2]; + const newPosition = this.halfwayPosition_(prevRealSP, nextRealSP); + prevVirtualSP.position = newPosition; + } + + let removedSPs; + if (lastPointClicked) { + // remove 2 SPs backward + removedSPs = this.sketchPoints_.splice(pressedIdx2 - 1, 2); + } else { + // remove 2 SP forward + removedSPs = this.sketchPoints_.splice(pressedIdx2, 2); + } + this.sketchPoints_.forEach((s, index) => s.properties!.index = Math.floor(index / divider)); + removedSPs.forEach(s => this.drawingDataSource.entities.remove(s)); + } else if (this.type === 'polygon' || this.type === 'line') { + this.sketchPoints_.splice(this.sketchPoint_.properties!.index, 1); + this.sketchPoints_.forEach((sp, idx) => sp.properties!.index = idx); + this.drawingDataSource.entities.remove(this.sketchPoint_); + } + this.viewer_.scene.requestRender(); + } + } + + getCorrectRectCorner(corner, oppositePoint, checkPoint1, checkPoint2) { + const distance = Cartesian3.distance(checkPoint1, oppositePoint); + const newDistance = Cartesian3.distance(corner, checkPoint2); + const dScale = distance / newDistance; + let dDiff = Cartesian3.subtract(corner, checkPoint2, new Cartesian3()); + dDiff = Cartesian3.multiplyByScalar(dDiff, dScale, new Cartesian3()); + return Cartesian3.add(checkPoint2, dDiff, new Cartesian3()); + } + + checkForNegateMove(draggedPoint, oppositePoint, leftPoint, rightPoint) { + const draggedPoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(draggedPoint); + const rightPoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(rightPoint); + const leftPoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(leftPoint); + const oppositePoint2D = this.viewer_.scene.cartesianToCanvasCoordinates(oppositePoint); + if (!draggedPoint2D || !rightPoint2D || !leftPoint2D || !oppositePoint2D) { + return { + right: false, + left: false + }; + } + return { + right: !!Intersections2D.computeLineSegmentLineSegmentIntersection( + draggedPoint2D.x, + draggedPoint2D.y, + rightPoint2D.x, + rightPoint2D.y, + leftPoint2D.x, + leftPoint2D.y, + oppositePoint2D.x, + oppositePoint2D.y + ), + left: !!Intersections2D.computeLineSegmentLineSegmentIntersection( + draggedPoint2D.x, + draggedPoint2D.y, + leftPoint2D.x, + leftPoint2D.y, + rightPoint2D.x, + rightPoint2D.y, + oppositePoint2D.x, + oppositePoint2D.y + ) + }; + } + + getSegmentsInfo(): SegmentInfo[] { + const positions = this.activePoints_; + return this.activeDistances_.map((dist, indx) => { + let easting = 0; + let northing = 0; + let height = 0; + if (positions[indx + 1]) { + const cartPosition1 = Cartographic.fromCartesian(positions[indx]); + const cartPosition2 = Cartographic.fromCartesian(positions[indx + 1]); + const lv95Position1 = cartesianToLv95(positions[indx]); + const lv95Position2 = cartesianToLv95(positions[indx + 1]); + easting = Math.abs(lv95Position2[0] - lv95Position1[0]) / 1000; + northing = Math.abs(lv95Position2[1] - lv95Position1[1]) / 1000; + height = Math.abs(cartPosition2.height - cartPosition1.height); + } + return { + length: dist, + eastingDiff: easting, + northingDiff: northing, + heightDiff: height, + }; + }); + } +} + + diff --git a/ui/src/elements/dashboard/ngm-dashboard.ts b/ui/src/elements/dashboard/ngm-dashboard.ts index c5b273351..9a37f1d37 100644 --- a/ui/src/elements/dashboard/ngm-dashboard.ts +++ b/ui/src/elements/dashboard/ngm-dashboard.ts @@ -22,7 +22,7 @@ import {CustomDataSource} from 'cesium'; import {showSnackbarError} from '../../notifications'; import {DEFAULT_LAYER_OPACITY, DEFAULT_PROJECT_COLOR, PROJECT_ASSET_URL} from '../../constants'; import type {NgmGeometry} from '../../toolbox/interfaces'; -import {apiClient} from '../../api-client'; +import {ApiClient} from '../../api/api-client'; import AuthStore from '../../store/auth'; import '../hide-overflow'; import './ngm-project-edit'; @@ -31,6 +31,8 @@ import {isProject, isProjectOwnerOrEditor} from './helpers'; import {LayerConfig} from '../../layertree'; import EarthquakeVisualizer from '../../earthquakeVisualization/earthquakeVisualizer'; import {parseKml, renderWithDelay} from '../../cesiumutils'; +import {consume} from '@lit/context'; +import {apiClientContext} from '../../context'; type TextualAttribute = string | TranslatedText; @@ -127,6 +129,9 @@ export class NgmDashboard extends LitElementI18n { private userEmail: string | undefined; private tempKmlDataSource = new CustomDataSource('tempKmlDataSource'); + @consume({context: apiClientContext}) + accessor apiClient!: ApiClient; + constructor() { super(); MainStore.viewer.subscribe(viewer => { @@ -155,7 +160,7 @@ export class NgmDashboard extends LitElementI18n { // } else if (value.kind === 'project') { removeProject(); - const project = await apiClient.getProject(value.param.projectId); + const project = await this.apiClient.getProject(value.param.projectId); this.selectTopicOrProject(project); } else return; if (value.param.viewId) { @@ -195,9 +200,6 @@ export class NgmDashboard extends LitElementI18n { AuthStore.user.subscribe(() => { this.userEmail = AuthStore.userEmail; }); - apiClient.projectsChange.subscribe((projects) => { - this.refreshProjects(projects); - }); DashboardStore.geometriesUpdate.subscribe(geometries => { if (this.selectedTopicOrProject) { this.selectTopicOrProject({...this.selectedTopicOrProject, geometries}); @@ -205,7 +207,6 @@ export class NgmDashboard extends LitElementI18n { this.projectToCreate = {...this.projectToCreate, geometries}; } }); - apiClient.refreshProjects(); DashboardStore.onSaveOrCancelWarning.subscribe(show => { if (this.projectTabState !== 'view') { @@ -214,6 +215,13 @@ export class NgmDashboard extends LitElementI18n { }); } + firstUpdated() { + this.apiClient.refreshProjects(); + this.apiClient.projectsChange.subscribe((projects) => { + this.refreshProjects(projects); + }); + } + refreshProjects(projects: Project[]) { this.projects = projects; const project = this.projects.find(p => p.id === this.selectedTopicOrProject?.id); @@ -377,12 +385,12 @@ export class NgmDashboard extends LitElementI18n { async onProjectSave(project: Project | CreateProject) { if (this.projectTabState === 'edit' && isProject(project)) { - await apiClient.updateProject(project); + await this.apiClient.updateProject(project); } else if (this.projectTabState === 'create' && this.projectToCreate) { try { - const response = await apiClient.createProject(project); + const response = await this.apiClient.createProject(project); const id = await response.json(); - const createdProject = await apiClient.getProject(id); + const createdProject = await this.apiClient.getProject(id); this.selectTopicOrProject(createdProject); } catch (e) { console.error(e); @@ -402,7 +410,7 @@ export class NgmDashboard extends LitElementI18n { } cancelEditCreate() { - apiClient.refreshProjects(); + this.apiClient.refreshProjects(); this.projectTabState = 'view'; this.saveOrCancelWarning = false; this.projectToCreate = undefined; @@ -420,7 +428,7 @@ export class NgmDashboard extends LitElementI18n { async onProjectPreviewClick(projOrTopic: Topic | Project) { if (isProject(projOrTopic)) { this.showCursorPreloader = true; - projOrTopic = await apiClient.getProject(projOrTopic.id); + projOrTopic = await this.apiClient.getProject(projOrTopic.id); this.showCursorPreloader = false; } this.selectTopicOrProject(projOrTopic); @@ -457,7 +465,7 @@ export class NgmDashboard extends LitElementI18n { recentlyViewedTemplate() { if (this.isProjectSelected || this.activeTab === 'projects' || this.activeTab === 'shared' || - (this.activeTab === 'overview' && !apiClient.token)) return ''; + (this.activeTab === 'overview' && !this.apiClient.token)) return ''; const topicsOrProjects = this.activeTab === 'topics' ? this.topics : this.projects; @@ -475,7 +483,7 @@ export class NgmDashboard extends LitElementI18n { overviewTemplate() { if (this.activeTab === 'overview' && !this.isProjectSelected) { - if (apiClient.token) { + if (this.apiClient.token) { return html`
${i18next.t('dashboard_my_projects')}
@@ -499,7 +507,7 @@ export class NgmDashboard extends LitElementI18n { (changed.has('hidden') || changed.has('activeTab') || changed.has('selectedTopicOrProject')) && this.activeTab !== 'topics' && !this.selectedTopicOrProject && !this.hidden ) { - apiClient.refreshProjects(); + this.apiClient.refreshProjects(); } super.updated(changed); } @@ -529,7 +537,7 @@ export class NgmDashboard extends LitElementI18n { ${i18next.t('dashboard_overview')}
{ this.runIfNotEditCreate(() => { this.activeTab = 'projects'; @@ -539,7 +547,7 @@ export class NgmDashboard extends LitElementI18n { (${this.projects.filter(p => p.owner.email === this.userEmail).length})
{ this.runIfNotEditCreate(() => { this.activeTab = 'shared'; @@ -563,7 +571,7 @@ export class NgmDashboard extends LitElementI18n {
-
+
${this.overviewTemplate()}
@@ -584,8 +592,8 @@ export class NgmDashboard extends LitElementI18n {
${this.projectTabState !== 'view' ? - html` m.email === memberEmail); - if (index > -1) { - arrayToEdit.splice(index, 1); - } - this.project = {...this.project}; + onMemberAdd(evt: { detail: MemberToAdd }) { + if (!this.project) return; + const role = evt.detail.role; + const member = { + name: evt.detail.name, + surname: evt.detail.surname, + email: evt.detail.email + }; + if (role === 'editor') { + this.project.editors.push(member); + } else if (role === 'viewer') { + this.project.viewers.push(member); } + this.project = {...this.project}; + } - shouldUpdate(_changedProperties: PropertyValues): boolean { - return this.project !== undefined; + onMemberDelete(evt: { detail: MemberToAdd }) { + if (!this.project) return; + const role = evt.detail.role; + const memberEmail = evt.detail.email; + let arrayToEdit: Member[] = []; + if (role === 'editor') { + arrayToEdit = this.project.editors; + } else if (role === 'viewer') { + arrayToEdit = this.project.viewers; } - - showSaveOrCancelWarning() { - if (!isBannerShown(this.toastPlaceholder)) showBannerWarning(this.toastPlaceholder, i18next.t('project_lost_changes_warning')); - DashboardStore.showSaveOrCancelWarning(false); + const index = arrayToEdit.findIndex(m => m.email === memberEmail); + if (index > -1) { + arrayToEdit.splice(index, 1); } + this.project = {...this.project}; + } - updated(changedProperties) { - if (changedProperties.has('saveOrCancelWarning') && this.saveOrCancelWarning) { - this.showSaveOrCancelWarning(); - } - if (changedProperties.has('project')) { - this.querySelectorAll('.ui.dropdown').forEach(elem => $(elem).dropdown()); - } - } + shouldUpdate(_changedProperties: PropertyValues): boolean { + return this.project !== undefined; + } - firstUpdated(_changedProperties: PropertyValues) { - this.querySelectorAll('.ui.dropdown').forEach(elem => $(elem).dropdown()); - super.firstUpdated(_changedProperties); + showSaveOrCancelWarning() { + if (!isBannerShown(this.toastPlaceholder)) showBannerWarning(this.toastPlaceholder, i18next.t('project_lost_changes_warning')); + DashboardStore.showSaveOrCancelWarning(false); + } + + updated(changedProperties) { + if (changedProperties.has('saveOrCancelWarning') && this.saveOrCancelWarning) { + this.showSaveOrCancelWarning(); + } + if (changedProperties.has('project')) { + this.querySelectorAll('.ui.dropdown').forEach(elem => $(elem).dropdown()); } + } - render() { - if (!this.project) return ''; - const project = this.project; - const backgroundImage = project.image?.length ? `url('${project.image}')` : ''; - return html` -
-
-
-
- project.title} - @input=${evt => { - project.title = evt.target.value; - this.requestUpdate(); - }}/> - ${i18next.t('project_title')} -
-
-
- ${this.createMode ? i18next.t('dashboard_project_create') : i18next.t('dashboard_project_in_edit')}
-
-
-
- ${this.createMode || !isProject(project) ? '' : html` -
- ${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString(project.modified)} ${i18next.t('dashboard_by_swisstopo_title')}`} -
`} -
-
-
-
-
- ${PROJECT_COLORS.map(color => html` + firstUpdated(_changedProperties: PropertyValues) { + this.querySelectorAll('.ui.dropdown').forEach(elem => $(elem).dropdown()); + super.firstUpdated(_changedProperties); + } + + render() { + if (!this.project) return ''; + const project = this.project; + const backgroundImage = project.image?.length ? `url('${project.image}')` : ''; + return html` +
+
+
+
+ project.title} + @input=${evt => { + project.title = evt.target.value; + this.requestUpdate(); + }}/> + ${i18next.t('project_title')} +
+
+
+ ${this.createMode ? i18next.t('dashboard_project_create') : i18next.t('dashboard_project_in_edit')} +
+
+
+
+ ${this.createMode || !isProject(project) ? '' : html` +
+ ${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString(project.modified)} ${i18next.t('dashboard_by_swisstopo_title')}`} +
`} +
+
+
+
+
+ ${PROJECT_COLORS.map(color => html`
{ - project.color = color; - this.requestUpdate(); - }} - class="ngm-geom-color ${classMap({ - active: project.color === color, - 'black-tick': COLORS_WITH_BLACK_TICK.includes(color) - })}"> + style="background-color: ${color};" + @click=${() => { + project.color = color; + this.requestUpdate(); + }} + class="ngm-geom-color ${classMap({ + active: project.color === color, + 'black-tick': COLORS_WITH_BLACK_TICK.includes(color) + })}">
` - )} -
-
-
-
+ )} +
+
+
+
- ${i18next.t('project_description')} -
-
+ ${i18next.t('project_description')} +
+
+
+
+
+
+
+
${i18next.t('dashboard_views')}
+
+ +
+
+ ${project.views.map((view, index) => html` +
+
+ view.title} + @input=${evt => { + view.title = evt.target.value; + this.requestUpdate(); + }}/> + ${i18next.t('project_view')}
-
-
-
-
-
${i18next.t('dashboard_views')}
+ +
+ `)} +
+
+
+ + +
+
+ this.onMemberAdd(evt)} + @onMemberDelete=${evt => this.onMemberDelete(evt)}> +
+
this.dispatchEvent(new CustomEvent('onBack'))}> +
+ ${i18next.t('dashboard_back_to_topics')} +
+
+ + +
`; - } + } - async saveViewToProject() { - if (!this.project) return; - const project = {...this.project}; - const view: View = { - id: crypto.randomUUID(), - title: `${i18next.t('view')} ${project.views.length + 1}`, - permalink: getPermalink(), - }; - project.views.push(view); - this.project = project; - } + async saveViewToProject() { + if (!this.project) return; + const project = {...this.project}; + const view: View = { + id: crypto.randomUUID(), + title: `${i18next.t('view')} ${project.views.length + 1}`, + permalink: getPermalink(), + }; + project.views.push(view); + this.project = project; + } - createRenderRoot() { - return this; - } + createRenderRoot() { + return this; + } } function array_move(arr, old_index, new_index) { - if (new_index >= arr.length) { - let k = new_index - arr.length + 1; - while (k--) { - arr.push(undefined); - } + if (new_index >= arr.length) { + let k = new_index - arr.length + 1; + while (k--) { + arr.push(undefined); } - arr.splice(new_index, 0, arr.splice(old_index, 1)[0]); -} \ No newline at end of file + } + arr.splice(new_index, 0, arr.splice(old_index, 1)[0]); +} diff --git a/ui/src/elements/dashboard/ngm-project-topic-overview.ts b/ui/src/elements/dashboard/ngm-project-topic-overview.ts index be63b2ebf..d16cd36df 100644 --- a/ui/src/elements/dashboard/ngm-project-topic-overview.ts +++ b/ui/src/elements/dashboard/ngm-project-topic-overview.ts @@ -6,7 +6,7 @@ import {classMap} from 'lit/directives/class-map.js'; import {styleMap} from 'lit/directives/style-map.js'; import DashboardStore from '../../store/dashboard'; import {CreateProject, Project, TabTypes, Topic, type View} from './ngm-dashboard'; -import {apiClient} from '../../api-client'; +import {ApiClient} from '../../api/api-client'; import {showBannerSuccess} from '../../notifications'; import $ from '../../jquery'; import {DEFAULT_PROJECT_COLOR} from '../../constants'; @@ -17,69 +17,76 @@ import './ngm-project-members-section'; import {isProject} from './helpers'; import {NgmConfirmationModal} from '../ngm-confirmation-modal'; import {getPermalink} from '../../permalink'; +import {consume} from '@lit/context'; +import {apiClientContext} from '../../context'; @customElement('ngm-project-topic-overview') export class NgmProjectTopicOverview extends LitElementI18n { - @property({type: Object}) - accessor topicOrProject: Project | Topic | undefined; - @property({type: Object}) - accessor toastPlaceholder: HTMLElement | undefined; - @property({type: String}) - accessor activeTab: TabTypes = 'topics'; - @property({type: String}) - accessor userEmail: string = ''; - @property({type: Number}) - accessor selectedViewIndx: number | undefined; - @query('ngm-confirmation-modal') - accessor deleteWarningModal!: NgmConfirmationModal; + @property({type: Object}) + accessor topicOrProject: Project | Topic | undefined; + @property({type: Object}) + accessor toastPlaceholder: HTMLElement | undefined; + @property({type: String}) + accessor activeTab: TabTypes = 'topics'; + @property({type: String}) + accessor userEmail: string = ''; + @property({type: Number}) + accessor selectedViewIndx: number | undefined; + @query('ngm-confirmation-modal') + accessor deleteWarningModal!: NgmConfirmationModal; - shouldUpdate(_changedProperties: PropertyValues): boolean { - return this.topicOrProject !== undefined; - } + @consume({context: apiClientContext}) + accessor apiClient!: ApiClient; - firstUpdated(_changedProperties: PropertyValues) { - this.querySelectorAll('.ui.dropdown').forEach(elem => $(elem).dropdown()); - super.firstUpdated(_changedProperties); - } + shouldUpdate(_changedProperties: PropertyValues): boolean { + return this.topicOrProject !== undefined; + } + + firstUpdated(_changedProperties: PropertyValues) { + this.querySelectorAll('.ui.dropdown').forEach(elem => $(elem).dropdown()); + super.firstUpdated(_changedProperties); + } - render() { - if (!this.topicOrProject) return ''; - const project = isProject(this.topicOrProject) ? this.topicOrProject : undefined; - const ownerEmail = project?.owner?.email; - const owner = ownerEmail || i18next.t('swisstopo'); - const date = this.topicOrProject?.modified ? this.topicOrProject?.modified : this.topicOrProject?.created; - const backgroundImage = this.topicOrProject.image?.length ? `url('${this.topicOrProject.image}')` : 'none'; - const editorEmails = project?.editors?.map(m => m.email) || []; - const projectModerator = [ownerEmail, ...editorEmails].includes(this.userEmail); + render() { + if (!this.topicOrProject) return ''; + const project = isProject(this.topicOrProject) ? this.topicOrProject : undefined; + const ownerEmail = project?.owner?.email; + const owner = ownerEmail || i18next.t('swisstopo'); + const date = this.topicOrProject?.modified ? this.topicOrProject?.modified : this.topicOrProject?.created; + const backgroundImage = this.topicOrProject.image?.length ? `url('${this.topicOrProject.image}')` : 'none'; + const editorEmails = project?.editors?.map(m => m.email) || []; + const projectModerator = [ownerEmail, ...editorEmails].includes(this.userEmail); - return html` + return html` + >
${translated(this.topicOrProject.title)}
this.dispatchEvent(new CustomEvent('onEdit'))}> - ${i18next.t('edit_project')}
+ ?hidden=${this.activeTab === 'topics' || !projectModerator} + @click=${() => this.dispatchEvent(new CustomEvent('onEdit'))}> + ${i18next.t('edit_project')} +
${ - `${this.topicOrProject.modified ? i18next.t('modified_on') : i18next.t('created_on')} ${toLocaleDateString(date)} ${i18next.t('by')} ${owner}` - }
+ `${this.topicOrProject.modified ? i18next.t('modified_on') : i18next.t('created_on')} ${toLocaleDateString(date)} ${i18next.t('by')} ${owner}` + } +
${i18next.t('dashboard_description')}
${ - this.topicOrProject.description ? translated(this.topicOrProject.description) : '' - }
+ this.topicOrProject.description ? translated(this.topicOrProject.description) : '' + } +
-
-
-
${i18next.t('dashboard_views')}
-
- +
+
+
${i18next.t('dashboard_views')}
+
+
${this.topicOrProject.views.map((view, index) => html` @@ -123,16 +131,16 @@ export class NgmProjectTopicOverview extends LitElementI18n {
- - + +
${!project ? '' : html` -
- +
+ `}
this.dispatchEvent(new CustomEvent('onDeselect'))}> @@ -140,16 +148,16 @@ export class NgmProjectTopicOverview extends LitElementI18n { ${i18next.t('dashboard_back_to_topics')}
`; - } + } - contextMenu() { - return html` + contextMenu() { + return html` `; - } + } - async duplicateToProject() { - const createProject = this.toCreateProject(this.topicOrProject!); - const response = await apiClient.duplicateProject(createProject); - const id = await response.json(); - const project = await apiClient.getProject(id); - this.dispatchEvent(new CustomEvent('onProjectDuplicated', {detail: {project}})); - } + async duplicateToProject() { + const createProject = this.toCreateProject(this.topicOrProject!); + const response = await this.apiClient.duplicateProject(createProject); + const id = await response.json(); + const project = await this.apiClient.getProject(id); + this.dispatchEvent(new CustomEvent('onProjectDuplicated', {detail: {project}})); + } - async deleteProject() { - await apiClient.deleteProject(this.topicOrProject!.id); - } + async deleteProject() { + await this.apiClient.deleteProject(this.topicOrProject!.id); + } - async copyLink(viewId?: string) { - try { - const link = this.getLink(viewId); - if (link) await navigator.clipboard.writeText(link); - showBannerSuccess(this.toastPlaceholder!, i18next.t('shortlink_copied')); - } catch (e) { - console.error(e); - } + async copyLink(viewId?: string) { + try { + const link = this.getLink(viewId); + if (link) await navigator.clipboard.writeText(link); + showBannerSuccess(this.toastPlaceholder!, i18next.t('shortlink_copied')); + } catch (e) { + console.error(e); } + } - getLink(viewId?: string): string | undefined { - if (!this.topicOrProject) return; - let link = `${location.protocol}//${location.host}${location.pathname}?`; - const idKey = isProject(this.topicOrProject) ? 'projectId' : 'topicId'; - link = `${link}${idKey}=${this.topicOrProject.id}`; - if (viewId) link = `${link}&viewId=${viewId}`; - return link; - } + getLink(viewId?: string): string | undefined { + if (!this.topicOrProject) return; + let link = `${location.protocol}//${location.host}${location.pathname}?`; + const idKey = isProject(this.topicOrProject) ? 'projectId' : 'topicId'; + link = `${link}${idKey}=${this.topicOrProject.id}`; + if (viewId) link = `${link}&viewId=${viewId}`; + return link; + } - toCreateProject(topicOrProject: Topic | Project): CreateProject { - const title = isProject(topicOrProject) ? - `${i18next.t('tbx_copy_of_label')} ${topicOrProject.title}` : - translated(topicOrProject.title); - let description: string | undefined; - if (isProject(topicOrProject)) { - description = topicOrProject.description; - } else if (topicOrProject.description) { - description = translated(topicOrProject.description); - } - return { - title, - description, - color: isProject(topicOrProject) ? topicOrProject.color : DEFAULT_PROJECT_COLOR, - geometries: isProject(topicOrProject) ? topicOrProject.geometries : [], // not a copy for topic - assets: isProject(topicOrProject) ? topicOrProject.assets : [], // not a copy for topic - views: topicOrProject.views.map(view => ({ - id: crypto.randomUUID(), - title: translated(view.title), - permalink: view.permalink - })), - owner: { - email: this.userEmail, - name: this.userEmail.split('@')[0], - surname: '', - }, - editors: [], - viewers: [], - }; + toCreateProject(topicOrProject: Topic | Project): CreateProject { + const title = isProject(topicOrProject) ? + `${i18next.t('tbx_copy_of_label')} ${topicOrProject.title}` : + translated(topicOrProject.title); + let description: string | undefined; + if (isProject(topicOrProject)) { + description = topicOrProject.description; + } else if (topicOrProject.description) { + description = translated(topicOrProject.description); } + return { + title, + description, + color: isProject(topicOrProject) ? topicOrProject.color : DEFAULT_PROJECT_COLOR, + geometries: isProject(topicOrProject) ? topicOrProject.geometries : [], // not a copy for topic + assets: isProject(topicOrProject) ? topicOrProject.assets : [], // not a copy for topic + views: topicOrProject.views.map(view => ({ + id: crypto.randomUUID(), + title: translated(view.title), + permalink: view.permalink + })), + owner: { + email: this.userEmail, + name: this.userEmail.split('@')[0], + surname: '', + }, + editors: [], + viewers: [], + }; + } - async saveViewToProject() { - const project: Project | undefined = isProject(this.topicOrProject) ? this.topicOrProject : undefined; - const editorEmails = project?.editors.map(e => e.email) || []; - if (!project || ![project.owner.email, ...editorEmails].includes(this.userEmail)) return; - const view: View = { - id: crypto.randomUUID(), - title: `${i18next.t('view')} ${project?.views.length + 1}`, - permalink: getPermalink(), - }; - if (typeof this.selectedViewIndx !== 'number') { - project.views.push(view); - const success = await apiClient.updateProject(project); - if (success) { - DashboardStore.setViewIndex(project?.views.length - 1); - } - } else { - project.views.splice(this.selectedViewIndx + 1, 0, view); - const success = await apiClient.updateProject(project); - if (success) { - DashboardStore.setViewIndex(this.selectedViewIndx + 1); - } - } + async saveViewToProject() { + const project: Project | undefined = isProject(this.topicOrProject) ? this.topicOrProject : undefined; + const editorEmails = project?.editors.map(e => e.email) || []; + if (!project || ![project.owner.email, ...editorEmails].includes(this.userEmail)) return; + const view: View = { + id: crypto.randomUUID(), + title: `${i18next.t('view')} ${project?.views.length + 1}`, + permalink: getPermalink(), + }; + if (typeof this.selectedViewIndx !== 'number') { + project.views.push(view); + const success = await this.apiClient.updateProject(project); + if (success) { + DashboardStore.setViewIndex(project?.views.length - 1); + } + } else { + project.views.splice(this.selectedViewIndx + 1, 0, view); + const success = await this.apiClient.updateProject(project); + if (success) { + DashboardStore.setViewIndex(this.selectedViewIndx + 1); + } } + } - createRenderRoot() { - return this; - } -} \ No newline at end of file + createRenderRoot() { + return this; + } +} diff --git a/ui/src/elements/ngm-auth.ts b/ui/src/elements/ngm-auth.ts index d509d83e3..d5726ac93 100644 --- a/ui/src/elements/ngm-auth.ts +++ b/ui/src/elements/ngm-auth.ts @@ -1,72 +1,76 @@ -import {html} from 'lit'; -import type {AuthUser} from '../auth'; -import Auth from '../auth'; -import {LitElementI18n} from '../i18n.js'; -import auth from '../store/auth'; -import {classMap} from 'lit/directives/class-map.js'; -import {customElement, property, state} from 'lit/decorators.js'; -import DashboardStore from '../store/dashboard'; - - -/** - * Authentication component - */ -@customElement('ngm-auth') -export class NgmAuth extends LitElementI18n { - @property({type: String}) - accessor endpoint: string | undefined; - @property({type: String}) - accessor clientId: string | undefined; - @state() - accessor user: AuthUser | null = null; - private popup: Window | null = null; - - constructor() { - super(); - auth.user.subscribe(user => { - this.user = user; - if (this.popup) { - this.popup.close(); - this.popup = null; - } - }); - } - - async login() { - // open the authentication popup - const url = `${this.endpoint}?` - + 'response_type=token' - + `&client_id=${this.clientId}` - + `&redirect_uri=${location.origin}${location.pathname}` - + '&scope=openid+profile' - + `&state=${Auth.state()}`; - - // open the authentication popup - this.popup = window.open(url); - // wait for the user to be authenticated - await Auth.waitForAuthenticate(); - Auth.initialize(); - window.location.reload(); - } - - logout() { - if (DashboardStore.projectMode.value === 'edit') { - DashboardStore.showSaveOrCancelWarning(true); - return; - } - Auth.logout(); - } - - render() { - return html` -
-
-
`; - } - - createRenderRoot() { - // no shadow dom - return this; - } -} +import {html} from 'lit'; +import type {AuthUser} from '../authService'; +import AuthService from '../authService'; +import {LitElementI18n} from '../i18n.js'; +import auth from '../store/auth'; +import {classMap} from 'lit/directives/class-map.js'; +import {customElement, property, state} from 'lit/decorators.js'; +import DashboardStore from '../store/dashboard'; +import {consume} from '@lit/context'; +import {authServiceContext} from '../context'; + +/** + * Authentication component + */ +@customElement('ngm-auth') +export class NgmAuth extends LitElementI18n { + @property({type: String}) + accessor endpoint: string | undefined; + @property({type: String}) + accessor clientId: string | undefined; + @state() + accessor user: AuthUser | null = null; + private popup: Window | null = null; + + @consume({context: authServiceContext}) + accessor authService!: AuthService; + + constructor() { + super(); + auth.user.subscribe(user => { + this.user = user; + if (this.popup) { + this.popup.close(); + this.popup = null; + } + }); + } + + async login() { + // open the authentication popup + const url = `${this.endpoint}?` + + 'response_type=token' + + `&client_id=${this.clientId}` + + `&redirect_uri=${location.origin}${location.pathname}` + + '&scope=openid+profile' + + `&state=${this.authService.state()}`; + + // open the authentication popup + this.popup = window.open(url); + // wait for the user to be authenticated + await this.authService.waitForAuthenticate(); + this.authService.initialize(); + window.location.reload(); + } + + logout() { + if (DashboardStore.projectMode.value === 'edit') { + DashboardStore.showSaveOrCancelWarning(true); + return; + } + this.authService.logout(); + } + + render() { + return html` +
+
+
`; + } + + createRenderRoot() { + // no shadow dom + return this; + } +} diff --git a/ui/src/elements/ngm-side-bar.ts b/ui/src/elements/ngm-side-bar.ts index 7a53fc648..bcfb5b0f3 100644 --- a/ui/src/elements/ngm-side-bar.ts +++ b/ui/src/elements/ngm-side-bar.ts @@ -1,747 +1,747 @@ -import {html} from 'lit'; -import {LitElementI18n} from '../i18n.js'; -import '../toolbox/ngm-toolbox'; -import '../layers/ngm-layers'; -import '../layers/ngm-layers-sort'; -import '../layers/ngm-catalog'; -import './dashboard/ngm-dashboard'; -import LayersActions from '../layers/LayersActions'; -import {DEFAULT_LAYER_OPACITY, LayerType} from '../constants'; -import defaultLayerTree, {LayerConfig} from '../layertree'; -import { - addAssetId, - getAssetIds, - getAttribute, - getCesiumToolbarParam, - getLayerParams, - getSliceParam, - getZoomToPosition, - setCesiumToolbarParam, - syncLayersParam -} from '../permalink'; -import {createCesiumObject} from '../layers/helpers'; -import i18next from 'i18next'; -import 'fomantic-ui-css/components/accordion.js'; -import './ngm-map-configuration'; -import type {Cartesian2, Viewer} from 'cesium'; -import { - BoundingSphere, - Cartesian3, - CustomDataSource, - GeoJsonDataSource, - HeadingPitchRange, - Math as CMath, - ScreenSpaceEventHandler, - ScreenSpaceEventType, -} from 'cesium'; -import {showSnackbarError, showSnackbarInfo} from '../notifications'; -import auth from '../store/auth'; -import './ngm-share-link'; -import '../layers/ngm-layers-upload'; -import MainStore from '../store/main'; -import {classMap} from 'lit/directives/class-map.js'; -import $ from '../jquery'; -import {customElement, property, query, state} from 'lit/decorators.js'; -import type QueryManager from '../query/QueryManager'; - -import DashboardStore from '../store/dashboard'; -import {getAssets} from '../api-ion'; -import {parseKml, renderWithDelay} from '../cesiumutils'; - -type SearchLayer = { - layer: string - label: string - type?: LayerType - title?: string - dataSourceName?: string -} - -@customElement('ngm-side-bar') -export class SideBar extends LitElementI18n { - @property({type: Object}) - accessor queryManager: QueryManager | null = null; - @property({type: Boolean}) - accessor mobileView = false; - @property({type: Boolean}) - accessor displayUndergroundHint = true; - @state() - accessor catalogLayers: LayerConfig[] | undefined; - @state() - accessor activeLayers: LayerConfig[] = []; - @state() - accessor activePanel: string | null = null; - @state() - accessor showHeader = false; - @state() - accessor globeQueueLength_ = 0; - @state() - accessor mobileShowAll = false; - @state() - accessor hideDataDisplayed = false; - @state() - accessor layerOrderChangeActive = false; - @state() - accessor debugToolsActive = getCesiumToolbarParam(); - @query('.ngm-side-bar-panel > .ngm-toast-placeholder') - accessor toastPlaceholder; - @query('ngm-catalog') - accessor catalogElement; - private viewer: Viewer | null = null; - private layerActions: LayersActions | undefined; - private zoomedToPosition = false; - private accordionInited = false; - private shareListenerAdded = false; - private shareDownListener = evt => { - if (!evt.composedPath().includes(this)) this.activePanel = null; - }; - - constructor() { - super(); - MainStore.viewer.subscribe(viewer => this.viewer = viewer); - - auth.user.subscribe((user) => { - if (!user && this.activeLayers) { - // user logged out, remove restricted layers. - const restricted = this.activeLayers.filter(config => config.restricted?.length); - restricted.forEach(config => { - const idx = this.activeLayers.indexOf(config); - this.activeLayers.splice(idx, 1); - this.removeLayer(config); - }); - } - }); - MainStore.setUrlLayersSubject.subscribe(async () => { - if (this.activeLayers) { - this.activeLayers.forEach(layer => this.removeLayerWithoutSync(layer)); - } - await this.syncActiveLayers(); - this.catalogElement.requestUpdate(); - MainStore.nextLayersRemove(); - }); - - MainStore.syncLayerParams.subscribe(() => { - syncLayersParam(this.activeLayers); - }); - - MainStore.onIonAssetAdd.subscribe(asset => { - const assetIds = getAssetIds(); - if (!asset.id || assetIds.includes(asset.id.toString())) { - showSnackbarInfo(i18next.t('dtd_asset_exists_info')); - return; - } - const token = MainStore.ionToken.value; - if (!token) return; - const layer: LayerConfig = { - type: LayerType.tiles3d, - assetId: asset.id, - ionToken: token, - label: asset.name, - layer: asset.id.toString(), - visible: true, - displayed: true, - opacityDisabled: true, - pickable: true, - customAsset: true, - }; - layer.load = () => this.addLayer(layer); - this.activeLayers.push(layer); - - addAssetId(asset.id); - this.activeLayers = [...this.activeLayers]; - syncLayersParam(this.activeLayers); - }); - - MainStore.onRemoveIonAssets.subscribe(async () => { - const assets = this.activeLayers.filter(l => !!l.assetId); - for (const asset of assets) { - await this.removeLayerWithoutSync(asset); - } - this.viewer!.scene.requestRender(); - this.requestUpdate(); - syncLayersParam(this.activeLayers); - }); - - const sliceOptions = getSliceParam(); - if (sliceOptions && sliceOptions.type && sliceOptions.slicePoints) - this.activePanel = 'tools'; - } - - render() { - if (!this.queryManager) { - return ''; - } - - this.queryManager.activeLayers = this.activeLayers - .filter(config => config.visible && !config.noQuery); - - const shareBtn = html` - `; - const settingsBtn = html` -
this.togglePanel('settings')}> -
-
`; - const dataMobileHeader = html` -
this.hideDataDisplayed = true} - class="ngm-data-catalog-label ${classMap({active: this.hideDataDisplayed})}"> - ${i18next.t('lyr_geocatalog_label')} -
-
this.hideDataDisplayed = false} - class="ngm-data-catalog-label ${classMap({active: !this.hideDataDisplayed})}"> - ${i18next.t('dtd_displayed_data_label')} -
`; - - return html` -
- ${shareBtn} - ${settingsBtn} - -
-
-
-
-
-
this.togglePanel('dashboard')}> -
-
-
this.togglePanel('data')}> -
-
-
this.togglePanel('tools', false)}> -
-
- ${!this.mobileView ? shareBtn : ''} -
this.mobileShowAll = !this.mobileShowAll}> -
-
-
-
- ${settingsBtn} -
-
- this.activePanel = ''} - @layerclick=${evt => this.onCatalogLayerClicked(evt.detail.layer)} - > -
-
- ${this.mobileView ? dataMobileHeader : i18next.t('lyr_geocatalog_label')} -
this.activePanel = ''}>
-
-
this.hideDataDisplayed = !this.hideDataDisplayed}> - ${i18next.t('dtd_configure_data_btn')} -
- this.onCatalogLayerClicked(evt.detail.layer)}> - -
-
- this.activePanel = 'tools'} - @close=${() => this.activePanel = ''}> -
- -
-
${i18next.t('lsb_settings')} -
this.activePanel = ''}>
-
-
-
- -
( this.querySelector('.ngm-debug-tools-toggle > input')).click()}> - - - -
- ${i18next.t('contact_mailto_text')} - ${i18next.t('disclaimer_text')} -
-
-
-
-
- ${this.mobileView ? dataMobileHeader : i18next.t('dtd_displayed_data_label')} -
this.mobileView ? this.activePanel = '' : this.hideDataDisplayed = true}>
-
-
-
-
- ${this.layerOrderChangeActive ? i18next.t('dtd_finish_ordering_label') : i18next.t('dtd_change_order_label')} -
- ${this.layerOrderChangeActive ? - html` - this.onLayersOrderChange(evt.detail)}> - ` : - html` - this.onRemoveDisplayedLayer(evt)} - @layerChanged=${evt => this.onLayerChanged(evt)}> - ` - } -
${i18next.t('dtd_user_content_label')}
- this.onKmlUpload(file, clampToGround)}> - - -
- ${i18next.t('dtd_background_map_label')} -
- ${this.globeQueueLength_} -
-
- -
-
-
- `; - } - - togglePanel(panelName, showHeader = true) { - if (DashboardStore.projectMode.value === 'edit') { - DashboardStore.showSaveOrCancelWarning(true); - return; - } - this.showHeader = showHeader; - if (this.activePanel === panelName) { - this.activePanel = null; - return; - } - this.activePanel = panelName; - if (this.activePanel === 'data' && !this.mobileView) this.hideDataDisplayed = false; - } - - async syncActiveLayers() { - const attributeParams = getAttribute(); - const callback = attributeParams ? - this.getTileLoadCallback(attributeParams.attributeKey, attributeParams.attributeValue) : - undefined; - const flatLayers = this.getFlatLayers(this.catalogLayers, callback); - const urlLayers = getLayerParams(); - const assetIds = getAssetIds(); - const ionToken = MainStore.ionToken.value; - - if (!urlLayers.length && !assetIds.length) { - this.activeLayers = flatLayers.filter(l => l.displayed); - syncLayersParam(this.activeLayers); - return; - } - - // First - make everything hidden - flatLayers.forEach(l => { - l.visible = false; - l.displayed = false; - }); - - const activeLayers: LayerConfig[] = []; - for (const urlLayer of urlLayers) { - let layer = flatLayers.find(fl => fl.layer === urlLayer.layer); - if (!layer) { - // Layers from the search are not present in the flat layers. - layer = this.createSearchLayer({layer: urlLayer.layer, label: urlLayer.layer}); // the proper label will be taken from getCapabilities - } else { - await (layer.promise || this.addLayer(layer)); - layer.add && layer.add(); - } - layer.visible = urlLayer.visible; - layer.opacity = urlLayer.opacity; - layer.wmtsCurrentTime = urlLayer.timestamp || layer.wmtsCurrentTime; - layer.setOpacity && layer.setOpacity(layer.opacity); - layer.displayed = true; - layer.setVisibility && layer.setVisibility(layer.visible); - activeLayers.push(layer); - } - - if (ionToken) { - const ionAssetsRes = await getAssets(ionToken); - const ionAssets = ionAssetsRes?.items || []; - - assetIds.forEach(assetId => { - const ionAsset = ionAssets.find(asset => asset.id === Number(assetId)); - const layer: LayerConfig = { - type: LayerType.tiles3d, - assetId: Number(assetId), - ionToken: ionToken, - label: ionAsset?.name || assetId, - layer: assetId, - visible: true, - displayed: true, - opacityDisabled: true, - pickable: true, - customAsset: true - }; - layer.load = () => this.addLayer(layer); - activeLayers.push(layer); - }); - } - - this.activeLayers = activeLayers; - syncLayersParam(this.activeLayers); - } - - getTileLoadCallback(attributeKey, attributeValue) { - return (tile, removeTileLoadListener) => { - const content = tile.content; - const featuresLength = content.featuresLength; - for (let i = 0; i < featuresLength; i++) { - const feature = content.getFeature(i); - if (feature.getProperty(attributeKey) === attributeValue) { - removeTileLoadListener(); - this.queryManager!.selectTile(feature); - return; - } - } - }; - } - - async update(changedProperties) { - if (this.viewer && !this.layerActions) { - this.layerActions = new LayersActions(this.viewer); - if (!this.catalogLayers) { - this.catalogLayers = [...defaultLayerTree]; - await this.syncActiveLayers(); - } - this.viewer.scene.globe.tileLoadProgressEvent.addEventListener(queueLength => { - this.globeQueueLength_ = queueLength; - }); - } - // hide share panel on any action outside side bar - if (!this.shareListenerAdded && this.activePanel === 'share') { - document.addEventListener('pointerdown', this.shareDownListener); - document.addEventListener('keydown', this.shareDownListener); - this.shareListenerAdded = true; - } else if (this.shareListenerAdded) { - this.shareListenerAdded = false; - document.removeEventListener('pointerdown', this.shareDownListener); - document.removeEventListener('keydown', this.shareDownListener); - } - super.update(changedProperties); - } - - updated(changedProperties) { - if (this.queryManager) { - !this.zoomedToPosition && this.zoomToPermalinkObject(); - - if (!this.accordionInited && this.activePanel === 'data') { - const panelElement = this.querySelector('.ngm-layer-catalog'); - - if (panelElement) { - for (let i = 0; i < panelElement.childElementCount; i++) { - const element = panelElement.children.item(i); - if (element && element.classList.contains('accordion')) { - $(element).accordion({duration: 150}); - } - } - this.accordionInited = true; - } - } - if (changedProperties.has('activeLayers')) { - this.layerActions!.reorderLayers(this.activeLayers); - } - } - - super.updated(changedProperties); - } - - async onCatalogLayerClicked(layer) { - // toggle whether the layer is displayed or not (=listed in the side bar) - if (layer.displayed) { - if (layer.visible) { - layer.displayed = false; - layer.visible = false; - layer.remove && layer.remove(); - const idx = this.activeLayers.findIndex(l => l.label === layer.label); - this.activeLayers.splice(idx, 1); - } else { - layer.visible = true; - } - } else { - await (layer.promise || this.addLayer(layer)); - layer.add && layer.add(); - layer.visible = true; - layer.displayed = true; - this.activeLayers.push(layer); - this.maybeShowVisibilityHint(layer); - } - layer.setVisibility && layer.setVisibility(layer.visible); - - syncLayersParam(this.activeLayers); - const catalogLayers = this.catalogLayers ? this.catalogLayers : []; - this.catalogLayers = [...catalogLayers]; - this.activeLayers = [...this.activeLayers]; - this.viewer!.scene.requestRender(); - } - - onLayerChanged(evt) { - this.queryManager!.hideObjectInformation(); - const catalogLayers = this.catalogLayers ? this.catalogLayers : []; - this.catalogLayers = [...catalogLayers]; - this.activeLayers = [...this.activeLayers]; - syncLayersParam(this.activeLayers); - if (evt.detail) { - this.maybeShowVisibilityHint(evt.detail); - } - this.requestUpdate(); - } - - maybeShowVisibilityHint(config: LayerConfig) { - if (this.displayUndergroundHint - && config.visible - && [LayerType.tiles3d, LayerType.earthquakes].includes(config.type!) - && !this.viewer?.scene.cameraUnderground) { - showSnackbarInfo(i18next.t('lyr_subsurface_hint'), {displayTime: 20000}); - this.displayUndergroundHint = false; - } - } - - async onRemoveDisplayedLayer(evt) { - const {config, idx} = evt.detail; - this.activeLayers.splice(idx, 1); - await this.removeLayer(config); - } - - async removeLayerWithoutSync(config: LayerConfig) { - if (config.setVisibility) { - config.setVisibility(false); - } else { - const c = await config.promise; - if (c instanceof CustomDataSource || c instanceof GeoJsonDataSource) { - this.viewer!.dataSources.getByName(c.name)[0].show = false; - } - } - config.visible = false; - config.displayed = false; - if (config.remove) { - config.remove(); - } - } - - async removeLayer(config: LayerConfig) { - await this.removeLayerWithoutSync(config); - this.viewer!.scene.requestRender(); - syncLayersParam(this.activeLayers); - const catalogLayers = this.catalogLayers ? this.catalogLayers : []; - this.catalogLayers = [...catalogLayers]; - this.activeLayers = [...this.activeLayers]; - this.requestUpdate(); - } - - getFlatLayers(tree, tileLoadCallback): any[] { - const flat: any[] = []; - for (const layer of tree) { - if (layer.children) { - flat.push(...this.getFlatLayers(layer.children, tileLoadCallback)); - } else { - layer.load = () => this.addLayer(layer); - flat.push(layer); - } - } - return flat; - } - - // adds layer from search to 'Displayed Layers' - async addLayerFromSearch(searchLayer: SearchLayer) { - let layer; - if (searchLayer.dataSourceName) { - layer = this.activeLayers.find(l => l.type === searchLayer.dataSourceName); // check for layers like earthquakes - } else { - layer = this.activeLayers.find(l => l.layer === searchLayer.layer); // check for swisstopoWMTS layers - } - - if (layer) { // for layers added before - if (layer.type === LayerType.swisstopoWMTS) { - const index = this.activeLayers.indexOf(layer); - this.activeLayers.splice(index, 1); - layer.remove(); - layer.add(0); - this.activeLayers.push(layer); - } - layer.setVisibility(true); - layer.visible = true; - layer.displayed = true; - this.viewer!.scene.requestRender(); - } else { // for new layers - this.activeLayers.push(this.createSearchLayer(searchLayer)); - } - this.activeLayers = [...this.activeLayers]; - syncLayersParam(this.activeLayers); - this.requestUpdate(); - } - - createSearchLayer(searchLayer: SearchLayer) { - let config: LayerConfig; - if (searchLayer.type) { - config = searchLayer; - config.visible = true; - config.origin = 'layer'; - config.label = searchLayer.title || searchLayer.label; - config.legend = config.type === LayerType.swisstopoWMTS ? config.layer : undefined; - } else { - config = { - type: LayerType.swisstopoWMTS, - label: searchLayer.title || searchLayer.label, - layer: searchLayer.layer, - visible: true, - displayed: true, - opacity: DEFAULT_LAYER_OPACITY, - queryType: 'geoadmin', - legend: searchLayer.layer - }; - } - config.load = async () => { - const layer = await this.addLayer(config); - this.activeLayers = [...this.activeLayers]; - syncLayersParam(this.activeLayers); - return layer; - }; - - return config; - } - - zoomToPermalinkObject() { - this.zoomedToPosition = true; - const zoomToPosition = getZoomToPosition(); - if (zoomToPosition) { - let altitude = 0, cartesianPosition: Cartesian3 | undefined, windowPosition: Cartesian2 | undefined; - const updateValues = () => { - altitude = this.viewer!.scene.globe.getHeight(this.viewer!.scene.camera.positionCartographic) || 0; - cartesianPosition = Cartesian3.fromDegrees(zoomToPosition.longitude, zoomToPosition.latitude, zoomToPosition.height + altitude); - windowPosition = this.viewer!.scene.cartesianToCanvasCoordinates(cartesianPosition); - }; - updateValues(); - const completeCallback = () => { - if (windowPosition) { - let maxTries = 25; - let triesCounter = 0; - const eventHandler = new ScreenSpaceEventHandler(this.viewer!.canvas); - eventHandler.setInputAction(() => maxTries = 0, ScreenSpaceEventType.LEFT_DOWN); - // Waits while will be possible to select an object - const tryToSelect = () => setTimeout(() => { - updateValues(); - this.zoomToObjectCoordinates(cartesianPosition); - windowPosition && this.queryManager!.pickObject(windowPosition); - triesCounter += 1; - if (!this.queryManager!.objectSelector.selectedObj && triesCounter <= maxTries) { - tryToSelect(); - } else { - eventHandler.destroy(); - if (triesCounter > maxTries) { - showSnackbarError(i18next.t('dtd_object_on_coordinates_not_found_warning')); - } - } - }, 500); - tryToSelect(); - } - - }; - this.zoomToObjectCoordinates(cartesianPosition, completeCallback); - } - } - - zoomToObjectCoordinates(center, complete?) { - const boundingSphere = new BoundingSphere(center, 1000); - const zoomHeadingPitchRange = new HeadingPitchRange( - 0, - -CMath.toRadians(45), - boundingSphere.radius); - this.viewer!.scene.camera.flyToBoundingSphere(boundingSphere, { - duration: 0, - offset: zoomHeadingPitchRange, - complete: complete - }); - } - - addLayer(layer: LayerConfig) { - layer.promise = createCesiumObject(this.viewer!, layer); - this.dispatchEvent(new CustomEvent('layeradded', { - detail: { - layer - } - })); - return layer.promise; - } - - toggleLayerOrderChange() { - this.layerOrderChangeActive = !this.layerOrderChangeActive; - } - - async onLayersOrderChange(layers: LayerConfig[]) { - await this.layerActions!.reorderLayers(layers); - // update activeLayers only when ordering finished - if (!this.layerOrderChangeActive) { - this.activeLayers = [...layers]; - } - this.dispatchEvent(new CustomEvent('layerChanged')); - } - - async onKmlUpload(file: File, clampToGround: boolean) { - if (!this.viewer) return; - const dataSource = new CustomDataSource(); - const name = await parseKml(this.viewer, file, dataSource, clampToGround); - const layer = `${name.replace(' ', '_')}_${Date.now()}`; - // name used as id for datasource - dataSource.name = layer; - MainStore.addUploadedKmlName(dataSource.name); - await this.viewer.dataSources.add(dataSource); - await renderWithDelay(this.viewer); - // done like this to have correct rerender of component - const promise = Promise.resolve(dataSource); - const config: LayerConfig = { - load() { return promise; }, - label: name, - layer, - promise: promise, - opacity: DEFAULT_LAYER_OPACITY, - notSaveToPermalink: true, - ownKml: true, - opacityDisabled: true - }; - await this.onCatalogLayerClicked(config); - this.viewer.zoomTo(dataSource); - this.requestUpdate(); - } - - toggleDebugTools(event) { - const active = event.target.checked; - this.debugToolsActive = active; - setCesiumToolbarParam(active); - this.dispatchEvent(new CustomEvent('toggleDebugTools', {detail: {active}})); - } - - createRenderRoot() { - return this; - } -} +import {html} from 'lit'; +import {LitElementI18n} from '../i18n.js'; +import '../toolbox/ngm-toolbox'; +import '../layers/ngm-layers'; +import '../layers/ngm-layers-sort'; +import '../layers/ngm-catalog'; +import './dashboard/ngm-dashboard'; +import LayersActions from '../layers/LayersActions'; +import {DEFAULT_LAYER_OPACITY, LayerType} from '../constants'; +import defaultLayerTree, {LayerConfig} from '../layertree'; +import { + addAssetId, + getAssetIds, + getAttribute, + getCesiumToolbarParam, + getLayerParams, + getSliceParam, + getZoomToPosition, + setCesiumToolbarParam, + syncLayersParam +} from '../permalink'; +import {createCesiumObject} from '../layers/helpers'; +import i18next from 'i18next'; +import 'fomantic-ui-css/components/accordion.js'; +import './ngm-map-configuration'; +import type {Cartesian2, Viewer} from 'cesium'; +import { + BoundingSphere, + Cartesian3, + CustomDataSource, + GeoJsonDataSource, + HeadingPitchRange, + Math as CMath, + ScreenSpaceEventHandler, + ScreenSpaceEventType, +} from 'cesium'; +import {showSnackbarError, showSnackbarInfo} from '../notifications'; +import auth from '../store/auth'; +import './ngm-share-link'; +import '../layers/ngm-layers-upload'; +import MainStore from '../store/main'; +import {classMap} from 'lit/directives/class-map.js'; +import $ from '../jquery'; +import {customElement, property, query, state} from 'lit/decorators.js'; +import type QueryManager from '../query/QueryManager'; + +import DashboardStore from '../store/dashboard'; +import {getAssets} from '../api-ion'; +import {parseKml, renderWithDelay} from '../cesiumutils'; + +type SearchLayer = { + layer: string + label: string + type?: LayerType + title?: string + dataSourceName?: string +} + +@customElement('ngm-side-bar') +export class SideBar extends LitElementI18n { + @property({type: Object}) + accessor queryManager: QueryManager | null = null; + @property({type: Boolean}) + accessor mobileView = false; + @property({type: Boolean}) + accessor displayUndergroundHint = true; + @state() + accessor catalogLayers: LayerConfig[] | undefined; + @state() + accessor activeLayers: LayerConfig[] = []; + @state() + accessor activePanel: string | null = null; + @state() + accessor showHeader = false; + @state() + accessor globeQueueLength_ = 0; + @state() + accessor mobileShowAll = false; + @state() + accessor hideDataDisplayed = false; + @state() + accessor layerOrderChangeActive = false; + @state() + accessor debugToolsActive = getCesiumToolbarParam(); + @query('.ngm-side-bar-panel > .ngm-toast-placeholder') + accessor toastPlaceholder; + @query('ngm-catalog') + accessor catalogElement; + private viewer: Viewer | null = null; + private layerActions: LayersActions | undefined; + private zoomedToPosition = false; + private accordionInited = false; + private shareListenerAdded = false; + private shareDownListener = evt => { + if (!evt.composedPath().includes(this)) this.activePanel = null; + }; + + constructor() { + super(); + MainStore.viewer.subscribe(viewer => this.viewer = viewer); + + auth.user.subscribe((user) => { + if (!user && this.activeLayers) { + // user logged out, remove restricted layers. + const restricted = this.activeLayers.filter(config => config.restricted?.length); + restricted.forEach(config => { + const idx = this.activeLayers.indexOf(config); + this.activeLayers.splice(idx, 1); + this.removeLayer(config); + }); + } + }); + MainStore.setUrlLayersSubject.subscribe(async () => { + if (this.activeLayers) { + this.activeLayers.forEach(layer => this.removeLayerWithoutSync(layer)); + } + await this.syncActiveLayers(); + this.catalogElement.requestUpdate(); + MainStore.nextLayersRemove(); + }); + + MainStore.syncLayerParams.subscribe(() => { + syncLayersParam(this.activeLayers); + }); + + MainStore.onIonAssetAdd.subscribe(asset => { + const assetIds = getAssetIds(); + if (!asset.id || assetIds.includes(asset.id.toString())) { + showSnackbarInfo(i18next.t('dtd_asset_exists_info')); + return; + } + const token = MainStore.ionToken.value; + if (!token) return; + const layer: LayerConfig = { + type: LayerType.tiles3d, + assetId: asset.id, + ionToken: token, + label: asset.name, + layer: asset.id.toString(), + visible: true, + displayed: true, + opacityDisabled: true, + pickable: true, + customAsset: true, + }; + layer.load = () => this.addLayer(layer); + this.activeLayers.push(layer); + + addAssetId(asset.id); + this.activeLayers = [...this.activeLayers]; + syncLayersParam(this.activeLayers); + }); + + MainStore.onRemoveIonAssets.subscribe(async () => { + const assets = this.activeLayers.filter(l => !!l.assetId); + for (const asset of assets) { + await this.removeLayerWithoutSync(asset); + } + this.viewer!.scene.requestRender(); + this.requestUpdate(); + syncLayersParam(this.activeLayers); + }); + + const sliceOptions = getSliceParam(); + if (sliceOptions && sliceOptions.type && sliceOptions.slicePoints) + this.activePanel = 'tools'; + } + + render() { + if (!this.queryManager) { + return ''; + } + + this.queryManager.activeLayers = this.activeLayers + .filter(config => config.visible && !config.noQuery); + + const shareBtn = html` + `; + const settingsBtn = html` +
this.togglePanel('settings')}> +
+
`; + const dataMobileHeader = html` +
this.hideDataDisplayed = true} + class="ngm-data-catalog-label ${classMap({active: this.hideDataDisplayed})}"> + ${i18next.t('lyr_geocatalog_label')} +
+
this.hideDataDisplayed = false} + class="ngm-data-catalog-label ${classMap({active: !this.hideDataDisplayed})}"> + ${i18next.t('dtd_displayed_data_label')} +
`; + + return html` +
+ ${shareBtn} + ${settingsBtn} + +
+
+
+
+
+
this.togglePanel('dashboard')}> +
+
+
this.togglePanel('data')}> +
+
+
this.togglePanel('tools', false)}> +
+
+ ${!this.mobileView ? shareBtn : ''} +
this.mobileShowAll = !this.mobileShowAll}> +
+
+
+
+ ${settingsBtn} +
+
+ this.activePanel = ''} + @layerclick=${evt => this.onCatalogLayerClicked(evt.detail.layer)} + > +
+
+ ${this.mobileView ? dataMobileHeader : i18next.t('lyr_geocatalog_label')} +
this.activePanel = ''}>
+
+
this.hideDataDisplayed = !this.hideDataDisplayed}> + ${i18next.t('dtd_configure_data_btn')} +
+ this.onCatalogLayerClicked(evt.detail.layer)}> + +
+
+ this.activePanel = 'tools'} + @close=${() => this.activePanel = ''}> +
+ +
+
${i18next.t('lsb_settings')} +
this.activePanel = ''}>
+
+
+
+ +
( this.querySelector('.ngm-debug-tools-toggle > input')).click()}> + + + +
+ ${i18next.t('contact_mailto_text')} + ${i18next.t('disclaimer_text')} +
+
+
+
+
+ ${this.mobileView ? dataMobileHeader : i18next.t('dtd_displayed_data_label')} +
this.mobileView ? this.activePanel = '' : this.hideDataDisplayed = true}>
+
+
+
+
+ ${this.layerOrderChangeActive ? i18next.t('dtd_finish_ordering_label') : i18next.t('dtd_change_order_label')} +
+ ${this.layerOrderChangeActive ? + html` + this.onLayersOrderChange(evt.detail)}> + ` : + html` + this.onRemoveDisplayedLayer(evt)} + @layerChanged=${evt => this.onLayerChanged(evt)}> + ` + } +
${i18next.t('dtd_user_content_label')}
+ this.onKmlUpload(file, clampToGround)}> + + +
+ ${i18next.t('dtd_background_map_label')} +
+ ${this.globeQueueLength_} +
+
+ +
+
+
+ `; + } + + togglePanel(panelName, showHeader = true) { + if (DashboardStore.projectMode.value === 'edit') { + DashboardStore.showSaveOrCancelWarning(true); + return; + } + this.showHeader = showHeader; + if (this.activePanel === panelName) { + this.activePanel = null; + return; + } + this.activePanel = panelName; + if (this.activePanel === 'data' && !this.mobileView) this.hideDataDisplayed = false; + } + + async syncActiveLayers() { + const attributeParams = getAttribute(); + const callback = attributeParams ? + this.getTileLoadCallback(attributeParams.attributeKey, attributeParams.attributeValue) : + undefined; + const flatLayers = this.getFlatLayers(this.catalogLayers, callback); + const urlLayers = getLayerParams(); + const assetIds = getAssetIds(); + const ionToken = MainStore.ionToken.value; + + if (!urlLayers.length && !assetIds.length) { + this.activeLayers = flatLayers.filter(l => l.displayed); + syncLayersParam(this.activeLayers); + return; + } + + // First - make everything hidden + flatLayers.forEach(l => { + l.visible = false; + l.displayed = false; + }); + + const activeLayers: LayerConfig[] = []; + for (const urlLayer of urlLayers) { + let layer = flatLayers.find(fl => fl.layer === urlLayer.layer); + if (!layer) { + // Layers from the search are not present in the flat layers. + layer = this.createSearchLayer({layer: urlLayer.layer, label: urlLayer.layer}); // the proper label will be taken from getCapabilities + } else { + await (layer.promise || this.addLayer(layer)); + layer.add && layer.add(); + } + layer.visible = urlLayer.visible; + layer.opacity = urlLayer.opacity; + layer.wmtsCurrentTime = urlLayer.timestamp || layer.wmtsCurrentTime; + layer.setOpacity && layer.setOpacity(layer.opacity); + layer.displayed = true; + layer.setVisibility && layer.setVisibility(layer.visible); + activeLayers.push(layer); + } + + if (ionToken) { + const ionAssetsRes = await getAssets(ionToken); + const ionAssets = ionAssetsRes?.items || []; + + assetIds.forEach(assetId => { + const ionAsset = ionAssets.find(asset => asset.id === Number(assetId)); + const layer: LayerConfig = { + type: LayerType.tiles3d, + assetId: Number(assetId), + ionToken: ionToken, + label: ionAsset?.name || assetId, + layer: assetId, + visible: true, + displayed: true, + opacityDisabled: true, + pickable: true, + customAsset: true + }; + layer.load = () => this.addLayer(layer); + activeLayers.push(layer); + }); + } + + this.activeLayers = activeLayers; + syncLayersParam(this.activeLayers); + } + + getTileLoadCallback(attributeKey, attributeValue) { + return (tile, removeTileLoadListener) => { + const content = tile.content; + const featuresLength = content.featuresLength; + for (let i = 0; i < featuresLength; i++) { + const feature = content.getFeature(i); + if (feature.getProperty(attributeKey) === attributeValue) { + removeTileLoadListener(); + this.queryManager!.selectTile(feature); + return; + } + } + }; + } + + async update(changedProperties) { + if (this.viewer && !this.layerActions) { + this.layerActions = new LayersActions(this.viewer); + if (!this.catalogLayers) { + this.catalogLayers = [...defaultLayerTree]; + await this.syncActiveLayers(); + } + this.viewer.scene.globe.tileLoadProgressEvent.addEventListener(queueLength => { + this.globeQueueLength_ = queueLength; + }); + } + // hide share panel on any action outside side bar + if (!this.shareListenerAdded && this.activePanel === 'share') { + document.addEventListener('pointerdown', this.shareDownListener); + document.addEventListener('keydown', this.shareDownListener); + this.shareListenerAdded = true; + } else if (this.shareListenerAdded) { + this.shareListenerAdded = false; + document.removeEventListener('pointerdown', this.shareDownListener); + document.removeEventListener('keydown', this.shareDownListener); + } + super.update(changedProperties); + } + + updated(changedProperties) { + if (this.queryManager) { + !this.zoomedToPosition && this.zoomToPermalinkObject(); + + if (!this.accordionInited && this.activePanel === 'data') { + const panelElement = this.querySelector('.ngm-layer-catalog'); + + if (panelElement) { + for (let i = 0; i < panelElement.childElementCount; i++) { + const element = panelElement.children.item(i); + if (element && element.classList.contains('accordion')) { + $(element).accordion({duration: 150}); + } + } + this.accordionInited = true; + } + } + if (changedProperties.has('activeLayers')) { + this.layerActions!.reorderLayers(this.activeLayers); + } + } + + super.updated(changedProperties); + } + + async onCatalogLayerClicked(layer) { + // toggle whether the layer is displayed or not (=listed in the side bar) + if (layer.displayed) { + if (layer.visible) { + layer.displayed = false; + layer.visible = false; + layer.remove && layer.remove(); + const idx = this.activeLayers.findIndex(l => l.label === layer.label); + this.activeLayers.splice(idx, 1); + } else { + layer.visible = true; + } + } else { + await (layer.promise || this.addLayer(layer)); + layer.add && layer.add(); + layer.visible = true; + layer.displayed = true; + this.activeLayers.push(layer); + this.maybeShowVisibilityHint(layer); + } + layer.setVisibility && layer.setVisibility(layer.visible); + + syncLayersParam(this.activeLayers); + const catalogLayers = this.catalogLayers ? this.catalogLayers : []; + this.catalogLayers = [...catalogLayers]; + this.activeLayers = [...this.activeLayers]; + this.viewer!.scene.requestRender(); + } + + onLayerChanged(evt) { + this.queryManager!.hideObjectInformation(); + const catalogLayers = this.catalogLayers ? this.catalogLayers : []; + this.catalogLayers = [...catalogLayers]; + this.activeLayers = [...this.activeLayers]; + syncLayersParam(this.activeLayers); + if (evt.detail) { + this.maybeShowVisibilityHint(evt.detail); + } + this.requestUpdate(); + } + + maybeShowVisibilityHint(config: LayerConfig) { + if (this.displayUndergroundHint + && config.visible + && [LayerType.tiles3d, LayerType.earthquakes].includes(config.type!) + && !this.viewer?.scene.cameraUnderground) { + showSnackbarInfo(i18next.t('lyr_subsurface_hint'), {displayTime: 20000}); + this.displayUndergroundHint = false; + } + } + + async onRemoveDisplayedLayer(evt) { + const {config, idx} = evt.detail; + this.activeLayers.splice(idx, 1); + await this.removeLayer(config); + } + + async removeLayerWithoutSync(config: LayerConfig) { + if (config.setVisibility) { + config.setVisibility(false); + } else { + const c = await config.promise; + if (c instanceof CustomDataSource || c instanceof GeoJsonDataSource) { + this.viewer!.dataSources.getByName(c.name)[0].show = false; + } + } + config.visible = false; + config.displayed = false; + if (config.remove) { + config.remove(); + } + } + + async removeLayer(config: LayerConfig) { + await this.removeLayerWithoutSync(config); + this.viewer!.scene.requestRender(); + syncLayersParam(this.activeLayers); + const catalogLayers = this.catalogLayers ? this.catalogLayers : []; + this.catalogLayers = [...catalogLayers]; + this.activeLayers = [...this.activeLayers]; + this.requestUpdate(); + } + + getFlatLayers(tree, tileLoadCallback): any[] { + const flat: any[] = []; + for (const layer of tree) { + if (layer.children) { + flat.push(...this.getFlatLayers(layer.children, tileLoadCallback)); + } else { + layer.load = () => this.addLayer(layer); + flat.push(layer); + } + } + return flat; + } + + // adds layer from search to 'Displayed Layers' + async addLayerFromSearch(searchLayer: SearchLayer) { + let layer; + if (searchLayer.dataSourceName) { + layer = this.activeLayers.find(l => l.type === searchLayer.dataSourceName); // check for layers like earthquakes + } else { + layer = this.activeLayers.find(l => l.layer === searchLayer.layer); // check for swisstopoWMTS layers + } + + if (layer) { // for layers added before + if (layer.type === LayerType.swisstopoWMTS) { + const index = this.activeLayers.indexOf(layer); + this.activeLayers.splice(index, 1); + layer.remove(); + layer.add(0); + this.activeLayers.push(layer); + } + layer.setVisibility(true); + layer.visible = true; + layer.displayed = true; + this.viewer!.scene.requestRender(); + } else { // for new layers + this.activeLayers.push(this.createSearchLayer(searchLayer)); + } + this.activeLayers = [...this.activeLayers]; + syncLayersParam(this.activeLayers); + this.requestUpdate(); + } + + createSearchLayer(searchLayer: SearchLayer) { + let config: LayerConfig; + if (searchLayer.type) { + config = searchLayer; + config.visible = true; + config.origin = 'layer'; + config.label = searchLayer.title || searchLayer.label; + config.legend = config.type === LayerType.swisstopoWMTS ? config.layer : undefined; + } else { + config = { + type: LayerType.swisstopoWMTS, + label: searchLayer.title || searchLayer.label, + layer: searchLayer.layer, + visible: true, + displayed: true, + opacity: DEFAULT_LAYER_OPACITY, + queryType: 'geoadmin', + legend: searchLayer.layer + }; + } + config.load = async () => { + const layer = await this.addLayer(config); + this.activeLayers = [...this.activeLayers]; + syncLayersParam(this.activeLayers); + return layer; + }; + + return config; + } + + zoomToPermalinkObject() { + this.zoomedToPosition = true; + const zoomToPosition = getZoomToPosition(); + if (zoomToPosition) { + let altitude = 0, cartesianPosition: Cartesian3 | undefined, windowPosition: Cartesian2 | undefined; + const updateValues = () => { + altitude = this.viewer!.scene.globe.getHeight(this.viewer!.scene.camera.positionCartographic) || 0; + cartesianPosition = Cartesian3.fromDegrees(zoomToPosition.longitude, zoomToPosition.latitude, zoomToPosition.height + altitude); + windowPosition = this.viewer!.scene.cartesianToCanvasCoordinates(cartesianPosition); + }; + updateValues(); + const completeCallback = () => { + if (windowPosition) { + let maxTries = 25; + let triesCounter = 0; + const eventHandler = new ScreenSpaceEventHandler(this.viewer!.canvas); + eventHandler.setInputAction(() => maxTries = 0, ScreenSpaceEventType.LEFT_DOWN); + // Waits while will be possible to select an object + const tryToSelect = () => setTimeout(() => { + updateValues(); + this.zoomToObjectCoordinates(cartesianPosition); + windowPosition && this.queryManager!.pickObject(windowPosition); + triesCounter += 1; + if (!this.queryManager!.objectSelector.selectedObj && triesCounter <= maxTries) { + tryToSelect(); + } else { + eventHandler.destroy(); + if (triesCounter > maxTries) { + showSnackbarError(i18next.t('dtd_object_on_coordinates_not_found_warning')); + } + } + }, 500); + tryToSelect(); + } + + }; + this.zoomToObjectCoordinates(cartesianPosition, completeCallback); + } + } + + zoomToObjectCoordinates(center, complete?) { + const boundingSphere = new BoundingSphere(center, 1000); + const zoomHeadingPitchRange = new HeadingPitchRange( + 0, + -CMath.toRadians(45), + boundingSphere.radius); + this.viewer!.scene.camera.flyToBoundingSphere(boundingSphere, { + duration: 0, + offset: zoomHeadingPitchRange, + complete: complete + }); + } + + addLayer(layer: LayerConfig) { + layer.promise = createCesiumObject(this.viewer!, layer); + this.dispatchEvent(new CustomEvent('layeradded', { + detail: { + layer + } + })); + return layer.promise; + } + + toggleLayerOrderChange() { + this.layerOrderChangeActive = !this.layerOrderChangeActive; + } + + async onLayersOrderChange(layers: LayerConfig[]) { + await this.layerActions!.reorderLayers(layers); + // update activeLayers only when ordering finished + if (!this.layerOrderChangeActive) { + this.activeLayers = [...layers]; + } + this.dispatchEvent(new CustomEvent('layerChanged')); + } + + async onKmlUpload(file: File, clampToGround: boolean) { + if (!this.viewer) return; + const dataSource = new CustomDataSource(); + const name = await parseKml(this.viewer, file, dataSource, clampToGround); + const layer = `${name.replace(' ', '_')}_${Date.now()}`; + // name used as id for datasource + dataSource.name = layer; + MainStore.addUploadedKmlName(dataSource.name); + await this.viewer.dataSources.add(dataSource); + await renderWithDelay(this.viewer); + // done like this to have correct rerender of component + const promise = Promise.resolve(dataSource); + const config: LayerConfig = { + load() { return promise; }, + label: name, + layer, + promise: promise, + opacity: DEFAULT_LAYER_OPACITY, + notSaveToPermalink: true, + ownKml: true, + opacityDisabled: true + }; + await this.onCatalogLayerClicked(config); + this.viewer.zoomTo(dataSource); + this.requestUpdate(); + } + + toggleDebugTools(event) { + const active = event.target.checked; + this.debugToolsActive = active; + setCesiumToolbarParam(active); + this.dispatchEvent(new CustomEvent('toggleDebugTools', {detail: {active}})); + } + + createRenderRoot() { + return this; + } +} diff --git a/ui/src/index.ts b/ui/src/index.ts index bcfd23bd7..a8857f652 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -1,11 +1,7 @@ -import './style/index.css'; -import {ReactiveElement} from 'lit'; - -// Detect issues following lit2 migration -ReactiveElement.enableWarning?.('migration'); - -import Auth from './auth'; - -import './ngm-app'; - -Auth.initialize(); +import './style/index.css'; +import {ReactiveElement} from 'lit'; + +// Detect issues following lit2 migration +ReactiveElement.enableWarning?.('migration'); + +import './ngm-app-boot'; diff --git a/ui/src/layers/helpers.ts b/ui/src/layers/helpers.ts index 617547267..b8583d493 100644 --- a/ui/src/layers/helpers.ts +++ b/ui/src/layers/helpers.ts @@ -1,259 +1,259 @@ -import EarthquakeVisualizer from '../earthquakeVisualization/earthquakeVisualizer.js'; -import {ImageryLayer, Rectangle, Viewer} from 'cesium'; -import { - Cartesian3, - Cartographic, - Cesium3DTileColorBlendMode, - Cesium3DTileset, - Cesium3DTileStyle, - Cesium3DTilesVoxelProvider, - Ellipsoid, - GeoJsonDataSource, - IonResource, - LabelStyle, - Matrix3, - Matrix4, - VoxelPrimitive, -} from 'cesium'; -import {getSwisstopoImagery} from '../swisstopoImagery'; -import {LayerType} from '../constants'; -import {isLabelOutlineEnabled} from '../permalink'; -import AmazonS3Resource from '../AmazonS3Resource.js'; -import {getVoxelShader} from './voxels-helper'; -import MainStore from '../store/main'; -import {LayerConfig} from '../layertree'; - -export interface PickableCesium3DTileset extends Cesium3DTileset { - pickable?: boolean; -} -export interface PickableVoxelPrimitive extends VoxelPrimitive { - pickable?: boolean; - layer?: string; -} - -export async function createEarthquakeFromConfig(viewer: Viewer, config: LayerConfig) { - const earthquakeVisualizer = new EarthquakeVisualizer(viewer, config); - if (config.visible) { - await earthquakeVisualizer.setVisible(true); - } - config.setVisibility = visible => earthquakeVisualizer.setVisible(visible); - config.setOpacity = (opacity: number) => earthquakeVisualizer.setOpacity(opacity); - return earthquakeVisualizer; -} - -export function createIonGeoJSONFromConfig(viewer: Viewer, config) { - return IonResource.fromAssetId(config.assetId) - .then(resource => GeoJsonDataSource.load(resource)) - .then(dataSource => { - viewer.dataSources.add(dataSource); - dataSource.show = !!config.visible; - config.setVisibility = visible => dataSource.show = !!visible; - return dataSource; - }); -} - - -export async function create3DVoxelsTilesetFromConfig(viewer: Viewer, config: LayerConfig, _): Promise { - const provider = await Cesium3DTilesVoxelProvider.fromUrl(config.url!); - - const primitive: PickableVoxelPrimitive = new VoxelPrimitive({ - provider: provider, - }); - - const searchParams = new URLSearchParams(location.search); - const stepSize = parseFloat(searchParams.get('stepSize') || '1'); - - primitive.nearestSampling = true; - primitive.stepSize = stepSize; - primitive.depthTest = true; - primitive.show = !!config.visible; - primitive.pickable = config.pickable !== undefined ? config.pickable : false; - primitive.layer = config.layer; - - viewer.scene.primitives.add(primitive); - - config.setVisibility = visible => { - if (config.type === LayerType.voxels3dtiles) { - if (visible) MainStore.addVisibleVoxelLayer(config.layer); - else MainStore.removeVisibleVoxelLayer(config.layer); - } - primitive.show = !!visible; - }; - - if (config.voxelDataName && !primitive.provider.names.includes(config.voxelDataName)) { - throw new Error(`Voxel data name ${config.voxelDataName} not found in the tileset`); - } - primitive.customShader = getVoxelShader(config); - primitive.jitter = false; - - return primitive; -} -export async function create3DTilesetFromConfig(viewer: Viewer, config: LayerConfig, tileLoadCallback) { - let resource: string | IonResource | AmazonS3Resource; - if (config.aws_s3_bucket && config.aws_s3_key) { - resource = new AmazonS3Resource({ - bucket: config.aws_s3_bucket, - url: config.aws_s3_key, - }); - } else if (config.url) { - resource = config.url; - } else { - resource = await IonResource.fromAssetId(config.assetId!, { - accessToken: config.ionToken, - }); - } - - const tileset: PickableCesium3DTileset = await Cesium3DTileset.fromUrl(resource, { - show: !!config.visible, - backFaceCulling: false, - maximumScreenSpaceError: tileLoadCallback ? Number.NEGATIVE_INFINITY : 16, // 16 - default value - }); - - if (config.style) { - if (config.layer === 'ch.swisstopo.swissnames3d.3d') { // for performance testing - config.style.labelStyle = isLabelOutlineEnabled() ? LabelStyle.FILL_AND_OUTLINE : LabelStyle.FILL; - } - tileset.style = new Cesium3DTileStyle(config.style); - } - - tileset.pickable = config.pickable !== undefined ? config.pickable : false; - viewer.scene.primitives.add(tileset); - - config.setVisibility = visible => { - tileset.show = !!visible; - }; - - if (!config.opacityDisabled) { - config.setOpacity = opacity => { - const style = config.style; - if (style && (style.color || style.labelColor)) { - const {propertyName, colorType, colorValue} = styleColorParser(style); - const color = `${colorType}(${colorValue}, ${opacity})`; - tileset.style = new Cesium3DTileStyle({...style, [propertyName]: color}); - } else { - const color = `color("white", ${opacity})`; - tileset.style = new Cesium3DTileStyle({...style, color}); - } - }; - config.setOpacity(config.opacity ? config.opacity : 1); - } - - if (tileLoadCallback) { - const removeTileLoadListener = tileset.tileLoad.addEventListener(tile => tileLoadCallback(tile, removeTileLoadListener)); - } - - if (config.propsOrder) { - tileset.properties.propsOrder = config.propsOrder; - } - if (config.heightOffset) { - const cartographic = Cartographic.fromCartesian(tileset.boundingSphere.center); - const surface = Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, 0.0); - const offset = Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, config.heightOffset); - const translation = Cartesian3.subtract(offset, surface, new Cartesian3()); - tileset.modelMatrix = Matrix4.fromTranslation(translation); - viewer.scene.requestRender(); - } - // for correct highlighting - tileset.colorBlendMode = Cesium3DTileColorBlendMode.REPLACE; - return tileset; -} - -export async function createSwisstopoWMTSImageryLayer(viewer: Viewer, config: LayerConfig) { - const layer: ImageryLayer = await getSwisstopoImagery(config); - config.setVisibility = visible => layer.show = !!visible; - config.setOpacity = opacity => layer.alpha = opacity; - config.remove = () => viewer.scene.imageryLayers.remove(layer, false); - config.add = (toIndex) => { - const layersLength = viewer.scene.imageryLayers.length; - if (toIndex > 0 && toIndex < layersLength) { - const imageryIndex = layersLength - toIndex; - viewer.scene.imageryLayers.add(layer, imageryIndex); - return; - } - viewer.scene.imageryLayers.add(layer); - }; - config.setTime = (time: string) => { - config.wmtsCurrentTime = time; - layer.show = false; - viewer.scene.render(); - setTimeout(() => { - layer.show = true; - viewer.scene.render(); - }, 100); - }; - viewer.scene.imageryLayers.add(layer); - layer.alpha = config.opacity || 1; - layer.show = !!config.visible; - return layer; -} - - -export function createCesiumObject(viewer: Viewer, config: LayerConfig, tileLoadCallback?) { - const factories = { - [LayerType.ionGeoJSON]: createIonGeoJSONFromConfig, - [LayerType.tiles3d]: create3DTilesetFromConfig, - [LayerType.voxels3dtiles]: create3DVoxelsTilesetFromConfig, - [LayerType.swisstopoWMTS]: createSwisstopoWMTSImageryLayer, - [LayerType.earthquakes]: createEarthquakeFromConfig, - }; - return factories[config.type!](viewer, config, tileLoadCallback); -} - -function styleColorParser(style: any) { - const propertyName = style.color ? 'color' : 'labelColor'; - let colorType = style[propertyName].slice(0, style[propertyName].indexOf('(')); - const lastIndex = colorType === 'rgba' ? style[propertyName].lastIndexOf(',') : style[propertyName].indexOf(')'); - const colorValue = style[propertyName].slice(style[propertyName].indexOf('(') + 1, lastIndex); - colorType = colorType === 'rgb' ? 'rgba' : colorType; - return {propertyName, colorType, colorValue}; -} - - -export function getBoxFromRectangle(rectangle: Rectangle, height: number, result: Cartesian3 = new Cartesian3()): Cartesian3 { - const sw = Cartographic.toCartesian(Rectangle.southwest(rectangle, new Cartographic())); - const se = Cartographic.toCartesian(Rectangle.southeast(rectangle, new Cartographic())); - const nw = Cartographic.toCartesian(Rectangle.northwest(rectangle, new Cartographic())); - result.x = Cartesian3.distance(sw, se); // gets box width - result.y = Cartesian3.distance(sw, nw); // gets box length - result.z = height; - return result; -} - -/** - * Returns rectangle from width height and center point - */ -export function calculateRectangle(width: number, height: number, center: Cartesian3, result: Rectangle = new Rectangle()): Rectangle { - const w = new Cartesian3(center.x, center.y - width / 2, center.z); - result.west = Ellipsoid.WGS84.cartesianToCartographic(w).longitude; - const s = new Cartesian3(center.x + height / 2, center.y, center.z); - result.south = Ellipsoid.WGS84.cartesianToCartographic(s).latitude; - const e = new Cartesian3(center.x, center.y + width / 2, center.z); - result.east = Ellipsoid.WGS84.cartesianToCartographic(e).longitude; - const n = new Cartesian3(center.x - height / 2, center.y, center.z); - result.north = Ellipsoid.WGS84.cartesianToCartographic(n).latitude; - - return result; -} - -/** - * Calculates box from bounding volume - */ -export function calculateBox(halfAxes: Matrix3, boundingSphereRadius: number, result: Cartesian3 = new Cartesian3()): Cartesian3 { - const absMatrix = Matrix3.abs(halfAxes, new Matrix3()); - for (let i = 0; i < 3; i++) { - const column = Matrix3.getColumn(absMatrix, i, new Cartesian3()); - const row = Matrix3.getRow(absMatrix, i, new Cartesian3()); - result.y = result.y + column.x + row.x; - result.x = result.x + column.y + row.y; - result.z = result.z + column.z + row.z; - } - // scale according to bounding sphere - const diagonal = Math.sqrt(result.x * result.x + result.y * result.y); - const radius = boundingSphereRadius; - const scale = Math.max(diagonal / (radius * 2), (radius * 2) / diagonal); - result.x = result.x * scale; - result.y = result.y * scale; - result.z = result.z > 60000 ? 60000 : result.z; - - return new Cartesian3(result.x, result.y, result.z); -} +import EarthquakeVisualizer from '../earthquakeVisualization/earthquakeVisualizer.js'; +import {ImageryLayer, Rectangle, Viewer} from 'cesium'; +import { + Cartesian3, + Cartographic, + Cesium3DTileColorBlendMode, + Cesium3DTileset, + Cesium3DTileStyle, + Cesium3DTilesVoxelProvider, + Ellipsoid, + GeoJsonDataSource, + IonResource, + LabelStyle, + Matrix3, + Matrix4, + VoxelPrimitive, +} from 'cesium'; +import {getSwisstopoImagery} from '../swisstopoImagery'; +import {LayerType} from '../constants'; +import {isLabelOutlineEnabled} from '../permalink'; +import AmazonS3Resource from '../AmazonS3Resource.js'; +import {getVoxelShader} from './voxels-helper'; +import MainStore from '../store/main'; +import {LayerConfig} from '../layertree'; + +export interface PickableCesium3DTileset extends Cesium3DTileset { + pickable?: boolean; +} +export interface PickableVoxelPrimitive extends VoxelPrimitive { + pickable?: boolean; + layer?: string; +} + +export async function createEarthquakeFromConfig(viewer: Viewer, config: LayerConfig) { + const earthquakeVisualizer = new EarthquakeVisualizer(viewer, config); + if (config.visible) { + await earthquakeVisualizer.setVisible(true); + } + config.setVisibility = visible => earthquakeVisualizer.setVisible(visible); + config.setOpacity = (opacity: number) => earthquakeVisualizer.setOpacity(opacity); + return earthquakeVisualizer; +} + +export function createIonGeoJSONFromConfig(viewer: Viewer, config) { + return IonResource.fromAssetId(config.assetId) + .then(resource => GeoJsonDataSource.load(resource)) + .then(dataSource => { + viewer.dataSources.add(dataSource); + dataSource.show = !!config.visible; + config.setVisibility = visible => dataSource.show = !!visible; + return dataSource; + }); +} + + +export async function create3DVoxelsTilesetFromConfig(viewer: Viewer, config: LayerConfig, _): Promise { + const provider = await Cesium3DTilesVoxelProvider.fromUrl(config.url!); + + const primitive: PickableVoxelPrimitive = new VoxelPrimitive({ + provider: provider, + }); + + const searchParams = new URLSearchParams(location.search); + const stepSize = parseFloat(searchParams.get('stepSize') || '1'); + + primitive.nearestSampling = true; + primitive.stepSize = stepSize; + primitive.depthTest = true; + primitive.show = !!config.visible; + primitive.pickable = config.pickable !== undefined ? config.pickable : false; + primitive.layer = config.layer; + + viewer.scene.primitives.add(primitive); + + config.setVisibility = visible => { + if (config.type === LayerType.voxels3dtiles) { + if (visible) MainStore.addVisibleVoxelLayer(config.layer); + else MainStore.removeVisibleVoxelLayer(config.layer); + } + primitive.show = !!visible; + }; + + if (config.voxelDataName && !primitive.provider.names.includes(config.voxelDataName)) { + throw new Error(`Voxel data name ${config.voxelDataName} not found in the tileset`); + } + primitive.customShader = getVoxelShader(config); + primitive.jitter = false; + + return primitive; +} +export async function create3DTilesetFromConfig(viewer: Viewer, config: LayerConfig, tileLoadCallback) { + let resource: string | IonResource | AmazonS3Resource; + if (config.aws_s3_bucket && config.aws_s3_key) { + resource = new AmazonS3Resource({ + bucket: config.aws_s3_bucket, + url: config.aws_s3_key, + }); + } else if (config.url) { + resource = config.url; + } else { + resource = await IonResource.fromAssetId(config.assetId!, { + accessToken: config.ionToken, + }); + } + + const tileset: PickableCesium3DTileset = await Cesium3DTileset.fromUrl(resource, { + show: !!config.visible, + backFaceCulling: false, + maximumScreenSpaceError: tileLoadCallback ? Number.NEGATIVE_INFINITY : 16, // 16 - default value + }); + + if (config.style) { + if (config.layer === 'ch.swisstopo.swissnames3d.3d') { // for performance testing + config.style.labelStyle = isLabelOutlineEnabled() ? LabelStyle.FILL_AND_OUTLINE : LabelStyle.FILL; + } + tileset.style = new Cesium3DTileStyle(config.style); + } + + tileset.pickable = config.pickable !== undefined ? config.pickable : false; + viewer.scene.primitives.add(tileset); + + config.setVisibility = visible => { + tileset.show = !!visible; + }; + + if (!config.opacityDisabled) { + config.setOpacity = opacity => { + const style = config.style; + if (style && (style.color || style.labelColor)) { + const {propertyName, colorType, colorValue} = styleColorParser(style); + const color = `${colorType}(${colorValue}, ${opacity})`; + tileset.style = new Cesium3DTileStyle({...style, [propertyName]: color}); + } else { + const color = `color("white", ${opacity})`; + tileset.style = new Cesium3DTileStyle({...style, color}); + } + }; + config.setOpacity(config.opacity ? config.opacity : 1); + } + + if (tileLoadCallback) { + const removeTileLoadListener = tileset.tileLoad.addEventListener(tile => tileLoadCallback(tile, removeTileLoadListener)); + } + + if (config.propsOrder) { + tileset.properties.propsOrder = config.propsOrder; + } + if (config.heightOffset) { + const cartographic = Cartographic.fromCartesian(tileset.boundingSphere.center); + const surface = Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, 0.0); + const offset = Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, config.heightOffset); + const translation = Cartesian3.subtract(offset, surface, new Cartesian3()); + tileset.modelMatrix = Matrix4.fromTranslation(translation); + viewer.scene.requestRender(); + } + // for correct highlighting + tileset.colorBlendMode = Cesium3DTileColorBlendMode.REPLACE; + return tileset; +} + +export async function createSwisstopoWMTSImageryLayer(viewer: Viewer, config: LayerConfig) { + const layer: ImageryLayer = await getSwisstopoImagery(config); + config.setVisibility = visible => layer.show = !!visible; + config.setOpacity = opacity => layer.alpha = opacity; + config.remove = () => viewer.scene.imageryLayers.remove(layer, false); + config.add = (toIndex) => { + const layersLength = viewer.scene.imageryLayers.length; + if (toIndex > 0 && toIndex < layersLength) { + const imageryIndex = layersLength - toIndex; + viewer.scene.imageryLayers.add(layer, imageryIndex); + return; + } + viewer.scene.imageryLayers.add(layer); + }; + config.setTime = (time: string) => { + config.wmtsCurrentTime = time; + layer.show = false; + viewer.scene.render(); + setTimeout(() => { + layer.show = true; + viewer.scene.render(); + }, 100); + }; + viewer.scene.imageryLayers.add(layer); + layer.alpha = config.opacity || 1; + layer.show = !!config.visible; + return layer; +} + + +export function createCesiumObject(viewer: Viewer, config: LayerConfig, tileLoadCallback?) { + const factories = { + [LayerType.ionGeoJSON]: createIonGeoJSONFromConfig, + [LayerType.tiles3d]: create3DTilesetFromConfig, + [LayerType.voxels3dtiles]: create3DVoxelsTilesetFromConfig, + [LayerType.swisstopoWMTS]: createSwisstopoWMTSImageryLayer, + [LayerType.earthquakes]: createEarthquakeFromConfig, + }; + return factories[config.type!](viewer, config, tileLoadCallback); +} + +function styleColorParser(style: any) { + const propertyName = style.color ? 'color' : 'labelColor'; + let colorType = style[propertyName].slice(0, style[propertyName].indexOf('(')); + const lastIndex = colorType === 'rgba' ? style[propertyName].lastIndexOf(',') : style[propertyName].indexOf(')'); + const colorValue = style[propertyName].slice(style[propertyName].indexOf('(') + 1, lastIndex); + colorType = colorType === 'rgb' ? 'rgba' : colorType; + return {propertyName, colorType, colorValue}; +} + + +export function getBoxFromRectangle(rectangle: Rectangle, height: number, result: Cartesian3 = new Cartesian3()): Cartesian3 { + const sw = Cartographic.toCartesian(Rectangle.southwest(rectangle, new Cartographic())); + const se = Cartographic.toCartesian(Rectangle.southeast(rectangle, new Cartographic())); + const nw = Cartographic.toCartesian(Rectangle.northwest(rectangle, new Cartographic())); + result.x = Cartesian3.distance(sw, se); // gets box width + result.y = Cartesian3.distance(sw, nw); // gets box length + result.z = height; + return result; +} + +/** + * Returns rectangle from width height and center point + */ +export function calculateRectangle(width: number, height: number, center: Cartesian3, result: Rectangle = new Rectangle()): Rectangle { + const w = new Cartesian3(center.x, center.y - width / 2, center.z); + result.west = Ellipsoid.WGS84.cartesianToCartographic(w).longitude; + const s = new Cartesian3(center.x + height / 2, center.y, center.z); + result.south = Ellipsoid.WGS84.cartesianToCartographic(s).latitude; + const e = new Cartesian3(center.x, center.y + width / 2, center.z); + result.east = Ellipsoid.WGS84.cartesianToCartographic(e).longitude; + const n = new Cartesian3(center.x - height / 2, center.y, center.z); + result.north = Ellipsoid.WGS84.cartesianToCartographic(n).latitude; + + return result; +} + +/** + * Calculates box from bounding volume + */ +export function calculateBox(halfAxes: Matrix3, boundingSphereRadius: number, result: Cartesian3 = new Cartesian3()): Cartesian3 { + const absMatrix = Matrix3.abs(halfAxes, new Matrix3()); + for (let i = 0; i < 3; i++) { + const column = Matrix3.getColumn(absMatrix, i, new Cartesian3()); + const row = Matrix3.getRow(absMatrix, i, new Cartesian3()); + result.y = result.y + column.x + row.x; + result.x = result.x + column.y + row.y; + result.z = result.z + column.z + row.z; + } + // scale according to bounding sphere + const diagonal = Math.sqrt(result.x * result.x + result.y * result.y); + const radius = boundingSphereRadius; + const scale = Math.max(diagonal / (radius * 2), (radius * 2) / diagonal); + result.x = result.x * scale; + result.y = result.y * scale; + result.z = result.z > 60000 ? 60000 : result.z; + + return new Cartesian3(result.x, result.y, result.z); +} diff --git a/ui/src/ngm-app-boot.ts b/ui/src/ngm-app-boot.ts new file mode 100644 index 000000000..fa4618c9d --- /dev/null +++ b/ui/src/ngm-app-boot.ts @@ -0,0 +1,39 @@ +import {LitElement, html} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import './ngm-app'; +import {Task} from '@lit/task'; + +import {ClientConfig} from './api/client-config'; +import {registerAppContext} from './context'; +import {ConfigService} from './api/config.service'; + + +@customElement('ngm-app-boot') +export class NgmAppBoot extends LitElement { + private viewerInitialization = new Task(this, { + task: async () => { + const clientConfig = await new ConfigService().getConfig() as ClientConfig; + if (!clientConfig) { + console.error('Failed to load client config'); + return; + } + + registerAppContext(this, clientConfig); + }, + args: () => [], + }); + + render() { + return this.viewerInitialization.render({ + pending: () => html`

Loading

`, + complete: () => html` + `, + error: (e) => html`

Error: ${e}

` + }); + } + + // This deactivates shadow DOM. Because this is done for all other components, we have to add it for the time being. + createRenderRoot() { + return this; + } +} diff --git a/ui/src/ngm-app.ts b/ui/src/ngm-app.ts index 374b61bb0..e1047f574 100644 --- a/ui/src/ngm-app.ts +++ b/ui/src/ngm-app.ts @@ -57,6 +57,9 @@ import DashboardStore from './store/dashboard'; import type {SideBar} from './elements/ngm-side-bar'; import {LayerConfig} from './layertree'; import $ from './jquery'; +import {clientConfigContext} from './context'; +import {consume} from '@lit/context'; +import {ClientConfig} from './api/client-config'; const SKIP_STEP2_TIMEOUT = 5000; @@ -89,7 +92,7 @@ export class NgmApp extends LitElementI18n { @state() accessor showMobileSearch = false; @state() - accessor loading = true; + accessor loading = false; @state() accessor determinateLoading = false; @state() @@ -123,6 +126,9 @@ export class NgmApp extends LitElementI18n { private waitForViewLoading = false; private resolutionScaleRemoveCallback: Event.RemoveCallback | undefined; + @consume({context: clientConfigContext}) + accessor clientConfig!: ClientConfig; + constructor() { super(); @@ -505,7 +511,7 @@ export class NgmApp extends LitElementI18n { ${this.showTrackingConsent ? html` ` : ''} this.showIonModal = false}>
diff --git a/ui/src/store/auth.ts b/ui/src/store/auth.ts index 7c2efdd15..c1ba6a919 100644 --- a/ui/src/store/auth.ts +++ b/ui/src/store/auth.ts @@ -1,19 +1,19 @@ -import {BehaviorSubject} from 'rxjs'; -import type {AuthUser} from '../auth'; - -export default class AuthStore { - private static userSubject = new BehaviorSubject(null); - - static get user(): BehaviorSubject { - return this.userSubject; - } - - static setUser(user: AuthUser | null): void { - this.userSubject.next(user); - } - - static get userEmail(): string | undefined { - // FIXME: extract from claims - return this.user?.value?.username.split('_')[1].toLowerCase(); - } -} +import {BehaviorSubject} from 'rxjs'; +import type {AuthUser} from '../authService'; + +export default class AuthStore { + private static userSubject = new BehaviorSubject(null); + + static get user(): BehaviorSubject { + return this.userSubject; + } + + static setUser(user: AuthUser | null): void { + this.userSubject.next(user); + } + + static get userEmail(): string | undefined { + // FIXME: extract from claims + return this.user?.value?.username.split('_')[1].toLowerCase(); + } +} diff --git a/ui/src/style/index.css b/ui/src/style/index.css index 55a3180f4..08f6bce74 100644 --- a/ui/src/style/index.css +++ b/ui/src/style/index.css @@ -133,7 +133,7 @@ main { overflow: hidden; } -ngm-app { +ngm-app, ngm-app-boot { flex: auto; display: flex; flex-direction: column; diff --git a/ui/src/test/auth.test.js b/ui/src/test/auth.test.js index e0615e3eb..3b84a1d7c 100644 --- a/ui/src/test/auth.test.js +++ b/ui/src/test/auth.test.js @@ -1,63 +1,63 @@ -/* eslint-env node, mocha */ - -import assert from 'assert'; -import jsdom from 'jsdom-global'; - -// initialize some constants -const url = 'http://localhost/'; -const user = {name: 'John Doe'}; -const payload = Buffer.from(JSON.stringify(user)).toString('base64').replace(/=/g, ''); -const jwt = `header.${payload}.signature`; -const token = `#access_token=${jwt}`; -const idToken = '&id_token=bidon'; -const type = '&token_type=Bearer'; -const state = '&state=test'; - -// initialize the window, document and localStorage objects -jsdom('', {url: url + token + type + state + idToken}); -global.localStorage = window.localStorage; - -// load the component -import Auth from '../auth'; - -describe('Auth', () => { - - describe('state', () => { - it('should initialize the state', () => { - const theState = Auth.state(); - assert.ok(theState.length > 0); - assert.ok(Auth.state() === theState); - Auth.state('test'); - assert.ok(Auth.state() === 'test'); - }); - }); - - describe('getUser, setUser and logout', () => { - it('should get, set and remove the user', () => { - Auth.logout(); - assert.ok(Auth.getUser() === null); - Auth.setUser(user); - assert.deepStrictEqual(Auth.getUser(), user); - Auth.logout(); - assert.ok(Auth.getUser() === null); - }); - }); - - describe('waitForAuthenticate', () => { - it('should wait until the user authenticates', async () => { - Auth.logout(); - setTimeout(() => Auth.setUser(user)); - await Auth.waitForAuthenticate(); - assert.deepStrictEqual(Auth.getUser(), user); - }); - }); - - describe('initialize', () => { - it('should extract the user from the hash in the response URL', () => { - Auth.logout(); - Auth.state('test'); - Auth.initialize(); - assert.deepStrictEqual(Auth.getUser(), user); - }); - }); -}); +/* eslint-env node, mocha */ + +import assert from 'assert'; +import jsdom from 'jsdom-global'; + +// initialize some constants +const url = 'http://localhost/'; +const user = {name: 'John Doe'}; +const payload = Buffer.from(JSON.stringify(user)).toString('base64').replace(/=/g, ''); +const jwt = `header.${payload}.signature`; +const token = `#access_token=${jwt}`; +const idToken = '&id_token=bidon'; +const type = '&token_type=Bearer'; +const state = '&state=test'; + +// initialize the window, document and localStorage objects +jsdom('', {url: url + token + type + state + idToken}); +global.localStorage = window.localStorage; + +// load the component +import AuthService from '../authService.js'; + +describe('Auth', () => { + + describe('state', () => { + it('should initialize the state', () => { + const theState = AuthService.state(); + assert.ok(theState.length > 0); + assert.ok(AuthService.state() === theState); + AuthService.state('test'); + assert.ok(AuthService.state() === 'test'); + }); + }); + + describe('getUser, setUser and logout', () => { + it('should get, set and remove the user', () => { + AuthService.logout(); + assert.ok(AuthService.getUser() === null); + AuthService.setUser(user); + assert.deepStrictEqual(AuthService.getUser(), user); + AuthService.logout(); + assert.ok(AuthService.getUser() === null); + }); + }); + + describe('waitForAuthenticate', () => { + it('should wait until the user authenticates', async () => { + AuthService.logout(); + setTimeout(() => AuthService.setUser(user)); + await AuthService.waitForAuthenticate(); + assert.deepStrictEqual(AuthService.getUser(), user); + }); + }); + + describe('initialize', () => { + it('should extract the user from the hash in the response URL', () => { + AuthService.logout(); + AuthService.state('test'); + AuthService.initialize(); + assert.deepStrictEqual(AuthService.getUser(), user); + }); + }); +}); diff --git a/ui/src/toolbox/ngm-toolbox.ts b/ui/src/toolbox/ngm-toolbox.ts index 6203caac9..de85b4837 100644 --- a/ui/src/toolbox/ngm-toolbox.ts +++ b/ui/src/toolbox/ngm-toolbox.ts @@ -24,8 +24,10 @@ import DrawStore from '../store/draw'; import {GeometryController} from './GeometryController'; import {showSnackbarInfo} from '../notifications'; import DashboardStore from '../store/dashboard'; -import {apiClient} from '../api-client'; import {pairwise} from 'rxjs'; +import {consume} from '@lit/context'; +import {apiClientContext} from '../context'; +import {ApiClient} from '../api/api-client'; @customElement('ngm-tools') export class NgmToolbox extends LitElementI18n { @@ -48,6 +50,9 @@ export class NgmToolbox extends LitElementI18n { private geometryControllerNoEdit: GeometryController | undefined; private forceSlicingToolOpen = false; + @consume({context: apiClientContext}) + accessor apiClient!: ApiClient; + constructor() { super(); MainStore.viewer.subscribe(viewer => { @@ -64,7 +69,7 @@ export class NgmToolbox extends LitElementI18n { const project = DashboardStore.selectedTopicOrProject.value; if (projectEditMode === 'viewEdit' && project && !ToolboxStore.openedGeometryOptions.value?.editing) { try { - apiClient.updateProjectGeometries(project.id, geometries); + this.apiClient.updateProjectGeometries(project.id, geometries); } catch (e) { console.error(e); } diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 2e6dc4f36..594b55959 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -1,25 +1,26 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "esnext", - "lib": ["es2022", "dom", "dom.iterable"], - "declaration": true, - "outDir": "./types", - "rootDir": "./src", - "strict": true, - "skipLibCheck": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, - "noImplicitAny": false, - "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "allowJs": true, - "forceConsistentCasingInFileNames": true, - "useDefineForClassFields": false - }, - "types": ["mocha"], - "include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.js", "test/**/*.js"], - "exclude": [] -} +{ + "compilerOptions": { + "target": "ES2020", + "module": "esnext", + "lib": ["es2022", "dom", "dom.iterable"], + "declaration": true, + "outDir": "./types", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": false, + "experimentalDecorators": false + }, + "types": ["mocha"], + "include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.js", "test/**/*.js"], + "exclude": [] +}