diff --git a/package-lock.json b/package-lock.json index e549411b3..c68ef8082 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,11 @@ "@ngx-translate/core": "^16.0.4", "@ngx-translate/http-loader": "^16.0.1", "@openid/appauth": "^1.3.1", + "@placemarkio/check-geojson": "^0.1.14", "@sentry/browser": "^7.37.2", + "@tmcw/togeojson": "^7.1.2", + "@turf/clean-coords": "^7.2.0", + "@turf/truncate": "^7.2.0", "@turf/turf": "^7.1.0", "@types/leaflet-draw": "^1.0.6", "angular-svg-icon": "^16.1.0", @@ -77,6 +81,7 @@ "ngx-file-drop": "^16.0.0", "ngx-logger": "^4.2.1", "ngx-markdown": "^20.0.0", + "nve-designsystem": "^2.16.0", "observable-webworker": "^3.4.0", "pouchdb-adapter-idb": "^7.2.2", "rxjs": "^7.8.1", @@ -4526,6 +4531,15 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "dev": true, @@ -5090,6 +5104,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@geoman-io/leaflet-geoman-free": { "version": "2.14.2", "license": "MIT", @@ -6196,6 +6235,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", + "integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "license": "BSD-3-Clause", + "peerDependencies": { + "@types/react": "17 || 18 || 19" + } + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz", + "integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0" + } + }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.4.2.tgz", @@ -7599,6 +7662,15 @@ "node": ">=14" } }, + "node_modules/@placemarkio/check-geojson": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@placemarkio/check-geojson/-/check-geojson-0.1.14.tgz", + "integrity": "sha512-PZvNKzt6STytUw21TUkqU+TG6dbwTWb1ACosvInBYTBm37zsr8C74J6crBTQ3BWkyd6YeitYd4HibJzBEyk6Aw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.52.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", @@ -8430,6 +8502,45 @@ "node": ">=10" } }, + "node_modules/@shoelace-style/animations": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz", + "integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, + "node_modules/@shoelace-style/localize": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz", + "integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==", + "license": "MIT" + }, + "node_modules/@shoelace-style/shoelace": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.20.1.tgz", + "integrity": "sha512-FSghU95jZPGbwr/mybVvk66qRZYpx5FkXL+vLNpy1Vp8UsdwSxXjIHE3fsvMbKWTKi9UFfewHTkc5e7jAqRYoQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.1.0", + "@floating-ui/dom": "^1.6.12", + "@lit/react": "^1.0.6", + "@shoelace-style/animations": "^1.2.0", + "@shoelace-style/localize": "^3.2.1", + "composed-offset-position": "^0.0.6", + "lit": "^3.2.1", + "qr-creator": "^1.0.0" + }, + "engines": { + "node": ">=14.17.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/claviska" + } + }, "node_modules/@sigstore/bundle": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", @@ -8664,6 +8775,15 @@ "node": ">=10" } }, + "node_modules/@tmcw/togeojson": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@tmcw/togeojson/-/togeojson-7.1.2.tgz", + "integrity": "sha512-QKnFs9DAuqqBVj4d6c69tV1Dj2TspSBTqffivoN0YoBCVdP/JY1+WaYCJbzU49RkoU5NOSOJ3jtFHCdEUVh21A==", + "license": "BSD-2-Clause", + "engines": { + "node": "*" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -10058,36 +10178,42 @@ } }, "node_modules/@turf/clean-coords": { - "version": "7.1.0", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/clean-coords/-/clean-coords-7.2.0.tgz", + "integrity": "sha512-+5+J1+D7wW7O/RDXn46IfCHuX1gIV1pIAQNSA7lcDbr3HQITZj334C4mOGZLEcGbsiXtlHWZiBtm785Vg8i+QQ==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.1.0", - "@turf/invariant": "^7.1.0", + "@turf/helpers": "^7.2.0", + "@turf/invariant": "^7.2.0", "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/clean-coords/node_modules/@turf/helpers": { - "version": "7.1.0", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", + "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", "license": "MIT", "dependencies": { "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/clean-coords/node_modules/@turf/invariant": { - "version": "7.1.0", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.2.0.tgz", + "integrity": "sha512-kV4u8e7Gkpq+kPbAKNC21CmyrXzlbBgFjO1PhrHPgEdNqXqDawoZ3i6ivE3ULJj2rSesCjduUaC/wyvH/sNr2Q==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.1.0", + "@turf/helpers": "^7.2.0", "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" @@ -11838,6 +11964,19 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@turf/line-split/node_modules/@turf/truncate": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz", + "integrity": "sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@turf/line-to-polygon": { "version": "7.1.0", "license": "MIT", @@ -13690,11 +13829,41 @@ } }, "node_modules/@turf/truncate": { - "version": "6.5.0", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-7.2.0.tgz", + "integrity": "sha512-jyFzxYbPugK4XjV5V/k6Xr3taBjjvo210IbPHJXw0Zh7Y6sF+hGxeRVtSuZ9VP/6oRyqAOHKUrze+OOkPqBgUg==", "license": "MIT", "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/meta": "^6.5.0" + "@turf/helpers": "^7.2.0", + "@turf/meta": "^7.2.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/truncate/node_modules/@turf/helpers": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", + "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/truncate/node_modules/@turf/meta": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.2.0.tgz", + "integrity": "sha512-igzTdHsQc8TV1RhPuOLVo74Px/hyPrVgVOTgjWQZzt3J9BVseCdpfY/0cJBdlSRI4S/yTmmHl7gAqjhpYH5Yaw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^7.2.0", + "@types/geojson": "^7946.0.10" }, "funding": { "url": "https://opencollective.com/turf" @@ -14042,19 +14211,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/turf/node_modules/@turf/truncate": { - "version": "7.1.0", - "license": "MIT", - "dependencies": { - "@turf/helpers": "^7.1.0", - "@turf/meta": "^7.1.0", - "@types/geojson": "^7946.0.10", - "tslib": "^2.6.2" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, "node_modules/@turf/union": { "version": "7.1.0", "license": "MIT", @@ -14785,6 +14941,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, "node_modules/@types/responselike": { "version": "1.0.0", "dev": true, @@ -14871,8 +15037,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/websql": { "version": "0.0.27", @@ -16572,6 +16737,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/composed-offset-position": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", + "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/utils": "^0.2.5" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -17162,6 +17336,13 @@ "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT", + "peer": true + }, "node_modules/custom-event": { "version": "1.0.1", "dev": true, @@ -19794,6 +19975,12 @@ } } }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -22217,6 +22404,37 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/lit": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", + "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz", + "integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.4.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz", + "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/lmdb": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.2.tgz", @@ -24269,6 +24487,20 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nve-designsystem": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/nve-designsystem/-/nve-designsystem-2.16.0.tgz", + "integrity": "sha512-5GYYHtfBBQOLHZjN+r6jX9Fj76IKysuN312THUPc7U5nkzRi+Bl99DZIMejALUjj3xNCmOZBUn7erowxummfVQ==", + "license": "MIT", + "dependencies": { + "@shoelace-style/shoelace": "^2.20.1", + "fontfaceobserver": "^2.3.0", + "lit": "^3.3.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.44.2" + } + }, "node_modules/object-assign": { "version": "4.1.1", "dev": true, @@ -25605,6 +25837,12 @@ "node": ">=0.9" } }, + "node_modules/qr-creator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz", + "integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.0", "dev": true, diff --git a/package.json b/package.json index c72d3d8d1..5ac1b8496 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,11 @@ "@ngx-translate/core": "^16.0.4", "@ngx-translate/http-loader": "^16.0.1", "@openid/appauth": "^1.3.1", + "@placemarkio/check-geojson": "^0.1.14", "@sentry/browser": "^7.37.2", + "@tmcw/togeojson": "^7.1.2", + "@turf/clean-coords": "^7.2.0", + "@turf/truncate": "^7.2.0", "@turf/turf": "^7.1.0", "@types/leaflet-draw": "^1.0.6", "angular-svg-icon": "^16.1.0", @@ -106,6 +110,7 @@ "ngx-file-drop": "^16.0.0", "ngx-logger": "^4.2.1", "ngx-markdown": "^20.0.0", + "nve-designsystem": "^2.16.0", "observable-webworker": "^3.4.0", "pouchdb-adapter-idb": "^7.2.2", "rxjs": "^7.8.1", diff --git a/src/app/core/services/database/database.service.ts b/src/app/core/services/database/database.service.ts index 2b53a0df0..b6f10ca3f 100644 --- a/src/app/core/services/database/database.service.ts +++ b/src/app/core/services/database/database.service.ts @@ -55,6 +55,7 @@ export class DatabaseService { * @returns Returns a promise with the value of the given key */ async get(key: string): Promise { + // TODO: Legg til null på type await firstValueFrom(this.ready$); return this.database.get(key); } diff --git a/src/app/core/services/geojson/geojson-item.model.ts b/src/app/core/services/geojson/geojson-item.model.ts new file mode 100644 index 000000000..bf2959257 --- /dev/null +++ b/src/app/core/services/geojson/geojson-item.model.ts @@ -0,0 +1,8 @@ +export interface GeoJSONItem { + id: string; + name: string; + date: number; + visibleOnMap?: boolean; + comment?: string; + lengthKm?: number; +} diff --git a/src/app/core/services/geojson/geojson.service.spec.ts b/src/app/core/services/geojson/geojson.service.spec.ts new file mode 100644 index 000000000..61ab7be84 --- /dev/null +++ b/src/app/core/services/geojson/geojson.service.spec.ts @@ -0,0 +1,199 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { GeoJSONService } from './geojson.service'; +import { DatabaseService } from '../database/database.service'; +import { LoggingService } from 'src/app/modules/shared/services/logging/logging.service'; +import { FeatureCollection } from 'geojson'; +import { GeoJSONItem } from './geojson-item.model'; + +describe('GeoJSONService', () => { + let service: GeoJSONService; + let databaseService: jasmine.SpyObj; + let loggingService: jasmine.SpyObj; + + const mockGeoJSON: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [10.0, 60.0], + }, + properties: { + name: 'Test Point', + }, + }, + ], + }; + + const mockMetadataItem: GeoJSONItem = { + id: 'test-id-1', + name: 'Test GeoJSON', + date: new Date().getTime(), + }; + + beforeEach(() => { + const databaseServiceSpy = jasmine.createSpyObj('DatabaseService', ['get', 'set', 'remove']); + const loggingServiceSpy = jasmine.createSpyObj('LoggingService', ['debug', 'error']); + + TestBed.configureTestingModule({ + providers: [ + GeoJSONService, + { provide: DatabaseService, useValue: databaseServiceSpy }, + { provide: LoggingService, useValue: loggingServiceSpy }, + ], + }); + + databaseService = TestBed.inject(DatabaseService) as jasmine.SpyObj; + loggingService = TestBed.inject(LoggingService) as jasmine.SpyObj; + + // Default spy returns + databaseService.get.and.returnValue(Promise.resolve([])); + databaseService.set.and.returnValue(Promise.resolve()); + databaseService.remove.and.returnValue(Promise.resolve()); + }); + + it('should be created', () => { + service = TestBed.inject(GeoJSONService); + expect(service).toBeTruthy(); + }); + + describe('init', () => { + it('should load metadata from database on initialization', fakeAsync(() => { + const existingMetadata: GeoJSONItem[] = [mockMetadataItem]; + databaseService.get.and.returnValue(Promise.resolve(existingMetadata)); + + service = TestBed.inject(GeoJSONService); + tick(); + + expect(databaseService.get).toHaveBeenCalledWith('geojson-metadata'); + expect(service.metadata()).toEqual(existingMetadata); + })); + + it('should initialize with empty metadata if none exists', fakeAsync(() => { + databaseService.get.and.returnValue(Promise.resolve(null)); + + service = TestBed.inject(GeoJSONService); + tick(); + + expect(service.metadata()).toEqual([]); + })); + }); + + describe('save', () => { + beforeEach(fakeAsync(() => { + service = TestBed.inject(GeoJSONService); + tick(); // Complete initialization + tick(); // Allow effect to run + databaseService.set.calls.reset(); + })); + + it('should save geojson and update metadata', fakeAsync(() => { + service.save(mockMetadataItem, mockGeoJSON); + tick(); + + expect(databaseService.set).toHaveBeenCalledWith(`geojson:${mockMetadataItem.id}`, mockGeoJSON); + expect(service.metadata()).toContain(mockMetadataItem); + })); + + it('should save metadata after adding item', fakeAsync(() => { + service.save(mockMetadataItem, mockGeoJSON); + tick(); + tick(); // Allow effect to trigger + + expect(databaseService.set).toHaveBeenCalledWith('geojson-metadata', [mockMetadataItem]); + })); + + it('should emit changed metadata item', fakeAsync(() => { + let emittedMetadata: GeoJSONItem | undefined; + service.changedMetadataItem$.subscribe((metadata) => (emittedMetadata = metadata)); + + service.save(mockMetadataItem, mockGeoJSON); + tick(); + + expect(emittedMetadata).toEqual(mockMetadataItem); + })); + + it('should throw error if save fails', fakeAsync(() => { + const error = new Error('Save failed'); + databaseService.set.and.returnValue(Promise.reject(error)); + + expectAsync(service.save(mockMetadataItem, mockGeoJSON)).toBeRejectedWith(error); + tick(); + + expect(loggingService.error).toHaveBeenCalled(); + })); + }); + + describe('get', () => { + beforeEach(fakeAsync(() => { + service = TestBed.inject(GeoJSONService); + tick(); + })); + + it('should retrieve geojson by id', fakeAsync(() => { + databaseService.get.and.returnValue(Promise.resolve(mockGeoJSON)); + + service.get(mockMetadataItem.id).then((result) => { + expect(result).toEqual(mockGeoJSON); + }); + tick(); + + expect(databaseService.get).toHaveBeenCalledWith(`geojson:${mockMetadataItem.id}`); + })); + }); + + describe('remove', () => { + beforeEach(fakeAsync(() => { + service = TestBed.inject(GeoJSONService); + tick(); + // Add an item first + service.save(mockMetadataItem, mockGeoJSON); + tick(); + databaseService.set.calls.reset(); + })); + + it('should remove geojson and update metadata', fakeAsync(() => { + service.remove(mockMetadataItem.id); + tick(); + + expect(databaseService.remove).toHaveBeenCalledWith(`geojson:${mockMetadataItem.id}`); + expect(service.metadata()).not.toContain(mockMetadataItem); + })); + + it('should emit removed id', fakeAsync(() => { + let emittedId: string | undefined; + service.removedMetadataItemId$.subscribe((id) => (emittedId = id)); + + service.remove(mockMetadataItem.id); + tick(); + + expect(emittedId).toBe(mockMetadataItem.id); + })); + + it('should save updated metadata after removal', fakeAsync(() => { + service.remove(mockMetadataItem.id); + tick(); + tick(); // Allow effect to trigger + + expect(databaseService.set).toHaveBeenCalledWith('geojson-metadata', []); + })); + }); + + describe('changedMetadataItem$', () => { + beforeEach(fakeAsync(() => { + service = TestBed.inject(GeoJSONService); + tick(); + })); + + it('should emit metadata changes', fakeAsync(() => { + const emittedValues: GeoJSONItem[] = []; + service.changedMetadataItem$.subscribe((metadata) => emittedValues.push(metadata)); + + service.updateMetadata(mockMetadataItem); + tick(); + + expect(emittedValues[emittedValues.length - 1]).toEqual(mockMetadataItem); + })); + }); +}); diff --git a/src/app/core/services/geojson/geojson.service.ts b/src/app/core/services/geojson/geojson.service.ts new file mode 100644 index 000000000..aa0090e4a --- /dev/null +++ b/src/app/core/services/geojson/geojson.service.ts @@ -0,0 +1,136 @@ +import { Injectable, effect, inject, signal } from '@angular/core'; +import { DatabaseService } from '../database/database.service'; +import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson'; +import { GeoJSONItem } from './geojson-item.model'; +import { LoggingService } from 'src/app/modules/shared/services/logging/logging.service'; +import { cleanFeatureCollection } from 'src/app/pages/plans/geojson'; +import { length } from '@turf/turf'; +import { Subject } from 'rxjs'; +import { toObservable } from '@angular/core/rxjs-interop'; + +const DEBUG_TAG = 'GeoJSON'; + +@Injectable({ + providedIn: 'root', +}) +export class GeoJSONService { + private db = inject(DatabaseService); + private logger = inject(LoggingService); + + // tur som skal vises på kartet når bruker klikker på vis i kart knappen i turlisten. + geojsonItemToShowOnMap = signal(null); + readonly geojsonItemToShowOnMap$ = toObservable(this.geojsonItemToShowOnMap); + private initialized = false; + private metadata_ = signal([]); + private changedMetadataItem = new Subject(); + private removedMetadataItemId = new Subject(); + + /** Metadata for alle lagrede spor */ + metadata = this.metadata_.asReadonly(); + + /** Lytt på denne for å få beskjed om nye eller endrede spor */ + changedMetadataItem$ = this.changedMetadataItem.asObservable(); + + /** Lytt på denne for å få beskjed om slettede spor */ + removedMetadataItemId$ = this.removedMetadataItemId.asObservable(); + + constructor() { + this.init(); + + effect(() => { + const metadata = this.metadata(); + if (!this.initialized) return; + this.saveMetadata(metadata); + }); + } + + private async init() { + this.logger.debug('Init', DEBUG_TAG); + const items = await this.getMetadata(); + if (items && items.length > 0) { + this.metadata_.set(items); + } + setTimeout(() => (this.initialized = true)); // For å unngå en første unødvendig lagring i effecten + } + + private async saveMetadata(items: GeoJSONItem[]) { + this.logger.debug('Saving metadata', DEBUG_TAG, { n: items.length }); + await this.db.set('geojson-metadata', items); + } + + private getMetadata() { + this.logger.debug('Reading metadata', DEBUG_TAG); + return this.db.get('geojson-metadata'); + } + + /** + * Kalkulerer lengden av LineString features i et GeoJSON objekt + * @param geojson + * @returns lengde i kilometer + */ + private calculateLengthKm(geojson: Feature[]): number { + return geojson.reduce((sum, feature) => sum + length(feature), 0); + } + + /** + * Updates metadata for a given item + * @param item the geojson item to update + */ + updateMetadata(item: GeoJSONItem) { + this.metadata_.update((items) => { + const other = items.filter((x) => x.id !== item.id); + return [...other, item]; + }); + this.changedMetadataItem.next(item); + } + + /** + * Save a geojson object with a given id + * @param metadata metadata for the geojson + * @param geojson geojson object + */ + async save(metadata: GeoJSONItem, geojson: FeatureCollection): Promise { + this.logger.debug('Save', DEBUG_TAG, { metadata }); + try { + cleanFeatureCollection(geojson); + } catch (error) { + this.logger.error(error, DEBUG_TAG, 'Error in cleaning process, but object may be mutated - half cleaned'); + } + + try { + const lineFeatures = geojson.features.filter((f) => f.geometry.type === 'LineString'); + + if (lineFeatures.length) { + const lengthKm = this.calculateLengthKm(lineFeatures); + metadata.lengthKm = lengthKm; + } + + await this.db.set(`geojson:${metadata.id}`, geojson); + this.metadata_.update((items) => [...items, metadata]); + this.changedMetadataItem.next(metadata); + } catch (error) { + this.logger.error(error, DEBUG_TAG, 'Could not save', { metadata, geojson }); + throw error; + } + } + + /** + * Get a geojson object by id + * @param id unique id for the geojson + */ + async get(id: GeoJSONItem['id']): Promise { + this.logger.debug('Get', DEBUG_TAG, { id }); + return this.db.get(`geojson:${id}`); + } + + /** + * Remove a geojson object by id + * @param id unique id for the geojson + */ + async remove(id: GeoJSONItem['id']): Promise { + this.logger.debug('Remove', DEBUG_TAG, { id }); + await this.db.remove(`geojson:${id}`); + this.metadata_.update((items) => items.filter((item) => item.id !== id)); + this.removedMetadataItemId.next(id); + } +} diff --git a/src/app/modules/map/components/map/map.component.html b/src/app/modules/map/components/map/map.component.html index 6c76c5152..7363737d0 100644 --- a/src/app/modules/map/components/map/map.component.html +++ b/src/app/modules/map/components/map/map.component.html @@ -9,18 +9,14 @@ > } -@if (showObserverTrips()) { -