@smarterplan/ngx-smarterplan-core 1.4.14 → 1.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +24 -24
  2. package/esm2022/lib/components/csv-export/csv-export.component.mjs +3 -3
  3. package/esm2022/lib/components/loader/loader.component.mjs +3 -3
  4. package/esm2022/lib/components/menu-bar/avatar/avatar.component.mjs +1 -1
  5. package/esm2022/lib/components/menu-bar/menu-bar.component.mjs +3 -3
  6. package/esm2022/lib/components/menu-bar/navigation-bar/navigation-bar.component.mjs +3 -3
  7. package/esm2022/lib/components/modal-switch-visit/modal-switch-visit.component.mjs +3 -3
  8. package/esm2022/lib/components/search-bar/search-bar.component.mjs +3 -3
  9. package/esm2022/lib/components/support-modal/support-modal.component.mjs +3 -3
  10. package/esm2022/lib/config.mjs +1 -1
  11. package/esm2022/lib/helpers.service.mjs +46 -1
  12. package/esm2022/lib/matterport-extensions/nest-thermostat/CanvasImage.mjs +1 -1
  13. package/esm2022/lib/matterport-extensions/nest-thermostat/CanvasRenderer.mjs +1 -1
  14. package/esm2022/lib/matterport-extensions/nest-thermostat/NestThermostat.mjs +1 -1
  15. package/esm2022/lib/matterport-extensions/nest-thermostat/PlaneRenderer.mjs +1 -1
  16. package/esm2022/lib/matterport-extensions/scene-component/SceneComponent.mjs +1 -1
  17. package/esm2022/lib/matterport-extensions/security-camera/SecurityCamera.mjs +22 -22
  18. package/esm2022/lib/matterport-extensions/tv-player/TvPlayer.mjs +1 -1
  19. package/esm2022/lib/matterport-extensions/view-frustum-mesh/ViewFrustumMesh.mjs +22 -22
  20. package/esm2022/lib/mattertagData.mjs +1 -1
  21. package/esm2022/lib/ngx-smarterplan-core.module.mjs +1 -1
  22. package/esm2022/lib/ngx-smarterplan-core.service.mjs +1 -1
  23. package/esm2022/lib/pipes/duration-to-string.pipe.mjs +1 -1
  24. package/esm2022/lib/pipes/format-date-number-to-digits.pipe.mjs +1 -1
  25. package/esm2022/lib/pipes/safe-url.pipe.mjs +1 -1
  26. package/esm2022/lib/pipes/time-date-to-local-string.pipe.mjs +1 -1
  27. package/esm2022/lib/pipes/username-from-id.pipe.mjs +1 -1
  28. package/esm2022/lib/services/amplify-cache.service.mjs +1 -1
  29. package/esm2022/lib/services/base-tab.service.mjs +1 -1
  30. package/esm2022/lib/services/baseVisibility.service.mjs +1 -1
  31. package/esm2022/lib/services/content.service.mjs +1 -1
  32. package/esm2022/lib/services/filter.service.mjs +1 -1
  33. package/esm2022/lib/services/intervention.service.mjs +1 -1
  34. package/esm2022/lib/services/locale.service.mjs +1 -1
  35. package/esm2022/lib/services/matterport-import.service.mjs +1 -1
  36. package/esm2022/lib/services/matterport-measurement.service.mjs +1 -1
  37. package/esm2022/lib/services/matterport-navigation.service.mjs +1 -1
  38. package/esm2022/lib/services/matterport-object3d.service.mjs +1 -1
  39. package/esm2022/lib/services/matterport-pointer.service.mjs +6 -6
  40. package/esm2022/lib/services/matterport-tag-copy.service.mjs +448 -62
  41. package/esm2022/lib/services/matterport-tag.service.mjs +1 -1
  42. package/esm2022/lib/services/matterport.service.mjs +17 -17
  43. package/esm2022/lib/services/models/affectation.service.mjs +1 -1
  44. package/esm2022/lib/services/models/base-object.service.mjs +1 -1
  45. package/esm2022/lib/services/models/capture.service.mjs +1 -1
  46. package/esm2022/lib/services/models/comment.service.mjs +1 -1
  47. package/esm2022/lib/services/models/domain.service.mjs +20 -1
  48. package/esm2022/lib/services/models/equipment.service.mjs +4 -2
  49. package/esm2022/lib/services/models/event.service.mjs +1 -1
  50. package/esm2022/lib/services/models/feature.service.mjs +1 -7
  51. package/esm2022/lib/services/models/hashtag.service.mjs +1 -1
  52. package/esm2022/lib/services/models/layer.service.mjs +1 -1
  53. package/esm2022/lib/services/models/measurement.service.mjs +1 -1
  54. package/esm2022/lib/services/models/mission.service.mjs +17 -1
  55. package/esm2022/lib/services/models/navigation.service.mjs +1 -1
  56. package/esm2022/lib/services/models/node.service.mjs +1 -1
  57. package/esm2022/lib/services/models/object3D.service.mjs +1 -1
  58. package/esm2022/lib/services/models/operation.service.mjs +1 -1
  59. package/esm2022/lib/services/models/organisation.service.mjs +1 -1
  60. package/esm2022/lib/services/models/plan.service.mjs +1 -1
  61. package/esm2022/lib/services/models/poi.service.mjs +27 -27
  62. package/esm2022/lib/services/models/profile.service.mjs +1 -1
  63. package/esm2022/lib/services/models/property.service.mjs +1 -1
  64. package/esm2022/lib/services/models/space.service.mjs +25 -6
  65. package/esm2022/lib/services/models/template.service.mjs +1 -1
  66. package/esm2022/lib/services/models/ticket.service.mjs +1 -1
  67. package/esm2022/lib/services/models/visit.service.mjs +17 -5
  68. package/esm2022/lib/services/models/zone.service.mjs +1 -1
  69. package/esm2022/lib/services/navigator.service.mjs +1 -1
  70. package/esm2022/lib/services/s3.service.mjs +18 -1
  71. package/esm2022/lib/services/search.service.mjs +1 -1
  72. package/esm2022/lib/services/support.service.mjs +1 -1
  73. package/esm2022/lib/services/tag.service.mjs +16 -16
  74. package/esm2022/lib/services/threejs-import.service.mjs +151 -0
  75. package/esm2022/lib/services/threejs.service.mjs +1306 -0
  76. package/esm2022/lib/services/user.service.mjs +1 -1
  77. package/esm2022/lib/services/utils/matterport-transform.util.mjs +1 -1
  78. package/esm2022/lib/services/validators.service.mjs +1 -1
  79. package/esm2022/lib/services/viewer.service.mjs +263 -5
  80. package/esm2022/lib/services/zone-drawer.service.mjs +1 -1
  81. package/esm2022/lib/services/zoneChange.service.mjs +1 -1
  82. package/esm2022/lib/tokens/matterport-sdk-key.token.mjs +1 -1
  83. package/esm2022/lib/types.service.mjs +2 -1
  84. package/esm2022/lib/validators/email.directive.mjs +1 -1
  85. package/esm2022/lib/validators/no-empty.directive.mjs +1 -1
  86. package/esm2022/lib/validators/number.directive.mjs +1 -1
  87. package/esm2022/lib/validators/text.directive.mjs +1 -1
  88. package/esm2022/public-api.mjs +3 -1
  89. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +2759 -551
  90. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
  91. package/lib/helpers.service.d.ts +43 -0
  92. package/lib/services/matterport-tag-copy.service.d.ts +137 -17
  93. package/lib/services/models/domain.service.d.ts +8 -0
  94. package/lib/services/models/feature.service.d.ts +0 -6
  95. package/lib/services/models/mission.service.d.ts +5 -0
  96. package/lib/services/models/space.service.d.ts +15 -1
  97. package/lib/services/models/visit.service.d.ts +9 -1
  98. package/lib/services/s3.service.d.ts +8 -0
  99. package/lib/services/threejs-import.service.d.ts +31 -0
  100. package/lib/services/threejs.service.d.ts +423 -0
  101. package/lib/services/viewer.service.d.ts +114 -1
  102. package/lib/types.service.d.ts +7 -1
  103. package/package.json +1 -1
  104. package/public-api.d.ts +2 -0
  105. package/src/assets/vs-app.json +67 -67
@@ -7,12 +7,19 @@ import * as i1$2 from '@ng-bootstrap/ng-bootstrap';
7
7
  import { NgbDate, NgbModule } from '@ng-bootstrap/ng-bootstrap';
8
8
  import * as i1$1 from '@angular/router';
9
9
  import { RouterModule } from '@angular/router';
10
+ import * as THREE from 'three';
10
11
  import { Matrix4, Vector3, Color, Object3D, Euler, PerspectiveCamera, CylinderBufferGeometry, BoxGeometry, MeshBasicMaterial, NormalBlending, BackSide, Mesh, EdgesGeometry, AdditiveBlending, ShaderMaterial, AnimationMixer, VectorKeyframeTrack, InterpolateSmooth, AnimationClip, LoopOnce, Group, PlaneBufferGeometry, CanvasTexture, Texture, LinearFilter } from 'three';
11
12
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
12
- import { Auth, API, graphqlOperation, Storage, Cache } from 'aws-amplify';
13
+ import { Storage, Auth, API, graphqlOperation, Cache } from 'aws-amplify';
13
14
  import { DateTime } from 'luxon';
14
15
  import * as i11 from '@angular/common';
15
16
  import { CommonModule } from '@angular/common';
17
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
18
+ import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
19
+ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
20
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
21
+ import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader';
22
+ import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
16
23
  import * as FileSaver from 'file-saver';
17
24
  import levenshtein from 'js-levenshtein';
18
25
  import InformationIcon from '@iconify/icons-mdi/information-outline';
@@ -29,6 +36,7 @@ import * as CryptoJS from 'crypto-js';
29
36
  import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist';
30
37
  import panzoom from 'panzoom';
31
38
  import videojs from 'video.js';
39
+ import { v4 } from 'uuid';
32
40
 
33
41
  class NgxSmarterplanCoreService {
34
42
  constructor() { }
@@ -79,11 +87,11 @@ class CsvExportComponent {
79
87
  link.remove();
80
88
  }
81
89
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CsvExportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
82
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: CsvExportComponent, selector: "lib-csv-export", inputs: { headers: "headers", rowsInput: "rowsInput", getRows: "getRows", title: "title", buttonLabel: "buttonLabel" }, ngImport: i0, template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\r\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\r\n {{ buttonLabel | translate}}\r\n</span>\r\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
90
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: CsvExportComponent, selector: "lib-csv-export", inputs: { headers: "headers", rowsInput: "rowsInput", getRows: "getRows", title: "title", buttonLabel: "buttonLabel" }, ngImport: i0, template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\n {{ buttonLabel | translate}}\n</span>\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
83
91
  }
84
92
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CsvExportComponent, decorators: [{
85
93
  type: Component,
86
- args: [{ selector: 'lib-csv-export', template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\r\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\r\n {{ buttonLabel | translate}}\r\n</span>\r\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"] }]
94
+ args: [{ selector: 'lib-csv-export', template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\n {{ buttonLabel | translate}}\n</span>\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"] }]
87
95
  }], propDecorators: { headers: [{
88
96
  type: Input
89
97
  }], rowsInput: [{
@@ -248,6 +256,7 @@ var CaptureViewer;
248
256
  CaptureViewer["CUPIX"] = "CUPIX";
249
257
  CaptureViewer["MATTERPORT"] = "MATTERPORT";
250
258
  CaptureViewer["SMARTERPLAN_V1"] = "SMARTERPLAN_V1";
259
+ CaptureViewer["THREEJS"] = "THREEJS";
251
260
  })(CaptureViewer || (CaptureViewer = {}));
252
261
  var InventoryStatus;
253
262
  (function (InventoryStatus) {
@@ -786,30 +795,30 @@ class SecurityCamera extends SceneComponent {
786
795
  }
787
796
  }
788
797
  function vertexShader$1() {
789
- return `
790
- uniform mat4 lightMatrix;
791
-
792
- varying vec4 vLightCoords;
793
-
794
- void main() {
795
- // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
796
- vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
797
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
798
- }
798
+ return `
799
+ uniform mat4 lightMatrix;
800
+
801
+ varying vec4 vLightCoords;
802
+
803
+ void main() {
804
+ // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
805
+ vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
806
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
807
+ }
799
808
  `;
800
809
  }
801
810
  function fragmentShader$1() {
802
- return `
803
- uniform vec3 color;
804
-
805
- varying vec4 vLightCoords;
806
-
807
- void main() {
808
- vec2 lightUV = vLightCoords.xy / vLightCoords.w;
809
- float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
810
-
811
- gl_FragColor = vec4(color, min(inView, 0.2));
812
- }
811
+ return `
812
+ uniform vec3 color;
813
+
814
+ varying vec4 vLightCoords;
815
+
816
+ void main() {
817
+ vec2 lightUV = vLightCoords.xy / vLightCoords.w;
818
+ float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
819
+
820
+ gl_FragColor = vec4(color, min(inView, 0.2));
821
+ }
813
822
  `;
814
823
  }
815
824
  function updateHighlightUniforms$1(projector, uniforms) {
@@ -1002,30 +1011,30 @@ class ViewFrustumMesh extends SceneComponent {
1002
1011
  }
1003
1012
  }
1004
1013
  function vertexShader() {
1005
- return `
1006
- uniform mat4 lightMatrix;
1007
-
1008
- varying vec4 vLightCoords;
1009
-
1010
- void main() {
1011
- // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
1012
- vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
1013
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1014
- }
1014
+ return `
1015
+ uniform mat4 lightMatrix;
1016
+
1017
+ varying vec4 vLightCoords;
1018
+
1019
+ void main() {
1020
+ // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
1021
+ vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
1022
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1023
+ }
1015
1024
  `;
1016
1025
  }
1017
1026
  function fragmentShader() {
1018
- return `
1019
- uniform vec3 color;
1020
-
1021
- varying vec4 vLightCoords;
1022
-
1023
- void main() {
1024
- vec2 lightUV = vLightCoords.xy / vLightCoords.w;
1025
- float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
1026
-
1027
- gl_FragColor = vec4(color, min(inView, 0.2));
1028
- }
1027
+ return `
1028
+ uniform vec3 color;
1029
+
1030
+ varying vec4 vLightCoords;
1031
+
1032
+ void main() {
1033
+ vec2 lightUV = vLightCoords.xy / vLightCoords.w;
1034
+ float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
1035
+
1036
+ gl_FragColor = vec4(color, min(inView, 0.2));
1037
+ }
1029
1038
  `;
1030
1039
  }
1031
1040
  function updateHighlightUniforms(projector, uniforms) {
@@ -1042,6 +1051,198 @@ const makeViewFrustumMesh = function () {
1042
1051
  return new ViewFrustumMesh();
1043
1052
  };
1044
1053
 
1054
+ const deleteFromS3 = async (path) => {
1055
+ await Storage.remove(path, { level: "public" })
1056
+ .then((result) => console.log("Successfully deleted =>", result))
1057
+ .catch((error) => console.log("Deleting from S3 error:", error));
1058
+ };
1059
+ const uploadFileToS3 = async (path, file, objectId) => {
1060
+ const prefix = objectId ? `${objectId.slice(0, 8)}_` : "";
1061
+ return Storage.put(path + prefix + file.name, file, {
1062
+ progressCallback(progress) {
1063
+ // console.log('Uploaded : ', progress);
1064
+ },
1065
+ contentType: file.type,
1066
+ })
1067
+ .then((result) => {
1068
+ console.log("Success =>", result);
1069
+ return result.key;
1070
+ });
1071
+ };
1072
+ /**
1073
+ * Uploads a file, reporting progress as a 0-1 fraction.
1074
+ *
1075
+ * uploadFileToS3 above swallows progress, which is fine for a photo and unusable for a 3D model:
1076
+ * these run to hundreds of megabytes, and an upload with no feedback is indistinguishable from a
1077
+ * frozen page. Returns the stored key, which is what gets saved on the record.
1078
+ */
1079
+ const uploadFileToS3WithProgress = async (path, file, onProgress) => {
1080
+ return Storage.put(path + file.name, file, {
1081
+ contentType: file.type || "application/octet-stream",
1082
+ progressCallback(progress) {
1083
+ if (onProgress && progress.total > 0) {
1084
+ onProgress(progress.loaded / progress.total);
1085
+ }
1086
+ },
1087
+ }).then((result) => result.key);
1088
+ };
1089
+ const getSignedImageUrlForEquipment = async (equip) => {
1090
+ if (!equip.photo) {
1091
+ return Promise.resolve(null);
1092
+ }
1093
+ return Storage.get(equip.photo, { level: "public" })
1094
+ .then((result) => {
1095
+ return result;
1096
+ })
1097
+ .catch((error) => {
1098
+ console.log("error fetching file from s3 =>", error);
1099
+ return null;
1100
+ });
1101
+ };
1102
+ const getSignedImageUrlForProfile = async (profile) => {
1103
+ return Storage.get(profile.avatar, { level: "public" })
1104
+ .then((result) => {
1105
+ return result;
1106
+ })
1107
+ .catch((error) => {
1108
+ console.log("error fetching file from s3 =>", error);
1109
+ });
1110
+ };
1111
+ const getSignedImageUrlForSpace = async (space) => {
1112
+ return Storage.get(space.annexes, { level: "public" })
1113
+ .then((result) => {
1114
+ return result;
1115
+ })
1116
+ .catch((error) => {
1117
+ console.log("error fetching file from s3 =>", error);
1118
+ });
1119
+ };
1120
+ const uploadBase64Image = async (uri, objectId, path, friendlyFileName) => {
1121
+ return fetch(uri)
1122
+ .then((res) => res.blob())
1123
+ .then((blob) => {
1124
+ const file = new File([blob], `${friendlyFileName}.jpeg`, {
1125
+ type: "image/jpeg",
1126
+ });
1127
+ return uploadFileToS3(path, file, objectId);
1128
+ });
1129
+ };
1130
+ const uploadBase64ImageWithRetry = async (uri, objectId, path, friendlyFileName, retry) => {
1131
+ return new Promise(async (resolve, reject) => {
1132
+ while (retry > 0) {
1133
+ try {
1134
+ const r = await uploadBase64Image(uri, objectId, path, friendlyFileName);
1135
+ resolve(r);
1136
+ return;
1137
+ }
1138
+ catch (e) {
1139
+ retry--;
1140
+ console.error(`Upload error, retry n° ${retry} : `, e);
1141
+ }
1142
+ }
1143
+ ;
1144
+ reject("Max retry exceeded");
1145
+ });
1146
+ };
1147
+ const removeAllFilesFromFolderS3 = async (folderPath) => {
1148
+ const arrayOfPromises = [];
1149
+ Storage.list(folderPath, { level: "public" })
1150
+ .then((res) => {
1151
+ Object.keys(res).map(async (key) => {
1152
+ const file = res[key];
1153
+ arrayOfPromises.push(deleteFromS3(file.key));
1154
+ });
1155
+ })
1156
+ .catch((error) => console.error("List failure", error));
1157
+ return Promise.all(arrayOfPromises);
1158
+ };
1159
+ const listFilesInFolder = async (folderPath) => {
1160
+ return Storage.list(folderPath, { level: "public", pageSize: 'ALL' })
1161
+ .then((res) => {
1162
+ return res.results;
1163
+ })
1164
+ .catch((error) => console.error("List failure", error));
1165
+ };
1166
+ const getSignedFile = async (path) => {
1167
+ return Storage.get(path, { level: "public" })
1168
+ .then((result) => {
1169
+ return result;
1170
+ })
1171
+ .catch((error) => {
1172
+ console.log("error fetching file from s3 =>", error);
1173
+ return null;
1174
+ });
1175
+ };
1176
+ const downloadFileAsObject = async (path) => {
1177
+ return Storage.get(path, { download: true, level: "public" })
1178
+ .then((result) => {
1179
+ return result;
1180
+ })
1181
+ .catch((error) => {
1182
+ console.log("error fetching file from s3 =>", error);
1183
+ });
1184
+ };
1185
+ const getBufferForFileFromS3 = async (path) => {
1186
+ const object = await downloadFileAsObject(path);
1187
+ if (object) {
1188
+ // @ts-ignore
1189
+ return new ArrayBuffer(object.Body, "binary");
1190
+ }
1191
+ return null;
1192
+ };
1193
+ const uploadJsonToS3 = async (path, json, filename) => {
1194
+ return Storage.put(path + filename, json, {
1195
+ contentType: "application/json",
1196
+ })
1197
+ .then((result) => {
1198
+ console.log("Success =>", result);
1199
+ return result.key;
1200
+ })
1201
+ .catch((error) => {
1202
+ console.log("error =>", error);
1203
+ return null;
1204
+ });
1205
+ };
1206
+ /**
1207
+ * Physically duplicates an S3 object to a new key. Used when copying attachments
1208
+ * between features/tickets/etc. so the copy is fully independent: deleting the
1209
+ * original file later must not affect the copy.
1210
+ * IMPORTANT: does NOT swallow errors — callers need the real error (e.g. NoSuchKey)
1211
+ * to distinguish an orphaned DB reference from a genuine copy failure.
1212
+ */
1213
+ const copyFileInS3 = async (sourceKey, destKey) => {
1214
+ const result = await Storage.copy({ key: sourceKey, level: "public" }, { key: destKey, level: "public" });
1215
+ return result.key;
1216
+ };
1217
+ /**
1218
+ * Fallback duplication: downloads the object and re-uploads it under the new key.
1219
+ *
1220
+ * Storage.copy() builds the S3 `x-amz-copy-source` header by plain string concatenation
1221
+ * (see @aws-amplify/storage AWSS3Provider.copy: `${bucket}/${srcPrefix}${srcKey}`), with no
1222
+ * URL-encoding of the key. A key holding a space, an accent or a parenthesis therefore
1223
+ * yields a header that does not match the signed canonical request, and S3 answers
1224
+ * "The request signature we calculated does not match the signature you provided".
1225
+ *
1226
+ * Round-tripping the bytes avoids that header entirely and works for any key, at the cost
1227
+ * of transferring the file through the browser — hence its use as a fallback only.
1228
+ * IMPORTANT: like copyFileInS3, this must NOT swallow errors.
1229
+ */
1230
+ const copyFileInS3ViaDownload = async (sourceKey, destKey) => {
1231
+ const downloaded = await Storage.get(sourceKey, {
1232
+ download: true,
1233
+ level: "public",
1234
+ });
1235
+ const body = downloaded?.Body;
1236
+ if (!body) {
1237
+ throw new Error(`Empty download for "${sourceKey}", cannot duplicate it`);
1238
+ }
1239
+ const result = await Storage.put(destKey, body, {
1240
+ level: "public",
1241
+ contentType: downloaded.ContentType || undefined,
1242
+ });
1243
+ return result.key;
1244
+ };
1245
+
1045
1246
  class ProfileService {
1046
1247
  API;
1047
1248
  constructor(apiInjected) {
@@ -1207,6 +1408,22 @@ class MissionService {
1207
1408
  async getMissionsForSpace(spaceID) {
1208
1409
  return this.API.__proto__.MissionsBySpace(spaceID).then((missions) => missions.items);
1209
1410
  }
1411
+ /**
1412
+ * Same as getMissionsForSpace(), but follows `nextToken` until the connection is
1413
+ * exhausted instead of returning only the first page.
1414
+ */
1415
+ async getAllMissionsForSpace(spaceID) {
1416
+ const all = [];
1417
+ let nextToken = null;
1418
+ do {
1419
+ const page = await this.API.__proto__.MissionsBySpace(spaceID, undefined, undefined, 1000, nextToken);
1420
+ if (page?.items?.length > 0) {
1421
+ all.push(...page.items.filter((mis) => !!mis && !mis.deletedAt));
1422
+ }
1423
+ nextToken = page?.nextToken ?? null;
1424
+ } while (nextToken);
1425
+ return all;
1426
+ }
1210
1427
  activateMission(mis) {
1211
1428
  if (mis &&
1212
1429
  (!mis.groups || !mis.groups.includes(ProfileStatus.ACTIVE))) {
@@ -1971,32 +2188,32 @@ class PoiService {
1971
2188
  if (!sweeps || sweeps.length === 0) {
1972
2189
  return [];
1973
2190
  }
1974
- const queryStatement = `
1975
- query ListPOISLight($filter: ModelPOIFilterInput, $limit: Int, $nextToken: String) {
1976
- listPOIS(filter: $filter, limit: $limit, nextToken: $nextToken) {
1977
- items {
1978
- id
1979
- coordinate
1980
- specific
1981
- private
1982
- global
1983
- type
1984
- elementID
1985
- level
1986
- role
1987
- category
1988
- domainID
1989
- metadata
1990
- tagIcon
1991
- tagDescription
1992
- tagImageID
1993
- matterportSweepID
1994
- createdAt
1995
- updatedAt
1996
- }
1997
- nextToken
1998
- }
1999
- }
2191
+ const queryStatement = `
2192
+ query ListPOISLight($filter: ModelPOIFilterInput, $limit: Int, $nextToken: String) {
2193
+ listPOIS(filter: $filter, limit: $limit, nextToken: $nextToken) {
2194
+ items {
2195
+ id
2196
+ coordinate
2197
+ specific
2198
+ private
2199
+ global
2200
+ type
2201
+ elementID
2202
+ level
2203
+ role
2204
+ category
2205
+ domainID
2206
+ metadata
2207
+ tagIcon
2208
+ tagDescription
2209
+ tagImageID
2210
+ matterportSweepID
2211
+ createdAt
2212
+ updatedAt
2213
+ }
2214
+ nextToken
2215
+ }
2216
+ }
2000
2217
  `;
2001
2218
  const CHUNK_SIZE = 50; // safe margin for an "or" filter expression's practical size
2002
2219
  const chunks = [];
@@ -2763,11 +2980,11 @@ class MatterportPointerService {
2763
2980
  }
2764
2981
  updateCursorDisplay(poseMatterport) {
2765
2982
  if (this.textDisplayCursorPositionPanel && poseMatterport) {
2766
- this.textDisplayCursorPositionPanel.innerHTML = `position:
2767
- ${pointToString(poseMatterport.position)}\n
2768
- normal:
2769
- ${pointToString(poseMatterport.normal)}\n
2770
- floorId:
2983
+ this.textDisplayCursorPositionPanel.innerHTML = `position:
2984
+ ${pointToString(poseMatterport.position)}\n
2985
+ normal:
2986
+ ${pointToString(poseMatterport.normal)}\n
2987
+ floorId:
2771
2988
  ${poseMatterport.floorId}`;
2772
2989
  if (this.getCursorPositionButton) {
2773
2990
  this.getCursorPositionButton.style.display = 'none';
@@ -4778,22 +4995,22 @@ class MatterportService {
4778
4995
  );
4779
4996
  }*/
4780
4997
  addCustomCss() {
4781
- const css = `
4782
- .footer-ui,
4783
- .showcase-footer,
4784
- .mp-nova-btn-group,
4785
- .footer-divider,
4786
- .logo-link,
4787
- .footer-logo,
4788
- .bottom-ui,
4789
- img.logo-image,
4790
- img[class^="logo-image"],
4791
- img[class*=" logo-image"],
4792
- [data-testid="showcase-footer"] {
4793
- opacity: 0 !important;
4794
- visibility: hidden !important;
4795
- pointer-events: none !important;
4796
- }
4998
+ const css = `
4999
+ .footer-ui,
5000
+ .showcase-footer,
5001
+ .mp-nova-btn-group,
5002
+ .footer-divider,
5003
+ .logo-link,
5004
+ .footer-logo,
5005
+ .bottom-ui,
5006
+ img.logo-image,
5007
+ img[class^="logo-image"],
5008
+ img[class*=" logo-image"],
5009
+ [data-testid="showcase-footer"] {
5010
+ opacity: 0 !important;
5011
+ visibility: hidden !important;
5012
+ pointer-events: none !important;
5013
+ }
4797
5014
  `;
4798
5015
  // 1st method: Via Scene SDK
4799
5016
  if (this.sdk?.Scene?.addStyle) {
@@ -5369,53 +5586,1351 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
5369
5586
  args: [MATTERPORT_SDK_KEY]
5370
5587
  }] }, { type: i1$1.Router }, { type: i1$1.ActivatedRoute }, { type: BaseVisibilityService }, { type: i0.NgZone }, { type: MatterportMeasurementService }, { type: MatterportNavigationService }, { type: MatterportPointerService }, { type: MatterportObject3DService }, { type: MatterportTagService }] });
5371
5588
 
5372
- /* eslint-disable no-await-in-loop */
5373
- /* eslint-disable camelcase */
5374
- /* eslint-disable no-underscore-dangle */
5375
- /* eslint-disable class-methods-use-this */
5376
- class ViewerService {
5377
- sdkKey;
5378
- poiService;
5379
- matterportService;
5380
- viewerMode = 'Matterport';
5381
- currentModel3D;
5382
- tourUrl;
5383
- viewerIsOn = new Subject();
5384
- dataIsLoaded = new Subject();
5385
- isLoaded = false;
5386
- showingViewer = false;
5387
- sweepToMove;
5388
- rotationToMove;
5389
- goToTriggered = new Subject();
5390
- toolboxStates = {
5391
- dollhouse: {
5392
- stat: false,
5393
- iconId: 'ui-dollhouse',
5394
- show() {
5395
- },
5396
- hide() {
5397
- },
5398
- },
5399
- floorplan: {
5400
- stat: false,
5401
- },
5402
- mesure: {
5403
- stat: false,
5404
- iconId: 'ui-mesure',
5405
- show() {
5406
- },
5407
- hide() {
5408
- },
5409
- },
5410
- filter: {
5411
- stat: false,
5412
- },
5413
- };
5414
- isObjectLoaded = false;
5415
- constructor(sdkKey, poiService, matterportService) {
5416
- this.sdkKey = sdkKey;
5417
- this.poiService = poiService;
5418
- this.matterportService = matterportService;
5589
+ /**
5590
+ * Builds an orthonormal frame from three points, or null when they are (nearly) collinear.
5591
+ *
5592
+ * Three points define a plane, and a plane plus an origin defines an orientation. Collinear points
5593
+ * do not: they leave the rotation about their common line undetermined, which is why the caller has
5594
+ * to reject them rather than produce a confident wrong answer.
5595
+ */
5596
+ function frameFromPoints(a, b, c) {
5597
+ const e1 = b.clone().sub(a);
5598
+ if (e1.lengthSq() < 1e-12) {
5599
+ return null;
5600
+ }
5601
+ e1.normalize();
5602
+ const toC = c.clone().sub(a);
5603
+ const e2 = toC.clone().sub(e1.clone().multiplyScalar(toC.dot(e1)));
5604
+ if (e2.lengthSq() < 1e-12) {
5605
+ return null;
5606
+ }
5607
+ e2.normalize();
5608
+ return new THREE.Matrix4().makeBasis(e1, e2, e1.clone().cross(e2));
5609
+ }
5610
+ /**
5611
+ * Solves the transform that maps stored tag coordinates onto the loaded model.
5612
+ *
5613
+ * Takes exactly the correspondences a person can produce by hand: three tags, each pointed at twice
5614
+ * - once where it currently sits, once where it belongs. That is the minimum that pins down all
5615
+ * three parts of the answer. One pair fixes only translation; two leave the rotation about the line
5616
+ * between them free; three resolve it.
5617
+ *
5618
+ * Returns null when the points are collinear or degenerate, because a wrong alignment applied
5619
+ * silently is worse than none.
5620
+ */
5621
+ function solveThreeJsAlignment(pairs) {
5622
+ if (!pairs || pairs.length < 3) {
5623
+ return null;
5624
+ }
5625
+ const sources = pairs.slice(0, 3).map((pair) => new THREE.Vector3(pair.source.x, pair.source.y, pair.source.z));
5626
+ const targets = pairs.slice(0, 3).map((pair) => new THREE.Vector3(pair.target.x, pair.target.y, pair.target.z));
5627
+ const sourceFrame = frameFromPoints(sources[0], sources[1], sources[2]);
5628
+ const targetFrame = frameFromPoints(targets[0], targets[1], targets[2]);
5629
+ if (!sourceFrame || !targetFrame) {
5630
+ return null;
5631
+ }
5632
+ // A rotation's inverse is its transpose, so this is the rotation carrying one frame onto the other.
5633
+ const rotation = targetFrame.multiply(sourceFrame.clone().transpose());
5634
+ // Scale from how far apart the points are in each space, averaged over the three edges so a single
5635
+ // sloppy click does not set it on its own.
5636
+ const edges = [[0, 1], [1, 2], [0, 2]];
5637
+ let ratioSum = 0;
5638
+ let ratioCount = 0;
5639
+ for (const [i, j] of edges) {
5640
+ const sourceLength = sources[i].distanceTo(sources[j]);
5641
+ const targetLength = targets[i].distanceTo(targets[j]);
5642
+ if (sourceLength > 1e-9) {
5643
+ ratioSum += targetLength / sourceLength;
5644
+ ratioCount += 1;
5645
+ }
5646
+ }
5647
+ if (ratioCount === 0) {
5648
+ return null;
5649
+ }
5650
+ const scale = ratioSum / ratioCount;
5651
+ // The group applies position, then rotation, then scale, so a point lands at t + s*R*p. Solving
5652
+ // that for t at the centroids gives the translation.
5653
+ const sourceCentroid = sources.reduce((sum, v) => sum.add(v), new THREE.Vector3()).multiplyScalar(1 / 3);
5654
+ const targetCentroid = targets.reduce((sum, v) => sum.add(v), new THREE.Vector3()).multiplyScalar(1 / 3);
5655
+ const translation = targetCentroid
5656
+ .clone()
5657
+ .sub(sourceCentroid.clone().applyMatrix4(rotation).multiplyScalar(scale));
5658
+ const euler = new THREE.Euler().setFromRotationMatrix(rotation);
5659
+ return {
5660
+ translation: { x: translation.x, y: translation.y, z: translation.z },
5661
+ rotation: { x: euler.x, y: euler.y, z: euler.z },
5662
+ scale,
5663
+ };
5664
+ }
5665
+ class ThreeJsService {
5666
+ ngZone;
5667
+ scene;
5668
+ camera;
5669
+ renderer;
5670
+ controls;
5671
+ animationFrameId;
5672
+ // Raycasting and interaction
5673
+ raycaster = new THREE.Raycaster();
5674
+ mouse = new THREE.Vector2();
5675
+ isDraggingCamera = false;
5676
+ isDraggingTimer;
5677
+ clickableObjects = [];
5678
+ // Observables for the viewer component
5679
+ objectHovered$ = new Subject();
5680
+ objectClicked$ = new Subject();
5681
+ /** Emits when the user clicks a tag, mirroring Matterport's tag click. */
5682
+ tagClicked$ = new Subject();
5683
+ // Tags live under the model rather than the scene, so the centering and scaling applied when the
5684
+ // model is placed carries over and POI coordinates can stay in the model's own space.
5685
+ tagGroup = new THREE.Group();
5686
+ tagSprites = new Map();
5687
+ canvasResizeObserver;
5688
+ /** Aborts the in-flight model download. Parsing cannot be interrupted, only not started. */
5689
+ loadAbortController;
5690
+ // The loaded base model, kept so tag placement can raycast against its geometry.
5691
+ modelRoot;
5692
+ /** True while the next click on the model places a tag instead of orbiting. */
5693
+ placingTag = false;
5694
+ /** Position and normal of the last placed tag, in model space, awaiting commit to a POI. */
5695
+ pendingTagPlacement = null;
5696
+ /** Emits once a tag is dropped on the model, so the UI can confirm and persist it. */
5697
+ tagPlaced$ = new Subject();
5698
+ /** Translucent marker shown under the cursor while placing. */
5699
+ placementPreview;
5700
+ aligning = false;
5701
+ alignmentPairs = [];
5702
+ alignmentSource = null;
5703
+ /** Drives the alignment walkthrough's UI: how many pairs are done and what it wants next. */
5704
+ alignmentProgress$ = new Subject();
5705
+ /** In-flight camera move started by goToTag, advanced by the render loop. */
5706
+ cameraTween = null;
5707
+ /** Emits true while a heavy OBJ file is being parsed (in a worker, when available), false once done. */
5708
+ modelLoading$ = new Subject();
5709
+ /**
5710
+ * Download progress for GLB models, as a 0-1 fraction, or null when the total size is unknown.
5711
+ *
5712
+ * A null means the server did not send Content-Length (chunked responses, some proxies) and no
5713
+ * percentage can be derived; show an indeterminate indicator rather than guessing at one.
5714
+ */
5715
+ modelLoadProgress$ = new Subject();
5716
+ /**
5717
+ * Emits when a load actually stopped short, which is not the same as asking it to.
5718
+ *
5719
+ * cancelModelLoad() can arrive after parse() has taken the main thread, and parse cannot be
5720
+ * interrupted - the model finishes loading regardless. Only this tells the UI which of the two
5721
+ * happened, so it does not offer to reload something that is already on screen.
5722
+ */
5723
+ modelLoadCancelled$ = new Subject();
5724
+ // Background parsing of heavy .obj files, provided by the hosting app (see setObjParserWorker).
5725
+ // Angular libraries built with ng-packagr can't bundle `new Worker(new URL(...))` themselves,
5726
+ // so the app instantiates the worker (its build pipeline supports it) and hands it over here.
5727
+ objParserWorker;
5728
+ objParserPendingRequests = new Map();
5729
+ constructor(ngZone) {
5730
+ this.ngZone = ngZone;
5731
+ }
5732
+ /**
5733
+ * Registers a Web Worker used to parse heavy .obj files off the main thread.
5734
+ * Must be instantiated by the consuming app (e.g. `new Worker(new URL('./obj-parser.worker', import.meta.url))`),
5735
+ * since a library packaged with ng-packagr cannot bundle worker files itself.
5736
+ * When no worker is provided, loadObjModel falls back to parsing on the main thread.
5737
+ */
5738
+ setObjParserWorker(worker) {
5739
+ this.objParserWorker = worker;
5740
+ this.objParserWorker.onmessage = ({ data }) => {
5741
+ const pending = this.objParserPendingRequests.get(data.requestId);
5742
+ if (!pending) {
5743
+ return;
5744
+ }
5745
+ this.objParserPendingRequests.delete(data.requestId);
5746
+ if (data.success && data.meshes) {
5747
+ pending.resolve(data.meshes);
5748
+ }
5749
+ else {
5750
+ pending.reject(new Error(data.error || 'Unknown error while parsing OBJ file in worker'));
5751
+ }
5752
+ };
5753
+ }
5754
+ requestObjParseInWorker(objUrl) {
5755
+ return new Promise((resolve, reject) => {
5756
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
5757
+ this.objParserPendingRequests.set(requestId, { resolve, reject });
5758
+ this.objParserWorker.postMessage({ requestId, objUrl });
5759
+ // Diagnostic only: large models legitimately take a while, this isn't a timeout/abort,
5760
+ // just a signal in case the worker went silent (e.g. it ran out of memory mid-parse).
5761
+ setTimeout(() => {
5762
+ if (this.objParserPendingRequests.has(requestId)) {
5763
+ console.warn(`ThreeJsService: obj-parser worker has not replied after 60s for request ${requestId} (objUrl=${objUrl}). Still waiting - large files can take a while, but this may also mean the worker hung or ran out of memory.`);
5764
+ }
5765
+ }, 60000);
5766
+ });
5767
+ }
5768
+ /** Rebuilds a THREE.Mesh from the raw typed arrays sent back by the parser worker. */
5769
+ meshFromParsedData(data, materials) {
5770
+ const geometry = new THREE.BufferGeometry();
5771
+ geometry.setAttribute('position', new THREE.BufferAttribute(data.position, 3));
5772
+ if (data.uv) {
5773
+ geometry.setAttribute('uv', new THREE.BufferAttribute(data.uv, 2));
5774
+ }
5775
+ // Index has to be set before any normal computation, which reads it to average shared faces.
5776
+ if (data.index) {
5777
+ geometry.setIndex(new THREE.BufferAttribute(data.index, 1));
5778
+ }
5779
+ if (data.normal) {
5780
+ geometry.setAttribute('normal', new THREE.BufferAttribute(data.normal, 3));
5781
+ }
5782
+ else {
5783
+ geometry.computeVertexNormals();
5784
+ }
5785
+ // Box3.setFromObject() reuses an already-set boundingBox instead of walking every vertex,
5786
+ // so adopting the worker's makes framing the camera cheap no matter how dense the mesh is.
5787
+ if (data.boundingBox) {
5788
+ geometry.boundingBox = new THREE.Box3(new THREE.Vector3(...data.boundingBox.min), new THREE.Vector3(...data.boundingBox.max));
5789
+ }
5790
+ const resolvedMaterials = data.materialNames.map((name) => {
5791
+ const material = materials && name ? materials.create(name) : undefined;
5792
+ if (!material) {
5793
+ console.warn(`ThreeJsService: no MTL material named "${name}" for mesh "${data.name}", using a plain fallback (no texture).`);
5794
+ }
5795
+ return material || new THREE.MeshPhongMaterial();
5796
+ });
5797
+ let mesh;
5798
+ if (data.groups && data.groups.length > 0) {
5799
+ data.groups.forEach((group) => geometry.addGroup(group.start, group.count, group.materialIndex));
5800
+ mesh = new THREE.Mesh(geometry, resolvedMaterials);
5801
+ }
5802
+ else {
5803
+ mesh = new THREE.Mesh(geometry, resolvedMaterials[0]);
5804
+ }
5805
+ mesh.name = data.name;
5806
+ return mesh;
5807
+ }
5808
+ init(canvas) {
5809
+ // This service is a singleton but the viewer component is rebuilt on every navigation, so state
5810
+ // from the previous scene is still here. Two things outlive it and have to go first:
5811
+ //
5812
+ // - A download still in flight. Its onLoaded resolves against whatever `this.scene` points at
5813
+ // by then, so the model being abandoned would be handed to the scene built below.
5814
+ // - The tag group, which is a field and keeps its sprites. They would be re-parented under the
5815
+ // new model and show the previous visit's tags on it.
5816
+ this.discardCurrentLoad();
5817
+ this.clearTags();
5818
+ this.cameraTween = null;
5819
+ this.modelRoot = undefined;
5820
+ // 1. Setup Scene
5821
+ this.scene = new THREE.Scene();
5822
+ this.scene.background = new THREE.Color(0xe0f7ff); // from POC
5823
+ // 2. Setup Camera
5824
+ this.camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
5825
+ this.camera.position.set(0, 5, 10); // will be overridden later if passed
5826
+ // 3. Setup WebGL Renderer
5827
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
5828
+ this.renderer.setSize(canvas.clientWidth, canvas.clientHeight);
5829
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
5830
+ this.observeCanvasResize(canvas);
5831
+ // Fix for Three.js r147 vs r152+ color management
5832
+ if ('outputColorSpace' in this.renderer) {
5833
+ this.renderer.outputColorSpace = 'srgb'; // THREE.SRGBColorSpace
5834
+ }
5835
+ else {
5836
+ this.renderer.outputEncoding = 3001; // THREE.sRGBEncoding
5837
+ }
5838
+ // Without tone mapping anything brighter than 1.0 is simply cut off to pure white. Captured
5839
+ // textures carry a small fraction of very bright pixels - sky reflected in windows, sunlit
5840
+ // render - and once lighting pushes those past the limit they flatten into white specks with
5841
+ // no detail left. Rolling the highlights off instead keeps that detail and keeps bright areas
5842
+ // reading as bright rather than blown.
5843
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
5844
+ this.renderer.toneMappingExposure = 1.0;
5845
+ // 4. Lighting, kept deliberately restrained and neutral.
5846
+ //
5847
+ // Captured/photogrammetry models already have lighting baked into their textures (the sun and
5848
+ // shadows came with the photos), so lighting them again distorts what they actually look like.
5849
+ // The intensities below add up to roughly 1.0 on a surface facing the viewer, which renders the
5850
+ // texture at its true value. Anything above 1.0 is clipped to pure white by the renderer, which
5851
+ // is what turns bright areas into flat white patches with no detail left in them.
5852
+ //
5853
+ // Neutral white is used rather than a warm tint for the same reason: a tinted light shifts every
5854
+ // colour in the model away from what was captured.
5855
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
5856
+ this.scene.add(ambientLight);
5857
+ // Headlight rather than a fixed sun: parented to the camera so it always points at whatever is
5858
+ // being looked at, giving shape to surfaces without ever leaving a face unlit. No shadow casting
5859
+ // - shadows would double up with the ones already baked into the textures, and the shadow map
5860
+ // was a real cost per frame.
5861
+ const headLight = new THREE.DirectionalLight(0xffffff, 0.35);
5862
+ headLight.position.set(0, 0, 1);
5863
+ this.camera.add(headLight);
5864
+ // A light only contributes while it is part of the scene graph, and this one hangs off the camera.
5865
+ this.scene.add(this.camera);
5866
+ // 5. Setup Controls (from POC)
5867
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
5868
+ this.controls.rotateSpeed = 1.0;
5869
+ this.controls.dampingFactor = 0.1;
5870
+ this.controls.enableDamping = true;
5871
+ this.controls.mouseButtons = {
5872
+ LEFT: THREE.MOUSE.ROTATE,
5873
+ MIDDLE: THREE.MOUSE.DOLLY,
5874
+ RIGHT: THREE.MOUSE.PAN,
5875
+ };
5876
+ this.controls.target.set(0, 0, 0);
5877
+ // Event listeners on controls to differentiate dragging vs clicking
5878
+ this.controls.addEventListener('start', () => {
5879
+ this.isDraggingTimer = setTimeout(() => {
5880
+ this.isDraggingCamera = true;
5881
+ }, 150);
5882
+ });
5883
+ this.controls.addEventListener('end', () => {
5884
+ clearTimeout(this.isDraggingTimer);
5885
+ if (this.isDraggingCamera) {
5886
+ setTimeout(() => {
5887
+ this.isDraggingCamera = false;
5888
+ }, 250);
5889
+ }
5890
+ });
5891
+ // Interaction Event Listeners on canvas
5892
+ canvas.addEventListener('click', this.onMouseClick.bind(this), false);
5893
+ canvas.addEventListener('mousemove', this.onMouseMove.bind(this), false);
5894
+ // 6. Start the render loop
5895
+ this.ngZone.runOutsideAngular(() => {
5896
+ this.animate();
5897
+ });
5898
+ }
5899
+ /**
5900
+ * Path (served by the app) holding basis_transcoder.js/.wasm, which KTX2Loader needs to turn
5901
+ * Basis Universal textures into whatever compressed format the local GPU actually supports.
5902
+ */
5903
+ ktx2TranscoderPath = 'assets/basis/';
5904
+ /** Overrides where the Basis transcoder files are served from. */
5905
+ setKtx2TranscoderPath(path) {
5906
+ this.ktx2TranscoderPath = path;
5907
+ }
5908
+ /**
5909
+ * Loads a base 3D model in GLTF/GLB format - the preferred format for large models.
5910
+ * Unlike .obj, geometry arrives as binary typed arrays (no text parsing to offload to a worker)
5911
+ * and stays indexed, so far less data reaches the GPU. KTX2 textures are supported too: they
5912
+ * stay compressed in VRAM instead of being decoded to raw pixels like JPEG/PNG.
5913
+ */
5914
+ loadGLTFModel(url, cameraPosition, rotation) {
5915
+ const loader = new GLTFLoader();
5916
+ // KTX2 needs the renderer to report which compressed formats this GPU accepts, so this has to
5917
+ // happen after init(). Guarded so a model without KTX2 textures still loads if it's missing.
5918
+ if (this.renderer) {
5919
+ try {
5920
+ const ktx2Loader = new KTX2Loader()
5921
+ .setTranscoderPath(this.ktx2TranscoderPath)
5922
+ .detectSupport(this.renderer);
5923
+ loader.setKTX2Loader(ktx2Loader);
5924
+ }
5925
+ catch (error) {
5926
+ console.warn('ThreeJsService: could not set up KTX2 texture support, falling back to uncompressed textures.', error);
5927
+ }
5928
+ }
5929
+ // Lets gltfpack/gltf-transform output using EXT_meshopt_compression load without extra setup.
5930
+ loader.setMeshoptDecoder(MeshoptDecoder);
5931
+ this.modelLoading$.next(true);
5932
+ this.modelLoadProgress$.next(0);
5933
+ // Order matters: normals must exist before anisotropy, and the Phong conversion has to run
5934
+ // before side/flatShading are touched so those settings land on the materials we keep.
5935
+ const onLoaded = (gltf) => {
5936
+ this.convertToPhongMaterials(gltf.scene);
5937
+ this.forceDoubleSidedMaterials(gltf.scene);
5938
+ this.ensureVertexNormals(gltf.scene);
5939
+ this.tuneTextureSampling(gltf.scene);
5940
+ this.placeLoadedObjectInScene(gltf.scene, cameraPosition, rotation);
5941
+ this.modelLoading$.next(false);
5942
+ };
5943
+ const onError = (error) => {
5944
+ // Cancelling is a decision, not a failure: clear the overlay without an error in the console.
5945
+ if (error?.name === 'AbortError') {
5946
+ // Only a cancel of the load that is still current came from the user; anything else is this
5947
+ // load having been superseded or discarded.
5948
+ if (this.loadAbortController?.signal.aborted) {
5949
+ this.modelLoadCancelled$.next();
5950
+ }
5951
+ }
5952
+ else {
5953
+ console.error('Error loading GLTF model:', error);
5954
+ }
5955
+ this.modelLoading$.next(false);
5956
+ this.modelLoadProgress$.next(null);
5957
+ };
5958
+ // GLTFLoader decodes embedded images with createImageBitmap when the browser offers it, and on
5959
+ // large texture atlases that path returns visibly corrupt data - scattered white blocks across
5960
+ // the model. The exact same bytes decoded through an HTMLImageElement, which is what the .obj
5961
+ // path uses via MTLLoader, come out clean. GLTFLoader already falls back to that decoder for
5962
+ // Safari and older Firefox; this simply extends the fallback to cover this case too.
5963
+ //
5964
+ // The decoder is chosen while GLTFParser is being constructed, which happens synchronously
5965
+ // inside parse(), so hiding createImageBitmap only has to hold across that call rather than
5966
+ // for the whole download.
5967
+ this.fetchWithProgress(url)
5968
+ // parse() holds the main thread for seconds on a large model. Yield until the browser has
5969
+ // actually painted first, otherwise the final progress update never reaches the screen and
5970
+ // the bar appears to stall short of the end.
5971
+ .then((buffer) => this.afterNextPaint().then(() => buffer))
5972
+ .then((buffer) => {
5973
+ // parse() cannot be interrupted, so a cancel that lands during the paint yield has to be
5974
+ // caught here - the last moment before the main thread is committed for several seconds.
5975
+ if (this.loadAbortController?.signal.aborted) {
5976
+ throw new DOMException('Model load cancelled', 'AbortError');
5977
+ }
5978
+ const globalScope = globalThis;
5979
+ const originalCreateImageBitmap = globalScope.createImageBitmap;
5980
+ globalScope.createImageBitmap = undefined;
5981
+ try {
5982
+ loader.parse(buffer, url.substring(0, url.lastIndexOf('/') + 1), onLoaded, onError);
5983
+ }
5984
+ finally {
5985
+ globalScope.createImageBitmap = originalCreateImageBitmap;
5986
+ }
5987
+ })
5988
+ .catch(onError);
5989
+ }
5990
+ /**
5991
+ * Stops the model download in progress, if any.
5992
+ *
5993
+ * Only the download can be interrupted: parse() runs synchronously and holds the main thread until
5994
+ * it finishes, so once it has begun there is nothing to cancel. In practice the download is the
5995
+ * long part, which is where someone reaches for this.
5996
+ */
5997
+ cancelModelLoad() {
5998
+ this.loadAbortController?.abort();
5999
+ }
6000
+ /**
6001
+ * Abandons the load in progress without reporting it as cancelled.
6002
+ *
6003
+ * Dropping the controller is what makes the difference: onError treats an abort as user-initiated
6004
+ * only while the controller that aborted is still the current one. Tearing down or rebuilding the
6005
+ * scene is not something the person asked for, so it must not surface as "loading cancelled".
6006
+ */
6007
+ discardCurrentLoad() {
6008
+ this.loadAbortController?.abort();
6009
+ this.loadAbortController = undefined;
6010
+ }
6011
+ /**
6012
+ * Resolves once the browser has painted at least one frame.
6013
+ *
6014
+ * A requestAnimationFrame callback runs *before* that frame is painted, so a single one is not
6015
+ * enough to guarantee pending DOM changes are on screen; the second fires after the first frame
6016
+ * has gone out. Falls back to a timeout where rAF isn't available (SSR, tests).
6017
+ */
6018
+ afterNextPaint() {
6019
+ if (typeof requestAnimationFrame !== 'function') {
6020
+ return new Promise((resolve) => setTimeout(resolve, 0));
6021
+ }
6022
+ return new Promise((resolve) => {
6023
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
6024
+ });
6025
+ }
6026
+ /**
6027
+ * Downloads a file, reporting progress on modelLoadProgress$ as it arrives.
6028
+ *
6029
+ * GLTFLoader.load() would report progress through XHR, but this path deliberately uses fetch() +
6030
+ * parse() instead (see loadGLTFModel for why), so progress is read off the response stream here.
6031
+ * That is also the more useful number: on a model of this size the download dominates, and the
6032
+ * stream reports it as it happens rather than in one jump at the end.
6033
+ */
6034
+ async fetchWithProgress(url) {
6035
+ // A load already running is replaced, not raced: whichever finished last would otherwise win.
6036
+ this.loadAbortController?.abort();
6037
+ this.loadAbortController = new AbortController();
6038
+ const { signal } = this.loadAbortController;
6039
+ const response = await fetch(url, { signal });
6040
+ if (!response.ok) {
6041
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
6042
+ }
6043
+ const contentLength = Number(response.headers.get('Content-Length'));
6044
+ const total = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0;
6045
+ // Without a readable stream (older browsers, some polyfills) there is nothing to measure, so
6046
+ // fall back to a plain buffered read and let the UI show an indeterminate indicator.
6047
+ if (!response.body?.getReader) {
6048
+ this.modelLoadProgress$.next(null);
6049
+ return response.arrayBuffer();
6050
+ }
6051
+ const reader = response.body.getReader();
6052
+ // With a known size, write chunks straight into one pre-allocated buffer. Collecting them in an
6053
+ // array and concatenating afterwards would hold both copies at once - on a 164 MB model that is
6054
+ // an avoidable 300+ MB peak.
6055
+ const buffer = total > 0 ? new Uint8Array(total) : null;
6056
+ const chunks = [];
6057
+ let usingBuffer = buffer !== null;
6058
+ let received = 0;
6059
+ // Chunks arrive in tens of kilobytes, so a 164 MB model produces thousands of them. Emitting on
6060
+ // every one would run change detection just as often to move a bar that is measured in whole
6061
+ // percent, so only report when the displayed value would actually change.
6062
+ let lastReported = -1;
6063
+ if (total === 0) {
6064
+ this.modelLoadProgress$.next(null);
6065
+ }
6066
+ for (;;) {
6067
+ const { done, value } = await reader.read();
6068
+ if (done) {
6069
+ break;
6070
+ }
6071
+ if (signal.aborted) {
6072
+ // Free the connection rather than leaving it draining in the background.
6073
+ await reader.cancel();
6074
+ throw new DOMException('Model load cancelled', 'AbortError');
6075
+ }
6076
+ if (usingBuffer && received + value.length > total) {
6077
+ // Content-Length understated the real size. Move what we have into the chunk list and stay
6078
+ // there for the rest of the response, rather than truncating the tail.
6079
+ chunks.push(buffer.slice(0, received));
6080
+ usingBuffer = false;
6081
+ }
6082
+ if (usingBuffer) {
6083
+ buffer.set(value, received);
6084
+ }
6085
+ else {
6086
+ chunks.push(value);
6087
+ }
6088
+ received += value.length;
6089
+ if (total > 0) {
6090
+ const percent = Math.min(Math.round((received / total) * 100), 100);
6091
+ if (percent !== lastReported) {
6092
+ lastReported = percent;
6093
+ this.modelLoadProgress$.next(percent / 100);
6094
+ }
6095
+ }
6096
+ }
6097
+ // The stream can end below the advertised size (or with no size at all). Land on a full bar
6098
+ // rather than leaving it stuck at whatever the last chunk happened to reach.
6099
+ if (total > 0 && lastReported !== 100) {
6100
+ this.modelLoadProgress$.next(1);
6101
+ }
6102
+ if (usingBuffer && received === total) {
6103
+ return buffer.buffer;
6104
+ }
6105
+ if (usingBuffer) {
6106
+ // Fewer bytes than advertised: hand back exactly what arrived.
6107
+ return buffer.buffer.slice(0, received);
6108
+ }
6109
+ const merged = new Uint8Array(received);
6110
+ let offset = 0;
6111
+ chunks.forEach((chunk) => {
6112
+ merged.set(chunk, offset);
6113
+ offset += chunk.length;
6114
+ });
6115
+ return merged.buffer;
6116
+ }
6117
+ /**
6118
+ * Fits the camera's clipping planes to how big the model actually is.
6119
+ *
6120
+ * Depth precision depends on the far/near ratio, and the fixed 0.1 / 10000 the camera starts with
6121
+ * is a ratio of 100000 for a model only ~10 units across. That wastes most of the depth buffer on
6122
+ * empty space and lets nearly coincident surfaces - which captured meshes have everywhere -
6123
+ * flicker against each other.
6124
+ */
6125
+ adjustClippingPlanes(radius) {
6126
+ if (radius < 10) {
6127
+ this.camera.near = 0.01;
6128
+ this.camera.far = 100;
6129
+ }
6130
+ else if (radius < 100) {
6131
+ this.camera.near = 0.1;
6132
+ this.camera.far = 1000;
6133
+ }
6134
+ else if (radius < 1000) {
6135
+ this.camera.near = 10;
6136
+ this.camera.far = 10000;
6137
+ }
6138
+ else {
6139
+ this.camera.near = 100;
6140
+ this.camera.far = 1000000;
6141
+ }
6142
+ this.camera.updateProjectionMatrix();
6143
+ }
6144
+ /**
6145
+ * Swaps the PBR materials GLTFLoader builds for the plain Phong ones the .obj path produces.
6146
+ *
6147
+ * MeshStandardMaterial keeps a specular reflection even at metalness 0, since every dielectric
6148
+ * reflects some light, and Fresnel drives that reflection towards white as a surface turns away
6149
+ * from the viewer. On an aerial view of a facade most surfaces sit at exactly those grazing
6150
+ * angles, which paints white highlights over window frames and roof edges - the same model
6151
+ * loaded as .obj never shows them, because MTLLoader builds Phong materials and OBJLoader zeroes
6152
+ * their specular when the .mtl declares Ns 0.
6153
+ *
6154
+ * Captured models carry their lighting in the texture and want no specular response at all, so
6155
+ * matching the .obj path here is both the fix and the more faithful choice.
6156
+ */
6157
+ convertToPhongMaterials(root) {
6158
+ const converted = new Map();
6159
+ root.traverse((child) => {
6160
+ const mesh = child;
6161
+ if (!mesh.isMesh) {
6162
+ return;
6163
+ }
6164
+ const convert = (source) => {
6165
+ if (!source || !source.isMeshStandardMaterial) {
6166
+ return source;
6167
+ }
6168
+ if (!converted.has(source)) {
6169
+ const phong = new THREE.MeshPhongMaterial({
6170
+ map: source.map,
6171
+ color: source.color,
6172
+ transparent: source.transparent,
6173
+ opacity: source.opacity,
6174
+ alphaTest: source.alphaTest,
6175
+ side: source.side,
6176
+ specular: new THREE.Color(0x000000),
6177
+ shininess: 0,
6178
+ });
6179
+ phong.name = source.name;
6180
+ converted.set(source, phong);
6181
+ }
6182
+ return converted.get(source);
6183
+ };
6184
+ mesh.material = Array.isArray(mesh.material)
6185
+ ? mesh.material.map(convert)
6186
+ : convert(mesh.material);
6187
+ });
6188
+ }
6189
+ /**
6190
+ * Renders every material double-sided.
6191
+ *
6192
+ * Reality capture meshes routinely contain triangles with reversed winding - an artefact of the
6193
+ * reconstruction, not of the file. With the default FrontSide those triangles are culled, leaving
6194
+ * scattered holes that show the scene background through the model. Against a light background
6195
+ * they read as white specks with no discernible pattern.
6196
+ *
6197
+ * Drawing both sides costs a little fill rate but is what makes captured geometry render as
6198
+ * solid; other viewers of this kind of content do the same unconditionally.
6199
+ */
6200
+ forceDoubleSidedMaterials(root) {
6201
+ const seen = new Set();
6202
+ root.traverse((child) => {
6203
+ const mesh = child;
6204
+ if (!mesh.isMesh) {
6205
+ return;
6206
+ }
6207
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
6208
+ materials.forEach((material) => {
6209
+ if (material && !seen.has(material)) {
6210
+ seen.add(material);
6211
+ material.side = THREE.DoubleSide;
6212
+ material.needsUpdate = true;
6213
+ }
6214
+ });
6215
+ });
6216
+ }
6217
+ /**
6218
+ * Computes real vertex normals for any geometry that arrived without them.
6219
+ *
6220
+ * A glTF without a NORMAL attribute is spec-compliant, and GLTFLoader handles it by switching the
6221
+ * material to flatShading, which makes the shader derive the normal per pixel from screen-space
6222
+ * derivatives. That derivation collapses on the degenerate, needle-thin triangles that reality
6223
+ * capture meshes are full of: the face has almost no area, the derived normal is meaningless, and
6224
+ * lighting blows those fragments out to white specks - densest exactly where fine detail is.
6225
+ *
6226
+ * Computing normals from the geometry instead is robust to those triangles, since a degenerate
6227
+ * face contributes almost nothing to the average and its neighbours dominate.
6228
+ */
6229
+ ensureVertexNormals(root) {
6230
+ root.traverse((child) => {
6231
+ const mesh = child;
6232
+ if (!mesh.isMesh) {
6233
+ return;
6234
+ }
6235
+ const geometry = mesh.geometry;
6236
+ if (geometry.getAttribute('normal')) {
6237
+ return;
6238
+ }
6239
+ geometry.computeVertexNormals();
6240
+ // GLTFLoader already switched these materials to flatShading because normals were missing;
6241
+ // now that real ones exist, let them be used instead of the derivative fallback.
6242
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
6243
+ materials.forEach((material) => {
6244
+ if (material && material.flatShading) {
6245
+ material.flatShading = false;
6246
+ material.needsUpdate = true;
6247
+ }
6248
+ });
6249
+ });
6250
+ }
6251
+ /**
6252
+ * Configures how every texture in a model is sampled: maximum anisotropy, and no mipmapping.
6253
+ *
6254
+ * Photogrammetry atlases pack their UV islands with unused gutter pixels between them. Mipmapping
6255
+ * averages neighbouring pixels at each level, so those gutters bleed further into the islands the
6256
+ * smaller the level gets - and viewing the whole model at once uses the smallest levels, painting
6257
+ * bright gutter pixels over the edges of fine detail. Sampling the full-resolution image avoids
6258
+ * that, at the cost of some shimmer on minified surfaces.
6259
+ *
6260
+ * Note that anisotropy only takes effect while mipmapping is on, so with the settings below it is
6261
+ * inert; it is set anyway so the sampling stays correct for anyone who re-enables mipmaps here.
6262
+ */
6263
+ tuneTextureSampling(root) {
6264
+ if (!this.renderer) {
6265
+ return;
6266
+ }
6267
+ const maxAnisotropy = this.renderer.capabilities.getMaxAnisotropy();
6268
+ if (!maxAnisotropy || maxAnisotropy <= 1) {
6269
+ return;
6270
+ }
6271
+ const seen = new Set();
6272
+ root.traverse((child) => {
6273
+ const mesh = child;
6274
+ if (!mesh.isMesh) {
6275
+ return;
6276
+ }
6277
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
6278
+ materials.forEach((material) => {
6279
+ if (!material) {
6280
+ return;
6281
+ }
6282
+ // Same texture is reused across the model's materials; only touch each one once.
6283
+ ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap'].forEach((slot) => {
6284
+ const texture = material[slot];
6285
+ if (texture && !seen.has(texture)) {
6286
+ seen.add(texture);
6287
+ texture.anisotropy = maxAnisotropy;
6288
+ texture.generateMipmaps = false;
6289
+ texture.minFilter = THREE.LinearFilter;
6290
+ texture.needsUpdate = true;
6291
+ }
6292
+ });
6293
+ });
6294
+ });
6295
+ }
6296
+ /**
6297
+ * Loads an OBJ model with MTL.
6298
+ * The heavy text-parsing step runs in a Web Worker when one has been registered via
6299
+ * setObjParserWorker (see there for why the app, not this library, provides it); this keeps
6300
+ * the main thread free while large architecture models (millions of vertices) are parsed.
6301
+ * Falls back to parsing on the main thread otherwise.
6302
+ */
6303
+ loadObjModel(mtlUrl, objUrl, cameraPosition, rotation) {
6304
+ const mtlLoader = new MTLLoader();
6305
+ const basePath = mtlUrl.substring(0, mtlUrl.lastIndexOf('/') + 1);
6306
+ mtlLoader.setPath(basePath);
6307
+ const mtlFileName = mtlUrl.substring(mtlUrl.lastIndexOf('/') + 1);
6308
+ this.modelLoading$.next(true);
6309
+ mtlLoader.load(mtlFileName, (materials) => {
6310
+ materials.preload();
6311
+ if (this.objParserWorker) {
6312
+ this.requestObjParseInWorker(objUrl)
6313
+ .then((meshes) => {
6314
+ const group = new THREE.Group();
6315
+ meshes.forEach((meshData) => {
6316
+ group.add(this.meshFromParsedData(meshData, materials));
6317
+ });
6318
+ this.placeLoadedObjectInScene(group, cameraPosition, rotation);
6319
+ })
6320
+ .catch((error) => {
6321
+ console.error('Error parsing OBJ model in worker:', error);
6322
+ })
6323
+ .finally(() => this.modelLoading$.next(false));
6324
+ return;
6325
+ }
6326
+ const objLoader = new OBJLoader();
6327
+ objLoader.setMaterials(materials);
6328
+ objLoader.load(objUrl, (object) => {
6329
+ this.placeLoadedObjectInScene(object, cameraPosition, rotation);
6330
+ this.modelLoading$.next(false);
6331
+ }, undefined, (error) => {
6332
+ console.error('Error loading OBJ model:', error);
6333
+ this.modelLoading$.next(false);
6334
+ });
6335
+ }, undefined, (error) => {
6336
+ console.error('Error loading MTL file:', error);
6337
+ this.modelLoading$.next(false);
6338
+ });
6339
+ }
6340
+ /** Centers, scales and adds a loaded object to the scene, then frames the camera on it. */
6341
+ placeLoadedObjectInScene(object, cameraPosition, rotation) {
6342
+ const box = new THREE.Box3().setFromObject(object);
6343
+ const center = box.getCenter(new THREE.Vector3());
6344
+ const size = box.getSize(new THREE.Vector3());
6345
+ const maxDim = Math.max(size.x, size.y, size.z);
6346
+ const scale = maxDim > 0 ? 10 / maxDim : 1;
6347
+ // Two nested groups so tags can share the model's frame: `centered` carries the offset that
6348
+ // moves the model's own origin to the scene origin, `group` carries the scale. A tag positioned
6349
+ // with raw POI coordinates then lands in the same place the model's geometry does.
6350
+ const group = new THREE.Group();
6351
+ const centered = new THREE.Group();
6352
+ centered.position.sub(center);
6353
+ if (rotation) {
6354
+ object.rotation.set(rotation.x, rotation.y, rotation.z);
6355
+ }
6356
+ this.modelRoot = object;
6357
+ centered.add(object);
6358
+ centered.add(this.tagGroup);
6359
+ group.add(centered);
6360
+ group.scale.setScalar(scale);
6361
+ this.scene.add(group);
6362
+ // Tags registered before the model finished loading were sized against the old scale.
6363
+ this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
6364
+ this.adjustClippingPlanes((size.length() / 2) * scale);
6365
+ if (cameraPosition) {
6366
+ this.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
6367
+ }
6368
+ else {
6369
+ this.camera.position.set(0, 5, 15);
6370
+ }
6371
+ this.controls.target.set(0, 0, 0);
6372
+ this.controls.update();
6373
+ }
6374
+ /**
6375
+ * Keeps the drawing buffer and camera matched to the canvas as it is resized.
6376
+ *
6377
+ * renderer.setSize() writes explicit pixel width/height into the canvas's style, overriding the
6378
+ * 100%/100% it starts with. Without this, the canvas keeps whatever size it had at startup: hiding
6379
+ * the top menu grows the container, the canvas does not follow, and the strip that opens up shows
6380
+ * the page behind it instead of the scene.
6381
+ *
6382
+ * The parent is observed rather than the canvas, because resizing the canvas is what this reacts
6383
+ * to - watching the element we resize would retrigger the observer.
6384
+ */
6385
+ observeCanvasResize(canvas) {
6386
+ const container = canvas.parentElement;
6387
+ if (!container || typeof ResizeObserver === 'undefined') {
6388
+ return;
6389
+ }
6390
+ this.canvasResizeObserver = new ResizeObserver(() => {
6391
+ const width = container.clientWidth;
6392
+ const height = container.clientHeight;
6393
+ if (width === 0 || height === 0) {
6394
+ return;
6395
+ }
6396
+ this.camera.aspect = width / height;
6397
+ this.camera.updateProjectionMatrix();
6398
+ this.renderer.setSize(width, height);
6399
+ });
6400
+ this.canvasResizeObserver.observe(container);
6401
+ }
6402
+ /**
6403
+ * Starts the alignment walkthrough: pick a tag, then click where it really is, three times.
6404
+ *
6405
+ * Existing tags are the correspondences - they already carry the stored coordinate, so the person
6406
+ * only has to supply the matching spot on the model. No new records are written; nothing is
6407
+ * applied until solveAlignmentFromPairs() succeeds.
6408
+ */
6409
+ startTagAlignment() {
6410
+ this.aligning = true;
6411
+ this.alignmentPairs = [];
6412
+ this.alignmentSource = null;
6413
+ if (this.renderer) {
6414
+ this.renderer.domElement.style.cursor = 'crosshair';
6415
+ }
6416
+ this.alignmentProgress$.next({ pairs: 0, awaiting: 'tag' });
6417
+ }
6418
+ cancelTagAlignment() {
6419
+ this.aligning = false;
6420
+ this.alignmentPairs = [];
6421
+ this.alignmentSource = null;
6422
+ if (this.renderer) {
6423
+ this.renderer.domElement.style.cursor = 'default';
6424
+ }
6425
+ this.alignmentProgress$.next({ pairs: 0, awaiting: null });
6426
+ }
6427
+ isAligningTags() {
6428
+ return this.aligning;
6429
+ }
6430
+ /**
6431
+ * Solves and applies the alignment collected so far, returning it so the caller can persist it.
6432
+ *
6433
+ * Returns null when the three tags turn out to be collinear, in which case nothing is applied and
6434
+ * the walkthrough stays open for the person to pick a better third point.
6435
+ */
6436
+ solveAlignmentFromPairs() {
6437
+ const alignment = solveThreeJsAlignment(this.alignmentPairs);
6438
+ if (!alignment) {
6439
+ return null;
6440
+ }
6441
+ this.setTagAlignment(alignment);
6442
+ this.aligning = false;
6443
+ this.renderer.domElement.style.cursor = 'default';
6444
+ this.alignmentProgress$.next({ pairs: this.alignmentPairs.length, awaiting: null });
6445
+ return alignment;
6446
+ }
6447
+ /** Handles a click while the alignment walkthrough is running. */
6448
+ handleAlignmentClick(event) {
6449
+ if (!this.alignmentSource) {
6450
+ // First half of a pair: which tag are we talking about?
6451
+ const hit = this.raycaster.intersectObjects(this.tagGroup.children, false)[0];
6452
+ if (!hit) {
6453
+ return;
6454
+ }
6455
+ // The sprite's own position is the stored coordinate, untouched by any alignment already
6456
+ // applied - that transform lives on the group, not on the sprite.
6457
+ const stored = hit.object.position;
6458
+ this.alignmentSource = { x: stored.x, y: stored.y, z: stored.z };
6459
+ this.alignmentProgress$.next({
6460
+ pairs: this.alignmentPairs.length,
6461
+ awaiting: 'point',
6462
+ tagId: hit.object.name,
6463
+ });
6464
+ return;
6465
+ }
6466
+ // Second half: where that tag actually belongs on the model.
6467
+ const placement = this.pickPlacementOnModel(event);
6468
+ if (!placement) {
6469
+ return;
6470
+ }
6471
+ this.alignmentPairs.push({ source: this.alignmentSource, target: placement.position });
6472
+ this.alignmentSource = null;
6473
+ this.alignmentProgress$.next({
6474
+ pairs: this.alignmentPairs.length,
6475
+ awaiting: this.alignmentPairs.length >= 3 ? null : 'tag',
6476
+ });
6477
+ }
6478
+ /** Arms tag placement: the next click on the model drops a tag instead of being ignored. */
6479
+ startTagPlacement() {
6480
+ this.placingTag = true;
6481
+ this.pendingTagPlacement = null;
6482
+ if (this.renderer) {
6483
+ this.renderer.domElement.style.cursor = 'crosshair';
6484
+ }
6485
+ }
6486
+ /** Leaves placement mode without placing anything. */
6487
+ cancelTagPlacement() {
6488
+ this.placingTag = false;
6489
+ this.pendingTagPlacement = null;
6490
+ this.removePlacementPreview();
6491
+ if (this.renderer) {
6492
+ this.renderer.domElement.style.cursor = 'default';
6493
+ }
6494
+ }
6495
+ /**
6496
+ * Shows a translucent tag under the cursor while placing, so the spot is visible before commiting.
6497
+ *
6498
+ * Matterport does this with a tag that follows the pointer; the equivalent here is a preview
6499
+ * sprite that gets removed the moment the real tag takes its place.
6500
+ */
6501
+ updatePlacementPreview(event) {
6502
+ const placement = this.pickPlacementOnModel(event);
6503
+ if (!placement) {
6504
+ this.removePlacementPreview();
6505
+ this.renderer.domElement.style.cursor = 'not-allowed';
6506
+ return;
6507
+ }
6508
+ this.renderer.domElement.style.cursor = 'crosshair';
6509
+ if (!this.placementPreview) {
6510
+ this.placementPreview = new THREE.Sprite(new THREE.SpriteMaterial({
6511
+ map: this.createTagTexture('#9E9E9E'),
6512
+ depthTest: false,
6513
+ transparent: true,
6514
+ opacity: 0.6,
6515
+ }));
6516
+ this.placementPreview.renderOrder = 999;
6517
+ this.tagGroup.add(this.placementPreview);
6518
+ this.scaleTagForModel(this.placementPreview);
6519
+ }
6520
+ this.placementPreview.position.set(placement.position.x, placement.position.y, placement.position.z);
6521
+ }
6522
+ removePlacementPreview() {
6523
+ if (!this.placementPreview) {
6524
+ return;
6525
+ }
6526
+ this.tagGroup.remove(this.placementPreview);
6527
+ this.placementPreview.material.map?.dispose();
6528
+ this.placementPreview.material.dispose();
6529
+ this.placementPreview = undefined;
6530
+ }
6531
+ /** Shows or hides one tag, leaving it registered so it can be brought back without reloading. */
6532
+ setTagVisibility(elementId, visible) {
6533
+ const sprite = this.tagSprites.get(elementId);
6534
+ if (sprite) {
6535
+ sprite.visible = visible;
6536
+ }
6537
+ }
6538
+ /**
6539
+ * Shows only tags of the given types, hiding the rest.
6540
+ *
6541
+ * Passing null shows everything - the "no filter" state, distinct from an empty array, which is a
6542
+ * filter that happens to match nothing.
6543
+ */
6544
+ setVisibleTagTypes(types) {
6545
+ this.tagSprites.forEach((sprite) => {
6546
+ sprite.visible = types === null || types.includes(sprite.userData.poiType);
6547
+ });
6548
+ }
6549
+ /**
6550
+ * Moves the camera to look at a tag, mirroring Matterport's goToTag.
6551
+ *
6552
+ * Animated rather than teleported: jumping the camera loses the viewer's sense of where they were
6553
+ * relative to the model. The approach distance follows the model's size so it frames sensibly
6554
+ * whatever the units are.
6555
+ */
6556
+ goToTag(elementId) {
6557
+ const sprite = this.tagSprites.get(elementId);
6558
+ if (!sprite || !this.camera) {
6559
+ return;
6560
+ }
6561
+ const target = sprite.getWorldPosition(new THREE.Vector3());
6562
+ const distance = this.modelRoot
6563
+ ? new THREE.Box3().setFromObject(this.modelRoot).getSize(new THREE.Vector3()).length() * 0.15
6564
+ : 5;
6565
+ // Approach along the current viewing direction, so the camera closes in rather than swinging
6566
+ // around to an arbitrary side.
6567
+ const direction = this.camera.position.clone().sub(this.controls.target).normalize();
6568
+ this.cameraTween = {
6569
+ fromPosition: this.camera.position.clone(),
6570
+ toPosition: target.clone().add(direction.multiplyScalar(distance)),
6571
+ fromTarget: this.controls.target.clone(),
6572
+ toTarget: target,
6573
+ startedAt: performance.now(),
6574
+ durationMs: 600,
6575
+ };
6576
+ }
6577
+ /** Advances the camera animation started by goToTag, if one is running. */
6578
+ stepCameraTween() {
6579
+ if (!this.cameraTween) {
6580
+ return;
6581
+ }
6582
+ const { fromPosition, toPosition, fromTarget, toTarget, startedAt, durationMs } = this.cameraTween;
6583
+ const linear = Math.min((performance.now() - startedAt) / durationMs, 1);
6584
+ // Ease out, so the move settles instead of stopping dead.
6585
+ const t = 1 - Math.pow(1 - linear, 3);
6586
+ this.camera.position.lerpVectors(fromPosition, toPosition, t);
6587
+ this.controls.target.lerpVectors(fromTarget, toTarget, t);
6588
+ if (linear >= 1) {
6589
+ this.cameraTween = null;
6590
+ }
6591
+ }
6592
+ isPlacingTag() {
6593
+ return this.placingTag;
6594
+ }
6595
+ /** The spot picked by the last placement, for whoever commits it to a POI. */
6596
+ getPendingTagPlacement() {
6597
+ return this.pendingTagPlacement;
6598
+ }
6599
+ /**
6600
+ * Turns a click into a point on the model, in model space.
6601
+ *
6602
+ * Returns null when the click missed the geometry, which is a normal outcome - the user clicked
6603
+ * the sky - and leaves placement mode armed so they can simply try again.
6604
+ */
6605
+ pickPlacementOnModel(event) {
6606
+ if (!this.modelRoot) {
6607
+ return null;
6608
+ }
6609
+ const rect = this.renderer.domElement.getBoundingClientRect();
6610
+ this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
6611
+ this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
6612
+ this.raycaster.setFromCamera(this.mouse, this.camera);
6613
+ const hit = this.raycaster.intersectObject(this.modelRoot, true)[0];
6614
+ if (!hit) {
6615
+ return null;
6616
+ }
6617
+ // The hit arrives in world space. Everything persisted has to be in the model's own space, so
6618
+ // it survives the centering and scaling applied at load - and any future re-export.
6619
+ const position = this.modelRoot.worldToLocal(hit.point.clone());
6620
+ // A face normal is in the geometry's local space already, but the mesh it belongs to may sit
6621
+ // under transforms of its own, so bring it back through those to match the position's frame.
6622
+ let normal = new THREE.Vector3(0, 1, 0);
6623
+ if (hit.face) {
6624
+ normal = hit.face.normal
6625
+ .clone()
6626
+ .transformDirection(hit.object.matrixWorld)
6627
+ .transformDirection(new THREE.Matrix4().copy(this.modelRoot.matrixWorld).invert())
6628
+ .normalize();
6629
+ }
6630
+ return {
6631
+ position: { x: position.x, y: position.y, z: position.z },
6632
+ normal: { x: normal.x, y: normal.y, z: normal.z },
6633
+ };
6634
+ }
6635
+ /**
6636
+ * Sets the transform mapping captured tag coordinates onto this model's space.
6637
+ *
6638
+ * Every tag comes from the same source space, so this is one transform on the group rather than
6639
+ * per-tag maths - which also means it applies retroactively to tags already placed, and can be
6640
+ * adjusted live while someone dials the numbers in.
6641
+ *
6642
+ * Passing null resets to identity, i.e. tag coordinates are already in model space.
6643
+ */
6644
+ setTagAlignment(alignment) {
6645
+ const { translation, rotation, scale } = alignment ?? {};
6646
+ this.tagGroup.position.set(translation?.x ?? 0, translation?.y ?? 0, translation?.z ?? 0);
6647
+ this.tagGroup.rotation.set(rotation?.x ?? 0, rotation?.y ?? 0, rotation?.z ?? 0);
6648
+ this.tagGroup.scale.setScalar(scale ?? 1);
6649
+ // Tag size is derived from the world scale it inherits, which this call just changed.
6650
+ this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
6651
+ }
6652
+ /**
6653
+ * Adds a tag, or moves/restyles the one already registered for this element.
6654
+ *
6655
+ * Upsert rather than a declarative "here is the full list" call, because tags arrive per type
6656
+ * (equipment, then tickets, then measurements...) from separate subscriptions - replacing the set
6657
+ * on each of those would wipe the types that came before.
6658
+ */
6659
+ upsertTag(tag) {
6660
+ const existing = this.tagSprites.get(tag.id);
6661
+ if (existing) {
6662
+ existing.position.set(tag.position.x, tag.position.y, tag.position.z);
6663
+ existing.userData.poiType = tag.poiType ?? existing.userData.poiType;
6664
+ return;
6665
+ }
6666
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
6667
+ map: this.createTagTexture(tag.color),
6668
+ // Tags read as UI rather than geometry, so they stay visible through the model instead of
6669
+ // being occluded by whatever wall happens to be between them and the camera.
6670
+ depthTest: false,
6671
+ transparent: true,
6672
+ }));
6673
+ sprite.name = tag.id;
6674
+ sprite.userData.poiType = tag.poiType ?? null;
6675
+ sprite.position.set(tag.position.x, tag.position.y, tag.position.z);
6676
+ sprite.renderOrder = 999;
6677
+ this.scaleTagForModel(sprite);
6678
+ this.tagGroup.add(sprite);
6679
+ this.tagSprites.set(tag.id, sprite);
6680
+ if (tag.iconUrl) {
6681
+ this.applyTagIcon(sprite, tag.iconUrl);
6682
+ }
6683
+ }
6684
+ /** Removes the tag registered for an element, if there is one. */
6685
+ removeTag(elementId) {
6686
+ const sprite = this.tagSprites.get(elementId);
6687
+ if (!sprite) {
6688
+ return;
6689
+ }
6690
+ this.tagGroup.remove(sprite);
6691
+ sprite.material.map?.dispose();
6692
+ sprite.material.dispose();
6693
+ this.tagSprites.delete(elementId);
6694
+ }
6695
+ /** Removes every tag from the scene. */
6696
+ clearTags() {
6697
+ [...this.tagSprites.keys()].forEach((id) => this.removeTag(id));
6698
+ }
6699
+ /** Loads an icon into an existing tag, leaving the fallback disc in place if it fails. */
6700
+ applyTagIcon(sprite, iconUrl) {
6701
+ new THREE.TextureLoader().load(iconUrl, (texture) => {
6702
+ sprite.material.map?.dispose();
6703
+ sprite.material.map = texture;
6704
+ sprite.material.needsUpdate = true;
6705
+ }, undefined, () => console.warn(`ThreeJsService: could not load tag icon "${iconUrl}", keeping the default marker.`));
6706
+ }
6707
+ /** Draws the default tag marker: a filled disc with a white ring, so it reads on any background. */
6708
+ createTagTexture(color = '#1E88E5') {
6709
+ const size = 128;
6710
+ const canvas = document.createElement('canvas');
6711
+ canvas.width = size;
6712
+ canvas.height = size;
6713
+ const context = canvas.getContext('2d');
6714
+ context.beginPath();
6715
+ context.arc(size / 2, size / 2, size / 2 - 8, 0, Math.PI * 2);
6716
+ context.fillStyle = color;
6717
+ context.fill();
6718
+ context.lineWidth = 8;
6719
+ context.strokeStyle = '#FFFFFF';
6720
+ context.stroke();
6721
+ return new THREE.CanvasTexture(canvas);
6722
+ }
6723
+ /**
6724
+ * Sizes a tag relative to the model.
6725
+ *
6726
+ * A sprite inherits the model's scale and the alignment's, so a fixed size would come out tiny on
6727
+ * a large model and huge on a small one. Dividing by the group's world scale keeps tags the same
6728
+ * size on screen whatever the model's units are.
6729
+ */
6730
+ scaleTagForModel(sprite) {
6731
+ const worldScale = this.tagGroup.getWorldScale(new THREE.Vector3()).x || 1;
6732
+ const size = 0.4 / worldScale;
6733
+ sprite.scale.set(size, size, 1);
6734
+ }
6735
+ /**
6736
+ * Adds secondary interactive objects (e.g., POIs, sensors) to the scene.
6737
+ */
6738
+ updateObjsToScene(objsAdded) {
6739
+ const existingIds = this.clickableObjects.map((obj) => obj.name);
6740
+ const objLoader = new OBJLoader();
6741
+ // delete objects that are not in the provided array
6742
+ this.clickableObjects.forEach((obj) => {
6743
+ if (!objsAdded.find((objData) => objData.id === obj.name)) {
6744
+ this.scene.remove(obj);
6745
+ this.clickableObjects = this.clickableObjects.filter((object) => object.name !== obj.name);
6746
+ }
6747
+ });
6748
+ objsAdded.forEach((objectData) => {
6749
+ if (!existingIds.includes(objectData.id)) {
6750
+ objLoader.load(objectData.file, (newObject) => {
6751
+ newObject.name = objectData.id;
6752
+ newObject.traverse((child) => {
6753
+ if (child.isMesh) {
6754
+ const mesh = child;
6755
+ if (mesh.material instanceof THREE.Material) {
6756
+ mesh.material.transparent = true;
6757
+ mesh.material.opacity = 0.8;
6758
+ }
6759
+ }
6760
+ });
6761
+ // set object position and rotation
6762
+ newObject.position.set(objectData.position.x, objectData.position.y, objectData.position.z);
6763
+ // The JSON configuration has rotation already in radians (e.g. 3.1415...)
6764
+ newObject.rotation.set(objectData.rotation.x, objectData.rotation.y, objectData.rotation.z);
6765
+ newObject.scale.set(objectData.scale.x, objectData.scale.y, objectData.scale.z);
6766
+ this.scene.add(newObject);
6767
+ this.clickableObjects.push(newObject);
6768
+ });
6769
+ }
6770
+ });
6771
+ }
6772
+ onMouseMove(event) {
6773
+ if (!this.isDraggingCamera) {
6774
+ const rect = this.renderer.domElement.getBoundingClientRect();
6775
+ this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
6776
+ this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
6777
+ this.raycaster.setFromCamera(this.mouse, this.camera);
6778
+ // While placing, the preview follows the pointer and nothing else should react to it.
6779
+ if (this.placingTag) {
6780
+ this.updatePlacementPreview(event);
6781
+ return;
6782
+ }
6783
+ // Tags first: they draw on top of the model, so they have to answer the pointer first too.
6784
+ if (this.raycaster.intersectObjects(this.tagGroup.children, false).some((hit) => hit.object.visible)) {
6785
+ this.renderer.domElement.style.cursor = 'pointer';
6786
+ this.objectHovered$.next(null);
6787
+ return;
6788
+ }
6789
+ const intersects = this.raycaster.intersectObjects(this.clickableObjects, true);
6790
+ if (intersects.length > 0) {
6791
+ this.renderer.domElement.style.cursor = 'pointer';
6792
+ const intersectedObjectId = intersects[0].object?.parent?.name;
6793
+ if (intersectedObjectId) {
6794
+ this.objectHovered$.next({ id: intersectedObjectId, event });
6795
+ }
6796
+ }
6797
+ else {
6798
+ this.renderer.domElement.style.cursor = 'default';
6799
+ this.objectHovered$.next(null);
6800
+ }
6801
+ }
6802
+ else {
6803
+ this.renderer.domElement.style.cursor = 'grabbing';
6804
+ this.objectHovered$.next(null);
6805
+ }
6806
+ }
6807
+ onMouseClick(event) {
6808
+ if (!this.isDraggingCamera) {
6809
+ const rect = this.renderer.domElement.getBoundingClientRect();
6810
+ this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
6811
+ this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
6812
+ // Alignment and placement both take the click before anything can be opened by it.
6813
+ if (this.aligning) {
6814
+ this.raycaster.setFromCamera(this.mouse, this.camera);
6815
+ this.handleAlignmentClick(event);
6816
+ return;
6817
+ }
6818
+ // Placement wins over everything: while armed, a click means "put it here", not "open that".
6819
+ if (this.placingTag) {
6820
+ const placement = this.pickPlacementOnModel(event);
6821
+ if (placement) {
6822
+ this.pendingTagPlacement = placement;
6823
+ this.placingTag = false;
6824
+ this.removePlacementPreview();
6825
+ this.renderer.domElement.style.cursor = 'default';
6826
+ this.tagPlaced$.next(placement);
6827
+ }
6828
+ return;
6829
+ }
6830
+ this.raycaster.setFromCamera(this.mouse, this.camera);
6831
+ // Same precedence as hover: a tag drawn over the model claims the click.
6832
+ const tagHits = this.raycaster.intersectObjects(this.tagGroup.children, false)
6833
+ .filter((hit) => hit.object.visible);
6834
+ if (tagHits.length > 0) {
6835
+ const tag = tagHits[0].object;
6836
+ this.tagClicked$.next({ elementId: tag.name, poiType: tag.userData.poiType ?? null });
6837
+ return;
6838
+ }
6839
+ const intersects = this.raycaster.intersectObjects(this.clickableObjects, true);
6840
+ if (intersects.length > 0) {
6841
+ const clickedObject = intersects[0].object;
6842
+ const id = clickedObject?.parent?.name;
6843
+ if (id) {
6844
+ this.objectClicked$.next(id);
6845
+ }
6846
+ }
6847
+ else {
6848
+ this.objectClicked$.next(null);
6849
+ }
6850
+ }
6851
+ }
6852
+ animate = () => {
6853
+ this.animationFrameId = requestAnimationFrame(this.animate);
6854
+ this.stepCameraTween();
6855
+ this.controls.update();
6856
+ this.renderer.render(this.scene, this.camera);
6857
+ };
6858
+ destroy() {
6859
+ if (this.animationFrameId) {
6860
+ cancelAnimationFrame(this.animationFrameId);
6861
+ }
6862
+ if (this.canvasResizeObserver) {
6863
+ this.canvasResizeObserver.disconnect();
6864
+ this.canvasResizeObserver = undefined;
6865
+ }
6866
+ this.discardCurrentLoad();
6867
+ this.clearTags();
6868
+ if (this.renderer) {
6869
+ this.renderer.dispose();
6870
+ }
6871
+ if (this.controls) {
6872
+ this.controls.dispose();
6873
+ }
6874
+ }
6875
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
6876
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, providedIn: 'root' });
6877
+ }
6878
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, decorators: [{
6879
+ type: Injectable,
6880
+ args: [{
6881
+ providedIn: 'root'
6882
+ }]
6883
+ }], ctorParameters: () => [{ type: i0.NgZone }] });
6884
+
6885
+ /* eslint-disable no-await-in-loop */
6886
+ /* eslint-disable camelcase */
6887
+ /* eslint-disable no-underscore-dangle */
6888
+ /* eslint-disable class-methods-use-this */
6889
+ class ViewerService {
6890
+ sdkKey;
6891
+ poiService;
6892
+ matterportService;
6893
+ threeJsService;
6894
+ viewerMode = 'Matterport';
6895
+ currentModel3D;
6896
+ tourUrl;
6897
+ viewerIsOn = new Subject();
6898
+ dataIsLoaded = new Subject();
6899
+ isLoaded = false;
6900
+ showingViewer = false;
6901
+ sweepToMove;
6902
+ rotationToMove;
6903
+ goToTriggered = new Subject();
6904
+ toolboxStates = {
6905
+ dollhouse: {
6906
+ stat: false,
6907
+ iconId: 'ui-dollhouse',
6908
+ show() {
6909
+ },
6910
+ hide() {
6911
+ },
6912
+ },
6913
+ floorplan: {
6914
+ stat: false,
6915
+ },
6916
+ mesure: {
6917
+ stat: false,
6918
+ iconId: 'ui-mesure',
6919
+ show() {
6920
+ },
6921
+ hide() {
6922
+ },
6923
+ },
6924
+ filter: {
6925
+ stat: false,
6926
+ },
6927
+ };
6928
+ isObjectLoaded = false;
6929
+ constructor(sdkKey, poiService, matterportService, threeJsService) {
6930
+ this.sdkKey = sdkKey;
6931
+ this.poiService = poiService;
6932
+ this.matterportService = matterportService;
6933
+ this.threeJsService = threeJsService;
5419
6934
  this.setMode('Matterport');
5420
6935
  }
5421
6936
  /*-----------------------------------------------------------------------------------------------------------------
@@ -5428,6 +6943,13 @@ class ViewerService {
5428
6943
  Setters
5429
6944
  -----------------------------------------------------------------------------------------------------------------*/
5430
6945
  setMode(indexMode) {
6946
+ // Leaving Three.js means no Three.js work should still be running. The service behind it is a
6947
+ // singleton and outlives the component, so a download started for a model the user has navigated
6948
+ // away from would otherwise finish - and its parse would hold the main thread for seconds,
6949
+ // stalling whatever viewer they actually opened.
6950
+ if (this.viewerMode === 'threejs' && indexMode !== 'threejs') {
6951
+ this.threeJsService.destroy();
6952
+ }
5431
6953
  this.viewerMode = indexMode;
5432
6954
  }
5433
6955
  setTourUrl(options) {
@@ -5487,7 +7009,10 @@ class ViewerService {
5487
7009
  async clearAll() {
5488
7010
  // this.sweepToMove = null;
5489
7011
  // this.rotationToMove = null;
5490
- if (this.checkMode('Matterport')) {
7012
+ if (this.checkMode('threejs')) {
7013
+ this.threeJsService.destroy();
7014
+ }
7015
+ else if (this.checkMode('Matterport')) {
5491
7016
  this.showingViewer = false;
5492
7017
  this.isLoaded = false;
5493
7018
  await this.matterportService.clearAll();
@@ -5565,15 +7090,136 @@ class ViewerService {
5565
7090
  if (this.checkMode('Matterport')) {
5566
7091
  return this.matterportService.createMattertagFromPOI(poi.type, object, poi);
5567
7092
  }
7093
+ if (this.checkMode('threejs')) {
7094
+ return this.addThreeJsTagFromPOI(object, poi);
7095
+ }
5568
7096
  // Cupix
5569
7097
  // TODO
5570
7098
  return Promise.resolve();
5571
7099
  }
7100
+ /**
7101
+ * Draws a POI as a tag in the Three.js scene.
7102
+ *
7103
+ * Both viewers are fed by the same subscriptions on the same POI records, so a tag created in
7104
+ * either one shows up in the other without any syncing of its own - this is just the Three.js end
7105
+ * of the pipeline that already drives Matterport.
7106
+ */
7107
+ async addThreeJsTagFromPOI(object, poi) {
7108
+ if (!poi.coordinate) {
7109
+ return;
7110
+ }
7111
+ let position;
7112
+ try {
7113
+ position = JSON.parse(poi.coordinate);
7114
+ }
7115
+ catch {
7116
+ console.warn(`ViewerService: unparseable POI coordinate for element ${object.id}:`, poi.coordinate);
7117
+ return;
7118
+ }
7119
+ if (![position?.x, position?.y, position?.z].every((value) => typeof value === 'number')) {
7120
+ return;
7121
+ }
7122
+ this.threeJsService.upsertTag({
7123
+ id: object.id,
7124
+ position,
7125
+ poiType: poi.type,
7126
+ iconUrl: poi.tagIcon ? await getSignedFile(poi.tagIcon) : null,
7127
+ });
7128
+ }
7129
+ /** Moves the Three.js camera to frame a tag, mirroring Matterport's goToTag. */
7130
+ goToThreeJsTag(elementId) {
7131
+ if (this.checkMode('threejs')) {
7132
+ this.threeJsService.goToTag(elementId);
7133
+ }
7134
+ }
7135
+ /** Shows only tags of the given POI types; null clears the filter. */
7136
+ setVisibleThreeJsTagTypes(types) {
7137
+ if (this.checkMode('threejs')) {
7138
+ this.threeJsService.setVisibleTagTypes(types);
7139
+ }
7140
+ }
7141
+ /**
7142
+ * Alignment walkthrough: pick a tag, then click where it really sits, three times over.
7143
+ *
7144
+ * Uses tags that already exist, so nothing is created and nothing is written until the caller
7145
+ * saves the result. See threeJsAlignmentProgress$ for what the UI should be asking for.
7146
+ */
7147
+ startThreeJsTagAlignment() {
7148
+ if (this.checkMode('threejs')) {
7149
+ this.threeJsService.startTagAlignment();
7150
+ }
7151
+ }
7152
+ cancelThreeJsTagAlignment() {
7153
+ if (this.checkMode('threejs')) {
7154
+ this.threeJsService.cancelTagAlignment();
7155
+ }
7156
+ }
7157
+ get threeJsAlignmentProgress$() {
7158
+ return this.threeJsService.alignmentProgress$;
7159
+ }
7160
+ /**
7161
+ * Solves the alignment and applies it to the scene, returning it as the JSON to store in the
7162
+ * capture's metadata. Null means the three tags were collinear and nothing was applied.
7163
+ */
7164
+ solveThreeJsTagAlignment(existingMetadata) {
7165
+ if (!this.checkMode('threejs')) {
7166
+ return null;
7167
+ }
7168
+ const alignment = this.threeJsService.solveAlignmentFromPairs();
7169
+ if (!alignment) {
7170
+ return null;
7171
+ }
7172
+ // Merged into whatever the capture already stores, so an existing camera placement survives.
7173
+ let stored = {};
7174
+ try {
7175
+ stored = existingMetadata ? JSON.parse(existingMetadata) : {};
7176
+ }
7177
+ catch {
7178
+ stored = {};
7179
+ }
7180
+ stored.alignment = alignment;
7181
+ return { alignment, metadata: JSON.stringify(stored) };
7182
+ }
7183
+ /** Shows or hides a single Three.js tag. */
7184
+ setThreeJsTagVisibility(elementId, visible) {
7185
+ if (this.checkMode('threejs')) {
7186
+ this.threeJsService.setTagVisibility(elementId, visible);
7187
+ }
7188
+ }
5572
7189
  async action_delete_element(element) {
5573
7190
  // Matterport
5574
7191
  if (this.checkMode('Matterport')) {
5575
7192
  await this.matterportService.deleteMattertagForObject(element.id);
5576
7193
  }
7194
+ if (this.checkMode('threejs')) {
7195
+ this.threeJsService.removeTag(element.id);
7196
+ }
7197
+ }
7198
+ /** Emits when a Three.js model load actually stopped short, rather than merely being asked to. */
7199
+ get threeJsModelLoadCancelled$() {
7200
+ return this.threeJsService.modelLoadCancelled$;
7201
+ }
7202
+ /** Stops the Three.js model download in progress. */
7203
+ cancelThreeJsModelLoad() {
7204
+ if (this.checkMode('threejs')) {
7205
+ this.threeJsService.cancelModelLoad();
7206
+ }
7207
+ }
7208
+ /** Emits the element id of a tag clicked in the Three.js scene. */
7209
+ get threeJsTagClicked$() {
7210
+ return this.threeJsService.tagClicked$;
7211
+ }
7212
+ /**
7213
+ * Applies the visit's stored tag alignment, mapping captured tag coordinates onto this model.
7214
+ *
7215
+ * Safe to call before the model or any tag exists: it is a transform on the group both end up
7216
+ * under, so it applies to whatever arrives later.
7217
+ */
7218
+ setThreeJsTagAlignmentFromMetadata(metadata) {
7219
+ if (!this.checkMode('threejs')) {
7220
+ return;
7221
+ }
7222
+ this.threeJsService.setTagAlignment(parseThreeJsPlacement(metadata)?.alignment ?? null);
5577
7223
  }
5578
7224
  /**
5579
7225
  * Enable tag placement menu to add a new element depending on its type (TICKET, DATA, EQUIPMENT etc)
@@ -5585,6 +7231,9 @@ class ViewerService {
5585
7231
  this.matterportService.getInteractionMode() === ViewerInteractions.DEFAULT) {
5586
7232
  await this.matterportService.addMattertagWhenAdding(poiType);
5587
7233
  }
7234
+ if (this.checkMode('threejs')) {
7235
+ this.threeJsService.startTagPlacement();
7236
+ }
5588
7237
  }
5589
7238
  /**
5590
7239
  * Launches the repositioning of an existing Mattertag related to parameter object
@@ -5605,6 +7254,9 @@ class ViewerService {
5605
7254
  if (this.checkMode('Matterport')) {
5606
7255
  this.matterportService.cancelFollowingCursor();
5607
7256
  }
7257
+ if (this.checkMode('threejs')) {
7258
+ this.threeJsService.cancelTagPlacement();
7259
+ }
5608
7260
  }
5609
7261
  action_toolbox_inside_view() {
5610
7262
  // Matterport
@@ -5688,8 +7340,51 @@ class ViewerService {
5688
7340
  this.matterportService.deleteLastMattertag();
5689
7341
  return Promise.resolve(poi);
5690
7342
  }
7343
+ if (this.checkMode('threejs')) {
7344
+ return this.commitThreeJsTagPlacement(poi, isValid, element);
7345
+ }
5691
7346
  return Promise.resolve(poi);
5692
7347
  }
7348
+ /**
7349
+ * Writes the spot picked in the Three.js scene into the POI, mirroring the Matterport branch.
7350
+ *
7351
+ * The coordinate goes in the same field and the same space Matterport writes, which is what makes
7352
+ * a tag created here show up in the right place there - and the other way round.
7353
+ */
7354
+ async commitThreeJsTagPlacement(poi, isValid, element) {
7355
+ const placement = this.threeJsService.getPendingTagPlacement();
7356
+ if (!isValid || !placement || !poi) {
7357
+ this.threeJsService.cancelTagPlacement();
7358
+ return Promise.resolve(poi);
7359
+ }
7360
+ const poiMetadata = poi.metadata ? JSON.parse(poi.metadata) : {};
7361
+ poiMetadata.normal = placement.normal;
7362
+ const updatedPoi = await this.poiService.updatePoi({
7363
+ id: poi.id,
7364
+ coordinate: JSON.stringify(placement.position),
7365
+ metadata: JSON.stringify(poiMetadata),
7366
+ // matterportSweepID is left as it was: this viewer has no sweeps, and Matterport filters tag
7367
+ // visibility by them, so clearing it would hide the tag over there.
7368
+ });
7369
+ this.threeJsService.upsertTag({
7370
+ id: element.id,
7371
+ position: placement.position,
7372
+ poiType: updatedPoi?.type ?? poi.type,
7373
+ iconUrl: updatedPoi?.tagIcon ? await getSignedFile(updatedPoi.tagIcon) : null,
7374
+ });
7375
+ this.threeJsService.cancelTagPlacement();
7376
+ return updatedPoi;
7377
+ }
7378
+ /** Emits when a tag is dropped on the model in the Three.js scene. */
7379
+ get threeJsTagPlaced$() {
7380
+ return this.threeJsService.tagPlaced$;
7381
+ }
7382
+ /** Leaves Three.js tag placement mode without placing anything. */
7383
+ cancelThreeJsTagPlacement() {
7384
+ if (this.checkMode('threejs')) {
7385
+ this.threeJsService.cancelTagPlacement();
7386
+ }
7387
+ }
5693
7388
  editOnlyTagStyleForPoi(poi, object) {
5694
7389
  if (this.checkMode('Matterport')) {
5695
7390
  const tagData = this.matterportService.getTagFromElementId(poi.elementID);
@@ -5741,6 +7436,79 @@ class ViewerService {
5741
7436
  https://matterport.github.io/showcase-sdk/docs/sdk/reference/current/modules/mattertag.html#add
5742
7437
  https://matterport.github.io/showcase-sdk/docs/sdk/reference/current/modules/mattertag.html#remove
5743
7438
  -----------------------------------------------------------------------------------------------------------------*/
7439
+ initThreeJs(canvas) {
7440
+ if (this.checkMode('threejs')) {
7441
+ this.threeJsService.init(canvas);
7442
+ }
7443
+ }
7444
+ /**
7445
+ * Loads a 3D model into the Three.js scene (GLTF).
7446
+ * Only works when the viewer is in 'threejs' mode.
7447
+ */
7448
+ loadGLTFModel(url, cameraPosition, rotation) {
7449
+ if (this.checkMode('threejs')) {
7450
+ this.threeJsService.loadGLTFModel(url, cameraPosition, rotation);
7451
+ }
7452
+ }
7453
+ /**
7454
+ * Loads a GLB stored in S3, given its key (e.g. `visits/<id>/model.glb`).
7455
+ *
7456
+ * Resolves the key to a signed URL first, then loads it like any other. The signed URL only
7457
+ * covers authentication - the bucket still needs a CORS rule allowing GET from the app's origin,
7458
+ * or the browser blocks the download before it starts.
7459
+ *
7460
+ * `placementMetadata` is the capture's stored placement; when absent or unusable the viewer falls
7461
+ * back to framing the model automatically.
7462
+ */
7463
+ async loadGLTFModelFromS3(key, placementMetadata) {
7464
+ if (!this.checkMode('threejs')) {
7465
+ return;
7466
+ }
7467
+ // A key under assets/ is served by the app itself, so it needs no signing. This is what lets a
7468
+ // model bundled with the build stand in for one in S3 while the two are otherwise identical.
7469
+ const isLocalAsset = key.startsWith('assets/');
7470
+ const url = isLocalAsset ? key : await getSignedFile(key);
7471
+ if (!url) {
7472
+ console.error(`ViewerService: could not resolve a signed URL for the 3D model at "${key}".`);
7473
+ return;
7474
+ }
7475
+ const placement = parseThreeJsPlacement(placementMetadata);
7476
+ this.threeJsService.loadGLTFModel(url, placement?.cameraPosition, placement?.rotation);
7477
+ }
7478
+ /**
7479
+ * Loads a 3D model into the Three.js scene (OBJ).
7480
+ */
7481
+ loadObjModel(mtlUrl, objUrl, cameraPosition, rotation) {
7482
+ if (this.checkMode('threejs')) {
7483
+ this.threeJsService.loadObjModel(mtlUrl, objUrl, cameraPosition, rotation);
7484
+ }
7485
+ }
7486
+ get threeJsObjectHovered$() {
7487
+ return this.threeJsService.objectHovered$;
7488
+ }
7489
+ get threeJsObjectClicked$() {
7490
+ return this.threeJsService.objectClicked$;
7491
+ }
7492
+ /** Emits true while a heavy .obj file is being parsed (in a worker, when available), false once done. */
7493
+ get threeJsModelLoading$() {
7494
+ return this.threeJsService.modelLoading$;
7495
+ }
7496
+ /** Download progress for GLB models as a 0-1 fraction, or null when the size isn't known upfront. */
7497
+ get threeJsModelLoadProgress$() {
7498
+ return this.threeJsService.modelLoadProgress$;
7499
+ }
7500
+ /**
7501
+ * Registers the Web Worker used to parse heavy .obj files off the main thread.
7502
+ * Must be created by the app (see obj-parser.worker.ts), since this library can't bundle worker files.
7503
+ */
7504
+ setObjParserWorker(worker) {
7505
+ this.threeJsService.setObjParserWorker(worker);
7506
+ }
7507
+ addThreeJsObjects(objs) {
7508
+ if (this.checkMode('threejs')) {
7509
+ this.threeJsService.updateObjsToScene(objs);
7510
+ }
7511
+ }
5744
7512
  async initMatterportSdk(spaceID, model3D, tagService, module = SpModule.IMMO) {
5745
7513
  this.isLoaded = false;
5746
7514
  this.matterportService.currentSpaceID = spaceID;
@@ -5800,7 +7568,7 @@ class ViewerService {
5800
7568
  isObject3DLoadedInPosition() {
5801
7569
  return this.isObjectLoaded;
5802
7570
  }
5803
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, deps: [{ token: MATTERPORT_SDK_KEY }, { token: PoiService }, { token: MatterportService }], target: i0.ɵɵFactoryTarget.Injectable });
7571
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, deps: [{ token: MATTERPORT_SDK_KEY }, { token: PoiService }, { token: MatterportService }, { token: ThreeJsService }], target: i0.ɵɵFactoryTarget.Injectable });
5804
7572
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, providedIn: 'root' });
5805
7573
  }
5806
7574
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, decorators: [{
@@ -5811,224 +7579,94 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
5811
7579
  }], ctorParameters: () => [{ type: undefined, decorators: [{
5812
7580
  type: Inject,
5813
7581
  args: [MATTERPORT_SDK_KEY]
5814
- }] }, { type: PoiService }, { type: MatterportService }] });
7582
+ }] }, { type: PoiService }, { type: MatterportService }, { type: ThreeJsService }] });
5815
7583
 
5816
7584
  class ModalSwitchVisitComponent {
5817
7585
  activeModal;
5818
7586
  router;
5819
7587
  viewerService;
5820
- content;
5821
- model3D;
5822
- spaceID;
5823
- dataToMove;
5824
- constructor(activeModal, router, viewerService) {
5825
- this.activeModal = activeModal;
5826
- this.router = router;
5827
- this.viewerService = viewerService;
5828
- }
5829
- ngOnInit() { }
5830
- switchVisit() {
5831
- this.activeModal.close();
5832
- if (this.dataToMove) {
5833
- this.viewerService.setSweepToMove(this.dataToMove.sweepToMove);
5834
- this.viewerService.setRotationToMove(this.dataToMove.rotation);
5835
- }
5836
- // this.objectSubService.forceRemove = true; // we reset the tags when changing visit
5837
- this.router.navigate(["visit", this.spaceID], {
5838
- queryParams: { model3D: this.model3D },
5839
- });
5840
- }
5841
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ModalSwitchVisitComponent, deps: [{ token: i1$2.NgbActiveModal }, { token: i1$1.Router }, { token: ViewerService }], target: i0.ɵɵFactoryTarget.Component });
5842
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: ModalSwitchVisitComponent, selector: "lib-modal-switch-visit", inputs: { content: "content", model3D: "model3D", spaceID: "spaceID", dataToMove: "dataToMove" }, ngImport: i0, template: "<div class=\"modal-container\">\r\n <p>{{content}}</p>\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\r\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\r\n</div>\r\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
5843
- }
5844
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ModalSwitchVisitComponent, decorators: [{
5845
- type: Component,
5846
- args: [{ selector: 'lib-modal-switch-visit', template: "<div class=\"modal-container\">\r\n <p>{{content}}</p>\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\r\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\r\n</div>\r\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"] }]
5847
- }], ctorParameters: () => [{ type: i1$2.NgbActiveModal }, { type: i1$1.Router }, { type: ViewerService }], propDecorators: { content: [{
5848
- type: Input
5849
- }], model3D: [{
5850
- type: Input
5851
- }], spaceID: [{
5852
- type: Input
5853
- }], dataToMove: [{
5854
- type: Input
5855
- }] } });
5856
-
5857
- const deleteFromS3 = async (path) => {
5858
- await Storage.remove(path, { level: "public" })
5859
- .then((result) => console.log("Successfully deleted =>", result))
5860
- .catch((error) => console.log("Deleting from S3 error:", error));
5861
- };
5862
- const uploadFileToS3 = async (path, file, objectId) => {
5863
- const prefix = objectId ? `${objectId.slice(0, 8)}_` : "";
5864
- return Storage.put(path + prefix + file.name, file, {
5865
- progressCallback(progress) {
5866
- // console.log('Uploaded : ', progress);
5867
- },
5868
- contentType: file.type,
5869
- })
5870
- .then((result) => {
5871
- console.log("Success =>", result);
5872
- return result.key;
5873
- });
5874
- };
5875
- const getSignedImageUrlForEquipment = async (equip) => {
5876
- if (!equip.photo) {
5877
- return Promise.resolve(null);
5878
- }
5879
- return Storage.get(equip.photo, { level: "public" })
5880
- .then((result) => {
5881
- return result;
5882
- })
5883
- .catch((error) => {
5884
- console.log("error fetching file from s3 =>", error);
5885
- return null;
5886
- });
5887
- };
5888
- const getSignedImageUrlForProfile = async (profile) => {
5889
- return Storage.get(profile.avatar, { level: "public" })
5890
- .then((result) => {
5891
- return result;
5892
- })
5893
- .catch((error) => {
5894
- console.log("error fetching file from s3 =>", error);
5895
- });
5896
- };
5897
- const getSignedImageUrlForSpace = async (space) => {
5898
- return Storage.get(space.annexes, { level: "public" })
5899
- .then((result) => {
5900
- return result;
5901
- })
5902
- .catch((error) => {
5903
- console.log("error fetching file from s3 =>", error);
5904
- });
5905
- };
5906
- const uploadBase64Image = async (uri, objectId, path, friendlyFileName) => {
5907
- return fetch(uri)
5908
- .then((res) => res.blob())
5909
- .then((blob) => {
5910
- const file = new File([blob], `${friendlyFileName}.jpeg`, {
5911
- type: "image/jpeg",
5912
- });
5913
- return uploadFileToS3(path, file, objectId);
5914
- });
5915
- };
5916
- const uploadBase64ImageWithRetry = async (uri, objectId, path, friendlyFileName, retry) => {
5917
- return new Promise(async (resolve, reject) => {
5918
- while (retry > 0) {
5919
- try {
5920
- const r = await uploadBase64Image(uri, objectId, path, friendlyFileName);
5921
- resolve(r);
5922
- return;
5923
- }
5924
- catch (e) {
5925
- retry--;
5926
- console.error(`Upload error, retry n° ${retry} : `, e);
5927
- }
5928
- }
5929
- ;
5930
- reject("Max retry exceeded");
5931
- });
5932
- };
5933
- const removeAllFilesFromFolderS3 = async (folderPath) => {
5934
- const arrayOfPromises = [];
5935
- Storage.list(folderPath, { level: "public" })
5936
- .then((res) => {
5937
- Object.keys(res).map(async (key) => {
5938
- const file = res[key];
5939
- arrayOfPromises.push(deleteFromS3(file.key));
5940
- });
5941
- })
5942
- .catch((error) => console.error("List failure", error));
5943
- return Promise.all(arrayOfPromises);
5944
- };
5945
- const listFilesInFolder = async (folderPath) => {
5946
- return Storage.list(folderPath, { level: "public", pageSize: 'ALL' })
5947
- .then((res) => {
5948
- return res.results;
5949
- })
5950
- .catch((error) => console.error("List failure", error));
5951
- };
5952
- const getSignedFile = async (path) => {
5953
- return Storage.get(path, { level: "public" })
5954
- .then((result) => {
5955
- return result;
5956
- })
5957
- .catch((error) => {
5958
- console.log("error fetching file from s3 =>", error);
5959
- return null;
5960
- });
5961
- };
5962
- const downloadFileAsObject = async (path) => {
5963
- return Storage.get(path, { download: true, level: "public" })
5964
- .then((result) => {
5965
- return result;
5966
- })
5967
- .catch((error) => {
5968
- console.log("error fetching file from s3 =>", error);
5969
- });
5970
- };
5971
- const getBufferForFileFromS3 = async (path) => {
5972
- const object = await downloadFileAsObject(path);
5973
- if (object) {
5974
- // @ts-ignore
5975
- return new ArrayBuffer(object.Body, "binary");
7588
+ content;
7589
+ model3D;
7590
+ spaceID;
7591
+ dataToMove;
7592
+ constructor(activeModal, router, viewerService) {
7593
+ this.activeModal = activeModal;
7594
+ this.router = router;
7595
+ this.viewerService = viewerService;
5976
7596
  }
5977
- return null;
5978
- };
5979
- const uploadJsonToS3 = async (path, json, filename) => {
5980
- return Storage.put(path + filename, json, {
5981
- contentType: "application/json",
5982
- })
5983
- .then((result) => {
5984
- console.log("Success =>", result);
5985
- return result.key;
5986
- })
5987
- .catch((error) => {
5988
- console.log("error =>", error);
5989
- return null;
5990
- });
5991
- };
5992
- /**
5993
- * Physically duplicates an S3 object to a new key. Used when copying attachments
5994
- * between features/tickets/etc. so the copy is fully independent: deleting the
5995
- * original file later must not affect the copy.
5996
- * IMPORTANT: does NOT swallow errors — callers need the real error (e.g. NoSuchKey)
5997
- * to distinguish an orphaned DB reference from a genuine copy failure.
5998
- */
5999
- const copyFileInS3 = async (sourceKey, destKey) => {
6000
- const result = await Storage.copy({ key: sourceKey, level: "public" }, { key: destKey, level: "public" });
6001
- return result.key;
6002
- };
7597
+ ngOnInit() { }
7598
+ switchVisit() {
7599
+ this.activeModal.close();
7600
+ if (this.dataToMove) {
7601
+ this.viewerService.setSweepToMove(this.dataToMove.sweepToMove);
7602
+ this.viewerService.setRotationToMove(this.dataToMove.rotation);
7603
+ }
7604
+ // this.objectSubService.forceRemove = true; // we reset the tags when changing visit
7605
+ this.router.navigate(["visit", this.spaceID], {
7606
+ queryParams: { model3D: this.model3D },
7607
+ });
7608
+ }
7609
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ModalSwitchVisitComponent, deps: [{ token: i1$2.NgbActiveModal }, { token: i1$1.Router }, { token: ViewerService }], target: i0.ɵɵFactoryTarget.Component });
7610
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: ModalSwitchVisitComponent, selector: "lib-modal-switch-visit", inputs: { content: "content", model3D: "model3D", spaceID: "spaceID", dataToMove: "dataToMove" }, ngImport: i0, template: "<div class=\"modal-container\">\n <p>{{content}}</p>\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\n</div>\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
7611
+ }
7612
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ModalSwitchVisitComponent, decorators: [{
7613
+ type: Component,
7614
+ args: [{ selector: 'lib-modal-switch-visit', template: "<div class=\"modal-container\">\n <p>{{content}}</p>\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\n</div>\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"] }]
7615
+ }], ctorParameters: () => [{ type: i1$2.NgbActiveModal }, { type: i1$1.Router }, { type: ViewerService }], propDecorators: { content: [{
7616
+ type: Input
7617
+ }], model3D: [{
7618
+ type: Input
7619
+ }], spaceID: [{
7620
+ type: Input
7621
+ }], dataToMove: [{
7622
+ type: Input
7623
+ }] } });
7624
+
6003
7625
  /**
6004
- * Fallback duplication: downloads the object and re-uploads it under the new key.
6005
- *
6006
- * Storage.copy() builds the S3 `x-amz-copy-source` header by plain string concatenation
6007
- * (see @aws-amplify/storage AWSS3Provider.copy: `${bucket}/${srcPrefix}${srcKey}`), with no
6008
- * URL-encoding of the key. A key holding a space, an accent or a parenthesis therefore
6009
- * yields a header that does not match the signed canonical request, and S3 answers
6010
- * "The request signature we calculated does not match the signature you provided".
7626
+ * Parses the placement stored in a Three.js capture's metadata, or null when there is none.
6011
7627
  *
6012
- * Round-tripping the bytes avoids that header entirely and works for any key, at the cost
6013
- * of transferring the file through the browser — hence its use as a fallback only.
6014
- * IMPORTANT: like copyFileInS3, this must NOT swallow errors.
7628
+ * Both the import (writing the navigation entry) and the viewer (framing the camera) need this, so
7629
+ * it lives here rather than on either service. Placement is optional - the viewer frames the model
7630
+ * automatically without it - so unusable metadata is warned about and dropped, never thrown.
6015
7631
  */
6016
- const copyFileInS3ViaDownload = async (sourceKey, destKey) => {
6017
- const downloaded = await Storage.get(sourceKey, {
6018
- download: true,
6019
- level: "public",
6020
- });
6021
- const body = downloaded?.Body;
6022
- if (!body) {
6023
- throw new Error(`Empty download for "${sourceKey}", cannot duplicate it`);
7632
+ function parseThreeJsPlacement(metadata) {
7633
+ if (!metadata || !metadata.trim()) {
7634
+ return null;
6024
7635
  }
6025
- const result = await Storage.put(destKey, body, {
6026
- level: "public",
6027
- contentType: downloaded.ContentType || undefined,
6028
- });
6029
- return result.key;
6030
- };
6031
-
7636
+ try {
7637
+ const parsed = JSON.parse(metadata);
7638
+ const isVector = (value) => value && ["x", "y", "z"].every((axis) => typeof value[axis] === "number");
7639
+ const placement = {};
7640
+ if (isVector(parsed.cameraPosition)) {
7641
+ placement.cameraPosition = parsed.cameraPosition;
7642
+ }
7643
+ if (isVector(parsed.rotation)) {
7644
+ placement.rotation = parsed.rotation;
7645
+ }
7646
+ const source = parsed.alignment;
7647
+ if (source) {
7648
+ const alignment = {};
7649
+ if (isVector(source.translation)) {
7650
+ alignment.translation = source.translation;
7651
+ }
7652
+ if (isVector(source.rotation)) {
7653
+ alignment.rotation = source.rotation;
7654
+ }
7655
+ // Scale 0 would collapse every tag onto a point, so treat it as unset rather than honour it.
7656
+ if (typeof source.scale === "number" && source.scale > 0) {
7657
+ alignment.scale = source.scale;
7658
+ }
7659
+ if (Object.keys(alignment).length > 0) {
7660
+ placement.alignment = alignment;
7661
+ }
7662
+ }
7663
+ return placement.cameraPosition || placement.rotation || placement.alignment ? placement : null;
7664
+ }
7665
+ catch {
7666
+ console.warn("Ignoring unparseable Three.js placement metadata:", metadata);
7667
+ return null;
7668
+ }
7669
+ }
6032
7670
  function getDistanceBetweenTwoPoints(startPoint, endPoint) {
6033
7671
  const dx = endPoint.x - startPoint.x;
6034
7672
  const dy = endPoint.y - startPoint.y;
@@ -8799,7 +10437,9 @@ class EquipmentService extends BaseObjectService {
8799
10437
  const subEquips = [];
8800
10438
  await Promise.all(equip.subequipmentsIDs.map(async (subId) => {
8801
10439
  const subEquip = await this.API.GetEquipment(subId);
8802
- subEquips.push(subEquip);
10440
+ if (subEquip) {
10441
+ subEquips.push(subEquip);
10442
+ }
8803
10443
  }));
8804
10444
  equip.subEquipments = subEquips;
8805
10445
  }
@@ -9571,11 +11211,11 @@ class SearchBarComponent {
9571
11211
  this.searchEvent.emit(results);
9572
11212
  }
9573
11213
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SearchBarComponent, deps: [{ token: SearchService }], target: i0.ɵɵFactoryTarget.Component });
9574
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SearchBarComponent, selector: "lib-search-bar", inputs: { searchable: "searchable", objectType: "objectType" }, outputs: { searchEvent: "searchEvent" }, ngImport: i0, template: "<div class=\"col-md-8 searchbar\">\r\n <div class=\"row depend\">\r\n <div class=\"col-md-5\">\r\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\r\n </div>\r\n <div class=\"col-md-5\">\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n</div>", styles: [".searchbar{margin-bottom:10px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
11214
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SearchBarComponent, selector: "lib-search-bar", inputs: { searchable: "searchable", objectType: "objectType" }, outputs: { searchEvent: "searchEvent" }, ngImport: i0, template: "<div class=\"col-md-8 searchbar\">\n <div class=\"row depend\">\n <div class=\"col-md-5\">\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\n </div>\n <div class=\"col-md-5\">\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\n translate}}</button>\n </div>\n </div>\n</div>", styles: [".searchbar{margin-bottom:10px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
9575
11215
  }
9576
11216
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SearchBarComponent, decorators: [{
9577
11217
  type: Component,
9578
- args: [{ selector: 'lib-search-bar', template: "<div class=\"col-md-8 searchbar\">\r\n <div class=\"row depend\">\r\n <div class=\"col-md-5\">\r\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\r\n </div>\r\n <div class=\"col-md-5\">\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n</div>", styles: [".searchbar{margin-bottom:10px}\n"] }]
11218
+ args: [{ selector: 'lib-search-bar', template: "<div class=\"col-md-8 searchbar\">\n <div class=\"row depend\">\n <div class=\"col-md-5\">\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\n </div>\n <div class=\"col-md-5\">\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\n translate}}</button>\n </div>\n </div>\n</div>", styles: [".searchbar{margin-bottom:10px}\n"] }]
9579
11219
  }], ctorParameters: () => [{ type: SearchService }], propDecorators: { searchable: [{
9580
11220
  type: Input
9581
11221
  }], objectType: [{
@@ -10023,11 +11663,11 @@ class LoaderComponent {
10023
11663
  document.documentElement.style.setProperty('--color', this.color);
10024
11664
  }
10025
11665
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: LoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
10026
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: LoaderComponent, selector: "lib-loader", inputs: { useLogo: "useLogo", color: "color" }, ngImport: i0, template: "<div class=\"sk-fading-circle\">\r\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\r\n <div class=\"sk-circle1 sk-circle\"></div>\r\n <div class=\"sk-circle2 sk-circle\"></div>\r\n <div class=\"sk-circle3 sk-circle\"></div>\r\n <div class=\"sk-circle4 sk-circle\"></div>\r\n <div class=\"sk-circle5 sk-circle\"></div>\r\n <div class=\"sk-circle6 sk-circle\"></div>\r\n <div class=\"sk-circle7 sk-circle\"></div>\r\n <div class=\"sk-circle8 sk-circle\"></div>\r\n <div class=\"sk-circle9 sk-circle\"></div>\r\n <div class=\"sk-circle10 sk-circle\"></div>\r\n <div class=\"sk-circle11 sk-circle\"></div>\r\n <div class=\"sk-circle12 sk-circle\"></div>\r\n</div>\r\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"], dependencies: [{ kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
11666
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: LoaderComponent, selector: "lib-loader", inputs: { useLogo: "useLogo", color: "color" }, ngImport: i0, template: "<div class=\"sk-fading-circle\">\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\n <div class=\"sk-circle1 sk-circle\"></div>\n <div class=\"sk-circle2 sk-circle\"></div>\n <div class=\"sk-circle3 sk-circle\"></div>\n <div class=\"sk-circle4 sk-circle\"></div>\n <div class=\"sk-circle5 sk-circle\"></div>\n <div class=\"sk-circle6 sk-circle\"></div>\n <div class=\"sk-circle7 sk-circle\"></div>\n <div class=\"sk-circle8 sk-circle\"></div>\n <div class=\"sk-circle9 sk-circle\"></div>\n <div class=\"sk-circle10 sk-circle\"></div>\n <div class=\"sk-circle11 sk-circle\"></div>\n <div class=\"sk-circle12 sk-circle\"></div>\n</div>\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"], dependencies: [{ kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
10027
11667
  }
10028
11668
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: LoaderComponent, decorators: [{
10029
11669
  type: Component,
10030
- args: [{ selector: 'lib-loader', template: "<div class=\"sk-fading-circle\">\r\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\r\n <div class=\"sk-circle1 sk-circle\"></div>\r\n <div class=\"sk-circle2 sk-circle\"></div>\r\n <div class=\"sk-circle3 sk-circle\"></div>\r\n <div class=\"sk-circle4 sk-circle\"></div>\r\n <div class=\"sk-circle5 sk-circle\"></div>\r\n <div class=\"sk-circle6 sk-circle\"></div>\r\n <div class=\"sk-circle7 sk-circle\"></div>\r\n <div class=\"sk-circle8 sk-circle\"></div>\r\n <div class=\"sk-circle9 sk-circle\"></div>\r\n <div class=\"sk-circle10 sk-circle\"></div>\r\n <div class=\"sk-circle11 sk-circle\"></div>\r\n <div class=\"sk-circle12 sk-circle\"></div>\r\n</div>\r\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"] }]
11670
+ args: [{ selector: 'lib-loader', template: "<div class=\"sk-fading-circle\">\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\n <div class=\"sk-circle1 sk-circle\"></div>\n <div class=\"sk-circle2 sk-circle\"></div>\n <div class=\"sk-circle3 sk-circle\"></div>\n <div class=\"sk-circle4 sk-circle\"></div>\n <div class=\"sk-circle5 sk-circle\"></div>\n <div class=\"sk-circle6 sk-circle\"></div>\n <div class=\"sk-circle7 sk-circle\"></div>\n <div class=\"sk-circle8 sk-circle\"></div>\n <div class=\"sk-circle9 sk-circle\"></div>\n <div class=\"sk-circle10 sk-circle\"></div>\n <div class=\"sk-circle11 sk-circle\"></div>\n <div class=\"sk-circle12 sk-circle\"></div>\n</div>\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"] }]
10031
11671
  }], ctorParameters: () => [], propDecorators: { useLogo: [{
10032
11672
  type: Input
10033
11673
  }], color: [{
@@ -10085,11 +11725,11 @@ class SupportModalComponent {
10085
11725
  }
10086
11726
  }
10087
11727
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SupportModalComponent, deps: [{ token: SupportService }, { token: i1$2.NgbActiveModal }, { token: i3.UntypedFormBuilder }, { token: BaseUserService }, { token: ValidatorsService }], target: i0.ɵɵFactoryTarget.Component });
10088
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SupportModalComponent, selector: "app-support-modal", ngImport: i0, template: "<div class=\"modal-header\">\r\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\r\n</div>\r\n<div class=\"modal-body\">\r\n <div class=\"support-loader-container\">\r\n <lib-loader *ngIf=\"loading\"></lib-loader>\r\n </div>\r\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\r\n <div>\r\n <label>{{'Title' | translate}} *</label>\r\n <div>\r\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\r\n </div>\r\n </div>\r\n <div *ngIf=\"!currentUser\">\r\n <label>{{'Email' | translate}} *</label>\r\n <div>\r\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\r\n </div>\r\n </div>\r\n <div>\r\n <label>{{'Description' | translate}} *</label>\r\n <div>\r\n <textarea formControlName=\"description\" class=\"form-control\"\r\n data-testid=\"new-support-description\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\r\n </div>\r\n <div class=\"invalid-feedback\" >\r\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\r\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\r\n </div>\r\n </div>\r\n </form>\r\n\r\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\r\n </div>\r\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\r\n data-testid=\"new-support-error-info\">\r\n\r\n </div>\r\n</div>\r\n<div class=\"modal-footer\">\r\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\r\n data-testid=\"new-support-close-btn\"></button>\r\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\r\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\r\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i3.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: i11.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.TranslateDirective, selector: "[translate],[ngx-translate]", inputs: ["translate", "translateParams"] }, { kind: "component", type: LoaderComponent, selector: "lib-loader", inputs: ["useLogo", "color"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
11728
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SupportModalComponent, selector: "app-support-modal", ngImport: i0, template: "<div class=\"modal-header\">\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\n</div>\n<div class=\"modal-body\">\n <div class=\"support-loader-container\">\n <lib-loader *ngIf=\"loading\"></lib-loader>\n </div>\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\n <div>\n <label>{{'Title' | translate}} *</label>\n <div>\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\n </div>\n </div>\n <div *ngIf=\"!currentUser\">\n <label>{{'Email' | translate}} *</label>\n <div>\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\n </div>\n </div>\n <div>\n <label>{{'Description' | translate}} *</label>\n <div>\n <textarea formControlName=\"description\" class=\"form-control\"\n data-testid=\"new-support-description\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\n </div>\n <div class=\"invalid-feedback\" >\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\n </div>\n </div>\n </form>\n\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\n </div>\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\n data-testid=\"new-support-error-info\">\n\n </div>\n</div>\n<div class=\"modal-footer\">\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\n data-testid=\"new-support-close-btn\"></button>\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i3.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: i11.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.TranslateDirective, selector: "[translate],[ngx-translate]", inputs: ["translate", "translateParams"] }, { kind: "component", type: LoaderComponent, selector: "lib-loader", inputs: ["useLogo", "color"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
10089
11729
  }
10090
11730
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SupportModalComponent, decorators: [{
10091
11731
  type: Component,
10092
- args: [{ selector: 'app-support-modal', template: "<div class=\"modal-header\">\r\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\r\n</div>\r\n<div class=\"modal-body\">\r\n <div class=\"support-loader-container\">\r\n <lib-loader *ngIf=\"loading\"></lib-loader>\r\n </div>\r\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\r\n <div>\r\n <label>{{'Title' | translate}} *</label>\r\n <div>\r\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\r\n </div>\r\n </div>\r\n <div *ngIf=\"!currentUser\">\r\n <label>{{'Email' | translate}} *</label>\r\n <div>\r\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\r\n </div>\r\n </div>\r\n <div>\r\n <label>{{'Description' | translate}} *</label>\r\n <div>\r\n <textarea formControlName=\"description\" class=\"form-control\"\r\n data-testid=\"new-support-description\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\r\n </div>\r\n <div class=\"invalid-feedback\" >\r\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\r\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\r\n </div>\r\n </div>\r\n </form>\r\n\r\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\r\n </div>\r\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\r\n data-testid=\"new-support-error-info\">\r\n\r\n </div>\r\n</div>\r\n<div class=\"modal-footer\">\r\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\r\n data-testid=\"new-support-close-btn\"></button>\r\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\r\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\r\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"] }]
11732
+ args: [{ selector: 'app-support-modal', template: "<div class=\"modal-header\">\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\n</div>\n<div class=\"modal-body\">\n <div class=\"support-loader-container\">\n <lib-loader *ngIf=\"loading\"></lib-loader>\n </div>\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\n <div>\n <label>{{'Title' | translate}} *</label>\n <div>\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\n </div>\n </div>\n <div *ngIf=\"!currentUser\">\n <label>{{'Email' | translate}} *</label>\n <div>\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\n </div>\n </div>\n <div>\n <label>{{'Description' | translate}} *</label>\n <div>\n <textarea formControlName=\"description\" class=\"form-control\"\n data-testid=\"new-support-description\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\n </div>\n <div class=\"invalid-feedback\" >\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\n </div>\n </div>\n </form>\n\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\n </div>\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\n data-testid=\"new-support-error-info\">\n\n </div>\n</div>\n<div class=\"modal-footer\">\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\n data-testid=\"new-support-close-btn\"></button>\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"] }]
10093
11733
  }], ctorParameters: () => [{ type: SupportService }, { type: i1$2.NgbActiveModal }, { type: i3.UntypedFormBuilder }, { type: BaseUserService }, { type: ValidatorsService }] });
10094
11734
 
10095
11735
  class LocaleService {
@@ -11505,7 +13145,15 @@ class VisitService {
11505
13145
  this.planService = planService;
11506
13146
  this.userService = userService;
11507
13147
  }
11508
- async getVisitsForSpaceForUser(spaceID) {
13148
+ /**
13149
+ * The visits a user may open for a space.
13150
+ *
13151
+ * `includeImageCount` drives one S3 listing per node, awaited in sequence, purely to fill
13152
+ * `nmbImagesImported`. Callers that only need to know which visits exist - the viewer's model
13153
+ * picker, for one - should pass false: waiting on S3 to draw a button is a visible delay, and
13154
+ * the count is not read on that path.
13155
+ */
13156
+ async getVisitsForSpaceForUser(spaceID, includeImageCount = true) {
11509
13157
  const visits = [];
11510
13158
  const missionsForSpace = this.userService.currentMissions.filter((mis) => mis.spaceID === spaceID);
11511
13159
  const allAllowed = this.userService.userHasAccessToAllZonesInSpace(spaceID);
@@ -11523,9 +13171,12 @@ class VisitService {
11523
13171
  }
11524
13172
  for (const node of allAllowedNodes) {
11525
13173
  // console.log(node)
11526
- const folder = `visits/${node.model3D}/sweeps/`;
11527
- const files = await listFilesInFolder(folder);
11528
- const nmbImages = files.length;
13174
+ let nmbImages = 0;
13175
+ if (includeImageCount) {
13176
+ const folder = `visits/${node.model3D}/sweeps/`;
13177
+ const files = await listFilesInFolder(folder);
13178
+ nmbImages = files.length;
13179
+ }
11529
13180
  const capture = captures.find((cap) => cap.nodes.items.includes(node));
11530
13181
  const visit = {
11531
13182
  captureID: node.captureID,
@@ -11537,6 +13188,7 @@ class VisitService {
11537
13188
  node,
11538
13189
  name: node.metadata,
11539
13190
  nmbImagesImported: nmbImages,
13191
+ captureMetadata: capture.metadata,
11540
13192
  };
11541
13193
  visits.push(visit);
11542
13194
  }
@@ -11781,12 +13433,6 @@ class FeatureService extends BaseObjectService {
11781
13433
  /**
11782
13434
  * Returns EVERY feature of a space, following `nextToken` until the index is
11783
13435
  * exhausted, with no user/archive filtering at all.
11784
- *
11785
- * `getFeaturesBySpaceWithoutFiltering()` reads a single page (the AppSync default
11786
- * caps it at 100 items) and calls `isArchived()`, which dereferences the current
11787
- * user's mission ON THAT SPACE — so it both truncates large spaces and throws for a
11788
- * space the operator has no mission on. Duplication has to see the raw, complete
11789
- * list, hence this separate accessor rather than a change of the existing one.
11790
13436
  */
11791
13437
  async getAllFeaturesBySpaceRaw(spaceID) {
11792
13438
  if (!spaceID) {
@@ -12331,12 +13977,31 @@ class SpaceService {
12331
13977
  }
12332
13978
  return null;
12333
13979
  }
13980
+ /**
13981
+ * The visit the current user is allowed to open for this space.
13982
+ *
13983
+ * Callers need more than the model reference - which viewer the visit was captured with decides
13984
+ * whether it opens in Matterport or Three.js - so the whole visit is returned here.
13985
+ */
13986
+ async getAllowedVisitForSpace(spaceID) {
13987
+ const visits = await this.getAllowedVisitsForSpace(spaceID);
13988
+ return visits.length > 0 ? visits[0] : null;
13989
+ }
13990
+ /**
13991
+ * Every visit the current user may open for this space.
13992
+ *
13993
+ * A site can carry more than one - a Matterport capture plus one or more 3D models - and the
13994
+ * viewer offers them as a list, so the caller needs all of them rather than just the first.
13995
+ */
13996
+ async getAllowedVisitsForSpace(spaceID) {
13997
+ // No image count: this feeds the viewer, which only needs to know which visits exist. Asking
13998
+ // for it would add an S3 listing per node before anything can be drawn.
13999
+ const visits = await this.visitService.getVisitsForSpaceForUser(spaceID, false);
14000
+ return visits ?? [];
14001
+ }
12334
14002
  async getAllowedModel3DForSpace(spaceID) {
12335
- const visits = await this.visitService.getVisitsForSpaceForUser(spaceID);
12336
- if (visits.length > 0) {
12337
- return visits[0].model3d;
12338
- }
12339
- return null;
14003
+ const visit = await this.getAllowedVisitForSpace(spaceID);
14004
+ return visit ? visit.model3d : null;
12340
14005
  }
12341
14006
  async softDeleteSpace(spaceID) {
12342
14007
  await this.updateSpace({
@@ -12800,11 +14465,11 @@ class NavigationBarComponent {
12800
14465
  this.baseVisibilityService.isChangePositionVisible.next(false);
12801
14466
  }
12802
14467
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationBarComponent, deps: [{ token: 'currentAwsKinesisAnalyticsService' }, { token: i1$1.Router }, { token: SpaceService }, { token: BaseUserService }, { token: ZoneService }, { token: VisitService }, { token: ViewerService }, { token: NavigatorService }, { token: ZoneChangeService }, { token: BaseVisibilityService }], target: i0.ɵɵFactoryTarget.Component });
12803
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: NavigationBarComponent, selector: "lib-navigation-bar", viewQueries: [{ propertyName: "navbarSelectMenu", first: true, predicate: ["navbarSelectMenu"], descendants: true }], ngImport: i0, template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\r\n <div class=\"d-flex w-100\">\r\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\r\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\r\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\r\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\r\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\r\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\r\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\r\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\r\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\r\n </option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\r\n (ngModelChange)=\"onFloorClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\r\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\r\n (ngModelChange)=\"onZoneClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\r\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\r\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\r\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\r\n </select>\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\r\n <p class=\"fw-bold lh-base align-middle text-center\">\r\n {{ showLocation() }}\r\n </p>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23565656;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23FFFFFF;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"], dependencies: [{ kind: "directive", type: i3.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i11.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
14468
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: NavigationBarComponent, selector: "lib-navigation-bar", viewQueries: [{ propertyName: "navbarSelectMenu", first: true, predicate: ["navbarSelectMenu"], descendants: true }], ngImport: i0, template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\n <div class=\"d-flex w-100\">\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\n </option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\n (ngModelChange)=\"onFloorClick()\">\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\n (ngModelChange)=\"onZoneClick()\">\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\n </select>\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\n <p class=\"fw-bold lh-base align-middle text-center\">\n {{ showLocation() }}\n </p>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n </div>\n </div>\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23565656;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23FFFFFF;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"], dependencies: [{ kind: "directive", type: i3.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i11.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
12804
14469
  }
12805
14470
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationBarComponent, decorators: [{
12806
14471
  type: Component,
12807
- args: [{ selector: 'lib-navigation-bar', template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\r\n <div class=\"d-flex w-100\">\r\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\r\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\r\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\r\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\r\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\r\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\r\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\r\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\r\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\r\n </option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\r\n (ngModelChange)=\"onFloorClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\r\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\r\n (ngModelChange)=\"onZoneClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\r\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\r\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\r\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\r\n </select>\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\r\n <p class=\"fw-bold lh-base align-middle text-center\">\r\n {{ showLocation() }}\r\n </p>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23565656;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23FFFFFF;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"] }]
14472
+ args: [{ selector: 'lib-navigation-bar', template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\n <div class=\"d-flex w-100\">\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\n </option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\n (ngModelChange)=\"onFloorClick()\">\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\n (ngModelChange)=\"onZoneClick()\">\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\n </select>\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\n <p class=\"fw-bold lh-base align-middle text-center\">\n {{ showLocation() }}\n </p>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n </div>\n </div>\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23565656;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23FFFFFF;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"] }]
12808
14473
  }], ctorParameters: () => [{ type: undefined, decorators: [{
12809
14474
  type: Inject,
12810
14475
  args: ['currentAwsKinesisAnalyticsService']
@@ -13073,11 +14738,11 @@ class MenuBarComponent {
13073
14738
  }
13074
14739
  }
13075
14740
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MenuBarComponent, deps: [{ token: i1$1.Router }, { token: LocaleService }, { token: BaseUserService }, { token: NavigatorService }, { token: SupportService }, { token: BaseVisibilityService }, { token: i1$2.NgbCalendar }, { token: i1$2.NgbDateParserFormatter }, { token: FilterService }, { token: AmplifyCacheService }], target: i0.ɵɵFactoryTarget.Component });
13076
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: MenuBarComponent, selector: "lib-menu-bar", inputs: { cacheName: "cacheName" }, ngImport: i0, template: "<div id=\"menu-bar\">\r\n <div *ngIf=\"!isVisitor\">\r\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\r\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\r\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\r\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\r\n </div>\r\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\r\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <p *ngIf=\"user && mission\" class=\"p-2\">\r\n <span class=\"username\">{{ user.displayName }}</span><br />\r\n <span class=\"user-role\">{{ mission.role }}</span><br />\r\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\r\n translate}}</span>\r\n </p>\r\n <div class=\"dropdown-divider\"></div>\r\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\r\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\r\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\r\n {{'Users management' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/profile\">\r\n {{'Profile' | translate}}</a>\r\n <div class=\"dropdown-divider\"></div>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\r\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\r\n <span class=\"navbar-toggler-icon\"></span>\r\n </button>\r\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\r\n [animation]=\"false\">\r\n <form\r\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\r\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\r\n (click)=\"onSubmit()\">{{'Search' |\r\n translate}}</button>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\r\n [value]=\"fromDate | formatDateNumberToDigits\"\r\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\r\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\r\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\r\n <ng-template #t let-date let-focused=\"focused\">\r\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\r\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\r\n (mouseleave)=\"hoveredDate = null\">\r\n {{ date.day }}\r\n </span>\r\n </ng-template>\r\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\r\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\r\n <button\r\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\r\n {{'Apply' | translate}}\r\n </button>\r\n <span\r\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n *ngIf=\"isDateFilterApplied && !dateChanged\">\r\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\r\n {{\"Applied\" | translate}}\r\n </span>\r\n <button\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\r\n |\r\n translate}}</button>\r\n <div\r\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\r\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\r\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\r\n for=\"btn-check-outlined\">{{\r\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\r\n </div>\r\n </div>\r\n </form>\r\n </div>\r\n </div>\r\n </nav>\r\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\r\n </div>\r\n <div *ngIf=\"isVisitor && isVisible\">\r\n <div class=\"visitor-div\">\r\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\r\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\r\n </div>\r\n <div class=\"avatar-container-menu\">\r\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: i1$2.NgbCollapse, selector: "[ngbCollapse]", inputs: ["animation", "ngbCollapse", "horizontal"], outputs: ["ngbCollapseChange", "shown", "hidden"], exportAs: ["ngbCollapse"] }, { kind: "directive", type: i1$2.NgbInputDatepicker, selector: "input[ngbDatepicker]", inputs: ["autoClose", "contentTemplate", "datepickerClass", "dayTemplate", "dayTemplateData", "displayMonths", "firstDayOfWeek", "footerTemplate", "markDisabled", "minDate", "maxDate", "navigation", "outsideDays", "placement", "popperOptions", "restoreFocus", "showWeekNumbers", "startDate", "container", "positionTarget", "weekdays", "disabled"], outputs: ["dateSelect", "navigate", "closed"], exportAs: ["ngbDatepicker"] }, { kind: "component", type: AvatarComponent, selector: "lib-avatar", inputs: ["size", "userID"] }, { kind: "component", type: NavigationBarComponent, selector: "lib-navigation-bar" }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }, { kind: "pipe", type: FormatDateNumberToDigitsPipe, name: "formatDateNumberToDigits" }] });
14741
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: MenuBarComponent, selector: "lib-menu-bar", inputs: { cacheName: "cacheName" }, ngImport: i0, template: "<div id=\"menu-bar\">\n <div *ngIf=\"!isVisitor\">\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\n </div>\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <p *ngIf=\"user && mission\" class=\"p-2\">\n <span class=\"username\">{{ user.displayName }}</span><br />\n <span class=\"user-role\">{{ mission.role }}</span><br />\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\n translate}}</span>\n </p>\n <div class=\"dropdown-divider\"></div>\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\n {{'Users management' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/profile\">\n {{'Profile' | translate}}</a>\n <div class=\"dropdown-divider\"></div>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\n <span class=\"navbar-toggler-icon\"></span>\n </button>\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\n [animation]=\"false\">\n <form\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\n (click)=\"onSubmit()\">{{'Search' |\n translate}}</button>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <div class=\"input-group date-selector m-1\">\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\n [value]=\"fromDate | formatDateNumberToDigits\"\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\n <ng-template #t let-date let-focused=\"focused\">\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\n (mouseleave)=\"hoveredDate = null\">\n {{ date.day }}\n </span>\n </ng-template>\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\n <div class=\"input-group date-selector m-1\">\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\n <button\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\n {{'Apply' | translate}}\n </button>\n <span\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n *ngIf=\"isDateFilterApplied && !dateChanged\">\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\n {{\"Applied\" | translate}}\n </span>\n <button\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\n |\n translate}}</button>\n <div\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\n for=\"btn-check-outlined\">{{\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\n </div>\n </div>\n </form>\n </div>\n </div>\n </nav>\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\n </div>\n <div *ngIf=\"isVisitor && isVisible\">\n <div class=\"visitor-div\">\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\n </div>\n <div class=\"avatar-container-menu\">\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n </div>\n </div>\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: i1$2.NgbCollapse, selector: "[ngbCollapse]", inputs: ["animation", "ngbCollapse", "horizontal"], outputs: ["ngbCollapseChange", "shown", "hidden"], exportAs: ["ngbCollapse"] }, { kind: "directive", type: i1$2.NgbInputDatepicker, selector: "input[ngbDatepicker]", inputs: ["autoClose", "contentTemplate", "datepickerClass", "dayTemplate", "dayTemplateData", "displayMonths", "firstDayOfWeek", "footerTemplate", "markDisabled", "minDate", "maxDate", "navigation", "outsideDays", "placement", "popperOptions", "restoreFocus", "showWeekNumbers", "startDate", "container", "positionTarget", "weekdays", "disabled"], outputs: ["dateSelect", "navigate", "closed"], exportAs: ["ngbDatepicker"] }, { kind: "component", type: AvatarComponent, selector: "lib-avatar", inputs: ["size", "userID"] }, { kind: "component", type: NavigationBarComponent, selector: "lib-navigation-bar" }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }, { kind: "pipe", type: FormatDateNumberToDigitsPipe, name: "formatDateNumberToDigits" }] });
13077
14742
  }
13078
14743
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MenuBarComponent, decorators: [{
13079
14744
  type: Component,
13080
- args: [{ selector: 'lib-menu-bar', template: "<div id=\"menu-bar\">\r\n <div *ngIf=\"!isVisitor\">\r\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\r\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\r\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\r\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\r\n </div>\r\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\r\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <p *ngIf=\"user && mission\" class=\"p-2\">\r\n <span class=\"username\">{{ user.displayName }}</span><br />\r\n <span class=\"user-role\">{{ mission.role }}</span><br />\r\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\r\n translate}}</span>\r\n </p>\r\n <div class=\"dropdown-divider\"></div>\r\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\r\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\r\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\r\n {{'Users management' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/profile\">\r\n {{'Profile' | translate}}</a>\r\n <div class=\"dropdown-divider\"></div>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\r\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\r\n <span class=\"navbar-toggler-icon\"></span>\r\n </button>\r\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\r\n [animation]=\"false\">\r\n <form\r\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\r\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\r\n (click)=\"onSubmit()\">{{'Search' |\r\n translate}}</button>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\r\n [value]=\"fromDate | formatDateNumberToDigits\"\r\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\r\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\r\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\r\n <ng-template #t let-date let-focused=\"focused\">\r\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\r\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\r\n (mouseleave)=\"hoveredDate = null\">\r\n {{ date.day }}\r\n </span>\r\n </ng-template>\r\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\r\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\r\n <button\r\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\r\n {{'Apply' | translate}}\r\n </button>\r\n <span\r\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n *ngIf=\"isDateFilterApplied && !dateChanged\">\r\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\r\n {{\"Applied\" | translate}}\r\n </span>\r\n <button\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\r\n |\r\n translate}}</button>\r\n <div\r\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\r\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\r\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\r\n for=\"btn-check-outlined\">{{\r\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\r\n </div>\r\n </div>\r\n </form>\r\n </div>\r\n </div>\r\n </nav>\r\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\r\n </div>\r\n <div *ngIf=\"isVisitor && isVisible\">\r\n <div class=\"visitor-div\">\r\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\r\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\r\n </div>\r\n <div class=\"avatar-container-menu\">\r\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"] }]
14745
+ args: [{ selector: 'lib-menu-bar', template: "<div id=\"menu-bar\">\n <div *ngIf=\"!isVisitor\">\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\n </div>\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <p *ngIf=\"user && mission\" class=\"p-2\">\n <span class=\"username\">{{ user.displayName }}</span><br />\n <span class=\"user-role\">{{ mission.role }}</span><br />\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\n translate}}</span>\n </p>\n <div class=\"dropdown-divider\"></div>\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\n {{'Users management' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/profile\">\n {{'Profile' | translate}}</a>\n <div class=\"dropdown-divider\"></div>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\n <span class=\"navbar-toggler-icon\"></span>\n </button>\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\n [animation]=\"false\">\n <form\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\n (click)=\"onSubmit()\">{{'Search' |\n translate}}</button>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <div class=\"input-group date-selector m-1\">\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\n [value]=\"fromDate | formatDateNumberToDigits\"\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\n <ng-template #t let-date let-focused=\"focused\">\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\n (mouseleave)=\"hoveredDate = null\">\n {{ date.day }}\n </span>\n </ng-template>\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\n <div class=\"input-group date-selector m-1\">\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\n <button\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\n {{'Apply' | translate}}\n </button>\n <span\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n *ngIf=\"isDateFilterApplied && !dateChanged\">\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\n {{\"Applied\" | translate}}\n </span>\n <button\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\n |\n translate}}</button>\n <div\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\n for=\"btn-check-outlined\">{{\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\n </div>\n </div>\n </form>\n </div>\n </div>\n </nav>\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\n </div>\n <div *ngIf=\"isVisitor && isVisible\">\n <div class=\"visitor-div\">\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\n </div>\n <div class=\"avatar-container-menu\">\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n </div>\n </div>\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"] }]
13081
14746
  }], ctorParameters: () => [{ type: i1$1.Router }, { type: LocaleService }, { type: BaseUserService }, { type: NavigatorService }, { type: SupportService }, { type: BaseVisibilityService }, { type: i1$2.NgbCalendar }, { type: i1$2.NgbDateParserFormatter }, { type: FilterService }, { type: AmplifyCacheService }], propDecorators: { cacheName: [{
13082
14747
  type: Input
13083
14748
  }] } });
@@ -13232,27 +14897,27 @@ class BaseTagService {
13232
14897
  throw new Error('updatePoiMetadata is not implemented in the base class.');
13233
14898
  }*/
13234
14899
  async prepareEquipmentHtml(equip) {
13235
- throw new Error(`prepareEquipmentHtml is not implemented in the base class.
14900
+ throw new Error(`prepareEquipmentHtml is not implemented in the base class.
13236
14901
  Please override this method in your extended class.`);
13237
14902
  }
13238
14903
  async prepareTicketHtml(ticket) {
13239
- throw new Error(`prepareTicketHtml is not implemented in the base class.
14904
+ throw new Error(`prepareTicketHtml is not implemented in the base class.
13240
14905
  Please override this method in your extended class.`);
13241
14906
  }
13242
14907
  async prepareFeatureHtml(feature) {
13243
- throw new Error(`prepareFeatureHtml is not implemented in the base class.
14908
+ throw new Error(`prepareFeatureHtml is not implemented in the base class.
13244
14909
  Please override this method in your extended class.`);
13245
14910
  }
13246
14911
  async prepareMeasurementHtml(measure) {
13247
- throw new Error(`prepareMeasurementHtml is not implemented in the base class.
14912
+ throw new Error(`prepareMeasurementHtml is not implemented in the base class.
13248
14913
  Please override this method in your extended class.`);
13249
14914
  }
13250
14915
  async prepareDeskHtml(feature) {
13251
- throw new Error(`prepareDeskHtml is not implemented in the base class.
14916
+ throw new Error(`prepareDeskHtml is not implemented in the base class.
13252
14917
  Please override this method in your extended class.`);
13253
14918
  }
13254
14919
  async prepareIndicatorHtml(feature) {
13255
- throw new Error(`prepareDeskHtml is not implemented in the base class.
14920
+ throw new Error(`prepareDeskHtml is not implemented in the base class.
13256
14921
  Please override this method in your extended class.`);
13257
14922
  }
13258
14923
  async getSignedTagIconSource(tagIconSrc) {
@@ -13261,15 +14926,15 @@ class BaseTagService {
13261
14926
  getIconTagImageForFeature(feature, poi) {
13262
14927
  // const tagIcon = JSON.parse(poi.tagIcon);
13263
14928
  // return tagIcon.src;
13264
- throw new Error(`getIconTagImageForFeature is not implemented in the base class.
14929
+ throw new Error(`getIconTagImageForFeature is not implemented in the base class.
13265
14930
  Please override this method in your extended class.`);
13266
14931
  }
13267
14932
  getScriptForTag(object, tagType) {
13268
- throw new Error(`getScriptForTag is not implemented in the base class.
14933
+ throw new Error(`getScriptForTag is not implemented in the base class.
13269
14934
  Please override this method in your extended class.`);
13270
14935
  }
13271
14936
  getAnnexeForCommentTypeInFeature(feature, commentType) {
13272
- throw new Error(`getAnnexeForCommentTypeInFeature is not implemented in the base class.
14937
+ throw new Error(`getAnnexeForCommentTypeInFeature is not implemented in the base class.
13273
14938
  Please override this method in your extended class.`);
13274
14939
  }
13275
14940
  getBillboardMediaToEmbed(object) {
@@ -13278,27 +14943,27 @@ class BaseTagService {
13278
14943
  return { comment: null, tagDescription: '' };
13279
14944
  }
13280
14945
  onActionDetailClick(url) {
13281
- throw new Error(`onActionDetailClick is not implemented in the base class.
14946
+ throw new Error(`onActionDetailClick is not implemented in the base class.
13282
14947
  Please override this method in your extended class.`);
13283
14948
  }
13284
14949
  onActionAudioClick(audioCommentID) {
13285
- throw new Error(`onActionDetailClick is not implemented in the base class.
14950
+ throw new Error(`onActionDetailClick is not implemented in the base class.
13286
14951
  Please override this method in your extended class.`);
13287
14952
  }
13288
14953
  onActionVideoClick(url) {
13289
- throw new Error(`onActionVideoClick is not implemented in the base class.
14954
+ throw new Error(`onActionVideoClick is not implemented in the base class.
13290
14955
  Please override this method in your extended class.`);
13291
14956
  }
13292
14957
  onActionImageClick(imageCommentID) {
13293
- throw new Error(`onActionImageClick is not implemented in the base class.
14958
+ throw new Error(`onActionImageClick is not implemented in the base class.
13294
14959
  Please override this method in your extended class.`);
13295
14960
  }
13296
14961
  onActionDocClick(url) {
13297
- throw new Error(`onActionDocClick is not implemented in the base class.
14962
+ throw new Error(`onActionDocClick is not implemented in the base class.
13298
14963
  Please override this method in your extended class.`);
13299
14964
  }
13300
14965
  onActionYoutubeClick(url) {
13301
- throw new Error(`onActionYoutubeClick is not implemented in the base class.
14966
+ throw new Error(`onActionYoutubeClick is not implemented in the base class.
13302
14967
  Please override this method in your extended class.`);
13303
14968
  }
13304
14969
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BaseTagService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -13821,6 +15486,147 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
13821
15486
  args: [MATTERPORT_SDK_KEY]
13822
15487
  }] }, { type: NavigationService }, { type: ZoneService }, { type: ViewerService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
13823
15488
 
15489
+ /** File extensions the Three.js viewer can load as a base model. */
15490
+ const SUPPORTED_MODEL_EXTENSIONS = ['.glb', '.gltf'];
15491
+ class ThreeJsImportService {
15492
+ navigationService;
15493
+ zoneService;
15494
+ layerService;
15495
+ userService;
15496
+ planService;
15497
+ _spaceID;
15498
+ get spaceID() {
15499
+ return this._spaceID;
15500
+ }
15501
+ set spaceID(value) {
15502
+ this._spaceID = value;
15503
+ }
15504
+ constructor(navigationService, zoneService, layerService, userService, planService) {
15505
+ this.navigationService = navigationService;
15506
+ this.zoneService = zoneService;
15507
+ this.layerService = layerService;
15508
+ this.userService = userService;
15509
+ this.planService = planService;
15510
+ }
15511
+ /**
15512
+ * Checks that an S3 key points at something the viewer can actually load.
15513
+ *
15514
+ * Returns the reason it can't, or null when it's fine. This runs before anything is written, so a
15515
+ * mistyped key fails on the form rather than creating zones and navigation for a model that will
15516
+ * 404 the first time someone opens the visit.
15517
+ */
15518
+ validateModelKey(key) {
15519
+ if (!key || !key.trim()) {
15520
+ return 'A 3D model file (.glb) is required for a Three.js visit.';
15521
+ }
15522
+ const trimmed = key.trim();
15523
+ if (!SUPPORTED_MODEL_EXTENSIONS.some((extension) => trimmed.toLowerCase().endsWith(extension))) {
15524
+ return `The 3D model must be a ${SUPPORTED_MODEL_EXTENSIONS.join(' or ')} file.`;
15525
+ }
15526
+ // A full https:// URL is a signed link that expires; the stored value has to be the key itself
15527
+ // so a fresh URL can be signed on every visit.
15528
+ if (/^https?:\/\//i.test(trimmed)) {
15529
+ return 'Enter the S3 key of the file (e.g. visits/my-model.glb), not a full URL.';
15530
+ }
15531
+ return null;
15532
+ }
15533
+ async importData(spaceID, surface, reloading = false, node = null, overrideExisting = true, placementMetadata = null) {
15534
+ if (reloading) {
15535
+ // Reloading is not fully applicable to static 3D models the same way as Matterport
15536
+ return Promise.resolve(true);
15537
+ }
15538
+ const keyError = this.validateModelKey(node?.model3D);
15539
+ if (keyError) {
15540
+ return Promise.reject(new Error(keyError));
15541
+ }
15542
+ this.spaceID = spaceID;
15543
+ const zones = await this.zoneService.getZonesBySpace(spaceID);
15544
+ const mainZone = zones.find((z) => !z.parentID);
15545
+ if (!mainZone) {
15546
+ return Promise.reject(new Error('Missing data (no main Building Zone)'));
15547
+ }
15548
+ console.log('[ThreeJsImportService] Importing data for Three.js model');
15549
+ // 1. Check/create layer of building type
15550
+ const layerBuilding = await this.getLayerByName('BUILDING');
15551
+ // We generate a dummy ID to act as our single "sweep" equivalent
15552
+ const dummySweepID = v4();
15553
+ // 2. Create the default Zone
15554
+ const createdDefaultZone = await this.createZone(node.metadata || 'Three.js 3D Model', layerBuilding.id, surface, mainZone.id, [dummySweepID], node);
15555
+ if (!createdDefaultZone) {
15556
+ return Promise.resolve(false);
15557
+ }
15558
+ // 3. Create a blank Plan for the model (to ensure consistency in the app structure)
15559
+ const plan = {
15560
+ name: `plan_threejs_${node.id}`,
15561
+ annexe: '', // No floorplan image generated automatically yet
15562
+ zoneID: createdDefaultZone.id,
15563
+ spaceID: this.spaceID,
15564
+ isCurrentForZone: true,
15565
+ isModified: false,
15566
+ isImportedMatterport: false, // Explicitly not matterport
15567
+ };
15568
+ await this.planService.createPlan(plan);
15569
+ // 4. Create the single Navigation entry this model is viewed from.
15570
+ // Matterport derives these from its sweeps; a static model has no sweeps, so the placement
15571
+ // comes from the metadata entered on the form, falling back to the origin.
15572
+ const placement = parseThreeJsPlacement(placementMetadata);
15573
+ const origin = { x: 0, y: 0, z: 0 };
15574
+ const navigationInput = {
15575
+ matterportSweepID: dummySweepID,
15576
+ position: JSON.stringify(placement?.cameraPosition ?? origin),
15577
+ rotation: JSON.stringify(placement?.rotation ?? origin),
15578
+ level: LevelStatus.LOW,
15579
+ nodeID: node.id,
15580
+ spaceID,
15581
+ };
15582
+ const nav = await this.navigationService.createNavigation(navigationInput);
15583
+ if (!nav) {
15584
+ console.log('Could not create navigation for', navigationInput);
15585
+ }
15586
+ console.log('[ThreeJsImportService] Import done.');
15587
+ return Promise.resolve(true);
15588
+ }
15589
+ async createZone(name, layerID, surface = null, parentID = null, sweeps = [], node = null, metadata = null) {
15590
+ const zoneInput = {
15591
+ name,
15592
+ spaceID: this.spaceID,
15593
+ layerID,
15594
+ sweepIDs: sweeps,
15595
+ nodeIDs: node ? [node.id] : null,
15596
+ };
15597
+ removeNullKeysFromObject(zoneInput);
15598
+ if (surface) {
15599
+ zoneInput.surface = surface;
15600
+ }
15601
+ if (parentID) {
15602
+ zoneInput.parentID = parentID;
15603
+ }
15604
+ if (metadata) {
15605
+ zoneInput.metadata = metadata;
15606
+ }
15607
+ const newLocal = await this.zoneService.create(zoneInput);
15608
+ return newLocal;
15609
+ }
15610
+ async getLayerByName(name) {
15611
+ const mission = this.userService.currentMission(this.spaceID);
15612
+ const currentOrgId = mission.organisationID;
15613
+ const layers = await this.layerService.getLayerByNameForOrganisation(name, currentOrgId);
15614
+ if (layers.length > 0) {
15615
+ return layers[0];
15616
+ }
15617
+ // Create it if it doesn't exist
15618
+ return this.layerService.createLayerForOrganisation(name, currentOrgId);
15619
+ }
15620
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsImportService, deps: [{ token: NavigationService }, { token: ZoneService }, { token: LayerService }, { token: BaseUserService }, { token: PlanService }], target: i0.ɵɵFactoryTarget.Injectable });
15621
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsImportService, providedIn: 'root' });
15622
+ }
15623
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsImportService, decorators: [{
15624
+ type: Injectable,
15625
+ args: [{
15626
+ providedIn: 'root',
15627
+ }]
15628
+ }], ctorParameters: () => [{ type: NavigationService }, { type: ZoneService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
15629
+
13824
15630
  // matterport-transform.util.ts
13825
15631
  /**
13826
15632
  * Computes the rigid transform (Y-axis rotation + translation) between two
@@ -14212,37 +16018,131 @@ class Object3DService {
14212
16018
  async deleteObject3D(objectId) {
14213
16019
  return this.API.__proto__.DeleteObject3D({ id: objectId });
14214
16020
  }
14215
- async create(input) {
14216
- return this.API.__proto__.CreateObject3D(input);
16021
+ async create(input) {
16022
+ return this.API.__proto__.CreateObject3D(input);
16023
+ }
16024
+ async updateObject3D(input) {
16025
+ return this.API.__proto__.UpdateObject3D(input);
16026
+ }
16027
+ /** Basic CRUD operations */
16028
+ async readObject3D(id) {
16029
+ return this.API.__proto__.GetObject3D(id);
16030
+ }
16031
+ async adaptObject3D(object3D) {
16032
+ // Not required here!
16033
+ return object3D;
16034
+ }
16035
+ async updateObject3DWithEvent(object3D, event) {
16036
+ const receivedObject3D = await this.updateObject3D(object3D);
16037
+ if (receivedObject3D) {
16038
+ console.log("Updated object3D :");
16039
+ console.log(receivedObject3D);
16040
+ // Not sent currently: ask if needed...
16041
+ // if (event) {
16042
+ // await this.addEventToObject3D(receivedObject3D, event);
16043
+ // }
16044
+ this.updateDone.next(true);
16045
+ }
16046
+ return receivedObject3D;
16047
+ }
16048
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Object3DService, deps: [{ token: 'currentAPIService' }, { token: BaseUserService }, { token: NavigatorService }, { token: ZoneChangeService }, { token: FilterService }], target: i0.ɵɵFactoryTarget.Injectable });
16049
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Object3DService, providedIn: "root" });
16050
+ }
16051
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Object3DService, decorators: [{
16052
+ type: Injectable,
16053
+ args: [{
16054
+ providedIn: "root",
16055
+ }]
16056
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
16057
+ type: Inject,
16058
+ args: ['currentAPIService']
16059
+ }] }, { type: BaseUserService }, { type: NavigatorService }, { type: ZoneChangeService }, { type: FilterService }] });
16060
+
16061
+ class DomainService {
16062
+ API; //AWS
16063
+ constructor(apiInjected) {
16064
+ this.API = apiInjected;
16065
+ }
16066
+ async getGenericDomainsAndForOrganisation(organisationID) {
16067
+ return this.API.__proto__.ListDomains().then((domains) => {
16068
+ return domains.items.filter((dom) => {
16069
+ return (dom.organisationID === organisationID || !dom.organisationID);
16070
+ });
16071
+ });
16072
+ }
16073
+ /**
16074
+ * Same as getGenericDomainsAndForOrganisation(), but drains every page instead of only
16075
+ * the first. `ListDomains` has no per-organisation index — every call already scans the
16076
+ * WHOLE table across every organisation before filtering client-side — so a catalogue
16077
+ * past the first page was silently invisible to callers that need the complete list
16078
+ * (e.g. matching an organisation-specific domain by name across a space-to-space copy).
16079
+ */
16080
+ async getAllGenericDomainsAndForOrganisation(organisationID) {
16081
+ const all = [];
16082
+ let nextToken = null;
16083
+ do {
16084
+ const page = await this.API.__proto__.ListDomains(undefined, 1000, nextToken);
16085
+ if (page?.items?.length > 0) {
16086
+ all.push(...page.items.filter((dom) => !!dom));
16087
+ }
16088
+ nextToken = page?.nextToken ?? null;
16089
+ } while (nextToken);
16090
+ return all.filter(dom => dom.organisationID === organisationID || !dom.organisationID);
16091
+ }
16092
+ async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
16093
+ // TODO: Change this (UUID can change depending on DB)
16094
+ if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
16095
+ // I am SmarterPlan, i can see all
16096
+ return this.API.__proto__.ListDomains().then((response) => response.items);
16097
+ }
16098
+ const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
16099
+ if (!orderedOrgID || orgID === orderedOrgID) {
16100
+ return currentOrgDomains;
16101
+ }
16102
+ const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
16103
+ const merged = [...currentOrgDomains, ...orderedOrgDomains];
16104
+ return filterUniqueArrayByID(merged);
14217
16105
  }
14218
- async updateObject3D(input) {
14219
- return this.API.__proto__.UpdateObject3D(input);
16106
+ async getDomainsForOrgExcludingTypes(types, organisationID) {
16107
+ return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
16108
+ return domains.filter((dom) => !types.includes(dom.type));
16109
+ });
14220
16110
  }
14221
- /** Basic CRUD operations */
14222
- async readObject3D(id) {
14223
- return this.API.__proto__.GetObject3D(id);
16111
+ async getDomain(id) {
16112
+ return this.API.__proto__.GetDomain(id);
14224
16113
  }
14225
- async adaptObject3D(object3D) {
14226
- // Not required here!
14227
- return object3D;
16114
+ async updateDomain(input) {
16115
+ return this.API.__proto__.UpdateDomain(input);
14228
16116
  }
14229
- async updateObject3DWithEvent(object3D, event) {
14230
- const receivedObject3D = await this.updateObject3D(object3D);
14231
- if (receivedObject3D) {
14232
- console.log("Updated object3D :");
14233
- console.log(receivedObject3D);
14234
- // Not sent currently: ask if needed...
14235
- // if (event) {
14236
- // await this.addEventToObject3D(receivedObject3D, event);
14237
- // }
14238
- this.updateDone.next(true);
14239
- }
14240
- return receivedObject3D;
16117
+ async deleteDomain(id) {
16118
+ return this.API.__proto__.DeleteDomain({ id });
14241
16119
  }
14242
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Object3DService, deps: [{ token: 'currentAPIService' }, { token: BaseUserService }, { token: NavigatorService }, { token: ZoneChangeService }, { token: FilterService }], target: i0.ɵɵFactoryTarget.Injectable });
14243
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Object3DService, providedIn: "root" });
16120
+ async getRootDomains(orgID) {
16121
+ const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
16122
+ const root = allDomains.filter((dom) => !dom.parentID);
16123
+ return root.sort((a, b) => a.name.localeCompare(b.name));
16124
+ }
16125
+ async createDomain(input) {
16126
+ return this.API.__proto__.CreateDomain(input);
16127
+ }
16128
+ async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
16129
+ return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
16130
+ return domains.filter((dom) => dom.type === type);
16131
+ });
16132
+ }
16133
+ async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
16134
+ return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
16135
+ return domains.filter((dom) => dom.type === type && !dom.parentID);
16136
+ });
16137
+ }
16138
+ async getDomainsForMissionsForOrg(orgID) {
16139
+ const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
16140
+ return domains.filter((dom) => !dom.parentID);
16141
+ }
16142
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
16143
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
14244
16144
  }
14245
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: Object3DService, decorators: [{
16145
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
14246
16146
  type: Injectable,
14247
16147
  args: [{
14248
16148
  providedIn: "root",
@@ -14250,7 +16150,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
14250
16150
  }], ctorParameters: () => [{ type: undefined, decorators: [{
14251
16151
  type: Inject,
14252
16152
  args: ['currentAPIService']
14253
- }] }, { type: BaseUserService }, { type: NavigatorService }, { type: ZoneChangeService }, { type: FilterService }] });
16153
+ }] }] });
14254
16154
 
14255
16155
  /** Safely extracts a readable message from an unknown catch value, including Amplify's
14256
16156
  * GraphQL error shape ({ data, errors: [{ errorType, message }] }). */
@@ -14304,7 +16204,9 @@ class MatterportTagCopyService {
14304
16204
  measurementService;
14305
16205
  commentService;
14306
16206
  hashtagService;
14307
- spaceService;
16207
+ missionService;
16208
+ operationService;
16209
+ domainService;
14308
16210
  translate;
14309
16211
  // Emits progress updates during copyTagsFromVisit(); null when idle
14310
16212
  copyProgress$ = new BehaviorSubject(null);
@@ -14312,7 +16214,8 @@ class MatterportTagCopyService {
14312
16214
  copyLog$ = new BehaviorSubject([]);
14313
16215
  spModule;
14314
16216
  log = [];
14315
- errorSeq = 0;
16217
+ /** Unique-id sequence shared by every permanent (non-step) log line — errors and healed-item notices alike. */
16218
+ logSeq = 0;
14316
16219
  // ---- Per-run remapping state (reset by resetRunState() at the start of every copy) ----
14317
16220
  /** source element id -> id of its copy in the target space. */
14318
16221
  elementIds = {};
@@ -14335,12 +16238,36 @@ class MatterportTagCopyService {
14335
16238
  /** source ids of the elements whose POIs were already created by an element phase.
14336
16239
  * Guards the standalone POI phase against creating a second, impoverished copy. */
14337
16240
  handledElementIds = new Set();
14338
- /** true when source and target spaces belong to the same organisation. Domains are
14339
- * organisation-scoped (no spaceID), so domainID survives the copy only in that case. */
14340
- sameOrganisation = false;
16241
+ // Comment authorship: `Comment.ownerID` is a foreign key to a Mission, and a Mission is
16242
+ // scoped to ONE space (userID + spaceID). A source comment's ownerID therefore points at
16243
+ // nobody in the target space, so it has to be translated to the Mission of the SAME user
16244
+ // there — see resolveCommentOwnerID().
16245
+ /** source Mission id -> its userID. */
16246
+ sourceMissionUserIds = new Map();
16247
+ /** userID -> that user's Mission id in the TARGET space (only set when they have one). */
16248
+ targetMissionIdsByUserId = new Map();
16249
+ // Domain (equipment/ticket/feature/POI category) resolution mirrors the exact rule the
16250
+ // edit forms use to populate their own category dropdown —
16251
+ // DomainService.getDomainsForOrgForType(type, mission.organisationID,
16252
+ // mission.orderedOrganisationID) — keyed to the OPERATOR's own Mission on each space,
16253
+ // NOT to the Space's own organisationID. Two spaces in different organisations can
16254
+ // still share a domain through that Mission link (an operator whose mission on the
16255
+ // target space carries an `orderedOrganisationID` matching the domain's own
16256
+ // organisationID sees — and can reuse — it directly). See resolveDomainID().
16257
+ /** id -> Domain, for every domain visible from EITHER space's operator mission (its
16258
+ * organisationID, its orderedOrganisationID, or generic/no-organisation domains) — a
16259
+ * superset covering whatever a source object's domainID could point at. */
16260
+ domainsById = new Map();
16261
+ /** organisationIDs the TARGET operator's mission can see domains from. A domain whose
16262
+ * own organisationID is in this set (or has none at all) is already directly
16263
+ * selectable in the target space's own dropdown, so its id is reused unchanged. */
16264
+ targetVisibleDomainOrgIds = new Set();
16265
+ /** `${type}::${normalised name}` -> that domain's id, among domains visible to the
16266
+ * TARGET operator — used to find an equivalent when the exact id isn't reusable. */
16267
+ targetDomainIdsByTypeAndName = new Map();
14341
16268
  /** Target space id of the run in progress — used to re-home copied tag icons. */
14342
16269
  targetSpaceId = null;
14343
- constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, spaceService, translate) {
16270
+ constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, missionService, operationService, domainService, translate) {
14344
16271
  this.navigationService = navigationService;
14345
16272
  this.userService = userService;
14346
16273
  this.poiService = poiService;
@@ -14351,7 +16278,9 @@ class MatterportTagCopyService {
14351
16278
  this.measurementService = measurementService;
14352
16279
  this.commentService = commentService;
14353
16280
  this.hashtagService = hashtagService;
14354
- this.spaceService = spaceService;
16281
+ this.missionService = missionService;
16282
+ this.operationService = operationService;
16283
+ this.domainService = domainService;
14355
16284
  this.translate = translate;
14356
16285
  this.spModule = userService.getSpModule();
14357
16286
  }
@@ -14360,6 +16289,7 @@ class MatterportTagCopyService {
14360
16289
  //
14361
16290
  async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
14362
16291
  let errorCount = 0;
16292
+ let healedCount = 0;
14363
16293
  this.resetLog();
14364
16294
  this.resetRunState(targetSpaceId);
14365
16295
  try {
@@ -14369,7 +16299,7 @@ class MatterportTagCopyService {
14369
16299
  phase: 'collecting',
14370
16300
  processedInPhase: 0, totalInPhase: 0,
14371
16301
  processedOverall: 0, totalOverall: 0,
14372
- percent: 0, errorCount: 0
16302
+ percent: 0, errorCount: 0, healedCount: 0
14373
16303
  });
14374
16304
  const targetMission = this.userService.currentMission(targetSpaceId);
14375
16305
  const targetMissionId = targetMission ? targetMission.id : null;
@@ -14379,9 +16309,6 @@ class MatterportTagCopyService {
14379
16309
  refSource: transform.refSource,
14380
16310
  refTarget: transform.refTarget
14381
16311
  });
14382
- // Domains are organisation-scoped, hashtags are space-scoped: both need to know
14383
- // whether we stay inside the same organisation before anything is remapped.
14384
- this.sameOrganisation = await this.spacesShareOrganisation(sourceSpaceId, targetSpaceId);
14385
16312
  const sourceSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(sourceSpaceId);
14386
16313
  const targetSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(targetSpaceId);
14387
16314
  this.setStepProgress('collecting', 'collecting', 30);
@@ -14422,6 +16349,14 @@ class MatterportTagCopyService {
14422
16349
  // Keywords: index both vocabularies now (read-only). The keywords the target space
14423
16350
  // is missing are created later, POI by POI, only for those actually used.
14424
16351
  await this.loadHashtagIndex(sourceSpaceId, targetSpaceId);
16352
+ // Missions: index both spaces now (read-only), so copied comments can credit
16353
+ // their real author instead of whoever is running the copy.
16354
+ await this.loadMissionIndex(sourceSpaceId, targetSpaceId);
16355
+ // Domains: index every domain either operator's mission can see (read-only), so
16356
+ // a category survives a copy across two DIFFERENT organisations whenever it
16357
+ // genuinely would be selectable in the target space's own dropdown — see
16358
+ // loadDomainIndex() for why this is keyed to Mission, not to the Space itself.
16359
+ await this.loadDomainIndex(sourceSpaceId, targetSpaceId);
14425
16360
  if (this.spModule == SpModule.IMMO) {
14426
16361
  sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
14427
16362
  targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
@@ -14479,7 +16414,7 @@ class MatterportTagCopyService {
14479
16414
  processedOverall,
14480
16415
  totalOverall,
14481
16416
  percent: totalOverall > 0 ? Math.round((processedOverall / totalOverall) * 100) : 0,
14482
- errorCount
16417
+ errorCount, healedCount
14483
16418
  });
14484
16419
  };
14485
16420
  if (this.spModule == SpModule.IMMO) {
@@ -14487,8 +16422,8 @@ class MatterportTagCopyService {
14487
16422
  for (let i = 0; i < sourceTickets.length; i++) {
14488
16423
  const sourceObj = sourceTickets[i];
14489
16424
  try {
14490
- const existsInTarget = this.matchesExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
14491
- if (!existsInTarget) {
16425
+ const existingMatch = this.findExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
16426
+ if (!existingMatch) {
14492
16427
  const rawSource = sourceObj;
14493
16428
  const ticketInput = {
14494
16429
  title: rawSource.title,
@@ -14509,8 +16444,9 @@ class MatterportTagCopyService {
14509
16444
  spaceID: targetSpaceId,
14510
16445
  // Remapped in the second pass below, once every ticket exists.
14511
16446
  parentID: null,
14512
- // Domains are organisation-scoped, like for equipments and features.
14513
- domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
16447
+ // Reused unchanged in the same organisation, matched by name
16448
+ // across two — see resolveDomainID().
16449
+ domainID: this.resolveDomainID(rawSource.domainID),
14514
16450
  ownerMissionID: targetMissionId
14515
16451
  };
14516
16452
  const createdObj = await this.ticketService.createTicket(ticketInput);
@@ -14531,7 +16467,7 @@ class MatterportTagCopyService {
14531
16467
  }
14532
16468
  for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14533
16469
  try {
14534
- const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
16470
+ const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId);
14535
16471
  if (created?.id && sourceComment.id) {
14536
16472
  this.commentIds.set(sourceComment.id, created.id);
14537
16473
  }
@@ -14549,6 +16485,18 @@ class MatterportTagCopyService {
14549
16485
  await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14550
16486
  }
14551
16487
  }
16488
+ else {
16489
+ // Same reasoning as the EQUIPMENTS phase (see the comment there):
16490
+ // a ticket matching this one already exists in the target space,
16491
+ // most likely from an earlier, incomplete run. Fill in whatever
16492
+ // identity field it is missing rather than leaving it untouched.
16493
+ elementIds[sourceObj.id] = existingMatch.id;
16494
+ const healedFields = await this.healTicketIdentity(sourceObj, existingMatch);
16495
+ if (healedFields.length > 0) {
16496
+ healedCount++;
16497
+ this.logHealedItem('ticketHealed', sourceObj?.title || sourceObj?.id, healedFields);
16498
+ }
16499
+ }
14552
16500
  }
14553
16501
  catch (itemError) {
14554
16502
  console.error(`Error copying ticket "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
@@ -14593,8 +16541,8 @@ class MatterportTagCopyService {
14593
16541
  try {
14594
16542
  // Equipment has no `title`/`description`: it identifies itself by name, and by
14595
16543
  // serial number when one is recorded.
14596
- const existsInTarget = this.matchesExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
14597
- if (!existsInTarget) {
16544
+ const existingMatch = this.findExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
16545
+ if (!existingMatch) {
14598
16546
  const rawSource = sourceObj;
14599
16547
  let copiedPhotoKey = null;
14600
16548
  if (rawSource.photo) {
@@ -14602,6 +16550,12 @@ class MatterportTagCopyService {
14602
16550
  }
14603
16551
  const equipmentInput = {
14604
16552
  name: rawSource.name,
16553
+ // Unlike every other model this file copies, Equipment declares
16554
+ // `createdAt` as a plain client-settable field rather than an
16555
+ // AppSync-managed timestamp (see schema.graphql) — so, uniquely
16556
+ // here, the original commissioning date can actually be kept
16557
+ // instead of the copy silently getting today's date.
16558
+ createdAt: rawSource.createdAt ?? null,
14605
16559
  annotation: rawSource.annotation ?? null,
14606
16560
  status: rawSource.status,
14607
16561
  // Full identity of the equipment — all of these were dropped
@@ -14618,8 +16572,9 @@ class MatterportTagCopyService {
14618
16572
  // while hiding the item from no one — reset it, as for features.
14619
16573
  archivedForMissionID: null,
14620
16574
  spaceID: targetSpaceId,
14621
- // Domains are organisation-scoped, like for features and POIs.
14622
- domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
16575
+ // Reused unchanged in the same organisation, matched by name
16576
+ // across two — see resolveDomainID().
16577
+ domainID: this.resolveDomainID(rawSource.domainID),
14623
16578
  photo: copiedPhotoKey,
14624
16579
  // `subequipmentsIDs` is deliberately absent: it is remapped to the
14625
16580
  // copied equipments in the second pass, once they all exist.
@@ -14656,6 +16611,30 @@ class MatterportTagCopyService {
14656
16611
  await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
14657
16612
  }
14658
16613
  }
16614
+ else {
16615
+ // An equipment matching this one already exists in the target space —
16616
+ // most often because a PREVIOUS run of this copy already created it.
16617
+ // That earlier run may have been interrupted, or hit a bug that has
16618
+ // since been fixed (missing family/manufacturer/domainID/... were once
16619
+ // dropped entirely — see the comment above), leaving an equipment with
16620
+ // its identity fields empty forever: re-running the copy used to skip
16621
+ // it outright, since it already "exists", so the gap could never heal.
16622
+ //
16623
+ // Re-running the tool must be able to repair that: any identity field
16624
+ // the existing target equipment is missing, and the source has, is
16625
+ // filled in. Fields the target already carries a value for are left
16626
+ // untouched, so a manual edit made in the target space is never
16627
+ // overwritten by an older or different source value. Notes and POIs are
16628
+ // NOT re-created here: unlike scalar fields they cannot be "missing vs.
16629
+ // present" checked the same way, and re-adding them on every re-run of
16630
+ // an already-healthy copy would duplicate them.
16631
+ elementIds[sourceObj.id] = existingMatch.id;
16632
+ const healedFields = await this.healEquipmentIdentity(sourceObj, existingMatch);
16633
+ if (healedFields.length > 0) {
16634
+ healedCount++;
16635
+ this.logHealedItem('equipmentHealed', sourceObj?.name || sourceObj?.id, healedFields);
16636
+ }
16637
+ }
14659
16638
  }
14660
16639
  catch (itemError) {
14661
16640
  console.error(`Error copying equipment "${sourceObj?.name}" (id: ${sourceObj?.id}):`, itemError);
@@ -14692,6 +16671,50 @@ class MatterportTagCopyService {
14692
16671
  }
14693
16672
  }
14694
16673
  }
16674
+ // OPERATIONS — the Ticket<->Equipment join (drives "Tickets actuels" on the
16675
+ // equipment page and the linked-equipment list on the ticket page). Both the
16676
+ // TICKETS and EQUIPMENTS phases above copy the record itself, but neither one
16677
+ // touches this join table, so every link between a copied ticket and a copied
16678
+ // equipment was silently dropped: both sides existed in the target space, but
16679
+ // pointing at nobody.
16680
+ //
16681
+ // Runs after both phases, over every SOURCE ticket that ended up mapped in
16682
+ // the target space (freshly created OR healed — elementIds covers both), so a
16683
+ // re-run over an already-copied pair of spaces can still pick up links that
16684
+ // an earlier, incomplete run never created.
16685
+ for (const sourceTicket of sourceTickets) {
16686
+ const newTicketId = elementIds[sourceTicket.id];
16687
+ if (!newTicketId) {
16688
+ continue;
16689
+ }
16690
+ try {
16691
+ const sourceOps = await this.operationService.getOperationsByTicketID(sourceTicket.id);
16692
+ if (sourceOps.length === 0) {
16693
+ continue;
16694
+ }
16695
+ // Read what the target ticket is already linked to, so a re-run never
16696
+ // creates a duplicate Operation for a link that already exists.
16697
+ const targetOps = await this.operationService.getOperationsByTicketID(newTicketId);
16698
+ const linkedEquipmentIds = new Set(targetOps.map(op => op.equipmentID));
16699
+ for (const sourceOp of sourceOps) {
16700
+ const newEquipmentId = sourceOp.equipmentID ? elementIds[sourceOp.equipmentID] : null;
16701
+ // No copied counterpart for the source equipment (not collected,
16702
+ // outside the current filter, ...) — same rule as everywhere else
16703
+ // in this file: drop the link rather than point it at nothing or
16704
+ // at the source space's equipment.
16705
+ if (!newEquipmentId || linkedEquipmentIds.has(newEquipmentId)) {
16706
+ continue;
16707
+ }
16708
+ await this.operationService.createOperation({ ticketID: newTicketId, equipmentID: newEquipmentId });
16709
+ linkedEquipmentIds.add(newEquipmentId);
16710
+ }
16711
+ }
16712
+ catch (opError) {
16713
+ console.error(`Error copying ticket-equipment links for "${sourceTicket?.title}":`, opError);
16714
+ errorCount++;
16715
+ this.logCopyItemError(opError, 'errorOperation', sourceTicket?.title || sourceTicket?.id);
16716
+ }
16717
+ }
14695
16718
  }
14696
16719
  // OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
14697
16720
  for (let i = 0; i < sourceObjects3D.length; i++) {
@@ -14814,8 +16837,8 @@ class MatterportTagCopyService {
14814
16837
  for (let i = 0; i < sourceFeatures.length; i++) {
14815
16838
  const sourceObj = sourceFeatures[i];
14816
16839
  try {
14817
- const existsInTarget = this.matchesExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
14818
- if (!existsInTarget) {
16840
+ const existingMatch = this.findExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
16841
+ if (!existingMatch) {
14819
16842
  const rawSource = sourceObj;
14820
16843
  // Resolved just before the write: a keyword is only created in the target
14821
16844
  // space when the POI carrying it is about to be created.
@@ -14841,9 +16864,9 @@ class MatterportTagCopyService {
14841
16864
  // equivalent hashtags of the target space (created on demand).
14842
16865
  hashtags: resolvedHashtags,
14843
16866
  description: rawSource.description || null,
14844
- // Domains are organisation-scoped, so the category survives a copy
14845
- // inside one organisation and only has to be dropped across two.
14846
- domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
16867
+ // Reused unchanged in the same organisation, matched by name
16868
+ // across two — see resolveDomainID().
16869
+ domainID: this.resolveDomainID(rawSource.domainID),
14847
16870
  spaceID: targetSpaceId,
14848
16871
  ownerMissionID: targetMissionId
14849
16872
  };
@@ -14868,7 +16891,7 @@ class MatterportTagCopyService {
14868
16891
  }
14869
16892
  for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
14870
16893
  try {
14871
- const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
16894
+ const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId);
14872
16895
  if (created?.id && sourceComment.id) {
14873
16896
  this.commentIds.set(sourceComment.id, created.id);
14874
16897
  }
@@ -14889,6 +16912,18 @@ class MatterportTagCopyService {
14889
16912
  }
14890
16913
  }
14891
16914
  }
16915
+ else {
16916
+ // Same reasoning as the EQUIPMENTS phase (see the comment there): a
16917
+ // feature matching this one already exists in the target space, most
16918
+ // likely from an earlier, incomplete run. Fill in whatever identity
16919
+ // field it is missing rather than leaving it untouched.
16920
+ elementIds[sourceObj.id] = existingMatch.id;
16921
+ const healedFields = await this.healFeatureIdentity(sourceObj, existingMatch);
16922
+ if (healedFields.length > 0) {
16923
+ healedCount++;
16924
+ this.logHealedItem('featureHealed', sourceObj?.title || sourceObj?.id, healedFields);
16925
+ }
16926
+ }
14892
16927
  }
14893
16928
  catch (itemError) {
14894
16929
  console.error(`Error copying feature "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
@@ -14921,6 +16956,9 @@ class MatterportTagCopyService {
14921
16956
  dimensions: rawComment.dimensions || null,
14922
16957
  annexe: copiedAnnexe,
14923
16958
  spaceID: targetSpaceId,
16959
+ // Same authorship rule as every other copied comment — see
16960
+ // resolveCommentOwnerID().
16961
+ ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
14924
16962
  };
14925
16963
  const createdComment = await this.commentService.createComment(commentInput);
14926
16964
  if (createdComment && createdComment.id && rawComment.id) {
@@ -14987,11 +17025,17 @@ class MatterportTagCopyService {
14987
17025
  phase: 'done',
14988
17026
  processedInPhase: 0, totalInPhase: 0,
14989
17027
  processedOverall, totalOverall,
14990
- percent: 100, errorCount
17028
+ percent: 100, errorCount, healedCount
14991
17029
  });
14992
17030
  this.setStepLog('done', errorCount > 0
14993
17031
  ? this.translate.instant('matterportCopy.doneWithErrors', { count: errorCount })
14994
17032
  : this.translate.instant('matterportCopy.done'));
17033
+ // Separate summary line, on top of (not instead of) the done/doneWithErrors one
17034
+ // above: healing is orthogonal to success/failure, an already-complete run can
17035
+ // still have repaired items left over from an earlier, incomplete attempt.
17036
+ if (healedCount > 0) {
17037
+ this.setStepLog('healedSummary', this.translate.instant('matterportCopy.healedSummary', { count: healedCount }));
17038
+ }
14995
17039
  return true;
14996
17040
  }
14997
17041
  catch (error) {
@@ -15007,6 +17051,17 @@ class MatterportTagCopyService {
15007
17051
  await this.cleanupUnusedCreatedHashtags();
15008
17052
  }
15009
17053
  }
17054
+ /**
17055
+ * Serialises the current copy log (step lines as last emitted, plus every error and
17056
+ * healed-item notice) to a plain-text file and triggers a browser download — wired to a
17057
+ * "download log" button in the copy UI, so an operator can keep or share the full report
17058
+ * of a run rather than only the ~200px scrollable panel showing it.
17059
+ */
17060
+ downloadLog(filename) {
17061
+ const text = this.log.map(entry => entry.text).join('\n');
17062
+ const defaultName = `matterport-copy-log-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
17063
+ downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), filename || defaultName);
17064
+ }
15010
17065
  //
15011
17066
  // ---------- Progress / log helpers ----------
15012
17067
  //
@@ -15024,14 +17079,21 @@ class MatterportTagCopyService {
15024
17079
  }
15025
17080
  /** Appends a new, permanent log line (never overwritten), then republishes the log. */
15026
17081
  appendErrorLog(text) {
15027
- this.errorSeq++;
15028
- this.log.push({ id: `error-${this.errorSeq}`, type: 'error', text });
17082
+ this.logSeq++;
17083
+ this.log.push({ id: `error-${this.logSeq}`, type: 'error', text });
17084
+ this.copyLog$.next([...this.log]);
17085
+ }
17086
+ /** Appends a permanent log line for an item that was repaired rather than newly
17087
+ * created — see healEquipmentIdentity() and its ticket/feature equivalents. */
17088
+ appendHealedLog(text) {
17089
+ this.logSeq++;
17090
+ this.log.push({ id: `healed-${this.logSeq}`, type: 'healed', text });
15029
17091
  this.copyLog$.next([...this.log]);
15030
17092
  }
15031
17093
  /** Resets the log for a new run. */
15032
17094
  resetLog() {
15033
17095
  this.log = [];
15034
- this.errorSeq = 0;
17096
+ this.logSeq = 0;
15035
17097
  this.copyLog$.next([]);
15036
17098
  }
15037
17099
  /** Clears every id map carried over from a previous run. The service is a root-level
@@ -15045,7 +17107,11 @@ class MatterportTagCopyService {
15045
17107
  this.createdHashtagIds = new Set();
15046
17108
  this.usedHashtagIds = new Set();
15047
17109
  this.handledElementIds = new Set();
15048
- this.sameOrganisation = false;
17110
+ this.sourceMissionUserIds = new Map();
17111
+ this.targetMissionIdsByUserId = new Map();
17112
+ this.domainsById = new Map();
17113
+ this.targetVisibleDomainOrgIds = new Set();
17114
+ this.targetDomainIdsByTypeAndName = new Map();
15049
17115
  this.targetSpaceId = targetSpaceId;
15050
17116
  }
15051
17117
  /** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
@@ -15058,6 +17124,16 @@ class MatterportTagCopyService {
15058
17124
  const text = this.translate.instant(`matterportCopy.${translationKey}`, params);
15059
17125
  this.appendErrorLog(text);
15060
17126
  }
17127
+ /** Logs a translated line for an item that already existed in the target space and had
17128
+ * its missing identity fields filled in, so the operator can tell it apart from a
17129
+ * freshly created item in the copy report. */
17130
+ logHealedItem(translationKey, itemLabel, healedFields) {
17131
+ const text = this.translate.instant(`matterportCopy.${translationKey}`, {
17132
+ name: itemLabel,
17133
+ fields: healedFields.join(', ')
17134
+ });
17135
+ this.appendHealedLog(text);
17136
+ }
15061
17137
  /** Logs an item-level error, using a specific "missing file / orphaned reference"
15062
17138
  * translation key when the cause is a MissingS3FileError, and the generic per-type
15063
17139
  * key otherwise. Keeps the copy log readable: a missing file is not a bug to
@@ -15094,27 +17170,87 @@ class MatterportTagCopyService {
15094
17170
  //
15095
17171
  // ---------- Cross-space reference remapping ----------
15096
17172
  //
17173
+ /** Normalised lookup key for a domain: type disambiguates same-named domains used for
17174
+ * different purposes (equipment vs. ticket vs. feature categories, ...). */
17175
+ domainKey(type, name) {
17176
+ return `${type || ''}::${(name || '').trim().toLowerCase()}`;
17177
+ }
15097
17178
  /**
15098
- * True when both spaces belong to the same organisation.
15099
- * Domains (POI/Feature categories) are organisation-scoped records with no spaceID, so
15100
- * a domainID stays meaningful inside one organisation and only becomes a dangling
15101
- * reference across two. A failed lookup answers "no": dropping a category is recoverable,
15102
- * pointing at another organisation's domain is not.
17179
+ * READ-ONLY indexing of every domain either operator's Mission can see. Creates
17180
+ * nothing — unlike hashtags, a domain is a business taxonomy entry an operator manages
17181
+ * deliberately; this tool only ever reuses an EXISTING equivalent, it never invents one.
17182
+ *
17183
+ * Visibility is keyed to `currentMission(spaceId).organisationID` /
17184
+ * `.orderedOrganisationID` for EACH space — exactly what
17185
+ * `DomainService.getDomainsForOrgForType()` uses to populate the category dropdown on
17186
+ * the equipment/ticket/feature edit forms (see form-equipment.component.ts,
17187
+ * batch-edit-equipment.component.ts, form-feature.component.ts). Comparing the spaces'
17188
+ * OWN `organisationID` instead — the previous approach — missed every domain reachable
17189
+ * only through the operator's `orderedOrganisationID` (set when a mission is created "on
17190
+ * behalf of" another organisation — see form-mission.component.ts), which is exactly how
17191
+ * a domain ends up visible on two spaces that belong to different organisations.
15103
17192
  */
15104
- async spacesShareOrganisation(sourceSpaceId, targetSpaceId) {
17193
+ async loadDomainIndex(sourceSpaceId, targetSpaceId) {
17194
+ const sourceMission = this.userService.currentMission(sourceSpaceId);
17195
+ const targetMission = this.userService.currentMission(targetSpaceId);
17196
+ const sourceOrgIds = [sourceMission?.organisationID, sourceMission?.orderedOrganisationID]
17197
+ .filter((id) => !!id);
17198
+ const targetOrgIds = [targetMission?.organisationID, targetMission?.orderedOrganisationID]
17199
+ .filter((id) => !!id);
17200
+ this.targetVisibleDomainOrgIds = new Set(targetOrgIds);
17201
+ const allOrgIds = Array.from(new Set([...sourceOrgIds, ...targetOrgIds]));
17202
+ if (allOrgIds.length === 0) {
17203
+ // The operator has no mission on either space — nothing to resolve against;
17204
+ // every domainID will be dropped, same as an unresolvable lookup elsewhere.
17205
+ return;
17206
+ }
15105
17207
  try {
15106
- const [sourceSpace, targetSpace] = await Promise.all([
15107
- this.spaceService.getSpace(sourceSpaceId),
15108
- this.spaceService.getSpace(targetSpaceId)
15109
- ]);
15110
- const sourceOrg = sourceSpace?.organisationID;
15111
- const targetOrg = targetSpace?.organisationID;
15112
- return !!sourceOrg && !!targetOrg && sourceOrg === targetOrg;
17208
+ const domainLists = await Promise.all(allOrgIds.map(orgId => this.domainService.getAllGenericDomainsAndForOrganisation(orgId)));
17209
+ for (const list of domainLists) {
17210
+ for (const domain of list || []) {
17211
+ if (domain?.id) {
17212
+ this.domainsById.set(domain.id, domain);
17213
+ }
17214
+ }
17215
+ }
15113
17216
  }
15114
17217
  catch (error) {
15115
- console.warn('[MatterportTagCopyService] Could not compare the spaces\' organisations, categories will be dropped:', error);
15116
- return false;
17218
+ console.warn('[MatterportTagCopyService] Could not read the domains, cross-organisation categories will be dropped:', error);
17219
+ return;
17220
+ }
17221
+ for (const domain of this.domainsById.values()) {
17222
+ const visibleToTarget = !domain.organisationID || this.targetVisibleDomainOrgIds.has(domain.organisationID);
17223
+ if (visibleToTarget && domain.name) {
17224
+ this.targetDomainIdsByTypeAndName.set(this.domainKey(domain.type, domain.name), domain.id);
17225
+ }
17226
+ }
17227
+ }
17228
+ /**
17229
+ * Resolves a source domainID (equipment/ticket/feature/POI category) for use in the
17230
+ * target space.
17231
+ *
17232
+ * - generic domain (no organisationID of its own — a shared taxonomy entry, e.g. a
17233
+ * standard category created by the platform): reused unchanged, valid everywhere.
17234
+ * - the domain's own organisationID is already one the TARGET operator's mission can
17235
+ * see (its organisationID, or its orderedOrganisationID): reused unchanged too — the
17236
+ * id is directly selectable in the target space's own dropdown as-is.
17237
+ * - otherwise: matched by (type, name) against the domains visible to the TARGET
17238
+ * operator (loadDomainIndex()); dropped when no equivalent exists there.
17239
+ */
17240
+ resolveDomainID(sourceDomainId) {
17241
+ if (!sourceDomainId) {
17242
+ return null;
17243
+ }
17244
+ const domain = this.domainsById.get(sourceDomainId);
17245
+ if (!domain) {
17246
+ // Not visible to either operator's mission (lookup failed, or it belongs to
17247
+ // neither) — unchanged, safest to drop.
17248
+ return null;
17249
+ }
17250
+ if (!domain.organisationID || this.targetVisibleDomainOrgIds.has(domain.organisationID)) {
17251
+ return sourceDomainId;
15117
17252
  }
17253
+ return this.targetDomainIdsByTypeAndName.get(this.domainKey(domain.type, domain.name)) ?? null;
15118
17254
  }
15119
17255
  /** Normalised lookup key for a hashtag name, so "Moyen Âge" and "moyen âge " match. */
15120
17256
  hashtagKey(name) {
@@ -15204,6 +17340,61 @@ class MatterportTagCopyService {
15204
17340
  }
15205
17341
  return resolved.length > 0 ? resolved : null;
15206
17342
  }
17343
+ /**
17344
+ * READ-ONLY indexing of both spaces' missions.
17345
+ *
17346
+ * A Mission is scoped to one space (it pairs a userID with a spaceID), so the id a
17347
+ * source comment's `ownerID` points at never designates anyone in the target space.
17348
+ * This builds the two lookup tables resolveCommentOwnerID() needs to translate it:
17349
+ * which user a source Mission id belongs to, and which Mission (if any) that same
17350
+ * user already holds in the target space.
17351
+ */
17352
+ async loadMissionIndex(sourceSpaceId, targetSpaceId) {
17353
+ let sourceMissions = [];
17354
+ let targetMissions = [];
17355
+ try {
17356
+ [sourceMissions, targetMissions] = await Promise.all([
17357
+ this.missionService.getAllMissionsForSpace(sourceSpaceId),
17358
+ this.missionService.getAllMissionsForSpace(targetSpaceId)
17359
+ ]);
17360
+ }
17361
+ catch (error) {
17362
+ console.warn('[MatterportTagCopyService] Could not read the missions, comment authorship will be dropped:', error);
17363
+ return;
17364
+ }
17365
+ for (const mission of sourceMissions || []) {
17366
+ if (mission?.id && mission.userID) {
17367
+ this.sourceMissionUserIds.set(mission.id, mission.userID);
17368
+ }
17369
+ }
17370
+ for (const mission of targetMissions || []) {
17371
+ if (mission?.id && mission.userID && !this.targetMissionIdsByUserId.has(mission.userID)) {
17372
+ this.targetMissionIdsByUserId.set(mission.userID, mission.id);
17373
+ }
17374
+ }
17375
+ }
17376
+ /**
17377
+ * Translates a source comment's `ownerID` (a per-space Mission id) into the Mission of
17378
+ * the SAME user in the target space, so the copy keeps crediting its real author.
17379
+ *
17380
+ * Substituting the current operator's mission — the way `ownerMissionID` legitimately
17381
+ * does for tickets/equipment/features, which really do change owner on a copy — would
17382
+ * misattribute every comment to whoever ran the copy instead of who actually wrote it.
17383
+ *
17384
+ * When the original author has no mission in the target space (never granted access
17385
+ * there), the comment is left with no owner rather than a wrong one: an absent author
17386
+ * is honest, a misattributed one is not.
17387
+ */
17388
+ resolveCommentOwnerID(sourceOwnerID) {
17389
+ if (!sourceOwnerID) {
17390
+ return null;
17391
+ }
17392
+ const userID = this.sourceMissionUserIds.get(sourceOwnerID);
17393
+ if (!userID) {
17394
+ return null;
17395
+ }
17396
+ return this.targetMissionIdsByUserId.get(userID) ?? null;
17397
+ }
15207
17398
  /** Records that these hashtags are now referenced by a feature that really exists, so
15208
17399
  * the end-of-run cleanup leaves them alone. */
15209
17400
  markHashtagsUsed(hashtagIds) {
@@ -15317,16 +17508,16 @@ class MatterportTagCopyService {
15317
17508
  * items — where the whole content lives, since a TEXT comment has no file — and any
15318
17509
  * per-media settings stored in metadata.
15319
17510
  */
15320
- async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId, targetMissionId) {
15321
- return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { featureID: targetFeatureId });
17511
+ async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId) {
17512
+ return this.copyComment(sourceComment, targetSpaceId, { featureID: targetFeatureId });
15322
17513
  }
15323
17514
  /** Same, for an attachment carried by a ticket rather than by a feature. */
15324
- async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId, targetMissionId) {
15325
- return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { ticketID: targetTicketId });
17515
+ async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId) {
17516
+ return this.copyComment(sourceComment, targetSpaceId, { ticketID: targetTicketId });
15326
17517
  }
15327
17518
  /** Shared body of the two above: `owner` carries whichever foreign key links the copy back
15328
17519
  * to its new parent, everything else is identical. */
15329
- async copyComment(sourceComment, targetSpaceId, targetMissionId, owner) {
17520
+ async copyComment(sourceComment, targetSpaceId, owner) {
15330
17521
  const rawComment = sourceComment;
15331
17522
  let copiedAnnexe = null;
15332
17523
  if (rawComment.annexe) {
@@ -15344,7 +17535,11 @@ class MatterportTagCopyService {
15344
17535
  shownInTag: rawComment.shownInTag,
15345
17536
  isCoverImage: rawComment.isCoverImage,
15346
17537
  spaceID: targetSpaceId,
15347
- ownerID: targetMissionId,
17538
+ // Keeps crediting the ORIGINAL author when they also have a mission in the
17539
+ // target space, and leaves the comment un-owned otherwise — see
17540
+ // resolveCommentOwnerID(). Never the current operator's own mission: that
17541
+ // would silently reassign authorship to whoever happens to run the copy.
17542
+ ownerID: this.resolveCommentOwnerID(rawComment.ownerID),
15348
17543
  ...owner
15349
17544
  };
15350
17545
  return this.commentService.createComment(commentInput);
@@ -15383,7 +17578,7 @@ class MatterportTagCopyService {
15383
17578
  level: raw.level ?? null,
15384
17579
  role: raw.role ?? null,
15385
17580
  category: raw.category ?? null,
15386
- domainID: this.sameOrganisation ? (raw.domainID || null) : null,
17581
+ domainID: this.resolveDomainID(raw.domainID),
15387
17582
  // --- Matterport anchoring ---
15388
17583
  metadata: this.getAdjustedPoiMetadata(sourcePoi.metadata, transform)
15389
17584
  };
@@ -15447,7 +17642,9 @@ class MatterportTagCopyService {
15447
17642
  //
15448
17643
  /**
15449
17644
  * Heuristic duplicate-detection: compares several identifying fields together rather than
15450
- * a single label, to reduce false positives when items share one of them.
17645
+ * a single label, to reduce false positives when items share one of them. Returns the
17646
+ * matched target item (or undefined) rather than a boolean, so the caller can act on the
17647
+ * existing record — heal its missing fields — instead of only skipping it.
15451
17648
  *
15452
17649
  * The fields are supplied by the caller because the models do not agree on them. The
15453
17650
  * previous version hard-coded `title`/`description`, which do not exist on Equipment at
@@ -15455,9 +17652,95 @@ class MatterportTagCopyService {
15455
17652
  * and a target space holding a single equipment silently caused the whole equipment phase
15456
17653
  * to copy nothing.
15457
17654
  */
15458
- matchesExisting(source, targetList, identity) {
17655
+ findExisting(source, targetList, identity) {
15459
17656
  const sourceKey = this.identityKey(identity(source));
15460
- return targetList.some(target => this.identityKey(identity(target)) === sourceKey);
17657
+ return targetList.find(target => this.identityKey(identity(target)) === sourceKey);
17658
+ }
17659
+ /**
17660
+ * Builds a patch of whichever fields in `fields` the existing target record is missing
17661
+ * (`null`/`undefined`/empty array) but the source has, plus `domainID` when requested —
17662
+ * domains are organisation-scoped everywhere in this file, so that one is only ever
17663
+ * healed across the SAME organisation, exactly like at creation time.
17664
+ *
17665
+ * Never overwrites a field the target already carries a value for: a value entered by
17666
+ * hand in the target space since an earlier, incomplete copy must not be clobbered by
17667
+ * an older or different source value.
17668
+ *
17669
+ * Returns both the patch to send and the list of healed field names, so the caller can
17670
+ * both perform the update and describe it in the copy log.
17671
+ */
17672
+ buildHealPatch(sourceObj, existingTarget, fields, includeDomain) {
17673
+ const patch = {};
17674
+ const healedFields = [];
17675
+ const fillIfMissing = (key, sourceValue) => {
17676
+ const currentValue = existingTarget[key];
17677
+ const isMissing = currentValue === null || currentValue === undefined
17678
+ || (Array.isArray(currentValue) && currentValue.length === 0);
17679
+ if (isMissing && sourceValue !== null && sourceValue !== undefined) {
17680
+ patch[key] = sourceValue;
17681
+ healedFields.push(key);
17682
+ }
17683
+ };
17684
+ for (const field of fields) {
17685
+ fillIfMissing(field, sourceObj[field]);
17686
+ }
17687
+ if (includeDomain && (existingTarget.domainID === null || existingTarget.domainID === undefined)) {
17688
+ const resolvedDomainId = this.resolveDomainID(sourceObj.domainID);
17689
+ if (resolvedDomainId) {
17690
+ patch.domainID = resolvedDomainId;
17691
+ healedFields.push('domainID');
17692
+ }
17693
+ }
17694
+ return { patch, healedFields };
17695
+ }
17696
+ /**
17697
+ * Fills in whichever identity field a PRE-EXISTING target equipment is missing but the
17698
+ * source has — see the call site in the EQUIPMENTS phase for why re-running the copy
17699
+ * has to be able to repair a previously-copied equipment, not just skip it again.
17700
+ *
17701
+ * A failed update is left to bubble up to the caller's existing per-item try/catch, the
17702
+ * same as a failed creation — it gets the same visible error line, rather than a silent
17703
+ * console warning.
17704
+ *
17705
+ * Returns the list of fields that were actually healed (empty if nothing was missing).
17706
+ */
17707
+ async healEquipmentIdentity(sourceObj, existingTarget) {
17708
+ const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['status', 'annotation', 'family', 'sku', 'manufacturer', 'installDate', 'warrantyEndDate', 'metadata', 'isCreatedByAI'], true);
17709
+ if (!existingTarget.photo && sourceObj.photo) {
17710
+ patch.photo = await this.copyS3KeyIfPresent(sourceObj.photo, `equipment photo "${sourceObj.name}"`);
17711
+ healedFields.push('photo');
17712
+ }
17713
+ if (healedFields.length === 0) {
17714
+ return healedFields;
17715
+ }
17716
+ patch.id = existingTarget.id;
17717
+ await this.equipmentService.updateEquipment(patch);
17718
+ return healedFields;
17719
+ }
17720
+ /**
17721
+ * Same idea as healEquipmentIdentity(), for a pre-existing ticket. `title`/`description`
17722
+ * are the pair findExisting() matched on to find this ticket in the first place, so they
17723
+ * are never part of the heal — by construction, they already agree with the source.
17724
+ */
17725
+ async healTicketIdentity(sourceObj, existingTarget) {
17726
+ const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['friendlyLabel', 'status', 'type', 'priority', 'level', 'metadata', 'read', 'projectID', 'problemCode'], true);
17727
+ if (healedFields.length === 0) {
17728
+ return healedFields;
17729
+ }
17730
+ patch.id = existingTarget.id;
17731
+ await this.ticketService.updateTicket(patch);
17732
+ return healedFields;
17733
+ }
17734
+ /** Same idea, for a pre-existing feature. Keywords, media and POIs are not healed here
17735
+ * for the same reason notes/POIs are not healed on equipment — see healEquipmentIdentity(). */
17736
+ async healFeatureIdentity(sourceObj, existingTarget) {
17737
+ const { patch, healedFields } = this.buildHealPatch(sourceObj, existingTarget, ['shortDescription', 'level', 'type', 'index', 'stripeProductID', 'stripePriceID', 'bookingPriceMetadata', 'locale'], true);
17738
+ if (healedFields.length === 0) {
17739
+ return healedFields;
17740
+ }
17741
+ patch.id = existingTarget.id;
17742
+ await this.featureService.updateFeature(patch);
17743
+ return healedFields;
15461
17744
  }
15462
17745
  /** Serialises the identifying fields unambiguously, so that ["a", "b"] and ["ab", ""]
15463
17746
  * can never produce the same key. */
@@ -15734,13 +18017,13 @@ class MatterportTagCopyService {
15734
18017
  // for the CopySource header. Do NOT re-encode it here.
15735
18018
  return extracted;
15736
18019
  }
15737
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: HashtagService }, { token: SpaceService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
18020
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: HashtagService }, { token: MissionService }, { token: OperationService }, { token: DomainService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
15738
18021
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
15739
18022
  }
15740
18023
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
15741
18024
  type: Injectable,
15742
18025
  args: [{ providedIn: 'root' }]
15743
- }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: SpaceService }, { type: i1.TranslateService }] });
18026
+ }], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: MissionService }, { type: OperationService }, { type: DomainService }, { type: i1.TranslateService }] });
15744
18027
 
15745
18028
  /* eslint-disable class-methods-use-this */
15746
18029
  class OrganisationService {
@@ -15814,81 +18097,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
15814
18097
  args: ['currentAPIService']
15815
18098
  }] }] });
15816
18099
 
15817
- class DomainService {
15818
- API; //AWS
15819
- constructor(apiInjected) {
15820
- this.API = apiInjected;
15821
- }
15822
- async getGenericDomainsAndForOrganisation(organisationID) {
15823
- return this.API.__proto__.ListDomains().then((domains) => {
15824
- return domains.items.filter((dom) => {
15825
- return (dom.organisationID === organisationID || !dom.organisationID);
15826
- });
15827
- });
15828
- }
15829
- async getGenericDomainsAndForOrgAndOrderedOrg(orgID, orderedOrgID = null) {
15830
- // TODO: Change this (UUID can change depending on DB)
15831
- if (orgID === "6e28201f-4679-4c29-9d2d-8a325f57cee9") {
15832
- // I am SmarterPlan, i can see all
15833
- return this.API.__proto__.ListDomains().then((response) => response.items);
15834
- }
15835
- const currentOrgDomains = await this.getGenericDomainsAndForOrganisation(orgID);
15836
- if (!orderedOrgID || orgID === orderedOrgID) {
15837
- return currentOrgDomains;
15838
- }
15839
- const orderedOrgDomains = await this.getGenericDomainsAndForOrganisation(orderedOrgID);
15840
- const merged = [...currentOrgDomains, ...orderedOrgDomains];
15841
- return filterUniqueArrayByID(merged);
15842
- }
15843
- async getDomainsForOrgExcludingTypes(types, organisationID) {
15844
- return this.getGenericDomainsAndForOrganisation(organisationID).then((domains) => {
15845
- return domains.filter((dom) => !types.includes(dom.type));
15846
- });
15847
- }
15848
- async getDomain(id) {
15849
- return this.API.__proto__.GetDomain(id);
15850
- }
15851
- async updateDomain(input) {
15852
- return this.API.__proto__.UpdateDomain(input);
15853
- }
15854
- async deleteDomain(id) {
15855
- return this.API.__proto__.DeleteDomain({ id });
15856
- }
15857
- async getRootDomains(orgID) {
15858
- const allDomains = await this.getGenericDomainsAndForOrganisation(orgID);
15859
- const root = allDomains.filter((dom) => !dom.parentID);
15860
- return root.sort((a, b) => a.name.localeCompare(b.name));
15861
- }
15862
- async createDomain(input) {
15863
- return this.API.__proto__.CreateDomain(input);
15864
- }
15865
- async getDomainsForOrgForType(type, organisationID, orderedOrg = null) {
15866
- return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
15867
- return domains.filter((dom) => dom.type === type);
15868
- });
15869
- }
15870
- async getParentDomainsForOrgAndOrderedOrgForType(type, organisationID, orderedOrg = null) {
15871
- return this.getGenericDomainsAndForOrgAndOrderedOrg(organisationID, orderedOrg).then((domains) => {
15872
- return domains.filter((dom) => dom.type === type && !dom.parentID);
15873
- });
15874
- }
15875
- async getDomainsForMissionsForOrg(orgID) {
15876
- const domains = await this.getDomainsForOrgExcludingTypes([DomainType.BOOKING, DomainType.FEATURE, DomainType.MISSION], orgID);
15877
- return domains.filter((dom) => !dom.parentID);
15878
- }
15879
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
15880
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, providedIn: "root" });
15881
- }
15882
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DomainService, decorators: [{
15883
- type: Injectable,
15884
- args: [{
15885
- providedIn: "root",
15886
- }]
15887
- }], ctorParameters: () => [{ type: undefined, decorators: [{
15888
- type: Inject,
15889
- args: ['currentAPIService']
15890
- }] }] });
15891
-
15892
18100
  class TemplateService {
15893
18101
  API; //AWS
15894
18102
  constructor(apiInjected) {
@@ -15949,5 +18157,5 @@ function floatValidator() {
15949
18157
  * Generated bundle index. Do not edit.
15950
18158
  */
15951
18159
 
15952
- export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
18160
+ export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, ThreeJsImportService, ThreeJsService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, parseThreeJsPlacement, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, solveThreeJsAlignment, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadFileToS3WithProgress, uploadJsonToS3, validEmail, wait, waitUntil };
15953
18161
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map