@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.
- package/README.md +24 -24
- package/esm2022/lib/components/csv-export/csv-export.component.mjs +3 -3
- package/esm2022/lib/components/loader/loader.component.mjs +3 -3
- package/esm2022/lib/components/menu-bar/avatar/avatar.component.mjs +1 -1
- package/esm2022/lib/components/menu-bar/menu-bar.component.mjs +3 -3
- package/esm2022/lib/components/menu-bar/navigation-bar/navigation-bar.component.mjs +3 -3
- package/esm2022/lib/components/modal-switch-visit/modal-switch-visit.component.mjs +3 -3
- package/esm2022/lib/components/search-bar/search-bar.component.mjs +3 -3
- package/esm2022/lib/components/support-modal/support-modal.component.mjs +3 -3
- package/esm2022/lib/config.mjs +1 -1
- package/esm2022/lib/helpers.service.mjs +46 -1
- package/esm2022/lib/matterport-extensions/nest-thermostat/CanvasImage.mjs +1 -1
- package/esm2022/lib/matterport-extensions/nest-thermostat/CanvasRenderer.mjs +1 -1
- package/esm2022/lib/matterport-extensions/nest-thermostat/NestThermostat.mjs +1 -1
- package/esm2022/lib/matterport-extensions/nest-thermostat/PlaneRenderer.mjs +1 -1
- package/esm2022/lib/matterport-extensions/scene-component/SceneComponent.mjs +1 -1
- package/esm2022/lib/matterport-extensions/security-camera/SecurityCamera.mjs +22 -22
- package/esm2022/lib/matterport-extensions/tv-player/TvPlayer.mjs +1 -1
- package/esm2022/lib/matterport-extensions/view-frustum-mesh/ViewFrustumMesh.mjs +22 -22
- package/esm2022/lib/mattertagData.mjs +1 -1
- package/esm2022/lib/ngx-smarterplan-core.module.mjs +1 -1
- package/esm2022/lib/ngx-smarterplan-core.service.mjs +1 -1
- package/esm2022/lib/pipes/duration-to-string.pipe.mjs +1 -1
- package/esm2022/lib/pipes/format-date-number-to-digits.pipe.mjs +1 -1
- package/esm2022/lib/pipes/safe-url.pipe.mjs +1 -1
- package/esm2022/lib/pipes/time-date-to-local-string.pipe.mjs +1 -1
- package/esm2022/lib/pipes/username-from-id.pipe.mjs +1 -1
- package/esm2022/lib/services/amplify-cache.service.mjs +1 -1
- package/esm2022/lib/services/base-tab.service.mjs +1 -1
- package/esm2022/lib/services/baseVisibility.service.mjs +1 -1
- package/esm2022/lib/services/content.service.mjs +1 -1
- package/esm2022/lib/services/filter.service.mjs +1 -1
- package/esm2022/lib/services/intervention.service.mjs +1 -1
- package/esm2022/lib/services/locale.service.mjs +1 -1
- package/esm2022/lib/services/matterport-import.service.mjs +1 -1
- package/esm2022/lib/services/matterport-measurement.service.mjs +1 -1
- package/esm2022/lib/services/matterport-navigation.service.mjs +1 -1
- package/esm2022/lib/services/matterport-object3d.service.mjs +1 -1
- package/esm2022/lib/services/matterport-pointer.service.mjs +6 -6
- package/esm2022/lib/services/matterport-tag-copy.service.mjs +448 -62
- package/esm2022/lib/services/matterport-tag.service.mjs +1 -1
- package/esm2022/lib/services/matterport.service.mjs +17 -17
- package/esm2022/lib/services/models/affectation.service.mjs +1 -1
- package/esm2022/lib/services/models/base-object.service.mjs +1 -1
- package/esm2022/lib/services/models/capture.service.mjs +1 -1
- package/esm2022/lib/services/models/comment.service.mjs +1 -1
- package/esm2022/lib/services/models/domain.service.mjs +20 -1
- package/esm2022/lib/services/models/equipment.service.mjs +4 -2
- package/esm2022/lib/services/models/event.service.mjs +1 -1
- package/esm2022/lib/services/models/feature.service.mjs +1 -7
- package/esm2022/lib/services/models/hashtag.service.mjs +1 -1
- package/esm2022/lib/services/models/layer.service.mjs +1 -1
- package/esm2022/lib/services/models/measurement.service.mjs +1 -1
- package/esm2022/lib/services/models/mission.service.mjs +17 -1
- package/esm2022/lib/services/models/navigation.service.mjs +1 -1
- package/esm2022/lib/services/models/node.service.mjs +1 -1
- package/esm2022/lib/services/models/object3D.service.mjs +1 -1
- package/esm2022/lib/services/models/operation.service.mjs +1 -1
- package/esm2022/lib/services/models/organisation.service.mjs +1 -1
- package/esm2022/lib/services/models/plan.service.mjs +1 -1
- package/esm2022/lib/services/models/poi.service.mjs +27 -27
- package/esm2022/lib/services/models/profile.service.mjs +1 -1
- package/esm2022/lib/services/models/property.service.mjs +1 -1
- package/esm2022/lib/services/models/space.service.mjs +25 -6
- package/esm2022/lib/services/models/template.service.mjs +1 -1
- package/esm2022/lib/services/models/ticket.service.mjs +1 -1
- package/esm2022/lib/services/models/visit.service.mjs +17 -5
- package/esm2022/lib/services/models/zone.service.mjs +1 -1
- package/esm2022/lib/services/navigator.service.mjs +1 -1
- package/esm2022/lib/services/s3.service.mjs +18 -1
- package/esm2022/lib/services/search.service.mjs +1 -1
- package/esm2022/lib/services/support.service.mjs +1 -1
- package/esm2022/lib/services/tag.service.mjs +16 -16
- package/esm2022/lib/services/threejs-import.service.mjs +151 -0
- package/esm2022/lib/services/threejs.service.mjs +1306 -0
- package/esm2022/lib/services/user.service.mjs +1 -1
- package/esm2022/lib/services/utils/matterport-transform.util.mjs +1 -1
- package/esm2022/lib/services/validators.service.mjs +1 -1
- package/esm2022/lib/services/viewer.service.mjs +263 -5
- package/esm2022/lib/services/zone-drawer.service.mjs +1 -1
- package/esm2022/lib/services/zoneChange.service.mjs +1 -1
- package/esm2022/lib/tokens/matterport-sdk-key.token.mjs +1 -1
- package/esm2022/lib/types.service.mjs +2 -1
- package/esm2022/lib/validators/email.directive.mjs +1 -1
- package/esm2022/lib/validators/no-empty.directive.mjs +1 -1
- package/esm2022/lib/validators/number.directive.mjs +1 -1
- package/esm2022/lib/validators/text.directive.mjs +1 -1
- package/esm2022/public-api.mjs +3 -1
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +2759 -551
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
- package/lib/helpers.service.d.ts +43 -0
- package/lib/services/matterport-tag-copy.service.d.ts +137 -17
- package/lib/services/models/domain.service.d.ts +8 -0
- package/lib/services/models/feature.service.d.ts +0 -6
- package/lib/services/models/mission.service.d.ts +5 -0
- package/lib/services/models/space.service.d.ts +15 -1
- package/lib/services/models/visit.service.d.ts +9 -1
- package/lib/services/s3.service.d.ts +8 -0
- package/lib/services/threejs-import.service.d.ts +31 -0
- package/lib/services/threejs.service.d.ts +423 -0
- package/lib/services/viewer.service.d.ts +114 -1
- package/lib/types.service.d.ts +7 -1
- package/package.json +1 -1
- package/public-api.d.ts +2 -0
- 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,
|
|
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\">\
|
|
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\">\
|
|
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
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
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('
|
|
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
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
5987
|
-
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
});
|
|
5991
|
-
}
|
|
5992
|
-
|
|
5993
|
-
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
|
|
5998
|
-
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6013
|
-
*
|
|
6014
|
-
*
|
|
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
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
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
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
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
|
-
|
|
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\">\
|
|
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\">\
|
|
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\">\
|
|
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\">\
|
|
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\">\
|
|
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\">\
|
|
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
|
-
|
|
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
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
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
|
|
12336
|
-
|
|
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\">\
|
|
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\">\
|
|
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
|
|
14219
|
-
return this.
|
|
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
|
-
|
|
14222
|
-
|
|
14223
|
-
return this.API.__proto__.GetObject3D(id);
|
|
16111
|
+
async getDomain(id) {
|
|
16112
|
+
return this.API.__proto__.GetDomain(id);
|
|
14224
16113
|
}
|
|
14225
|
-
async
|
|
14226
|
-
|
|
14227
|
-
return object3D;
|
|
16114
|
+
async updateDomain(input) {
|
|
16115
|
+
return this.API.__proto__.UpdateDomain(input);
|
|
14228
16116
|
}
|
|
14229
|
-
async
|
|
14230
|
-
|
|
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
|
-
|
|
14243
|
-
|
|
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:
|
|
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
|
-
}] }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14339
|
-
|
|
14340
|
-
|
|
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,
|
|
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.
|
|
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
|
|
14491
|
-
if (!
|
|
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
|
-
//
|
|
14513
|
-
|
|
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
|
|
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
|
|
14597
|
-
if (!
|
|
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
|
-
//
|
|
14622
|
-
|
|
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
|
|
14818
|
-
if (!
|
|
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
|
-
//
|
|
14845
|
-
//
|
|
14846
|
-
domainID: this.
|
|
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
|
|
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.
|
|
15028
|
-
this.log.push({ id: `error-${this.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
15099
|
-
*
|
|
15100
|
-
*
|
|
15101
|
-
*
|
|
15102
|
-
*
|
|
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
|
|
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
|
|
15107
|
-
|
|
15108
|
-
|
|
15109
|
-
|
|
15110
|
-
|
|
15111
|
-
|
|
15112
|
-
|
|
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
|
|
15116
|
-
return
|
|
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
|
|
15321
|
-
return this.copyComment(sourceComment, targetSpaceId,
|
|
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
|
|
15325
|
-
return this.copyComment(sourceComment, targetSpaceId,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
17655
|
+
findExisting(source, targetList, identity) {
|
|
15459
17656
|
const sourceKey = this.identityKey(identity(source));
|
|
15460
|
-
return targetList.
|
|
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:
|
|
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:
|
|
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
|