diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index fb116ab6c0d..6e2522bec60 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: axios: specifier: ^1.7.7 version: 1.7.7 + comlink: + specifier: 4.4.2 + version: 4.4.2 dayjs: specifier: ^1.11.13 version: 1.11.13 @@ -3835,109 +3838,109 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@webassemblyjs/ast@1.12.1: - resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} + /@webassemblyjs/ast@1.13.1: + resolution: {integrity: sha512-+Zp/YJMBws+tg2Nuy5jiFhwvPiSeIB0gPp1Ie/TyqFg69qJ/vRrOKQ7AsFLn3solq5/9ubkBjrGd0UcvFjFsYA==} dependencies: - '@webassemblyjs/helper-numbers': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-numbers': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.12.1 dev: true - /@webassemblyjs/floating-point-hex-parser@1.11.6: - resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} + /@webassemblyjs/floating-point-hex-parser@1.12.1: + resolution: {integrity: sha512-0vqwjuCO3Sa6pO3nfplawORkL1GUgza/H1A62SdXHSFCmAHoRcrtX/yVG3f1LuMYW951cvYRcRt6hThhz4FnCQ==} dev: true - /@webassemblyjs/helper-api-error@1.11.6: - resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} + /@webassemblyjs/helper-api-error@1.12.1: + resolution: {integrity: sha512-czovmKZdRk4rYauCOuMV/EImC3qyfcqyJuOYyDRYR6PZSOW37VWe26fAZQznbvKjlwJdyxLl6mIfx47Cfz8ykw==} dev: true - /@webassemblyjs/helper-buffer@1.12.1: - resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} + /@webassemblyjs/helper-buffer@1.13.1: + resolution: {integrity: sha512-J0gf97+D3CavG7aO5XmtwxRWMiMEuxQ6t8Aov8areSnyI5P5fM0HV0m8bE3iLfDQZBhxLCc15tRsFVOGyAJ0ng==} dev: true - /@webassemblyjs/helper-numbers@1.11.6: - resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} + /@webassemblyjs/helper-numbers@1.12.1: + resolution: {integrity: sha512-Vp6k5nXOMvI9dWJqDGCMvwAc8+G6tI2YziuYWqxk7XYnWHdxEJo19CGpqm/kRh86rJxwYANLGuyreARhM+C9lQ==} dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.11.6 - '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/floating-point-hex-parser': 1.12.1 + '@webassemblyjs/helper-api-error': 1.12.1 '@xtuc/long': 4.2.2 dev: true - /@webassemblyjs/helper-wasm-bytecode@1.11.6: - resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} + /@webassemblyjs/helper-wasm-bytecode@1.12.1: + resolution: {integrity: sha512-flsRYmCqN2ZJmvAyNxZXPPFkwKoezeTUczytfBovql8cOjYTr6OTcZvku4UzyKFW0Kj+PgD+UaG8/IdX31EYWg==} dev: true - /@webassemblyjs/helper-wasm-section@1.12.1: - resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} + /@webassemblyjs/helper-wasm-section@1.13.1: + resolution: {integrity: sha512-lcVNbrM5Wm7867lmbU61l+R4dU7emD2X70f9V0PuicvsdVUS5vvXODAxRYGVGBAJ6rWmXMuZKjM0PoeBjAcm2A==} dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/ast': 1.13.1 + '@webassemblyjs/helper-buffer': 1.13.1 + '@webassemblyjs/helper-wasm-bytecode': 1.12.1 + '@webassemblyjs/wasm-gen': 1.13.1 dev: true - /@webassemblyjs/ieee754@1.11.6: - resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} + /@webassemblyjs/ieee754@1.12.1: + resolution: {integrity: sha512-fcrUCqE2dVldeVAHTWFiTiKMS9ivc5jOgB2c30zYOZnm3O54SWeMJWS/HXYK862we2AYHtf6GYuP9xG9J+5zyQ==} dependencies: '@xtuc/ieee754': 1.2.0 dev: true - /@webassemblyjs/leb128@1.11.6: - resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} + /@webassemblyjs/leb128@1.12.1: + resolution: {integrity: sha512-jOU6pTFNf7aGm46NCrEU7Gj6cVuP55T7+kyo5TU/rCduZ5EdwMPBZwSwwzjPZ3eFXYFCmC5wZdPZN0ZWio6n4Q==} dependencies: '@xtuc/long': 4.2.2 dev: true - /@webassemblyjs/utf8@1.11.6: - resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} + /@webassemblyjs/utf8@1.12.1: + resolution: {integrity: sha512-zcZvnAY3/M28Of012dksIfC26qZQJlj2PQCCvxqlsRJHOYtasp+OvK8nRcg11TKzAAv3ja7Y0NEBMKAjH6ljnw==} dev: true - /@webassemblyjs/wasm-edit@1.12.1: - resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} + /@webassemblyjs/wasm-edit@1.13.1: + resolution: {integrity: sha512-YHnh/f4P4ggjPB+pcri8Pb2HHwCGK+B8qBE+NeEp/WTMQ7dAjgWTnLhXxUqb6WLOT25TK5m0VTCAKTYw8AKxcg==} dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/helper-wasm-section': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-opt': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - '@webassemblyjs/wast-printer': 1.12.1 + '@webassemblyjs/ast': 1.13.1 + '@webassemblyjs/helper-buffer': 1.13.1 + '@webassemblyjs/helper-wasm-bytecode': 1.12.1 + '@webassemblyjs/helper-wasm-section': 1.13.1 + '@webassemblyjs/wasm-gen': 1.13.1 + '@webassemblyjs/wasm-opt': 1.13.1 + '@webassemblyjs/wasm-parser': 1.13.1 + '@webassemblyjs/wast-printer': 1.13.1 dev: true - /@webassemblyjs/wasm-gen@1.12.1: - resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} + /@webassemblyjs/wasm-gen@1.13.1: + resolution: {integrity: sha512-AxWiaqIeLh3c1H+8d1gPgVNXHyKP7jDu2G828Of9/E0/ovVEsh6LjX1QZ6g1tFBHocCwuUHK9O4w35kgojZRqA==} dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 + '@webassemblyjs/ast': 1.13.1 + '@webassemblyjs/helper-wasm-bytecode': 1.12.1 + '@webassemblyjs/ieee754': 1.12.1 + '@webassemblyjs/leb128': 1.12.1 + '@webassemblyjs/utf8': 1.12.1 dev: true - /@webassemblyjs/wasm-opt@1.12.1: - resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} + /@webassemblyjs/wasm-opt@1.13.1: + resolution: {integrity: sha512-SUMlvCrfykC7dtWX5g4TSuMmWi9w9tK5kGIdvQMnLuvJfnFLsnAaF86FNbSBSAL33VhM/hOhx4t9o66N37IqSg==} dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/ast': 1.13.1 + '@webassemblyjs/helper-buffer': 1.13.1 + '@webassemblyjs/wasm-gen': 1.13.1 + '@webassemblyjs/wasm-parser': 1.13.1 dev: true - /@webassemblyjs/wasm-parser@1.12.1: - resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} + /@webassemblyjs/wasm-parser@1.13.1: + resolution: {integrity: sha512-8SPOcbqSb7vXHG+B0PTsJrvT/HilwV3WkJgxw34lmhWvO+7qM9xBTd9u4dn1Lb86WHpKswT5XwF277uBTHFikg==} dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-api-error': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 + '@webassemblyjs/ast': 1.13.1 + '@webassemblyjs/helper-api-error': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.12.1 + '@webassemblyjs/ieee754': 1.12.1 + '@webassemblyjs/leb128': 1.12.1 + '@webassemblyjs/utf8': 1.12.1 dev: true - /@webassemblyjs/wast-printer@1.12.1: - resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + /@webassemblyjs/wast-printer@1.13.1: + resolution: {integrity: sha512-q0zIfwpbFvaNkgbSzkZFzLsOs8ixZ5MSdTTMESilSAk1C3P8BKEWfbLEvIqyI/PjNpP9+ZU+/KwgfXx3T7ApKw==} dependencies: - '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/ast': 1.13.1 '@xtuc/long': 4.2.2 dev: true @@ -4706,6 +4709,10 @@ packages: delayed-stream: 1.0.0 dev: false + /comlink@4.4.2: + resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + dev: false + /comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} dev: true @@ -10068,9 +10075,9 @@ packages: dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/ast': 1.13.1 + '@webassemblyjs/wasm-edit': 1.13.1 + '@webassemblyjs/wasm-parser': 1.13.1 acorn: 8.14.0 browserslist: 4.24.2 chrome-trace-event: 1.0.4 diff --git a/packages/.eslintignore b/packages/.eslintignore index 04c01ba7ba0..fd743155172 100644 --- a/packages/.eslintignore +++ b/packages/.eslintignore @@ -1,2 +1,2 @@ -node_modules/ +node_modules/ dist/ \ No newline at end of file diff --git a/packages/.eslintrc b/packages/.eslintrc index 6e8d6a553af..903699de6c2 100644 --- a/packages/.eslintrc +++ b/packages/.eslintrc @@ -111,6 +111,14 @@ } ] } + }, + { + "files": ["webpack.*.js"], + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "ecmaVersion": 2018 + } } ] } diff --git a/packages/geoview-core/package.json b/packages/geoview-core/package.json index e2d1ed7ef26..d74abea4047 100644 --- a/packages/geoview-core/package.json +++ b/packages/geoview-core/package.json @@ -56,6 +56,7 @@ "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "axios": "^1.7.7", + "comlink": "4.4.2", "dayjs": "^1.11.13", "domhandler": "^5.0.3", "export-to-csv": "0.2.1", diff --git a/packages/geoview-core/public/locales/en/translation.json b/packages/geoview-core/public/locales/en/translation.json index 5898d347359..a418298c378 100644 --- a/packages/geoview-core/public/locales/en/translation.json +++ b/packages/geoview-core/public/locales/en/translation.json @@ -8,11 +8,14 @@ "open": "Open", "remove": "Remove", "view": "View", + "failed": "failed", + "started": "started", "openFullscreen": "Open in fullscreen", "closeFullscreen": "Close fullscreen", "openGuide": "Open guide", "guide": "Guide", - "fullScreen": "Full screen" + "fullScreen": "Full screen", + "processing": "Processing __param__ element(s) of __param__" }, "mapnav": { "arianavbar": "Vertical button group for map navigation", @@ -197,7 +200,7 @@ "zoom": "ZOOM", "details": "DETAILS", "exportBtn": "Download", - "jsonExportBtn": "Download GeoJSON", + "downloadAsGeoJSON": "Download GeoJSON", "downloadAsCSV": "Download CSV", "filterMap": "Filter map", "stopFilterMap": "Stop filter map", diff --git a/packages/geoview-core/public/locales/fr/translation.json b/packages/geoview-core/public/locales/fr/translation.json index 6aac062092c..37b71511da1 100644 --- a/packages/geoview-core/public/locales/fr/translation.json +++ b/packages/geoview-core/public/locales/fr/translation.json @@ -8,11 +8,14 @@ "open": "Ouvrir", "remove": "Retirer", "view": "Vue", + "failed": "a échoué", + "started": "démarré", "openFullscreen": "Ouvrir en plein écran", "closeFullscreen": "Fermer le plein écran", "openGuide": "Ouvrir le guide", "guide": "Guide", - "fullScreen": "Plein écran" + "fullScreen": "Plein écran", + "processing": "Traitement de __param__ element(s) sur __param__" }, "mapnav": { "arianavbar": "Groupe de buttons vertical pour navigation sur la carte", @@ -197,7 +200,7 @@ "zoom": "ZOOM", "details": "DÉTAILS", "exportBtn": "Télécharger", - "jsonExportBtn": "Télécharger GeoJSON", + "downloadAsGeoJSON": "Télécharger GeoJSON", "downloadAsCSV": "Télécharger CSV", "filterMap": "Filtrer la carte", "clearFilters": "Effacer les filtres", diff --git a/packages/geoview-core/src/api/config/types/classes/geoview-config/abstract-geoview-esri-layer-config.ts b/packages/geoview-core/src/api/config/types/classes/geoview-config/abstract-geoview-esri-layer-config.ts index af93c77e216..2d8682e0960 100644 --- a/packages/geoview-core/src/api/config/types/classes/geoview-config/abstract-geoview-esri-layer-config.ts +++ b/packages/geoview-core/src/api/config/types/classes/geoview-config/abstract-geoview-esri-layer-config.ts @@ -244,6 +244,7 @@ export abstract class AbstractGeoviewEsriLayerConfig extends AbstractGeoviewLaye * @static */ static convertEsriGeometryTypeToOLGeometryType(esriGeometryType: string): TypeStyleGeometry { + // TODO: Update with style refactor PR (function esriConvertEsriGeometryTypeToOLGeometryType gv-layer/util). Use only one function! switch (esriGeometryType) { case 'esriGeometryPoint': case 'esriGeometryMultipoint': diff --git a/packages/geoview-core/src/api/config/types/map-schema-types.ts b/packages/geoview-core/src/api/config/types/map-schema-types.ts index 08f27848c2f..aff590bc068 100644 --- a/packages/geoview-core/src/api/config/types/map-schema-types.ts +++ b/packages/geoview-core/src/api/config/types/map-schema-types.ts @@ -340,7 +340,9 @@ export { WfsLayerEntryConfig } from '@config/types/classes/sub-layer-config/leaf export { GeoJsonLayerEntryConfig } from '@config/types/classes/sub-layer-config/leaf/vector/geojson-layer-entry-config'; /** Valid keys for the geometryType property. */ -export type TypeStyleGeometry = 'point' | 'linestring' | 'polygon'; +// TODO: Refactor - Layers/Config refactoring. The values here have been renamed to lower case, make sure to lower here and adjust everywhere as part of config migration. +// TODO.CONT: What is the fastest option... change config or all code, see what is the fastest +export type TypeStyleGeometry = 'point' | 'linestring' | 'multilinestring' | 'polygon' | 'multipolygon'; /** Type of Style to apply to the GeoView vector layer source at creation time. */ export type TypeLayerEntryType = 'vector' | 'vector-tile' | 'raster-tile' | 'raster-image' | 'group'; diff --git a/packages/geoview-core/src/api/event-processors/event-processor-children/app-event-processor.ts b/packages/geoview-core/src/api/event-processors/event-processor-children/app-event-processor.ts index 3aebff09344..9c04600af8e 100644 --- a/packages/geoview-core/src/api/event-processors/event-processor-children/app-event-processor.ts +++ b/packages/geoview-core/src/api/event-processors/event-processor-children/app-event-processor.ts @@ -6,7 +6,9 @@ import { TypeHTMLElement } from '@/core/types/global-types'; import { createGuideObject } from '@/core/utils/utilities'; import { MapViewer } from '@/geo/map/map-viewer'; import { MapEventProcessor } from './map-event-processor'; +import { SnackbarType } from '@/core/utils/notifications'; import { logger } from '@/core/utils/logger'; +import { api } from '@/app'; // GV Important: See notes in header of MapEventProcessor file for information on the paradigm to apply when working with UIEventProcessor vs UIState @@ -56,6 +58,31 @@ export class AppEventProcessor extends AbstractEventProcessor { return this.getAppState(mapId).displayTheme; } + /** + * Adds a snackbar message. + * @param {SnackbarType} type - The type of message. + * @param {string} message - The message. + * @param {string} param - Optional param to replace in the string if it is a key + */ + static addMessage(mapId: string, type: SnackbarType, message: string, param?: string[]): void { + switch (type) { + case 'info': + api.maps[mapId].notifications.showMessage(message, param, false); + break; + case 'success': + api.maps[mapId].notifications.showSuccess(message, param, false); + break; + case 'warning': + api.maps[mapId].notifications.showWarning(message, param, false); + break; + case 'error': + api.maps[mapId].notifications.showError(message, param, false); + break; + default: + break; + } + } + static async addNotification(mapId: string, notif: NotificationDetailsType): Promise { // because notification is called before map is created, we use the async // version of getAppStateAsync diff --git a/packages/geoview-core/src/core/components/data-table/json-export-button.tsx b/packages/geoview-core/src/core/components/data-table/json-export-button.tsx index 1353b058ba8..18eb1532518 100644 --- a/packages/geoview-core/src/core/components/data-table/json-export-button.tsx +++ b/packages/geoview-core/src/core/components/data-table/json-export-button.tsx @@ -1,15 +1,15 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import _ from 'lodash'; - import { Geometry, Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon } from 'ol/geom'; - import { MenuItem } from '@/ui'; + import { logger } from '@/core/utils/logger'; -import { useMapStoreActions } from '@/core/stores/store-interface-and-intial-values/map-state'; -import { TypeJsonObject } from '@/core/types/global-types'; -import { useLayerStoreActions } from '@/core/stores/store-interface-and-intial-values/layer-state'; +import { JsonExportWorker } from '@/core/workers/json-export-worker'; import { TypeFeatureInfoEntry } from '@/geo/map/map-schema-types'; +import { useLayerStoreActions } from '@/core/stores/store-interface-and-intial-values/layer-state'; +import { TypeJsonObject } from '@/core/types/global-types'; +import { useAppStoreActions } from '@/core/stores/store-interface-and-intial-values/app-state'; +import { useMapProjection } from '@/core/stores/store-interface-and-intial-values/map-state'; interface JSONExportButtonProps { rows: unknown[]; @@ -27,169 +27,154 @@ interface JSONExportButtonProps { function JSONExportButton({ rows, features, layerPath }: JSONExportButtonProps): JSX.Element { const { t } = useTranslation(); - // get store value - projection config to transfer lat long and layer - const { transformPoints } = useMapStoreActions(); + // get store action and map projection const { getLayer, queryLayerEsriDynamic } = useLayerStoreActions(); + const { addMessage } = useAppStoreActions(); + const mapProjection = useMapProjection(); + + // Keep exporting state + const [isExporting, setIsExporting] = useState(false); /** - * Creates a geometry json + * Helper function to serialize a json geometry to pass to the worker + * * @param {Geometry} geometry - The geometry - * @returns {TypeJsonObject} The geometry json + * @returns {TypeJsonObject} The serialized geometry json */ - const buildGeometry = useCallback( - (geometry: Geometry): TypeJsonObject => { - let builtGeometry = {}; - - if (geometry instanceof Polygon) { - // coordinates are in the form of Coordinate[][] - builtGeometry = { - type: 'Polygon', - coordinates: geometry.getCoordinates().map((coords) => { - return coords.map((coord) => transformPoints([coord], 4326)[0]); - }), - }; - } else if (geometry instanceof MultiPolygon) { - // coordinates are in the form of Coordinate[][][] - builtGeometry = { - type: 'MultiPolygon', - coordinates: geometry.getCoordinates().map((coords1) => { - return coords1.map((coords2) => { - return coords2.map((coord) => transformPoints([coord], 4326)[0]); - }); - }), - }; - } else if (geometry instanceof LineString) { - // coordinates are in the form of Coordinate[] - builtGeometry = { type: 'LineString', coordinates: geometry.getCoordinates().map((coord) => transformPoints([coord], 4326)[0]) }; - } else if (geometry instanceof MultiLineString) { - // coordinates are in the form of Coordinate[][] - builtGeometry = { - type: 'MultiLineString', - coordinates: geometry.getCoordinates().map((coords) => { - return coords.map((coord) => transformPoints([coord], 4326)[0]); - }), - }; - } else if (geometry instanceof Point) { - // coordinates are in the form of Coordinate - builtGeometry = { type: 'Point', coordinates: transformPoints([geometry.getCoordinates()], 4326)[0] }; - } else if (geometry instanceof MultiPoint) { - // coordinates are in the form of Coordinate[] - builtGeometry = { type: 'MultiPoint', coordinates: geometry.getCoordinates().map((coord) => transformPoints([coord], 4326)[0]) }; + const serializeGeometry = (geometry: Geometry): TypeJsonObject => { + let builtGeometry = {}; + + if (geometry instanceof Polygon) { + builtGeometry = { type: 'Polygon', coordinates: geometry.getCoordinates() }; + } else if (geometry instanceof MultiPolygon) { + builtGeometry = { type: 'MultiPolygon', coordinates: geometry.getCoordinates() }; + } else if (geometry instanceof LineString) { + builtGeometry = { type: 'LineString', coordinates: geometry.getCoordinates() }; + } else if (geometry instanceof MultiLineString) { + builtGeometry = { type: 'MultiLineString', coordinates: geometry.getCoordinates() }; + } else if (geometry instanceof Point) { + // TODO: There is no proper support for esriDynamic MultiPoint issue 2589... this is a workaround + if (Array.isArray(geometry.getCoordinates())) { + builtGeometry = { type: 'MultiPoint', coordinates: geometry.getCoordinates() }; + } else { + builtGeometry = { type: 'Point', coordinates: geometry.getCoordinates() }; } + } else if (geometry instanceof MultiPoint) { + builtGeometry = { type: 'MultiPoint', coordinates: geometry.getCoordinates() }; + } + + return builtGeometry; + }; + + const fetchESRI = useCallback( + (chunk: TypeFeatureInfoEntry[]): Promise => { + try { + // Create a new promise that will resolved when features have been updated with their geometries + return new Promise((resolve, reject) => { + // Get the ids + const objectids = chunk.map((record) => { + return record.geometry?.get('OBJECTID') as number; + }); - return builtGeometry; - }, - [transformPoints] - ); + // Query + queryLayerEsriDynamic(layerPath, objectids) + .then((results) => { + // For each result + results.forEach((result) => { + // Filter + const recFound = chunk.filter((record) => record.geometry?.get('OBJECTID') === result.fieldInfo?.OBJECTID?.value); + + // If found it + if (recFound && recFound.length === 1) { + // Officially attribute the geometry to that particular record + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (recFound[0].geometry as any).setGeometry(result.geometry); + } + }); - /** - * Builds the JSON features section of the file - * @returns {string} Json file content as string - */ - const getJsonFeatures = useCallback( - (theFeatures: TypeFeatureInfoEntry[]): TypeJsonObject[] => { - // Create GeoJSON feature - return theFeatures.map((feature) => { - const { geometry, fieldInfo } = feature; - - // Format the feature info to extract only value and remove the geoviewID field - const formattedInfo: Record[] = []; - Object.keys(fieldInfo).forEach((key) => { - if (key !== 'geoviewID') { - const tmpObj: Record = {}; - tmpObj[key] = fieldInfo[key]!.value; - formattedInfo.push(tmpObj); - } + // Only now, resolve the promise + resolve(chunk); + }) + .catch(reject); }); - - return { - type: 'Feature', - geometry: buildGeometry(geometry?.getGeometry() as Geometry), - properties: formattedInfo, - } as unknown as TypeJsonObject; - }); + } catch (err) { + // Handle error + logger.logError('Failed to query the features to get their geometries. The output will not have the geometries.', err); + return Promise.resolve(chunk); // Return the original chunk if there's an error + } }, - [buildGeometry] + [layerPath, queryLayerEsriDynamic] ); /** - * Builds the JSON file - * @returns {string} Json file content as string + * Callback function to get JSON data for export. + * This function is memoized using useCallback to optimize performance. + * It will only be recreated if the dependencies (in the empty array) change. + * + * @returns {Promise} A promise that resolves to the JSON string to be exported. */ const getJson = useCallback( - async (fetchGeometriesDuringProcess: boolean): Promise => { - // Filter features from filtered rows - const rowsID = rows.map((row) => { - if ( - typeof row === 'object' && - row !== null && - 'geoviewID' in row && - typeof row.geoviewID === 'object' && - row.geoviewID !== null && - 'value' in row.geoviewID - ) { - return row.geoviewID.value; - } - return ''; - }); - - const filteredFeatures = features.filter((feature) => rowsID.includes(feature.fieldInfo.geoviewID!.value)); - - // If must fetch the geometries during the process - if (fetchGeometriesDuringProcess) { - try { - // Split the array in arrays of 100 features maximum - const sublists = _.chunk(filteredFeatures, 100); - - // For each sub list - const promises = sublists.map((sublist) => { - // Create a new promise that will resolved when features have been updated with their geometries - return new Promise((resolve, reject) => { - // Get the ids - const objectids = sublist.map((record) => { - return record.geometry?.get('OBJECTID') as number; - }); + // The function* is crucial here because this is a generator function, specifically an async generator function. + // - The * (asterisk) indicates that this is a generator function that can yield multiple values over time + // - async function* is the syntax for declaring an async generator function + // - The combination allows you to use both await and yield in the function body + // eslint-disable-next-line func-names + async function* (fetchGeometriesDuringProcess: boolean): AsyncGenerator { + // create a set with the geoviewID available for download and filteres the features + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rowsIDSet = new Set(rows.map((row: any) => row?.geoviewID?.value).filter(Boolean)); + const filteredFeatures = features.filter((feature) => rowsIDSet.has(feature.fieldInfo.geoviewID?.value)); + + // create the worker + const worker = new JsonExportWorker(); + const chunkSize = 100; // Adjust based on performance testing + + try { + // Initialize the worker + await worker.init({ + sourceCRS: `EPSG:${mapProjection}`, + targetCRS: 'EPSG:4326', + }); - // Query - queryLayerEsriDynamic(layerPath, objectids) - .then((results) => { - // For each result - results.forEach((result) => { - // Filter - const recFound = filteredFeatures.filter( - (record) => record.geometry?.get('OBJECTID') === result.fieldInfo?.OBJECTID?.value - ); - - // If found it - if (recFound && recFound.length === 1) { - // Officially attribute the geometry to that particular record - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (recFound[0].geometry as any).setGeometry(result.geometry); - } - }); - - // Only now, resolve the promise - resolve(); - }) - .catch(reject); - }); - }); + // Loop the chunk and get the JSON + for (let i = 0; i < filteredFeatures.length; i += chunkSize) { + let chunk = filteredFeatures.slice(i, i + chunkSize); - // Once all promises complete - await Promise.all(promises); - } catch (err) { - // Handle error - logger.logError('Failed to query the features to get their geometries. The output will not have the geometries.', err); - } - } + // If must fetch the geometries during the process + if (fetchGeometriesDuringProcess) { + // eslint-disable-next-line no-await-in-loop + chunk = await fetchESRI(chunk); + } - // Get the Json Features - const geoData = getJsonFeatures(filteredFeatures); + // Use rowsIDSet to get features that needs to be exported, then serialize geometry + const serializedChunk = chunk + .filter((feature) => rowsIDSet.has(feature.fieldInfo.geoviewID?.value)) + .map((feature) => ({ + geometry: serializeGeometry(feature.geometry?.getGeometry()?.clone() as Geometry), + properties: Object.fromEntries( + Object.entries(feature.fieldInfo) + .filter(([key]) => key !== 'geoviewID') + .map(([key, value]) => [key, value?.value]) + ), + })); + + if (serializedChunk.length > 0) { + // eslint-disable-next-line no-await-in-loop + const result = await worker.process(serializedChunk, i === 0); + yield result; + } - // Stringify with some indentation - return JSON.stringify({ type: 'FeatureCollection', features: geoData }, null, 2); + // Defers execution to the next event loop iteration. This allows other pending micro + // and macro tasks to execute, preventing long-running operations from blocking the main thread. + // eslint-disable-next-line no-promise-executor-return, no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 0)); + } + yield ']}'; + } finally { + worker.terminate(); + } }, - [layerPath, features, rows, getJsonFeatures, queryLayerEsriDynamic] + [features, fetchESRI, mapProjection, rows] ); /** @@ -209,32 +194,44 @@ function JSONExportButton({ rows, features, layerPath }: JSONExportButtonProps): URL.revokeObjectURL(url); }, []); - /** - * Exports data table in csv format. - */ - const handleExportData = useCallback((): void => { - const layer = getLayer(layerPath); - const layerIsEsriDynamic = layer?.type === 'esriDynamic'; - - // Get the Json content for the layer - getJson(layerIsEsriDynamic) - .then((jsonString: string | undefined) => { - // If defined - if (jsonString) { - const blob = new Blob([jsonString], { - type: 'text/json', - }); + const handleExportData = useCallback(async () => { + setIsExporting(true); + try { + const layer = getLayer(layerPath); + const layerIsEsriDynamic = layer?.type === 'esriDynamic'; - exportBlob(blob, `table-${layer?.layerName.replaceAll(' ', '-')}.json`); - } - }) - .catch((err) => { - // Log - logger.logPromiseFailed('Not able to export', err); - }); - }, [exportBlob, getJson, getLayer, layerPath]); - - return {t('dataTable.jsonExportBtn')}; + const jsonGenerator = getJson(layerIsEsriDynamic); + const chunks = []; + let i = 0; + + addMessage('info', `${t('dataTable.downloadAsGeoJSON')} ${t('general.started')}...`); + for await (const chunk of jsonGenerator) { + chunks.push(chunk); + i++; + + // Update progress here + const count = i * 100 < rows.length ? i * 100 : rows.length; + addMessage('info', 'general.processing', [String(count), String(rows.length)]); + } + + const fullJson = chunks.join(''); + const blob = new Blob([fullJson], { type: 'application/json' }); + exportBlob(blob, `table-${getLayer(layerPath)?.layerName.replaceAll(' ', '-')}.json`); + } catch (error) { + addMessage('error', `${t('dataTable.downloadAsGeoJSON')} ${t('general.failed')}`); + logger.logError('Download GeoJSON failed:', error); + } finally { + setIsExporting(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getJson]); + + return ( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + + {t('dataTable.downloadAsGeoJSON')} + + ); } export default JSONExportButton; diff --git a/packages/geoview-core/src/core/containers/shell.tsx b/packages/geoview-core/src/core/containers/shell.tsx index 05bc1dd0e61..fee08991783 100644 --- a/packages/geoview-core/src/core/containers/shell.tsx +++ b/packages/geoview-core/src/core/containers/shell.tsx @@ -321,6 +321,14 @@ export function Shell(props: ShellProps): JSX.Element { {interaction === 'dynamic' && } + {geoviewConfig!.footerBar !== undefined && mapLoaded && } {Object.keys(mapViewer.modal.modals).map((modalId) => ( @@ -342,14 +350,6 @@ export function Shell(props: ShellProps): JSX.Element { {Object.keys(components).map((key: string) => { return {components[key]}; })} - diff --git a/packages/geoview-core/src/core/stores/store-interface-and-intial-values/app-state.ts b/packages/geoview-core/src/core/stores/store-interface-and-intial-values/app-state.ts index 39b6ea56824..75acb3a1195 100644 --- a/packages/geoview-core/src/core/stores/store-interface-and-intial-values/app-state.ts +++ b/packages/geoview-core/src/core/stores/store-interface-and-intial-values/app-state.ts @@ -8,6 +8,7 @@ import { TypeHTMLElement, TypeMapFeaturesConfig } from '@/core/types/global-type import { logger } from '@/core/utils/logger'; import { getScriptAndAssetURL } from '@/core/utils/utilities'; import { VALID_DISPLAY_LANGUAGE } from '@/api/config/types/config-constants'; +import { SnackbarType } from '@/core/utils/notifications'; // GV Important: See notes in header of MapEventProcessor file for information on the paradigm to apply when working with AppEventProcessor vs AppState @@ -30,6 +31,7 @@ export interface IAppState { setDefaultConfigValues: (geoviewConfig: TypeMapFeaturesConfig) => void; actions: { + addMessage: (type: SnackbarType, message: string, param?: string[]) => void; addNotification: (notif: NotificationDetailsType) => void; setCrosshairActive: (active: boolean) => void; setDisplayLanguage: (lang: TypeDisplayLanguage) => Promise<[void, void]>; @@ -90,6 +92,17 @@ export function initializeAppState(set: TypeSetStore, get: TypeGetStore): IAppSt // #region ACTIONS actions: { + /** + * Adds a snackbar message. + * @param {SnackbarType} type - The type of message. + * @param {string} message - The message. + * @param {string} param - Optional param to replace in the string if it is a key + */ + addMessage: (type: SnackbarType, message: string, param?: string[]): void => { + // Redirect to processor + AppEventProcessor.addMessage(get().mapId, type, message, param); + }, + /** * Adds a notification. * @param {NotificationDetailsType} notif - The notification to add. diff --git a/packages/geoview-core/src/core/stores/store-interface-and-intial-values/layer-state.ts b/packages/geoview-core/src/core/stores/store-interface-and-intial-values/layer-state.ts index 0786c1122f2..89a7dbc53a3 100644 --- a/packages/geoview-core/src/core/stores/store-interface-and-intial-values/layer-state.ts +++ b/packages/geoview-core/src/core/stores/store-interface-and-intial-values/layer-state.ts @@ -112,12 +112,17 @@ export function initializeLayerState(set: TypeSetStore, get: TypeGetStore): ILay // Check if EsriDynamic config if (layerConfig && layerEntryIsEsriDynamic(layerConfig)) { // Query for the specific object ids + // TODO: Make sure / is append at the end of metadataAccessPath/dataAccessPath when we read config + // TODO: Put the server original projection in the config metadata (add a new optional param in source for esri) + // TODO.CONT: When we get the projection we can get the projection in original server (will solve error trying to reproject https://maps-cartes.ec.gc.ca/arcgis/rest/services/CESI/MapServer/7 in 3857) + // TODO.CONT: Then we need to modify the DownloadGeoJSON to use mapProjection for vector and original projection for dynamic. return esriQueryRecordsByUrlObjectIds( - `${layerConfig.source?.dataAccessPath}${layerConfig.layerId}`, + `${layerConfig.source?.dataAccessPath}/${layerConfig.layerId}`, geometryType, objectIDs, 'OBJECTID', - true + true, + MapEventProcessor.getMapState(get().mapId).currentProjection ); } diff --git a/packages/geoview-core/src/core/utils/logger.ts b/packages/geoview-core/src/core/utils/logger.ts index 6e5c5b7d7ed..9f19ae2b5c9 100644 --- a/packages/geoview-core/src/core/utils/logger.ts +++ b/packages/geoview-core/src/core/utils/logger.ts @@ -20,6 +20,8 @@ export const LOG_TRACE_CORE_STORE_SUBSCRIPTION = 8; export const LOG_TRACE_CORE_API_EVENT = 9; // For tracing core functions. Disabled by default. Only shows if running in dev environment or GEOVIEW_LOG_ACTIVE key is set in local storage. export const LOG_TRACE_CORE = 10; +// For tracing worker functions. Disabled by default. Only shows if running in dev environment or GEOVIEW_LOG_ACTIVE key is set in local storage. +export const LOG_TRACE_WORKER = 15; // Default. For debugging and development. Enabled by default. Only shows if running in dev environment or GEOVIEW_LOG_ACTIVE key is set in local storage. export const LOG_DEBUG = 20; // Tracks the general flow of the app. Enabled by default. Shows all the time. @@ -179,6 +181,18 @@ export class ConsoleLogger { this.#logLevel(LOG_TRACE_CORE, 'TRACE', 'dodgerblue', ...messages); } + /** + * Logs tracing calls workers. + * Only shows if LOG_ACTIVE is true. + * @param {unknown[]} messages - The messages to log + */ + logTraceWorker(...messages: unknown[]): void { + // Validate log active + if (!LOG_ACTIVE) return; + // Redirect + this.#logLevel(LOG_TRACE_WORKER, 'WORKR', 'pink', ...messages); // Not a typo, 5 characters for alignment + } + /** * Logs debug information. * Only shows if LOG_ACTIVE is true. @@ -427,6 +441,7 @@ type ColorCode = { yellowgreen: string; goldenrod: string; green: string; + pink: string; }; /** @@ -457,6 +472,7 @@ logger.logInfo('Logger initialized'); // logger.logTraceCoreStoreSubscription('trace store subscription'); // logger.logTraceCoreAPIEvent('trace api event'); // logger.logTraceCore('trace core'); +// logger.logTraceWorker('trace worker'); // logger.logDebug('debug'); // logger.logMarkerStart('test time marker'); // logger.logMarkerCheck('test time marker'); diff --git a/packages/geoview-core/src/core/workers/abstract-worker.ts b/packages/geoview-core/src/core/workers/abstract-worker.ts new file mode 100644 index 00000000000..26369a7ec48 --- /dev/null +++ b/packages/geoview-core/src/core/workers/abstract-worker.ts @@ -0,0 +1,106 @@ +import { wrap, Remote } from 'comlink'; +import { logger } from '../utils/logger'; +import { WorkerLogLevel } from './helper/logger-worker'; + +/** + * To create a new worker: + * 1. Create a new TypeScript file for your worker in src/workers folder (e.g., my-work-script.ts). + * 2. Implement the worker's functionality in this file. + * 3. In the samme folder, create a new class that extends AbstractWorker, implementing the `init` and `processs` methods (e.g., my-work-worker.ts). + * 4. In your main application code: + * - Create a new Worker instance using the worker file. + * - Pass this Worker instance to your AbstractWorker subclass constructor. + * - Call the `init` method to set up the worker and `process` to do your work. + * - Call the `terminate` method when you're done with the worker. + */ + +/** + * Abstract base class for creating worker instances. + * @template T - The type of the worker's exposed methods and properties. + */ +export abstract class AbstractWorker { + /** The worker name for logging purposes. */ + protected name: string; + + /** The actual Web Worker instance. */ + protected worker: Worker; + + /** A proxy object to interact with the worker using Comlink. */ + protected proxy: Remote; + + /** + * Creates an instance of AbstractWorker. + * @param {string} name - The Web Worker name for logging. + * @param {Worker} worker - The Web Worker instance to wrap. + */ + constructor(name: string, worker: Worker) { + this.name = name; + this.worker = worker; + // Wrap the worker with Comlink to enable easy communication + this.proxy = wrap(this.worker); + + this.#setupLogging(); + } + + /** + * Sets up logging configuration for the worker instance. + * This private method initializes and configures the logging system + * to handle worker-specific logging requirements. + * @private + */ + #setupLogging(): void { + // Configures logging settings for the worker process + // Ensures worker-specific log entries can be properly tracked and identified - event.data.type === 'log' + // Establishes logging context for debugging and monitoring worker operations + this.worker.onmessage = (event) => { + if (event.data && event.data.type === 'log') { + const { level, message } = event.data; + switch (level as WorkerLogLevel) { + case 'trace': + logger.logTraceWorker(...message); + break; + case 'info': + logger.logInfo(...message); + break; + case 'warning': + logger.logWarning(...message); + break; + case 'error': + logger.logError(...message); + break; + case 'debug': + logger.logDebug(...message); + break; + default: + break; + } + } + }; + } + + /** + * Initializes the worker. This method should be implemented by subclasses. + * @param args - Arguments to pass to the worker for initialization. + * @returns A promise that resolves when the worker is initialized. + */ + protected abstract init(...args: unknown[]): Promise; + + /** + * Process the worker. This method should be implemented by subclasses. + * @param args - Arguments to pass to the worker for process. + * @returns A promise that resolves when the worker is processed. + */ + protected abstract process(...args: unknown[]): Promise; + + /** + * Terminates the worker. + */ + terminate(): void { + try { + this.worker.terminate(); + logger.logTraceWorker('Done terminating worker:', this.name); + } catch (error) { + logger.logError('Error terminating worker:', this.name, error); + } + } +} diff --git a/packages/geoview-core/src/core/workers/helper/logger-worker.ts b/packages/geoview-core/src/core/workers/helper/logger-worker.ts new file mode 100644 index 00000000000..e1d28545631 --- /dev/null +++ b/packages/geoview-core/src/core/workers/helper/logger-worker.ts @@ -0,0 +1,86 @@ +/** + * Represents the log levels available for logging. + */ +export type WorkerLogLevel = 'info' | 'warning' | 'error' | 'debug' | 'trace'; + +/** + * WorkerLogger class for handling logging in a worker context. + * + * This logger allows for centralized logging from workers back to the main thread, + * maintaining a consistent logging interface across the application. + */ +class WorkerLogger { + #prefix: string; + + /** + * Creates an instance of WorkerLogger. + * @param {string} [prefix=''] - The prefix to be added to all log messages. + */ + constructor(prefix: string = '') { + this.#prefix = prefix; + } + + /** + * Internal method to send log messages to the main thread. + * @private + * @param {WorkerLogLevel} level - The log level of the message. + * @param {...unknown[]} args - The message and any additional arguments to log. + */ + #log(level: WorkerLogLevel, ...args: unknown[]): void { + const message = this.#prefix ? [this.#prefix, ...args] : args; + // Send the log message to the main thread + // eslint-disable-next-line no-restricted-globals + self.postMessage({ + type: 'log', + level, + message, + }); + } + + /** + * Logs an informational message. + * @param {...unknown[]} args - The message and any additional arguments to log. + */ + logInfo(...args: unknown[]): void { + this.#log('info', ...args); + } + + /** + * Logs a warning message. + * @param {...unknown[]} args - The message and any additional arguments to log. + */ + logWarning(...args: unknown[]): void { + this.#log('warning', ...args); + } + + /** + * Logs an error message. + * @param {...unknown[]} args - The message and any additional arguments to log. + */ + logError(...args: unknown[]): void { + this.#log('error', ...args); + } + + /** + * Logs a debug message. + * @param {...unknown[]} args - The message and any additional arguments to log. + */ + logDebug(...args: unknown[]): void { + this.#log('debug', ...args); + } + + /** + * Logs a trace message. + * @param {...unknown[]} args - The message and any additional arguments to log. + */ + logTrace(...args: unknown[]): void { + this.#log('trace', ...args); + } +} + +/** + * Creates and returns a new WorkerLogger instance. + * @param {string} [prefix] - Optional prefix for all log messages from this logger. + * @returns {WorkerLogger} A new WorkerLogger instance. + */ +export const createWorkerLogger = (prefix?: string): WorkerLogger => new WorkerLogger(prefix); diff --git a/packages/geoview-core/src/core/workers/json-export-script.ts b/packages/geoview-core/src/core/workers/json-export-script.ts new file mode 100644 index 00000000000..2b82b8952bd --- /dev/null +++ b/packages/geoview-core/src/core/workers/json-export-script.ts @@ -0,0 +1,185 @@ +import { expose } from 'comlink'; +import proj4 from 'proj4'; +import { register } from 'ol/proj/proj4'; +import { Coordinate } from 'ol/coordinate'; + +import { createWorkerLogger } from '@/core/workers/helper/logger-worker'; +import { TypeJsonObject } from '@/api/config/types/config-types'; + +/** + * This worker script is designed to be used with the JsonExportWorker class. + * It handles the transformation of GeoJSON features from one coordinate system to another. + * + * The main operations are: + * 1. Initialization: Set up the source and target coordinate reference systems. + * 2. Processing: Transform chunks of GeoJSON features, converting their geometries. + * + * The worker uses proj4 for coordinate transformations and includes a custom + * definition for the EPSG:3978 projection. + */ + +type TypeWorkerExportGeometry = { + type: string; + coordinates: Coordinate | Coordinate[] | Coordinate[][] | Coordinate[][][]; +}; + +// Type related to the worker +export type TypeWorkerExportChunk = { + geometry: TypeJsonObject; + properties: { + [k: string]: unknown; + }; +}; +export type TypeWorkerExportProjectionInfo = { + sourceCRS: string; + targetCRS: string; +}; + +// Initialize the worker logger +const logger = createWorkerLogger('json-export-worker'); + +// Variables to store the source and target coordinate reference systems +let sourceCRS: string; +let targetCRS: string; + +// Register the EPSG:3978 projection. This is needed because wroker does not work on same thread as main +// and do not have access to our already define proj4 version. +proj4.defs( + 'EPSG:3978', + '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=49 +lon_0=-95 +x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs' +); +register(proj4); + +/** + * Transforms an array of points from the source CRS to the target CRS. + * @param {Coordinate[]} points - Array of points coordinates to transform. + * @returns {Coordinate[]} Array of transformed points coordinates. + */ +function transformPoints(points: Coordinate[]): Coordinate[] { + const converted: Array> = []; + + if (Array.isArray(points) && points.length > 0) { + if (Array.isArray(points[0])) { + for (let i = 0; i < points.length; i++) { + const coords = proj4(sourceCRS, targetCRS, points[i]); + converted.push(coords); + } + } + } + + return converted; +} + +/** + * Transforms the geometry of a GeoJSON feature. + * @param {TypeWorkerExportGeometry} geometry - The geometry to transform. + * @returns {TypeJsonObject} The transformed geometry. + */ +function transformGeometry(geometry: TypeWorkerExportGeometry): TypeJsonObject { + const { type, coordinates } = geometry; + + let transformedGeometry = {}; + if (type === 'Polygon') { + // coordinates are in the form of Coordinate[][] + transformedGeometry = { + type: 'Polygon', + coordinates: (coordinates as Coordinate[][]).map((coords: Coordinate[]) => { + return coords.map((coord: Coordinate) => transformPoints([coord])[0]); + }), + }; + } else if (type === 'MultiPolygon') { + // coordinates are in the form of Coordinate[][][] + transformedGeometry = { + type: 'MultiPolygon', + coordinates: (coordinates as Coordinate[][][]).map((coords1: Coordinate[][]) => { + return coords1.map((coords2: Coordinate[]) => { + return coords2.map((coord: Coordinate) => transformPoints([coord])[0]); + }); + }), + }; + } else if (type === 'LineString') { + // coordinates are in the form of Coordinate[] + transformedGeometry = { + type: 'LineString', + coordinates: (coordinates as Coordinate[]).map((coord: Coordinate) => transformPoints([coord])[0]), + }; + } else if (type === 'MultiLineString') { + // coordinates are in the form of Coordinate[][] + transformedGeometry = { + type: 'MultiLineString', + coordinates: (coordinates as Coordinate[][]).map((coords: Coordinate[]) => { + return coords.map((coord: Coordinate) => transformPoints([coord])[0]); + }), + }; + } else if (type === 'Point') { + // coordinates are in the form of Coordinate + transformedGeometry = { type: 'Point', coordinates: transformPoints([coordinates as Coordinate])[0] }; + } else if (type === 'MultiPoint') { + // coordinates are in the form of Coordinate[] + transformedGeometry = { + type: 'MultiPoint', + coordinates: (coordinates as Coordinate[]).map((coord: Coordinate) => transformPoints([coord])[0]), + }; + } + + return transformedGeometry; +} + +/** + * The main worker object containing methods for initialization and processing. + */ +const worker = { + /** + * Initializes the worker with projection information. + * @param {TypeWorkerExportProjectionInfo} projectionInfo - The projection information. + */ + init(projectionInfo: TypeWorkerExportProjectionInfo) { + try { + sourceCRS = projectionInfo.sourceCRS; + targetCRS = projectionInfo.targetCRS; + logger.logTrace('init', `Worker initialized with sourceCRS: ${sourceCRS}, targetCRS: ${targetCRS}`); + } catch (error) { + logger.logError('init', error); + } + }, + + /** + * Processes a chunk of GeoJSON features, transforming their geometries. + * @param {TypeWorkerExportChunk[]} chunk - The chunk of GeoJSON features to process. + * @param {boolean} isFirst - Indicates if this is the first chunk of the dataset. + * @returns {string} A JSON string of the processed features. + */ + process(chunk: TypeWorkerExportChunk[], isFirst: boolean): string { + try { + logger.logTrace('process', `Processing chunk of ${chunk.length} items`); + let result = ''; + if (isFirst) { + result += '{"type":"FeatureCollection","features":['; + } else if (chunk.length > 0) { + result += ','; + } + + const processedChunk = chunk.map((feature: TypeWorkerExportChunk) => { + const { geometry, properties } = feature; + const transformedGeometry = transformGeometry(geometry as unknown as TypeWorkerExportGeometry); + return JSON.stringify({ + type: 'Feature', + geometry: transformedGeometry, + properties, + }); + }); + + result += processedChunk.join(','); + + logger.logTrace('process', `Finished processing`); + return result; + } catch (error) { + logger.logError('process', error); + return ''; + } + }, +}; + +// Expose the worker methods to be accessible from the main thread +expose(worker); +export default {} as typeof Worker & { new (): Worker }; diff --git a/packages/geoview-core/src/core/workers/json-export-worker.ts b/packages/geoview-core/src/core/workers/json-export-worker.ts new file mode 100644 index 00000000000..9e77bdc331f --- /dev/null +++ b/packages/geoview-core/src/core/workers/json-export-worker.ts @@ -0,0 +1,67 @@ +import { AbstractWorker } from './abstract-worker'; +import { TypeWorkerExportChunk, TypeWorkerExportProjectionInfo } from './json-export-script'; + +/** + * How to create a new worker: + * + * 1. Define an interface for your worker's exposed methods (init, process and other is needed) + * 2. Create a new class extending AbstractWorker (e.g. export class MyWorker extends AbstractWorker) + * 3. Create the actual worker script (my-worker-script.ts): + * 4. Use your new worker in the main application: + * const myWorker = new MyWorker(); + * const result1 = await myWorker.init('test'); + * const result2 = await myWorker.process(42, true); + */ + +/** + * Interface defining the methods exposed by the JSON export worker. + */ +interface JsonExportWorkerType { + /** + * Initializes the worker with projection information. + * @param {TypeWorkerExportProjectionInfo} projectionInfo - Object containing source and target CRS. + */ + init: (projectionInfo: TypeWorkerExportProjectionInfo) => Promise; + + /** + * Processes a chunk of data for JSON export. + * @param {TypeWorkerExportChunk[]} chunk - Array of data to process. + * @param {boolean} isFirst - Boolean indicating if this is the first chunk. + * @returns A promise that resolves to the processed JSON string. + */ + process: (chunk: TypeWorkerExportChunk[], isFirst: boolean) => Promise; +} + +/** + * Class representing a JSON export worker. + * Extends AbstractWorker to handle JSON export operations in a separate thread. + */ +export class JsonExportWorker extends AbstractWorker { + /** + * Creates an instance of JsonExportWorker. + * Initializes the worker with the 'json-export' script. + */ + constructor() { + super('json-export', new Worker(new URL('./json-export-script.ts', import.meta.url))); + } + + /** + * Initializes the worker with projection information. + * @param {TypeWorkerExportProjectionInfo} projectionInfo - Object containing source and target CRS. + * @returns A promise that resolves when initialization is complete. + */ + public async init(projectionInfo: TypeWorkerExportProjectionInfo): Promise { + await this.proxy.init(projectionInfo); + } + + /** + * Processes a chunk of data for JSON export. + * @param {TypeWorkerExportChunk[]} chunk - Array of data to process. + * @param {boolean} isFirst - Boolean indicating if this is the first chunk. + * @returns A promise that resolves to the processed JSON string. + */ + public async process(chunk: TypeWorkerExportChunk[], isFirst: boolean): Promise { + const result = await this.proxy.process(chunk, isFirst); + return result; + } +} diff --git a/packages/geoview-core/src/geo/layer/geometry/geometry.ts b/packages/geoview-core/src/geo/layer/geometry/geometry.ts index 17315c0f869..a99a8496b23 100644 --- a/packages/geoview-core/src/geo/layer/geometry/geometry.ts +++ b/packages/geoview-core/src/geo/layer/geometry/geometry.ts @@ -1,7 +1,7 @@ import VectorLayer from 'ol/layer/Vector'; import Feature from 'ol/Feature'; import VectorSource, { Options as VectorSourceOptions } from 'ol/source/Vector'; -import { Geometry as OLGeometry, Circle, LineString, MultiLineString, Point, Polygon, MultiPolygon } from 'ol/geom'; +import { Geometry as OLGeometry, Circle, LineString, MultiLineString, Point, Polygon, MultiPolygon, MultiPoint } from 'ol/geom'; import { Coordinate } from 'ol/coordinate'; import { Fill, Stroke, Style, Icon } from 'ol/style'; import { Options as VectorLayerOptions } from 'ol/layer/BaseVector'; @@ -665,8 +665,16 @@ export class GeometryApi { ): OLGeometry { switch (geometryType) { case 'Point': + // If it's actually a MultiPoint + if (GeometryApi.isArrayOfCoordinates(coordinates)) { + // Create a MultiLine geometry + return new MultiPoint(coordinates as Coordinate[]); + } // Create a Point geometry return new Point(coordinates as Coordinate); + case 'MultiPoint': + // Create a MultiPoint geometry + return new MultiPoint(coordinates as Coordinate[]); case 'LineString': // If it's actually a MultiLineString @@ -674,7 +682,6 @@ export class GeometryApi { // Create a MultiLine geometry return new MultiLineString(coordinates); } - // Create a Line geometry return new LineString(coordinates as Coordinate[]); @@ -688,7 +695,6 @@ export class GeometryApi { // Create a MultiPolygon geometry return new MultiPolygon(coordinates); } - // Create a Polygon geometry return new Polygon(coordinates as Coordinate[][]); diff --git a/packages/geoview-core/src/geo/layer/geoview-layers/abstract-geoview-layers.ts b/packages/geoview-core/src/geo/layer/geoview-layers/abstract-geoview-layers.ts index c41bdee8136..8eab405be73 100644 --- a/packages/geoview-core/src/geo/layer/geoview-layers/abstract-geoview-layers.ts +++ b/packages/geoview-core/src/geo/layer/geoview-layers/abstract-geoview-layers.ts @@ -1053,10 +1053,10 @@ export abstract class AbstractGeoViewLayer { * @returns {null | codedValueType | rangeDomainType} The domain of the field. */ // Added eslint-disable here, because we do want to override this method in children and keep 'this'. - // eslint-disable-next-line @typescript-eslint/class-methods-use-this + // eslint-disable-next-line @typescript-eslint/class-methods-use-this, @typescript-eslint/no-unused-vars protected getFieldDomain(fieldName: string, layerConfig: AbstractBaseLayerEntryConfig): null | codedValueType | rangeDomainType { - // Log - logger.logWarning(`getFieldDomain is not implemented for ${fieldName} - ${layerConfig}`); + // Log - REMOVED as it is trigger for every row of data table, just enable for debuggin purpose + // logger.logWarning(`getFieldDomain is not implemented for ${fieldName} - ${layerConfig}`); return null; } diff --git a/packages/geoview-core/src/geo/layer/geoview-layers/raster/wms.ts b/packages/geoview-core/src/geo/layer/geoview-layers/raster/wms.ts index 80e78be8460..d53fd53bba1 100644 --- a/packages/geoview-core/src/geo/layer/geoview-layers/raster/wms.ts +++ b/packages/geoview-core/src/geo/layer/geoview-layers/raster/wms.ts @@ -132,7 +132,7 @@ export class WMS extends AbstractGeoViewRaster { this.#processMetadataInheritance(); } catch (error) { // Log - logger.logError(`Unable to read service metadata for GeoView layer ${this.geoviewLayerId} of map ${this.mapId}.`); + logger.logError(`Unable to read service metadata for GeoView layer ${this.geoviewLayerId} of map ${this.mapId}.`, error); } } else { // Uses GetCapabilities to get the metadata. However, to allow geomet metadata to be retrieved using the non-standard @@ -174,6 +174,7 @@ export class WMS extends AbstractGeoViewRaster { } } this.#processMetadataInheritance(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { this.setAllLayerStatusTo('error', this.listOfLayerEntryConfig, 'Unable to read metadata'); } @@ -200,6 +201,7 @@ export class WMS extends AbstractGeoViewRaster { const parser = new WMSCapabilities(); const metadata: TypeJsonObject = parser.read(capabilitiesString); return metadata; + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { this.setAllLayerStatusTo('error', this.listOfLayerEntryConfig, 'Unable to read metadata'); return null; @@ -238,6 +240,7 @@ export class WMS extends AbstractGeoViewRaster { } else { this.setAllLayerStatusTo('error', this.listOfLayerEntryConfig, 'Unable to read metadata'); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { this.setAllLayerStatusTo('error', this.listOfLayerEntryConfig, 'Unable to read metadata'); } @@ -898,6 +901,8 @@ export class WMS extends AbstractGeoViewRaster { name: wmsStyle, legend: null, } as TypeWmsLegendStyle; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { return { name: wmsStyle, diff --git a/packages/geoview-core/src/geo/layer/gv-layers/abstract-gv-layer.ts b/packages/geoview-core/src/geo/layer/gv-layers/abstract-gv-layer.ts index c047d4405f4..d96770cd687 100644 --- a/packages/geoview-core/src/geo/layer/gv-layers/abstract-gv-layer.ts +++ b/packages/geoview-core/src/geo/layer/gv-layers/abstract-gv-layer.ts @@ -378,9 +378,10 @@ export abstract class AbstractGVLayer extends AbstractBaseLayer { * @param {string} fieldName - The field name for which we want to get the domain. * @returns {null | codedValueType | rangeDomainType} The domain of the field. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/class-methods-use-this protected getFieldDomain(fieldName: string): null | codedValueType | rangeDomainType { - // Log - logger.logWarning(`getFieldDomain is not implemented for ${fieldName} on layer path ${this.getLayerPath()}`); + // Log - REMOVED as it is trigger for every row of data table, just enable for debuggin purpose + // logger.logWarning(`getFieldDomain is not implemented for ${fieldName} on layer path ${this.getLayerPath()}`); return null; } diff --git a/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts b/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts index 447f57baaaf..4f9656a290e 100644 --- a/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts +++ b/packages/geoview-core/src/geo/layer/gv-layers/raster/gv-wms.ts @@ -384,6 +384,7 @@ export class GVWMS extends AbstractGVRaster { legend: null, } as TypeWmsLegendStyle; } catch (error) { + logger.logError('gv-wms.#getStyleLegend()\n', error); return { name: wmsStyle, legend: null, diff --git a/packages/geoview-core/src/geo/layer/gv-layers/utils.ts b/packages/geoview-core/src/geo/layer/gv-layers/utils.ts index 5694d172df8..73068545a94 100644 --- a/packages/geoview-core/src/geo/layer/gv-layers/utils.ts +++ b/packages/geoview-core/src/geo/layer/gv-layers/utils.ts @@ -136,6 +136,7 @@ export async function esriQueryRecordsByUrl(url: string, geometryType?: TypeStyl * @param {number[]} objectIds - The list of objectids to filter the query on * @param {string} fields - The list of field names to include in the output * @param {boolean} geometry - True to return the geometries in the output + * @param {number} outSR - The spatial reference of the output geometries from the query * @returns {TypeFeatureInfoEntryPartial[] | null} An array of relared records of type TypeFeatureInfoEntryPartial, or an empty array. */ export function esriQueryRecordsByUrlObjectIds( @@ -143,11 +144,12 @@ export function esriQueryRecordsByUrlObjectIds( geometryType: TypeStyleGeometry, objectIds: number[], fields: string, - geometry: boolean + geometry: boolean, + outSR?: number ): Promise { // Query const oids = objectIds.join(','); - const url = `${layerUrl}/query?where=&objectIds=${oids}&outFields=${fields}&returnGeometry=${geometry}&f=json`; + const url = `${layerUrl}/query?where=&objectIds=${oids}&outFields=${fields}&returnGeometry=${geometry}&outSR=${outSR}&f=json`; // Redirect return esriQueryRecordsByUrl(url, geometryType); @@ -189,15 +191,17 @@ export async function esriQueryRelatedRecordsByUrl(url: string, recordGroupIndex export function esriConvertEsriGeometryTypeToOLGeometryType(esriGeometryType: string): TypeStyleGeometry { switch (esriGeometryType) { case 'esriGeometryPoint': - case 'esriGeometryMultipoint': return 'Point'; - + case 'esriGeometryMultipoint': + return 'MultiPoint'; case 'esriGeometryPolyline': return 'LineString'; - + case 'esriGeometryMultiPolyline': + return 'MultiLineString'; case 'esriGeometryPolygon': - case 'esriGeometryMultiPolygon': return 'Polygon'; + case 'esriGeometryMultiPolygon': + return 'MultiPolygon'; default: throw new Error(`Unsupported geometry type: ${esriGeometryType}`); diff --git a/packages/geoview-core/src/geo/layer/layer.ts b/packages/geoview-core/src/geo/layer/layer.ts index e690bf452c3..e3e5f0d7236 100644 --- a/packages/geoview-core/src/geo/layer/layer.ts +++ b/packages/geoview-core/src/geo/layer/layer.ts @@ -1049,6 +1049,7 @@ export class LayerApi { // Check and add time slider layer when needed TimeSliderEventProcessor.checkInitTimeSliderLayerAndApplyFilters(this.getMapId(), layerConfig); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { // Layer failed to load, abandon it for the TimeSlider registration, too bad. // The error itself, regarding the loaded failure, is already being taken care of elsewhere. diff --git a/packages/geoview-core/src/geo/map/map-schema-types.ts b/packages/geoview-core/src/geo/map/map-schema-types.ts index 9d0235c3e46..af9f8159d15 100644 --- a/packages/geoview-core/src/geo/map/map-schema-types.ts +++ b/packages/geoview-core/src/geo/map/map-schema-types.ts @@ -828,7 +828,7 @@ export type TypeStyleSettings = TypeBaseStyleConfig | TypeSimpleStyleConfig | Ty * Valid keys for the TypeStyleConfig object. */ // TODO: Refactor - Layers/Config refactoring. The values here have been renamed to lower case, make sure to lower here and adjust everywhere as part of config migration. -export type TypeStyleGeometry = 'Point' | 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon'; +export type TypeStyleGeometry = 'Point' | 'MultiPoint' | 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon'; /** ****************************************************************************************************************************** * Type of Style to apply to the GeoView vector layer based on geometry types. diff --git a/packages/geoview-core/src/geo/map/map-viewer.ts b/packages/geoview-core/src/geo/map/map-viewer.ts index 389eab8841b..c1293e1846d 100644 --- a/packages/geoview-core/src/geo/map/map-viewer.ts +++ b/packages/geoview-core/src/geo/map/map-viewer.ts @@ -1196,6 +1196,7 @@ export class MapViewer { this.layer.removeAllGeoviewLayers(); } catch (err) { // Failed to remove layers, eat the exception and continue to remove the map + logger.logError('Failed to remove layers', err); } // Delete store and event processor @@ -1454,6 +1455,7 @@ export class MapViewer { // return angle (180 is pointing north) return ((bearing + 360) % 360).toFixed(1); + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { return '180.0'; } diff --git a/packages/geoview-core/src/geo/utils/renderer/geoview-renderer.ts b/packages/geoview-core/src/geo/utils/renderer/geoview-renderer.ts index c3fcd8e2c2a..107521cef87 100644 --- a/packages/geoview-core/src/geo/utils/renderer/geoview-renderer.ts +++ b/packages/geoview-core/src/geo/utils/renderer/geoview-renderer.ts @@ -1221,6 +1221,7 @@ export async function getLegendStyles(styleConfig: TypeStyleConfig | undefined): } return legendStyles; } catch (error) { + logger.logError('Error getLegendStyles', error); return {}; } } @@ -1506,6 +1507,7 @@ function processClassBreaksPolygon( const processStyle: Record> = { simple: { Point: processSimplePoint, + MultiPoint: processSimplePoint, LineString: processSimpleLineString, MultiLineString: processSimpleLineString, Polygon: processSimplePolygon, @@ -1513,6 +1515,7 @@ const processStyle: Record { // Animation completed, run additional logic here - console.log('Animation completed!'); - if (panelContainerRef.current && open && mapInfo) { const mapInfoHeight = mapInfo.getBoundingClientRect().height; panelContainerRef.current.style.height = `calc(100% - ${mapInfoHeight}px)`; diff --git a/packages/geoview-core/tsconfig.json b/packages/geoview-core/tsconfig.json index 420151bab06..b9eea14846b 100644 --- a/packages/geoview-core/tsconfig.json +++ b/packages/geoview-core/tsconfig.json @@ -20,7 +20,7 @@ "noImplicitThis": true, "isolatedModules": true, "esModuleInterop": true, - "lib": ["esnext", "dom", "dom.iterable", "scripthost"], + "lib": ["esnext", "dom", "dom.iterable", "scripthost", "webworker"], "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/packages/geoview-core/webpack.common.js b/packages/geoview-core/webpack.common.js index bf1142dbe4d..5fa51e37a2f 100644 --- a/packages/geoview-core/webpack.common.js +++ b/packages/geoview-core/webpack.common.js @@ -8,11 +8,11 @@ const CopyWebpackPlugin = require('copy-webpack-plugin'); const LodashWebpackPlugin = require('lodash-webpack-plugin'); const glob = require('glob'); const childProcess = require('child_process'); -const package = require('./package.json'); +const packageJSON = require('./package.json'); // get date, version numbers and the hash of the current commit const date = new Date().toISOString(); -const [major, minor, patch] = package.version.split('.'); +const [major, minor, patch] = packageJSON.version.split('.'); const hash = JSON.stringify(childProcess.execSync('git rev-parse HEAD').toString().trim()); // eslint-disable-next-line no-console