@visns-studio/visns-components 5.15.6 → 5.15.8
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.
|
@@ -3,7 +3,24 @@ import mapboxgl from 'mapbox-gl';
|
|
|
3
3
|
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
|
4
4
|
import html2canvas from 'html2canvas';
|
|
5
5
|
import Swal from 'sweetalert2';
|
|
6
|
-
import {
|
|
6
|
+
import { pdf } from '@react-pdf/renderer';
|
|
7
|
+
import CameraReportPDF from './utils/CameraReportPDF';
|
|
8
|
+
import SimpleCameraReportPDF from './utils/SimpleCameraReportPDF';
|
|
9
|
+
import {
|
|
10
|
+
Edit3,
|
|
11
|
+
Trash2,
|
|
12
|
+
Upload,
|
|
13
|
+
Save,
|
|
14
|
+
FolderOpen,
|
|
15
|
+
Eye,
|
|
16
|
+
EyeOff,
|
|
17
|
+
Download,
|
|
18
|
+
FileImage,
|
|
19
|
+
Lightbulb,
|
|
20
|
+
Mail,
|
|
21
|
+
Users,
|
|
22
|
+
FileText,
|
|
23
|
+
} from 'lucide-react';
|
|
7
24
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
8
25
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
9
26
|
import styles from './styles/CameraPlacement.module.scss';
|
|
@@ -24,64 +41,149 @@ const CameraPlacement = () => {
|
|
|
24
41
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
25
42
|
const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
|
|
26
43
|
const [uploadedImage, setUploadedImage] = useState(null);
|
|
27
|
-
const [addCameraMode, setAddCameraMode] = useState(false);
|
|
28
44
|
const mapContainer = useRef(null);
|
|
29
45
|
const mapRef = useRef(null);
|
|
30
46
|
const geocoderRef = useRef(null);
|
|
31
47
|
const imageContainer = useRef(null);
|
|
32
48
|
const fileInputRef = useRef(null);
|
|
49
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
50
|
+
const [isRotating, setIsRotating] = useState(false);
|
|
33
51
|
|
|
34
52
|
// Mapbox configuration
|
|
35
|
-
const MAPBOX_ACCESS_TOKEN =
|
|
53
|
+
const MAPBOX_ACCESS_TOKEN =
|
|
54
|
+
'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
|
|
36
55
|
|
|
37
56
|
// Handle image upload
|
|
38
|
-
const handleImageUpload = useCallback(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
const handleImageUpload = useCallback(
|
|
58
|
+
(event) => {
|
|
59
|
+
const file = event.target.files[0];
|
|
60
|
+
if (file && file.type.startsWith('image/')) {
|
|
61
|
+
const reader = new FileReader();
|
|
62
|
+
reader.onload = (e) => {
|
|
63
|
+
setUploadedImage(e.target.result);
|
|
64
|
+
setMode('image');
|
|
65
|
+
// Reset cameras when switching to image mode
|
|
66
|
+
setCameras([]);
|
|
67
|
+
// Cleanup map if it exists
|
|
68
|
+
if (map) {
|
|
69
|
+
try {
|
|
70
|
+
map.remove();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.warn('Error removing map:', error);
|
|
73
|
+
}
|
|
74
|
+
setMap(null);
|
|
53
75
|
}
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
};
|
|
77
|
+
reader.readAsDataURL(file);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[map]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Handle image click for camera placement (only when image is uploaded)
|
|
84
|
+
const handleImageClick = useCallback(
|
|
85
|
+
(event) => {
|
|
86
|
+
if (
|
|
87
|
+
mode !== 'image' ||
|
|
88
|
+
!imageContainer.current ||
|
|
89
|
+
!uploadedImage ||
|
|
90
|
+
isRotating
|
|
91
|
+
)
|
|
92
|
+
return;
|
|
93
|
+
|
|
94
|
+
// Check if we clicked on a camera marker or rotation handle
|
|
95
|
+
const clickedElement = event.target;
|
|
96
|
+
if (
|
|
97
|
+
clickedElement.closest('.camera-marker-container') ||
|
|
98
|
+
clickedElement.closest(`.${styles.rotationHandle}`) ||
|
|
99
|
+
clickedElement.closest(`.${styles.circularMarker}`)
|
|
100
|
+
) {
|
|
101
|
+
return; // Don't add camera if clicking on existing camera elements
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rect = imageContainer.current.getBoundingClientRect();
|
|
105
|
+
const x = event.clientX - rect.left;
|
|
106
|
+
const y = event.clientY - rect.top;
|
|
107
|
+
|
|
108
|
+
const newCamera = {
|
|
109
|
+
id: Date.now(),
|
|
110
|
+
x: x, // Image coordinates instead of lat/lng
|
|
111
|
+
y: y,
|
|
112
|
+
name: `Camera ${cameras.length + 1}`,
|
|
113
|
+
verkadaModel: null,
|
|
114
|
+
customName: '',
|
|
115
|
+
direction: 0,
|
|
56
116
|
};
|
|
57
|
-
reader.readAsDataURL(file);
|
|
58
|
-
}
|
|
59
|
-
}, [map]);
|
|
60
|
-
|
|
61
|
-
// Handle image click for camera placement (only when in add camera mode)
|
|
62
|
-
const handleImageClick = useCallback((event) => {
|
|
63
|
-
if (mode !== 'image' || !imageContainer.current || !addCameraMode) return;
|
|
64
|
-
|
|
65
|
-
const rect = imageContainer.current.getBoundingClientRect();
|
|
66
|
-
const x = event.clientX - rect.left;
|
|
67
|
-
const y = event.clientY - rect.top;
|
|
68
|
-
|
|
69
|
-
const newCamera = {
|
|
70
|
-
id: Date.now(),
|
|
71
|
-
x: x, // Image coordinates instead of lat/lng
|
|
72
|
-
y: y,
|
|
73
|
-
name: `Camera ${cameras.length + 1}`,
|
|
74
|
-
verkadaModel: null,
|
|
75
|
-
customName: '',
|
|
76
|
-
direction: 0,
|
|
77
|
-
};
|
|
78
117
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
118
|
+
setCameras((prev) => [...prev, newCamera]);
|
|
119
|
+
setSelectedCamera(newCamera);
|
|
120
|
+
setShowModal(true);
|
|
121
|
+
},
|
|
122
|
+
[mode, cameras.length, uploadedImage, isRotating]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Drag and drop handlers
|
|
126
|
+
const handleDragOver = useCallback((event) => {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
event.stopPropagation();
|
|
129
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
130
|
+
setIsDragOver(true);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const handleDragEnter = useCallback((event) => {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
setIsDragOver(true);
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const handleDragLeave = useCallback((event) => {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
// Only set to false if we're leaving the main container
|
|
143
|
+
if (event.currentTarget === event.target) {
|
|
144
|
+
setIsDragOver(false);
|
|
145
|
+
}
|
|
146
|
+
}, []);
|
|
84
147
|
|
|
148
|
+
const handleDrop = useCallback(
|
|
149
|
+
(event) => {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
event.stopPropagation();
|
|
152
|
+
setIsDragOver(false);
|
|
153
|
+
|
|
154
|
+
const files = event.dataTransfer.files;
|
|
155
|
+
if (files && files.length > 0) {
|
|
156
|
+
const file = files[0];
|
|
157
|
+
if (file.type.startsWith('image/')) {
|
|
158
|
+
const reader = new FileReader();
|
|
159
|
+
reader.onload = (e) => {
|
|
160
|
+
setUploadedImage(e.target.result);
|
|
161
|
+
setMode('image');
|
|
162
|
+
// Reset cameras when switching to image mode
|
|
163
|
+
setCameras([]);
|
|
164
|
+
// Cleanup map if it exists
|
|
165
|
+
if (map) {
|
|
166
|
+
try {
|
|
167
|
+
map.remove();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.warn('Error removing map:', error);
|
|
170
|
+
}
|
|
171
|
+
setMap(null);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
reader.readAsDataURL(file);
|
|
175
|
+
} else {
|
|
176
|
+
Swal.fire({
|
|
177
|
+
icon: 'error',
|
|
178
|
+
title: 'Invalid File Type',
|
|
179
|
+
text: 'Please upload an image file (JPG, PNG, GIF, etc.)',
|
|
180
|
+
confirmButtonColor: '#ef4444',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[map]
|
|
186
|
+
);
|
|
85
187
|
|
|
86
188
|
// Initialize Mapbox GL map
|
|
87
189
|
useEffect(() => {
|
|
@@ -195,21 +297,42 @@ const CameraPlacement = () => {
|
|
|
195
297
|
|
|
196
298
|
const removeCamera = useCallback(
|
|
197
299
|
(cameraId) => {
|
|
198
|
-
|
|
300
|
+
// Find camera info for confirmation message
|
|
301
|
+
const camera = cameras.find((cam) => cam.id === cameraId);
|
|
302
|
+
const cameraName = camera
|
|
303
|
+
? camera.customName || camera.name
|
|
304
|
+
: 'this camera';
|
|
199
305
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
306
|
+
Swal.fire({
|
|
307
|
+
title: 'Delete Camera?',
|
|
308
|
+
text: `Are you sure you want to delete ${cameraName}? This action cannot be undone.`,
|
|
309
|
+
icon: 'warning',
|
|
310
|
+
showCancelButton: true,
|
|
311
|
+
confirmButtonColor: '#ef4444',
|
|
312
|
+
cancelButtonColor: '#6b7280',
|
|
313
|
+
confirmButtonText: 'Yes, delete it!',
|
|
314
|
+
cancelButtonText: 'Cancel',
|
|
315
|
+
}).then((result) => {
|
|
316
|
+
if (result.isConfirmed) {
|
|
317
|
+
setCameras((prev) =>
|
|
318
|
+
prev.filter((cam) => cam.id !== cameraId)
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Remove FOV layer and source from map
|
|
322
|
+
if (map) {
|
|
323
|
+
const layerId = `fov-layer-${cameraId}`;
|
|
324
|
+
const sourceId = `fov-${cameraId}`;
|
|
325
|
+
if (map.getLayer(layerId)) {
|
|
326
|
+
map.removeLayer(layerId);
|
|
327
|
+
}
|
|
328
|
+
if (map.getSource(sourceId)) {
|
|
329
|
+
map.removeSource(sourceId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
209
332
|
}
|
|
210
|
-
}
|
|
333
|
+
});
|
|
211
334
|
},
|
|
212
|
-
[map]
|
|
335
|
+
[map, cameras]
|
|
213
336
|
);
|
|
214
337
|
|
|
215
338
|
// Save placement as JSON file
|
|
@@ -219,7 +342,7 @@ const CameraPlacement = () => {
|
|
|
219
342
|
icon: 'warning',
|
|
220
343
|
title: 'No Data to Save',
|
|
221
344
|
text: 'Please add cameras or upload an image first.',
|
|
222
|
-
confirmButtonColor: '#8b5cf6'
|
|
345
|
+
confirmButtonColor: '#8b5cf6',
|
|
223
346
|
});
|
|
224
347
|
return;
|
|
225
348
|
}
|
|
@@ -227,12 +350,14 @@ const CameraPlacement = () => {
|
|
|
227
350
|
const saveData = {
|
|
228
351
|
version: '1.0',
|
|
229
352
|
timestamp: new Date().toISOString(),
|
|
230
|
-
image: uploadedImage
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
353
|
+
image: uploadedImage
|
|
354
|
+
? {
|
|
355
|
+
src: uploadedImage,
|
|
356
|
+
name: 'uploaded-image',
|
|
357
|
+
}
|
|
358
|
+
: null,
|
|
234
359
|
mode: mode,
|
|
235
|
-
cameras: cameras.map(camera => ({
|
|
360
|
+
cameras: cameras.map((camera) => ({
|
|
236
361
|
id: camera.id,
|
|
237
362
|
name: camera.name,
|
|
238
363
|
customName: camera.customName,
|
|
@@ -240,15 +365,19 @@ const CameraPlacement = () => {
|
|
|
240
365
|
y: camera.y,
|
|
241
366
|
direction: camera.direction,
|
|
242
367
|
verkadaModel: camera.verkadaModel,
|
|
243
|
-
timestamp: camera.timestamp
|
|
244
|
-
}))
|
|
368
|
+
timestamp: camera.timestamp,
|
|
369
|
+
})),
|
|
245
370
|
};
|
|
246
371
|
|
|
247
372
|
const dataStr = JSON.stringify(saveData, null, 2);
|
|
248
|
-
const dataUri =
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
373
|
+
const dataUri =
|
|
374
|
+
'data:application/json;charset=utf-8,' +
|
|
375
|
+
encodeURIComponent(dataStr);
|
|
376
|
+
|
|
377
|
+
const exportFileDefaultName = `camera-placement-${
|
|
378
|
+
new Date().toISOString().split('T')[0]
|
|
379
|
+
}.json`;
|
|
380
|
+
|
|
252
381
|
const linkElement = document.createElement('a');
|
|
253
382
|
linkElement.setAttribute('href', dataUri);
|
|
254
383
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
@@ -262,7 +391,7 @@ const CameraPlacement = () => {
|
|
|
262
391
|
timer: 3000,
|
|
263
392
|
showConfirmButton: false,
|
|
264
393
|
toast: true,
|
|
265
|
-
position: 'top-end'
|
|
394
|
+
position: 'top-end',
|
|
266
395
|
});
|
|
267
396
|
}, [cameras, uploadedImage, mode]);
|
|
268
397
|
|
|
@@ -271,336 +400,218 @@ const CameraPlacement = () => {
|
|
|
271
400
|
const input = document.createElement('input');
|
|
272
401
|
input.type = 'file';
|
|
273
402
|
input.accept = '.json';
|
|
274
|
-
|
|
403
|
+
|
|
275
404
|
input.onchange = (event) => {
|
|
276
405
|
const file = event.target.files[0];
|
|
277
406
|
if (!file) return;
|
|
278
|
-
|
|
407
|
+
|
|
279
408
|
const reader = new FileReader();
|
|
280
409
|
reader.onload = (e) => {
|
|
281
410
|
try {
|
|
282
411
|
const saveData = JSON.parse(e.target.result);
|
|
283
|
-
|
|
412
|
+
|
|
284
413
|
// Validate data structure
|
|
285
414
|
if (!saveData.version || !Array.isArray(saveData.cameras)) {
|
|
286
415
|
Swal.fire({
|
|
287
416
|
icon: 'error',
|
|
288
417
|
title: 'Invalid File',
|
|
289
418
|
text: 'Please select a valid camera placement file.',
|
|
290
|
-
confirmButtonColor: '#f59e0b'
|
|
419
|
+
confirmButtonColor: '#f59e0b',
|
|
291
420
|
});
|
|
292
421
|
return;
|
|
293
422
|
}
|
|
294
|
-
|
|
423
|
+
|
|
295
424
|
// Load image if present
|
|
296
425
|
if (saveData.image && saveData.image.src) {
|
|
297
426
|
setUploadedImage(saveData.image.src);
|
|
298
427
|
}
|
|
299
|
-
|
|
428
|
+
|
|
300
429
|
// Set mode
|
|
301
430
|
if (saveData.mode) {
|
|
302
431
|
setMode(saveData.mode);
|
|
303
432
|
}
|
|
304
|
-
|
|
433
|
+
|
|
305
434
|
// Load cameras
|
|
306
435
|
setCameras(saveData.cameras || []);
|
|
307
|
-
|
|
436
|
+
|
|
308
437
|
Swal.fire({
|
|
309
438
|
icon: 'success',
|
|
310
439
|
title: 'File Loaded!',
|
|
311
|
-
text: `Successfully loaded ${
|
|
312
|
-
|
|
440
|
+
text: `Successfully loaded ${
|
|
441
|
+
saveData.cameras?.length || 0
|
|
442
|
+
} cameras from ${file.name}`,
|
|
443
|
+
confirmButtonColor: '#f59e0b',
|
|
313
444
|
});
|
|
314
|
-
|
|
315
445
|
} catch (error) {
|
|
316
446
|
console.error('Error loading file:', error);
|
|
317
447
|
Swal.fire({
|
|
318
448
|
icon: 'error',
|
|
319
449
|
title: 'File Error',
|
|
320
|
-
text:
|
|
321
|
-
confirmButtonColor: '#f59e0b'
|
|
450
|
+
text: "Error reading file. Please ensure it's a valid JSON file.",
|
|
451
|
+
confirmButtonColor: '#f59e0b',
|
|
322
452
|
});
|
|
323
453
|
}
|
|
324
454
|
};
|
|
325
455
|
reader.readAsText(file);
|
|
326
456
|
};
|
|
327
|
-
|
|
457
|
+
|
|
328
458
|
input.click();
|
|
329
459
|
}, []);
|
|
330
460
|
|
|
331
|
-
const
|
|
461
|
+
const exportToPDF = useCallback(async () => {
|
|
332
462
|
try {
|
|
333
463
|
if (!cameras.length) {
|
|
334
464
|
Swal.fire({
|
|
335
465
|
icon: 'warning',
|
|
336
466
|
title: 'No Cameras',
|
|
337
467
|
text: 'Please add cameras first before exporting.',
|
|
338
|
-
confirmButtonColor: '#
|
|
339
|
-
});
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const imageContainer = document.querySelector(`.${styles.imageContainer}`);
|
|
344
|
-
if (!imageContainer) {
|
|
345
|
-
Swal.fire({
|
|
346
|
-
icon: 'warning',
|
|
347
|
-
title: 'No Image',
|
|
348
|
-
text: 'Please upload an image first before exporting.',
|
|
349
|
-
confirmButtonColor: '#10b981'
|
|
468
|
+
confirmButtonColor: '#1e40af',
|
|
350
469
|
});
|
|
351
470
|
return;
|
|
352
471
|
}
|
|
353
472
|
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
473
|
+
// Show loading message
|
|
474
|
+
Swal.fire({
|
|
475
|
+
icon: 'info',
|
|
476
|
+
title: 'Generating PDF...',
|
|
477
|
+
text: 'Please wait while we create your professional camera installation report.',
|
|
478
|
+
allowOutsideClick: false,
|
|
479
|
+
allowEscapeKey: false,
|
|
480
|
+
showConfirmButton: false,
|
|
481
|
+
willOpen: () => {
|
|
482
|
+
Swal.showLoading();
|
|
483
|
+
},
|
|
361
484
|
});
|
|
362
485
|
|
|
363
|
-
|
|
364
|
-
const cameraListWidth = 550; // Fixed width for professional report
|
|
365
|
-
const headerHeight = 140; // Space for report header
|
|
366
|
-
const summaryHeight = 120; // Space for project summary
|
|
367
|
-
const cameraHeight = 180; // More space per camera card
|
|
368
|
-
const footerHeight = 300; // Space for legend and notes
|
|
369
|
-
const totalCameraListHeight = headerHeight + summaryHeight + (cameras.length * cameraHeight) + footerHeight;
|
|
370
|
-
|
|
371
|
-
// Use the larger of image height or camera list height
|
|
372
|
-
const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
|
|
373
|
-
const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
|
|
374
|
-
|
|
375
|
-
// Create the composite container with calculated dimensions
|
|
376
|
-
const exportContainer = document.createElement('div');
|
|
377
|
-
exportContainer.style.position = 'absolute';
|
|
378
|
-
exportContainer.style.top = '-10000px';
|
|
379
|
-
exportContainer.style.left = '-10000px';
|
|
380
|
-
exportContainer.style.width = finalWidth + 'px';
|
|
381
|
-
exportContainer.style.height = finalHeight + 'px';
|
|
382
|
-
exportContainer.style.backgroundColor = '#ffffff';
|
|
383
|
-
exportContainer.style.display = 'flex';
|
|
384
|
-
exportContainer.style.gap = '10px';
|
|
385
|
-
exportContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
|
|
386
|
-
document.body.appendChild(exportContainer);
|
|
387
|
-
|
|
388
|
-
// Left side: Image
|
|
389
|
-
const imageDiv = document.createElement('div');
|
|
390
|
-
imageDiv.style.width = imageCanvas.width + 'px';
|
|
391
|
-
imageDiv.style.height = finalHeight + 'px';
|
|
392
|
-
imageDiv.style.background = '#f8fafc';
|
|
393
|
-
imageDiv.style.display = 'flex';
|
|
394
|
-
imageDiv.style.alignItems = 'center';
|
|
395
|
-
imageDiv.style.justifyContent = 'center';
|
|
396
|
-
imageDiv.style.borderRight = '2px solid #e5e7eb';
|
|
397
|
-
imageDiv.style.flexShrink = '0';
|
|
398
|
-
|
|
399
|
-
// Scale the canvas to fit if needed
|
|
400
|
-
const scaledCanvas = document.createElement('canvas');
|
|
401
|
-
const ctx = scaledCanvas.getContext('2d');
|
|
402
|
-
scaledCanvas.width = imageCanvas.width;
|
|
403
|
-
scaledCanvas.height = imageCanvas.height;
|
|
404
|
-
ctx.drawImage(imageCanvas, 0, 0);
|
|
405
|
-
|
|
406
|
-
imageDiv.appendChild(scaledCanvas);
|
|
407
|
-
exportContainer.appendChild(imageDiv);
|
|
408
|
-
|
|
409
|
-
// Right side: Camera list
|
|
410
|
-
const cameraListDiv = document.createElement('div');
|
|
411
|
-
cameraListDiv.style.width = cameraListWidth + 'px';
|
|
412
|
-
cameraListDiv.style.height = finalHeight + 'px';
|
|
413
|
-
cameraListDiv.style.padding = '20px';
|
|
414
|
-
cameraListDiv.style.background = '#ffffff';
|
|
415
|
-
cameraListDiv.style.overflow = 'visible';
|
|
416
|
-
cameraListDiv.style.boxSizing = 'border-box';
|
|
417
|
-
|
|
418
|
-
// Generate professional report-style HTML
|
|
419
|
-
const cameraListHTML = `
|
|
420
|
-
<!-- Report Header -->
|
|
421
|
-
<div style="background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; padding: 32px; margin: -20px -20px 0 -20px; border-radius: 16px 16px 0 0; box-shadow: 0 6px 20px rgba(0,0,0,0.15);">
|
|
422
|
-
<h1 style="margin: 0 0 16px 0; font-size: 36px; font-weight: 800; text-align: center; color: #ffffff; text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.6px; line-height: 1.1;">Security Camera Installation Report</h1>
|
|
423
|
-
<div style="text-align: center; margin-bottom: 24px;">
|
|
424
|
-
<div style="display: inline-block; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); padding: 12px 24px; border-radius: 25px; font-size: 14px; font-weight: 600; letter-spacing: 1px; color: #e5e7eb;">
|
|
425
|
-
PROJECT LAYOUT & SPECIFICATIONS
|
|
426
|
-
</div>
|
|
427
|
-
</div>
|
|
428
|
-
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; border-top: 1px solid rgba(255,255,255,0.15); padding-top: 20px; letter-spacing: 0.3px;">
|
|
429
|
-
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</div>
|
|
430
|
-
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
|
|
431
|
-
</div>
|
|
432
|
-
</div>
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
${cameras.map((camera, index) => {
|
|
436
|
-
const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
|
|
437
|
-
// Generate sequential camera number
|
|
438
|
-
const cameraNumber = index + 1;
|
|
439
|
-
|
|
440
|
-
return `
|
|
441
|
-
<div style="margin-bottom: 20px; background: white; border: 1px solid #d1d5db; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); page-break-inside: avoid;">
|
|
442
|
-
<!-- Camera Header -->
|
|
443
|
-
<div style="background: ${verkadaCamera?.environment === 'indoor' ? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' : 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'}; color: white; padding: 16px 20px; position: relative;">
|
|
444
|
-
<div style="display: flex; align-items: center; gap: 16px;">
|
|
445
|
-
<div style="width: 40px; height: 40px; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.25); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 16px; flex-shrink: 0; text-shadow: 0 1px 3px rgba(0,0,0,0.3);">${cameraNumber}</div>
|
|
446
|
-
<div style="flex: 1;">
|
|
447
|
-
<h4 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.3px; text-shadow: 0 1px 3px rgba(0,0,0,0.2);">Camera ${cameraNumber}</h4>
|
|
448
|
-
<div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${verkadaCamera ? verkadaCamera.name : 'Model Not Selected'}</div>
|
|
449
|
-
</div>
|
|
450
|
-
${verkadaCamera?.ptz ? '<div style="background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); padding: 6px 12px; border-radius: 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px;">PTZ</div>' : ''}
|
|
451
|
-
</div>
|
|
452
|
-
</div>
|
|
453
|
-
|
|
454
|
-
<!-- Camera Details -->
|
|
455
|
-
<div style="padding: 20px;">
|
|
456
|
-
${verkadaCamera ? `
|
|
457
|
-
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
|
|
458
|
-
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
|
|
459
|
-
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
|
|
460
|
-
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.resolution}</div>
|
|
461
|
-
</div>
|
|
462
|
-
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
|
|
463
|
-
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Field of View</div>
|
|
464
|
-
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.fov}°</div>
|
|
465
|
-
</div>
|
|
466
|
-
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b'};">
|
|
467
|
-
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Environment</div>
|
|
468
|
-
<div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${verkadaCamera.environment}</div>
|
|
469
|
-
</div>
|
|
470
|
-
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
|
|
471
|
-
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
|
|
472
|
-
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.irRange}m</div>
|
|
473
|
-
</div>
|
|
474
|
-
</div>
|
|
475
|
-
|
|
476
|
-
<!-- Position & Direction -->
|
|
477
|
-
<div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
|
|
478
|
-
<div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; text-align: center; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px;">Installation Details</div>
|
|
479
|
-
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
480
|
-
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
481
|
-
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</div>
|
|
482
|
-
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${camera.direction ? `${camera.direction}° from North` : 'Not set'}</div>
|
|
483
|
-
</div>
|
|
484
|
-
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
485
|
-
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</div>
|
|
486
|
-
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${mode === 'map' ? `${camera.lat?.toFixed(6)}, ${camera.lng?.toFixed(6)}` : `X: ${Math.round(camera.x)}, Y: ${Math.round(camera.y)}`}</div>
|
|
487
|
-
</div>
|
|
488
|
-
</div>
|
|
489
|
-
</div>
|
|
490
|
-
|
|
491
|
-
<!-- Features -->
|
|
492
|
-
${verkadaCamera.features && verkadaCamera.features.length > 0 ? `
|
|
493
|
-
<div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
|
|
494
|
-
<div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 10px; text-align: center;">Key Features</div>
|
|
495
|
-
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
|
|
496
|
-
${verkadaCamera.features.slice(0, 6).map(feature =>
|
|
497
|
-
`<span style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); color: #1e40af; border: 1px solid #93c5fd; padding: 6px 12px; border-radius: 18px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;">${feature}</span>`
|
|
498
|
-
).join('')}
|
|
499
|
-
${verkadaCamera.features.length > 6 ? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${verkadaCamera.features.length - 6} more</span>` : ''}
|
|
500
|
-
</div>
|
|
501
|
-
</div>
|
|
502
|
-
` : ''}
|
|
503
|
-
` : `
|
|
504
|
-
<div style="text-align: center; padding: 30px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #fca5a5; border-radius: 12px; margin: 10px 0;">
|
|
505
|
-
<div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
|
|
506
|
-
<div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
|
|
507
|
-
</div>
|
|
508
|
-
`}
|
|
509
|
-
</div>
|
|
510
|
-
</div>
|
|
511
|
-
`;
|
|
512
|
-
}).join('')}
|
|
513
|
-
|
|
514
|
-
<!-- Report Footer -->
|
|
515
|
-
<div style="margin-top: 32px; padding: 24px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); border-radius: 16px; border: 1px solid #cbd5e1; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
|
|
516
|
-
<!-- Legend Section -->
|
|
517
|
-
<div style="margin-bottom: 24px;">
|
|
518
|
-
<h3 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 800; color: #0f172a; text-align: center; letter-spacing: -0.4px;">Camera Types & Legend</h3>
|
|
519
|
-
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
|
|
520
|
-
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
521
|
-
<div style="width: 24px; height: 24px; background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(13,148,136,0.3);"></div>
|
|
522
|
-
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
|
|
523
|
-
</div>
|
|
524
|
-
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
525
|
-
<div style="width: 24px; height: 24px; background: linear-gradient(135deg, #ea580c 0%, #dc2626 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(234,88,12,0.3);"></div>
|
|
526
|
-
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
|
|
527
|
-
</div>
|
|
528
|
-
${cameras.some(c => getCameraById(c.verkadaModel)?.ptz) ? `
|
|
529
|
-
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
530
|
-
<div style="width: 24px; height: 24px; background: #6b7280; border-radius: 50%; border: 3px solid #ef4444; box-shadow: 0 2px 6px rgba(239,68,68,0.3);"></div>
|
|
531
|
-
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
|
|
532
|
-
</div>` : ''}
|
|
533
|
-
</div>
|
|
534
|
-
</div>
|
|
535
|
-
|
|
536
|
-
<!-- Technical Notes -->
|
|
537
|
-
<div style="background: white; padding: 20px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
|
|
538
|
-
<h4 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: #0f172a; text-align: center; letter-spacing: -0.2px;">📋 Installation Notes</h4>
|
|
539
|
-
<div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
|
|
540
|
-
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #3b82f6;"><strong style="color: #1e40af;">Direction Reference:</strong> All camera directions are measured clockwise from North (0° = North, 90° = East, 180° = South, 270° = West)</div>
|
|
541
|
-
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #8b5cf6;"><strong style="color: #7c3aed;">Field of View:</strong> FOV angles represent the horizontal viewing angle. Actual coverage may vary based on mounting height and obstacles</div>
|
|
542
|
-
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${mode === 'map' ? 'Geographic coordinates (Latitude, Longitude) in decimal degrees' : 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'}</div>
|
|
543
|
-
<div style="padding: 12px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border-radius: 8px; border-left: 3px solid #ef4444;"><strong style="color: #dc2626;">Important:</strong> This layout should be verified on-site during installation. Actual camera placement may need adjustment based on physical constraints, lighting conditions, and site requirements</div>
|
|
544
|
-
</div>
|
|
545
|
-
</div>
|
|
546
|
-
|
|
547
|
-
<!-- Report ID & Disclaimer -->
|
|
548
|
-
<div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
|
|
549
|
-
<div style="font-size: 12px; color: #475569; margin-bottom: 8px; letter-spacing: 0.5px; font-weight: 600;">Report ID: <span style="font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px;">${Date.now().toString(36).toUpperCase()}</span></div>
|
|
550
|
-
<div style="font-size: 11px; color: #64748b; line-height: 1.6; letter-spacing: 0.3px; max-width: 400px; margin: 0 auto;">This report was generated automatically and should be reviewed by qualified security installation professionals</div>
|
|
551
|
-
</div>
|
|
552
|
-
</div>
|
|
553
|
-
`;
|
|
486
|
+
let mapImageData = null;
|
|
554
487
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
488
|
+
// Capture the site map image if available
|
|
489
|
+
if (uploadedImage || mode === 'image') {
|
|
490
|
+
const imageContainer = document.querySelector(
|
|
491
|
+
`.${styles.imageContainer}`
|
|
492
|
+
);
|
|
493
|
+
if (imageContainer && uploadedImage) {
|
|
494
|
+
try {
|
|
495
|
+
const canvas = await html2canvas(imageContainer, {
|
|
496
|
+
useCORS: true,
|
|
497
|
+
allowTaint: true,
|
|
498
|
+
scale: 1.2,
|
|
499
|
+
backgroundColor: '#f8fafc',
|
|
500
|
+
logging: false,
|
|
501
|
+
});
|
|
502
|
+
mapImageData = canvas.toDataURL('image/png', 0.9);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.warn('Could not capture site map image:', error);
|
|
505
|
+
// Continue without image if capture fails
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
571
509
|
|
|
572
|
-
//
|
|
573
|
-
|
|
510
|
+
// Generate PDF using React-PDF with fallback
|
|
511
|
+
let pdfBlob;
|
|
512
|
+
try {
|
|
513
|
+
// Try the enhanced PDF first
|
|
514
|
+
pdfBlob = await pdf(
|
|
515
|
+
React.createElement(CameraReportPDF, {
|
|
516
|
+
cameras: cameras,
|
|
517
|
+
mapImageData: mapImageData,
|
|
518
|
+
mode: mode,
|
|
519
|
+
customColors: {} // Can be extended to read from props or CSS variables
|
|
520
|
+
})
|
|
521
|
+
).toBlob();
|
|
522
|
+
} catch (pdfError) {
|
|
523
|
+
console.warn('Enhanced PDF failed, trying simple version:', pdfError);
|
|
524
|
+
|
|
525
|
+
// Fallback to simple PDF
|
|
526
|
+
pdfBlob = await pdf(
|
|
527
|
+
React.createElement(SimpleCameraReportPDF, {
|
|
528
|
+
cameras: cameras,
|
|
529
|
+
mapImageData: mapImageData,
|
|
530
|
+
mode: mode,
|
|
531
|
+
customColors: {} // Can be extended to read from props or CSS variables
|
|
532
|
+
})
|
|
533
|
+
).toBlob();
|
|
534
|
+
}
|
|
574
535
|
|
|
575
|
-
//
|
|
536
|
+
// Create download link
|
|
537
|
+
const url = URL.createObjectURL(pdfBlob);
|
|
576
538
|
const link = document.createElement('a');
|
|
577
|
-
const timestamp = new Date()
|
|
578
|
-
|
|
579
|
-
|
|
539
|
+
const timestamp = new Date()
|
|
540
|
+
.toISOString()
|
|
541
|
+
.slice(0, 19)
|
|
542
|
+
.replace(/[:.]/g, '-');
|
|
543
|
+
link.download = `Security-Camera-Installation-Report-${timestamp}.pdf`;
|
|
544
|
+
link.href = url;
|
|
580
545
|
link.click();
|
|
581
546
|
|
|
547
|
+
// Clean up
|
|
548
|
+
URL.revokeObjectURL(url);
|
|
582
549
|
|
|
583
550
|
// Show success message
|
|
584
551
|
Swal.fire({
|
|
585
552
|
icon: 'success',
|
|
586
|
-
title: 'Export Complete!',
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
553
|
+
title: 'PDF Export Complete!',
|
|
554
|
+
html: `
|
|
555
|
+
<div style="text-align: left; font-size: 14px; line-height: 1.6;">
|
|
556
|
+
<p><strong>Your professional camera installation report has been generated!</strong></p>
|
|
557
|
+
<div style="margin: 15px 0;">
|
|
558
|
+
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
|
559
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; flex-shrink: 0;">
|
|
560
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
561
|
+
<polyline points="14,2 14,8 20,8"></polyline>
|
|
562
|
+
</svg>
|
|
563
|
+
<span><strong>Page 1:</strong> Site layout with camera positions</span>
|
|
564
|
+
</div>
|
|
565
|
+
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
|
566
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; flex-shrink: 0;">
|
|
567
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
568
|
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
|
569
|
+
<polyline points="21,15 16,10 5,21"></polyline>
|
|
570
|
+
</svg>
|
|
571
|
+
<span><strong>Page 2:</strong> Detailed camera specifications</span>
|
|
572
|
+
</div>
|
|
573
|
+
<div style="display: flex; align-items: center;">
|
|
574
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px; flex-shrink: 0;">
|
|
575
|
+
<polyline points="20,6 9,17 4,12"></polyline>
|
|
576
|
+
</svg>
|
|
577
|
+
<span><strong>Includes:</strong> Camera legend and specifications</span>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
<p style="color: #6b7280; font-size: 12px; margin-top: 15px;">
|
|
581
|
+
File saved as: <code>${link.download}</code>
|
|
582
|
+
</p>
|
|
583
|
+
</div>
|
|
584
|
+
`,
|
|
585
|
+
confirmButtonColor: '#1e40af',
|
|
586
|
+
confirmButtonText: 'Great!',
|
|
592
587
|
});
|
|
593
|
-
|
|
594
588
|
} catch (error) {
|
|
595
|
-
console.error('Error
|
|
589
|
+
console.error('Error generating PDF:', error);
|
|
590
|
+
|
|
591
|
+
// More specific error handling
|
|
592
|
+
let errorMessage = 'Unable to generate PDF report. Please try again.';
|
|
593
|
+
if (error.message.includes('Buffer') || error.message.includes('DataView')) {
|
|
594
|
+
errorMessage = 'PDF generation failed due to font loading issue. This is a known issue with React-PDF in development mode.';
|
|
595
|
+
} else if (error.message.includes('font') || error.message.includes('glyph')) {
|
|
596
|
+
errorMessage = 'Font-related error occurred during PDF generation. Trying alternative approach...';
|
|
597
|
+
}
|
|
598
|
+
|
|
596
599
|
Swal.fire({
|
|
597
600
|
icon: 'error',
|
|
598
|
-
title: 'Export Failed',
|
|
599
|
-
|
|
600
|
-
|
|
601
|
+
title: 'PDF Export Failed',
|
|
602
|
+
html: `
|
|
603
|
+
<div style="text-align: left;">
|
|
604
|
+
<p>${errorMessage}</p>
|
|
605
|
+
<details style="margin-top: 10px;">
|
|
606
|
+
<summary style="cursor: pointer; color: #6b7280;">Technical Details</summary>
|
|
607
|
+
<pre style="font-size: 11px; background: #f3f4f6; padding: 8px; margin: 8px 0; border-radius: 4px; max-height: 100px; overflow-y: auto;">${error.message}</pre>
|
|
608
|
+
</details>
|
|
609
|
+
</div>
|
|
610
|
+
`,
|
|
611
|
+
confirmButtonColor: '#dc2626',
|
|
601
612
|
});
|
|
602
613
|
}
|
|
603
|
-
}, [cameras]);
|
|
614
|
+
}, [cameras, uploadedImage, mode]);
|
|
604
615
|
|
|
605
616
|
// Update cameras with markers on map
|
|
606
617
|
useEffect(() => {
|
|
@@ -615,12 +626,15 @@ const CameraPlacement = () => {
|
|
|
615
626
|
try {
|
|
616
627
|
const style = map.getStyle();
|
|
617
628
|
if (style && style.sources) {
|
|
618
|
-
existingSources = Object.keys(style.sources).filter(
|
|
619
|
-
|
|
629
|
+
existingSources = Object.keys(style.sources).filter((id) =>
|
|
630
|
+
id.startsWith('fov-')
|
|
620
631
|
);
|
|
621
632
|
}
|
|
622
633
|
} catch (error) {
|
|
623
|
-
console.warn(
|
|
634
|
+
console.warn(
|
|
635
|
+
'Could not access map style, skipping FOV layer cleanup:',
|
|
636
|
+
error
|
|
637
|
+
);
|
|
624
638
|
}
|
|
625
639
|
existingSources.forEach((sourceId) => {
|
|
626
640
|
try {
|
|
@@ -632,7 +646,10 @@ const CameraPlacement = () => {
|
|
|
632
646
|
map.removeSource(sourceId);
|
|
633
647
|
}
|
|
634
648
|
} catch (error) {
|
|
635
|
-
console.warn(
|
|
649
|
+
console.warn(
|
|
650
|
+
`Could not remove layer/source ${sourceId}:`,
|
|
651
|
+
error
|
|
652
|
+
);
|
|
636
653
|
}
|
|
637
654
|
});
|
|
638
655
|
|
|
@@ -679,6 +696,7 @@ const CameraPlacement = () => {
|
|
|
679
696
|
const handleRotationStart = (e) => {
|
|
680
697
|
e.stopPropagation();
|
|
681
698
|
isRotating = true;
|
|
699
|
+
setIsRotating(true);
|
|
682
700
|
const rect = markerContainer.getBoundingClientRect();
|
|
683
701
|
const centerX = rect.left + rect.width / 2;
|
|
684
702
|
const centerY = rect.top + rect.height / 2;
|
|
@@ -712,6 +730,7 @@ const CameraPlacement = () => {
|
|
|
712
730
|
|
|
713
731
|
const handleRotationEnd = () => {
|
|
714
732
|
isRotating = false;
|
|
733
|
+
setIsRotating(false);
|
|
715
734
|
markerContainer.classList.remove('rotating');
|
|
716
735
|
document.removeEventListener('mousemove', handleRotationMove);
|
|
717
736
|
document.removeEventListener('mouseup', handleRotationEnd);
|
|
@@ -858,167 +877,230 @@ const CameraPlacement = () => {
|
|
|
858
877
|
});
|
|
859
878
|
}, [map, cameras, mode]);
|
|
860
879
|
|
|
880
|
+
// Icon button with tooltip component
|
|
881
|
+
const IconButton = ({
|
|
882
|
+
icon: Icon,
|
|
883
|
+
onClick,
|
|
884
|
+
disabled = false,
|
|
885
|
+
tooltip,
|
|
886
|
+
className = '',
|
|
887
|
+
}) => (
|
|
888
|
+
<button
|
|
889
|
+
onClick={onClick}
|
|
890
|
+
disabled={disabled}
|
|
891
|
+
className={`${styles.iconButton} ${className}`}
|
|
892
|
+
title={tooltip}
|
|
893
|
+
>
|
|
894
|
+
<Icon size={20} />
|
|
895
|
+
</button>
|
|
896
|
+
);
|
|
861
897
|
|
|
862
898
|
return (
|
|
863
899
|
<div className={styles.cameraPlacement}>
|
|
864
900
|
<div className={styles.header}>
|
|
865
|
-
<
|
|
866
|
-
|
|
867
|
-
<div className={styles.
|
|
868
|
-
<
|
|
901
|
+
<div className={styles.headerContent}>
|
|
902
|
+
<h1 className={styles.title}>Site Layout Planner</h1>
|
|
903
|
+
<div className={styles.controls}>
|
|
904
|
+
<IconButton
|
|
905
|
+
icon={Upload}
|
|
869
906
|
onClick={() => fileInputRef.current?.click()}
|
|
907
|
+
tooltip="Upload Image"
|
|
870
908
|
className={styles.uploadBtn}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
{uploadedImage && (
|
|
875
|
-
<button
|
|
876
|
-
onClick={() => setAddCameraMode(!addCameraMode)}
|
|
877
|
-
className={`${styles.addCameraBtn} ${addCameraMode ? styles.activeControl : ''}`}
|
|
878
|
-
>
|
|
879
|
-
{addCameraMode ? 'Cancel Add' : 'Add Camera'}
|
|
880
|
-
</button>
|
|
881
|
-
)}
|
|
882
|
-
</div>
|
|
883
|
-
<div className={styles.rightControls}>
|
|
884
|
-
<button
|
|
909
|
+
/>
|
|
910
|
+
<IconButton
|
|
911
|
+
icon={Save}
|
|
885
912
|
onClick={saveAsFile}
|
|
886
|
-
className={styles.saveFileBtn}
|
|
887
913
|
disabled={!cameras.length && !uploadedImage}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
914
|
+
tooltip="Save camera placement as JSON file"
|
|
915
|
+
className={styles.saveFileBtn}
|
|
916
|
+
/>
|
|
917
|
+
<IconButton
|
|
918
|
+
icon={FolderOpen}
|
|
893
919
|
onClick={loadFromFile}
|
|
920
|
+
tooltip="Load camera placement from JSON file"
|
|
894
921
|
className={styles.loadFileBtn}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
</button>
|
|
899
|
-
<button
|
|
922
|
+
/>
|
|
923
|
+
<IconButton
|
|
924
|
+
icon={sidebarCollapsed ? Eye : EyeOff}
|
|
900
925
|
onClick={() =>
|
|
901
926
|
setSidebarCollapsed(!sidebarCollapsed)
|
|
902
927
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
928
|
+
tooltip={`${
|
|
929
|
+
sidebarCollapsed ? 'Show' : 'Hide'
|
|
930
|
+
} Cameras`}
|
|
931
|
+
/>
|
|
932
|
+
<IconButton
|
|
933
|
+
icon={Download}
|
|
934
|
+
onClick={exportToPDF}
|
|
935
|
+
disabled={!cameras.length}
|
|
936
|
+
tooltip="Export to PDF Report"
|
|
907
937
|
className={styles.exportBtn}
|
|
908
|
-
|
|
909
|
-
disabled={mode === 'image' && !uploadedImage}
|
|
910
|
-
>
|
|
911
|
-
Export {mode === 'map' ? 'Map' : 'Image'}
|
|
912
|
-
</button>
|
|
938
|
+
/>
|
|
913
939
|
</div>
|
|
914
940
|
</div>
|
|
915
941
|
</div>
|
|
916
942
|
|
|
917
943
|
<div className={styles.mainContent}>
|
|
918
|
-
<div
|
|
919
|
-
className={`${styles.imageContainer} ${
|
|
920
|
-
|
|
944
|
+
<div
|
|
945
|
+
className={`${styles.imageContainer} ${
|
|
946
|
+
uploadedImage ? styles.addMode : ''
|
|
947
|
+
} ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
|
|
948
|
+
isDragOver ? styles.dragOver : ''
|
|
949
|
+
}`}
|
|
950
|
+
ref={imageContainer}
|
|
921
951
|
onClick={handleImageClick}
|
|
952
|
+
onDragOver={handleDragOver}
|
|
953
|
+
onDragEnter={handleDragEnter}
|
|
954
|
+
onDragLeave={handleDragLeave}
|
|
955
|
+
onDrop={handleDrop}
|
|
922
956
|
>
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
957
|
+
{uploadedImage ? (
|
|
958
|
+
<div className={styles.imageWrapper}>
|
|
959
|
+
<img
|
|
960
|
+
src={uploadedImage}
|
|
961
|
+
alt="Floor plan or site layout"
|
|
962
|
+
className={styles.uploadedImage}
|
|
963
|
+
/>
|
|
964
|
+
{/* Render FOV triangles first (behind markers) */}
|
|
965
|
+
{cameras.map((camera) => {
|
|
966
|
+
const verkadaCamera = camera.verkadaModel
|
|
967
|
+
? getCameraById(camera.verkadaModel)
|
|
968
|
+
: null;
|
|
969
|
+
if (!verkadaCamera) return null;
|
|
970
|
+
|
|
971
|
+
let fov = verkadaCamera.ptz
|
|
972
|
+
? 90
|
|
973
|
+
: verkadaCamera.fov;
|
|
974
|
+
|
|
975
|
+
// Handle special FOV cases
|
|
976
|
+
if (fov >= 360)
|
|
977
|
+
fov = 120; // PTZ cameras showing max practical FOV
|
|
978
|
+
else if (fov >= 270)
|
|
979
|
+
fov = 150; // Multi-sensor cameras
|
|
980
|
+
else if (fov >= 180) fov = 130; // Fisheye cameras
|
|
981
|
+
|
|
982
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
983
|
+
|
|
984
|
+
// Calculate distance based on FOV - wider angles get shorter distances
|
|
985
|
+
// Base distance of 100px for 60° FOV, scaled inversely with bounds
|
|
986
|
+
const baseFov = 60; // Reference FOV angle
|
|
987
|
+
const baseDistance = verkadaCamera.ptz
|
|
988
|
+
? 120
|
|
989
|
+
: 100;
|
|
990
|
+
let distance = baseDistance * (baseFov / fov);
|
|
991
|
+
|
|
992
|
+
// Apply bounds: min 30px, max 200px
|
|
993
|
+
distance = Math.max(
|
|
994
|
+
30,
|
|
995
|
+
Math.min(200, distance)
|
|
996
|
+
);
|
|
997
|
+
const directionRad =
|
|
998
|
+
(camera.direction * Math.PI) / 180;
|
|
999
|
+
|
|
1000
|
+
// Calculate FOV triangle points in pixels
|
|
1001
|
+
const point1X =
|
|
1002
|
+
camera.x +
|
|
1003
|
+
distance *
|
|
1004
|
+
Math.cos(directionRad - fovRad / 2);
|
|
1005
|
+
const point1Y =
|
|
1006
|
+
camera.y +
|
|
1007
|
+
distance *
|
|
1008
|
+
Math.sin(directionRad - fovRad / 2);
|
|
1009
|
+
const point2X =
|
|
1010
|
+
camera.x +
|
|
1011
|
+
distance *
|
|
1012
|
+
Math.cos(directionRad + fovRad / 2);
|
|
1013
|
+
const point2Y =
|
|
1014
|
+
camera.y +
|
|
1015
|
+
distance *
|
|
1016
|
+
Math.sin(directionRad + fovRad / 2);
|
|
1017
|
+
|
|
1018
|
+
const fovColor =
|
|
1019
|
+
verkadaCamera.environment === 'indoor'
|
|
1020
|
+
? '#10b981'
|
|
1021
|
+
: '#f59e0b';
|
|
1022
|
+
|
|
1023
|
+
// Create SVG path for the triangle
|
|
1024
|
+
const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
|
|
1025
|
+
|
|
1026
|
+
return (
|
|
1027
|
+
<svg
|
|
1028
|
+
key={`fov-${camera.id}`}
|
|
1029
|
+
className={styles.fovTriangle}
|
|
1030
|
+
style={{
|
|
1031
|
+
position: 'absolute',
|
|
1032
|
+
top: 0,
|
|
1033
|
+
left: 0,
|
|
1034
|
+
width: '100%',
|
|
1035
|
+
height: '100%',
|
|
1036
|
+
pointerEvents: 'none',
|
|
1037
|
+
zIndex: 1,
|
|
1038
|
+
}}
|
|
1039
|
+
>
|
|
1040
|
+
<path
|
|
1041
|
+
d={pathData}
|
|
1042
|
+
fill={fovColor}
|
|
1043
|
+
fillOpacity="0.25"
|
|
1044
|
+
stroke={fovColor}
|
|
1045
|
+
strokeOpacity="0.5"
|
|
1046
|
+
strokeWidth="2"
|
|
1047
|
+
/>
|
|
1048
|
+
</svg>
|
|
1049
|
+
);
|
|
1050
|
+
})}
|
|
1051
|
+
|
|
1052
|
+
{/* Render camera markers on image */}
|
|
1053
|
+
{cameras.map((camera) => {
|
|
1054
|
+
const verkadaCamera = camera.verkadaModel
|
|
1055
|
+
? getCameraById(camera.verkadaModel)
|
|
1056
|
+
: null;
|
|
1057
|
+
const environment = verkadaCamera
|
|
1058
|
+
? verkadaCamera.environment
|
|
1059
|
+
: 'outdoor';
|
|
1060
|
+
const cameraNumber =
|
|
1061
|
+
(camera.customName || camera.name).replace(
|
|
1062
|
+
/\D/g,
|
|
1063
|
+
''
|
|
1064
|
+
) || camera.id;
|
|
1065
|
+
|
|
1066
|
+
return (
|
|
1067
|
+
<div
|
|
1068
|
+
key={camera.id}
|
|
1069
|
+
className={`camera-marker-container ${styles.imageMarker}`}
|
|
1070
|
+
style={{
|
|
1071
|
+
position: 'absolute',
|
|
1072
|
+
left: camera.x - 20, // Adjusted for smaller circular marker
|
|
1073
|
+
top: camera.y - 20, // Adjusted for smaller circular marker
|
|
1074
|
+
transform: 'none',
|
|
1075
|
+
zIndex: 2,
|
|
1076
|
+
}}
|
|
1077
|
+
onMouseEnter={(e) => {
|
|
1078
|
+
// Only show tooltip if not hovering over rotation handle
|
|
1079
|
+
if (
|
|
1080
|
+
verkadaCamera &&
|
|
1081
|
+
!e.target.closest(
|
|
1082
|
+
`.${styles.rotationHandle}`
|
|
1083
|
+
)
|
|
1084
|
+
) {
|
|
1085
|
+
// Add delay to prevent tooltip from showing during quick rotations
|
|
1086
|
+
const targetElement =
|
|
1087
|
+
e.currentTarget;
|
|
1088
|
+
targetElement._tooltipTimeout =
|
|
1089
|
+
setTimeout(() => {
|
|
1014
1090
|
// Check if element still exists and is in DOM
|
|
1015
|
-
if (
|
|
1091
|
+
if (
|
|
1092
|
+
!targetElement ||
|
|
1093
|
+
!targetElement.parentNode
|
|
1094
|
+
) {
|
|
1016
1095
|
return;
|
|
1017
1096
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1097
|
+
|
|
1098
|
+
const tooltip =
|
|
1099
|
+
document.createElement(
|
|
1100
|
+
'div'
|
|
1101
|
+
);
|
|
1102
|
+
tooltip.className =
|
|
1103
|
+
styles.cameraTooltip;
|
|
1022
1104
|
tooltip.innerHTML = `
|
|
1023
1105
|
<div class="${styles.tooltipImage}">
|
|
1024
1106
|
<img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
|
|
@@ -1032,515 +1114,633 @@ const CameraPlacement = () => {
|
|
|
1032
1114
|
</div>
|
|
1033
1115
|
</div>
|
|
1034
1116
|
`;
|
|
1035
|
-
|
|
1117
|
+
|
|
1036
1118
|
// Position tooltip away from rotation handle
|
|
1037
|
-
tooltip.style.left =
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1119
|
+
tooltip.style.left =
|
|
1120
|
+
'50px';
|
|
1121
|
+
tooltip.style.top =
|
|
1122
|
+
'-10px';
|
|
1123
|
+
|
|
1124
|
+
targetElement.appendChild(
|
|
1125
|
+
tooltip
|
|
1126
|
+
);
|
|
1127
|
+
targetElement._tooltip =
|
|
1128
|
+
tooltip;
|
|
1043
1129
|
}, 300); // 300ms delay
|
|
1130
|
+
}
|
|
1131
|
+
}}
|
|
1132
|
+
onMouseLeave={(e) => {
|
|
1133
|
+
// Clear timeout if leaving before tooltip shows
|
|
1134
|
+
if (
|
|
1135
|
+
e.currentTarget._tooltipTimeout
|
|
1136
|
+
) {
|
|
1137
|
+
clearTimeout(
|
|
1138
|
+
e.currentTarget
|
|
1139
|
+
._tooltipTimeout
|
|
1140
|
+
);
|
|
1141
|
+
e.currentTarget._tooltipTimeout =
|
|
1142
|
+
null;
|
|
1143
|
+
}
|
|
1144
|
+
// Remove tooltip if it exists
|
|
1145
|
+
if (e.currentTarget._tooltip) {
|
|
1146
|
+
e.currentTarget.removeChild(
|
|
1147
|
+
e.currentTarget._tooltip
|
|
1148
|
+
);
|
|
1149
|
+
e.currentTarget._tooltip = null;
|
|
1150
|
+
}
|
|
1151
|
+
}}
|
|
1152
|
+
onMouseDown={(e) => {
|
|
1153
|
+
e.stopPropagation();
|
|
1154
|
+
let isDragging = false;
|
|
1155
|
+
let startTime = Date.now();
|
|
1156
|
+
const startX = e.clientX;
|
|
1157
|
+
const startY = e.clientY;
|
|
1158
|
+
const containerRect =
|
|
1159
|
+
imageContainer.current.getBoundingClientRect();
|
|
1160
|
+
|
|
1161
|
+
const handleMouseMove = (
|
|
1162
|
+
moveEvent
|
|
1163
|
+
) => {
|
|
1164
|
+
const deltaX = Math.abs(
|
|
1165
|
+
moveEvent.clientX - startX
|
|
1166
|
+
);
|
|
1167
|
+
const deltaY = Math.abs(
|
|
1168
|
+
moveEvent.clientY - startY
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
// Start dragging if moved more than 5px
|
|
1172
|
+
if (
|
|
1173
|
+
!isDragging &&
|
|
1174
|
+
(deltaX > 5 || deltaY > 5)
|
|
1175
|
+
) {
|
|
1176
|
+
isDragging = true;
|
|
1044
1177
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1178
|
+
|
|
1179
|
+
if (isDragging) {
|
|
1180
|
+
const newX =
|
|
1181
|
+
moveEvent.clientX -
|
|
1182
|
+
containerRect.left;
|
|
1183
|
+
const newY =
|
|
1184
|
+
moveEvent.clientY -
|
|
1185
|
+
containerRect.top;
|
|
1186
|
+
|
|
1187
|
+
// Update camera position
|
|
1188
|
+
updateCamera(camera.id, {
|
|
1189
|
+
x: newX,
|
|
1190
|
+
y: newY,
|
|
1191
|
+
});
|
|
1051
1192
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
const handleMouseUp = () => {
|
|
1196
|
+
const clickDuration =
|
|
1197
|
+
Date.now() - startTime;
|
|
1198
|
+
|
|
1199
|
+
// If it was a quick click without dragging, show modal
|
|
1200
|
+
if (
|
|
1201
|
+
!isDragging &&
|
|
1202
|
+
clickDuration < 200
|
|
1203
|
+
) {
|
|
1204
|
+
setSelectedCamera(camera);
|
|
1205
|
+
setShowModal(true);
|
|
1056
1206
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1207
|
+
|
|
1208
|
+
document.removeEventListener(
|
|
1209
|
+
'mousemove',
|
|
1210
|
+
handleMouseMove
|
|
1211
|
+
);
|
|
1212
|
+
document.removeEventListener(
|
|
1213
|
+
'mouseup',
|
|
1214
|
+
handleMouseUp
|
|
1215
|
+
);
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
document.addEventListener(
|
|
1219
|
+
'mousemove',
|
|
1220
|
+
handleMouseMove
|
|
1221
|
+
);
|
|
1222
|
+
document.addEventListener(
|
|
1223
|
+
'mouseup',
|
|
1224
|
+
handleMouseUp
|
|
1225
|
+
);
|
|
1226
|
+
}}
|
|
1227
|
+
>
|
|
1228
|
+
<div
|
|
1229
|
+
className={`${
|
|
1230
|
+
styles.circularMarker
|
|
1231
|
+
} ${environment} ${
|
|
1232
|
+
verkadaCamera?.ptz
|
|
1233
|
+
? styles.ptzMarker
|
|
1234
|
+
: ''
|
|
1235
|
+
}`}
|
|
1236
|
+
>
|
|
1237
|
+
<div
|
|
1238
|
+
className={styles.markerNumber}
|
|
1239
|
+
>
|
|
1240
|
+
{cameraNumber}
|
|
1241
|
+
</div>
|
|
1242
|
+
<div
|
|
1243
|
+
className={
|
|
1244
|
+
styles.rotationHandle
|
|
1245
|
+
}
|
|
1246
|
+
style={{
|
|
1247
|
+
transform: `rotate(${
|
|
1248
|
+
camera.direction || 0
|
|
1249
|
+
}deg)`,
|
|
1250
|
+
}}
|
|
1251
|
+
onMouseEnter={(e) => {
|
|
1252
|
+
// Hide tooltip when hovering over rotation handle
|
|
1253
|
+
const container =
|
|
1254
|
+
e.currentTarget
|
|
1255
|
+
.parentElement
|
|
1256
|
+
.parentElement;
|
|
1257
|
+
if (
|
|
1258
|
+
container._tooltipTimeout
|
|
1259
|
+
) {
|
|
1260
|
+
clearTimeout(
|
|
1261
|
+
container._tooltipTimeout
|
|
1262
|
+
);
|
|
1263
|
+
container._tooltipTimeout =
|
|
1264
|
+
null;
|
|
1081
1265
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
if (!isDragging && clickDuration < 200) {
|
|
1089
|
-
setSelectedCamera(camera);
|
|
1090
|
-
setShowModal(true);
|
|
1266
|
+
if (container._tooltip) {
|
|
1267
|
+
container.removeChild(
|
|
1268
|
+
container._tooltip
|
|
1269
|
+
);
|
|
1270
|
+
container._tooltip =
|
|
1271
|
+
null;
|
|
1091
1272
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1273
|
+
}}
|
|
1274
|
+
onMouseDown={(e) => {
|
|
1275
|
+
e.stopPropagation();
|
|
1276
|
+
|
|
1277
|
+
// Store references to prevent React event pooling issues
|
|
1278
|
+
const rotationHandle =
|
|
1279
|
+
e.currentTarget;
|
|
1280
|
+
const startMouseX =
|
|
1281
|
+
e.clientX;
|
|
1282
|
+
const startMouseY =
|
|
1283
|
+
e.clientY;
|
|
1284
|
+
|
|
1285
|
+
let isRotating = false;
|
|
1286
|
+
let startAngle = 0;
|
|
1287
|
+
let currentRotation =
|
|
1288
|
+
camera.direction || 0;
|
|
1289
|
+
|
|
1290
|
+
// Get the center of the camera marker
|
|
1291
|
+
const markerContainer =
|
|
1292
|
+
rotationHandle.parentElement;
|
|
1293
|
+
const markerRect =
|
|
1294
|
+
markerContainer.getBoundingClientRect();
|
|
1295
|
+
const centerX =
|
|
1296
|
+
markerRect.left +
|
|
1297
|
+
markerRect.width / 2;
|
|
1298
|
+
const centerY =
|
|
1299
|
+
markerRect.top +
|
|
1300
|
+
markerRect.height / 2;
|
|
1301
|
+
|
|
1302
|
+
// Calculate initial angle from center to mouse position
|
|
1303
|
+
const startDeltaX =
|
|
1304
|
+
startMouseX - centerX;
|
|
1305
|
+
const startDeltaY =
|
|
1306
|
+
startMouseY - centerY;
|
|
1307
|
+
startAngle =
|
|
1308
|
+
(Math.atan2(
|
|
1309
|
+
startDeltaX,
|
|
1310
|
+
-startDeltaY
|
|
1311
|
+
) *
|
|
1312
|
+
180) /
|
|
1313
|
+
Math.PI;
|
|
1314
|
+
|
|
1315
|
+
const handleRotationMove = (
|
|
1316
|
+
moveEvent
|
|
1317
|
+
) => {
|
|
1318
|
+
const deltaX = Math.abs(
|
|
1319
|
+
moveEvent.clientX -
|
|
1320
|
+
startMouseX
|
|
1321
|
+
);
|
|
1322
|
+
const deltaY = Math.abs(
|
|
1323
|
+
moveEvent.clientY -
|
|
1324
|
+
startMouseY
|
|
1325
|
+
);
|
|
1326
|
+
|
|
1327
|
+
// Start rotating if mouse has moved more than 5 pixels
|
|
1328
|
+
if (
|
|
1329
|
+
!isRotating &&
|
|
1330
|
+
(deltaX > 5 ||
|
|
1331
|
+
deltaY > 5)
|
|
1332
|
+
) {
|
|
1333
|
+
isRotating = true;
|
|
1334
|
+
setIsRotating(true);
|
|
1335
|
+
markerContainer.classList.add(
|
|
1336
|
+
'rotating'
|
|
1337
|
+
);
|
|
1114
1338
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1339
|
+
|
|
1340
|
+
if (isRotating) {
|
|
1341
|
+
// Calculate current angle from center to mouse position
|
|
1342
|
+
const currentDeltaX =
|
|
1343
|
+
moveEvent.clientX -
|
|
1344
|
+
centerX;
|
|
1345
|
+
const currentDeltaY =
|
|
1346
|
+
moveEvent.clientY -
|
|
1347
|
+
centerY;
|
|
1348
|
+
const currentAngle =
|
|
1349
|
+
(Math.atan2(
|
|
1350
|
+
currentDeltaX,
|
|
1351
|
+
-currentDeltaY
|
|
1352
|
+
) *
|
|
1353
|
+
180) /
|
|
1354
|
+
Math.PI;
|
|
1355
|
+
|
|
1356
|
+
// Calculate rotation delta
|
|
1357
|
+
let angleDelta =
|
|
1358
|
+
currentAngle -
|
|
1359
|
+
startAngle;
|
|
1360
|
+
|
|
1361
|
+
// Handle angle wrapping
|
|
1362
|
+
if (
|
|
1363
|
+
angleDelta > 180
|
|
1364
|
+
)
|
|
1365
|
+
angleDelta -= 360;
|
|
1366
|
+
if (
|
|
1367
|
+
angleDelta <
|
|
1368
|
+
-180
|
|
1369
|
+
)
|
|
1370
|
+
angleDelta += 360;
|
|
1371
|
+
|
|
1372
|
+
// Calculate new rotation
|
|
1373
|
+
let newRotation =
|
|
1374
|
+
(currentRotation +
|
|
1375
|
+
angleDelta +
|
|
1376
|
+
360) %
|
|
1377
|
+
360;
|
|
1378
|
+
newRotation =
|
|
1379
|
+
Math.round(
|
|
1380
|
+
newRotation
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
// Update rotation handle visual using stored reference
|
|
1384
|
+
if (
|
|
1385
|
+
rotationHandle &&
|
|
1386
|
+
rotationHandle.style
|
|
1387
|
+
) {
|
|
1388
|
+
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
1389
|
+
} else {
|
|
1390
|
+
console.error(
|
|
1391
|
+
'Rotation handle is null or has no style property'
|
|
1392
|
+
);
|
|
1155
1393
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
// Calculate rotation delta
|
|
1164
|
-
let angleDelta = currentAngle - startAngle;
|
|
1165
|
-
|
|
1166
|
-
// Handle angle wrapping
|
|
1167
|
-
if (angleDelta > 180) angleDelta -= 360;
|
|
1168
|
-
if (angleDelta < -180) angleDelta += 360;
|
|
1169
|
-
|
|
1170
|
-
// Calculate new rotation
|
|
1171
|
-
let newRotation = (currentRotation + angleDelta + 360) % 360;
|
|
1172
|
-
newRotation = Math.round(newRotation);
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
// Update rotation handle visual using stored reference
|
|
1176
|
-
if (rotationHandle && rotationHandle.style) {
|
|
1177
|
-
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
1178
|
-
} else {
|
|
1179
|
-
console.error('🔄 Rotation handle is null or has no style property');
|
|
1394
|
+
|
|
1395
|
+
// Update camera direction
|
|
1396
|
+
updateCamera(
|
|
1397
|
+
camera.id,
|
|
1398
|
+
{
|
|
1399
|
+
direction:
|
|
1400
|
+
newRotation,
|
|
1180
1401
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
const handleRotationEnd = () => {
|
|
1188
|
-
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
const handleRotationEnd =
|
|
1407
|
+
() => {
|
|
1189
1408
|
if (isRotating) {
|
|
1190
|
-
markerContainer.classList.remove(
|
|
1409
|
+
markerContainer.classList.remove(
|
|
1410
|
+
'rotating'
|
|
1411
|
+
);
|
|
1191
1412
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1413
|
+
setIsRotating(
|
|
1414
|
+
false
|
|
1415
|
+
);
|
|
1416
|
+
document.removeEventListener(
|
|
1417
|
+
'mousemove',
|
|
1418
|
+
handleRotationMove
|
|
1419
|
+
);
|
|
1420
|
+
document.removeEventListener(
|
|
1421
|
+
'mouseup',
|
|
1422
|
+
handleRotationEnd
|
|
1423
|
+
);
|
|
1194
1424
|
};
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1425
|
+
|
|
1426
|
+
document.addEventListener(
|
|
1427
|
+
'mousemove',
|
|
1428
|
+
handleRotationMove
|
|
1429
|
+
);
|
|
1430
|
+
document.addEventListener(
|
|
1431
|
+
'mouseup',
|
|
1432
|
+
handleRotationEnd
|
|
1433
|
+
);
|
|
1434
|
+
}}
|
|
1435
|
+
/>
|
|
1201
1436
|
</div>
|
|
1202
|
-
|
|
1203
|
-
|
|
1437
|
+
</div>
|
|
1438
|
+
);
|
|
1439
|
+
})}
|
|
1440
|
+
</div>
|
|
1441
|
+
) : (
|
|
1442
|
+
<div className={styles.uploadPlaceholder}>
|
|
1443
|
+
<div className={styles.uploadIcon}>
|
|
1444
|
+
<FileImage size={48} />
|
|
1204
1445
|
</div>
|
|
1205
|
-
|
|
1206
|
-
<
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1446
|
+
<h3>Upload an Image</h3>
|
|
1447
|
+
<p>
|
|
1448
|
+
Drag and drop an image file here, or click the
|
|
1449
|
+
upload button to add a floor plan, site layout,
|
|
1450
|
+
or any image where you want to place cameras.
|
|
1451
|
+
</p>
|
|
1452
|
+
<button
|
|
1453
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1454
|
+
className={styles.uploadBtn}
|
|
1455
|
+
>
|
|
1456
|
+
Choose Image File
|
|
1457
|
+
</button>
|
|
1458
|
+
<div className={styles.dragDropHint}>
|
|
1459
|
+
<Lightbulb
|
|
1460
|
+
size={16}
|
|
1461
|
+
className={styles.hintIcon}
|
|
1462
|
+
/>
|
|
1463
|
+
<span>
|
|
1464
|
+
You can also drag and drop image files
|
|
1465
|
+
directly onto this area
|
|
1466
|
+
</span>
|
|
1216
1467
|
</div>
|
|
1217
|
-
|
|
1218
|
-
|
|
1468
|
+
</div>
|
|
1469
|
+
)}
|
|
1219
1470
|
</div>
|
|
1220
|
-
|
|
1221
|
-
{/* Hidden file input */}
|
|
1222
|
-
<input
|
|
1223
|
-
type="file"
|
|
1224
|
-
ref={fileInputRef}
|
|
1225
|
-
onChange={handleImageUpload}
|
|
1226
|
-
accept="image/*"
|
|
1227
|
-
style={{ display: 'none' }}
|
|
1228
|
-
/>
|
|
1471
|
+
</div>
|
|
1229
1472
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1473
|
+
{/* Hidden file input */}
|
|
1474
|
+
<input
|
|
1475
|
+
type="file"
|
|
1476
|
+
ref={fileInputRef}
|
|
1477
|
+
onChange={handleImageUpload}
|
|
1478
|
+
accept="image/*"
|
|
1479
|
+
style={{ display: 'none' }}
|
|
1480
|
+
/>
|
|
1481
|
+
|
|
1482
|
+
<div
|
|
1483
|
+
className={`${styles.sidebar} ${
|
|
1484
|
+
sidebarCollapsed ? styles.collapsed : ''
|
|
1485
|
+
}`}
|
|
1486
|
+
>
|
|
1487
|
+
<div className={styles.sidebarHeader}>
|
|
1488
|
+
<h3 className={styles.sidebarTitle}>Camera List</h3>
|
|
1489
|
+
<p className={styles.cameraCount}>
|
|
1490
|
+
{cameras.length} camera
|
|
1491
|
+
{cameras.length !== 1 ? 's' : ''} placed
|
|
1492
|
+
</p>
|
|
1493
|
+
</div>
|
|
1242
1494
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1495
|
+
<div className={styles.camerasList}>
|
|
1496
|
+
{cameras.length === 0 ? (
|
|
1497
|
+
<div
|
|
1498
|
+
style={{
|
|
1499
|
+
textAlign: 'center',
|
|
1500
|
+
color: '#6c757d',
|
|
1501
|
+
padding: '40px 20px',
|
|
1502
|
+
fontStyle: 'italic',
|
|
1503
|
+
}}
|
|
1504
|
+
>
|
|
1505
|
+
<div>
|
|
1506
|
+
Upload an image first, then click anywhere on it
|
|
1507
|
+
to place cameras
|
|
1508
|
+
</div>
|
|
1245
1509
|
<div
|
|
1246
1510
|
style={{
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
padding: '40px 20px',
|
|
1250
|
-
fontStyle: 'italic',
|
|
1511
|
+
fontSize: '12px',
|
|
1512
|
+
marginTop: '8px',
|
|
1251
1513
|
}}
|
|
1252
1514
|
>
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
<div
|
|
1257
|
-
style={{
|
|
1258
|
-
fontSize: '12px',
|
|
1259
|
-
marginTop: '8px',
|
|
1260
|
-
}}
|
|
1261
|
-
>
|
|
1262
|
-
Once placed, you can drag cameras to reposition them and use the rotation handle to adjust direction
|
|
1263
|
-
</div>
|
|
1515
|
+
Once placed, you can drag cameras to reposition
|
|
1516
|
+
them and use the rotation handle to adjust
|
|
1517
|
+
direction
|
|
1264
1518
|
</div>
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1519
|
+
</div>
|
|
1520
|
+
) : (
|
|
1521
|
+
cameras.map((camera) => {
|
|
1522
|
+
const verkadaCamera = camera.verkadaModel
|
|
1523
|
+
? getCameraById(camera.verkadaModel)
|
|
1524
|
+
: null;
|
|
1270
1525
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1526
|
+
return (
|
|
1527
|
+
<div
|
|
1528
|
+
key={camera.id}
|
|
1529
|
+
className={styles.cameraItem}
|
|
1530
|
+
>
|
|
1531
|
+
<div className={styles.cameraHeader}>
|
|
1532
|
+
<div
|
|
1533
|
+
className={styles.sidebarCameraInfo}
|
|
1534
|
+
>
|
|
1535
|
+
{verkadaCamera && (
|
|
1536
|
+
<div
|
|
1537
|
+
className={
|
|
1538
|
+
styles.sidebarCameraImageContainer
|
|
1539
|
+
}
|
|
1540
|
+
>
|
|
1541
|
+
<img
|
|
1542
|
+
src={
|
|
1543
|
+
verkadaCamera.image
|
|
1544
|
+
}
|
|
1545
|
+
alt={verkadaCamera.name}
|
|
1546
|
+
className={
|
|
1547
|
+
styles.sidebarCameraImage
|
|
1548
|
+
}
|
|
1549
|
+
onLoad={(e) => {
|
|
1550
|
+
e.target.style.opacity =
|
|
1551
|
+
'1';
|
|
1552
|
+
}}
|
|
1553
|
+
onError={(e) => {
|
|
1554
|
+
// Fallback to icon if image fails to load
|
|
1555
|
+
e.target.style.display =
|
|
1556
|
+
'none';
|
|
1557
|
+
e.target.nextSibling.style.display =
|
|
1558
|
+
'flex';
|
|
1559
|
+
}}
|
|
1560
|
+
style={{
|
|
1561
|
+
opacity: '0.3',
|
|
1562
|
+
}}
|
|
1563
|
+
/>
|
|
1564
|
+
<div
|
|
1565
|
+
className={
|
|
1566
|
+
styles.sidebarCameraIcon
|
|
1567
|
+
}
|
|
1568
|
+
style={{
|
|
1569
|
+
display: 'none',
|
|
1570
|
+
}}
|
|
1571
|
+
>
|
|
1572
|
+
<CameraIcon
|
|
1573
|
+
icon={
|
|
1574
|
+
verkadaCamera.icon
|
|
1575
|
+
}
|
|
1576
|
+
size={16}
|
|
1293
1577
|
/>
|
|
1294
|
-
<div className={styles.sidebarCameraIcon} style={{display: 'none'}}>
|
|
1295
|
-
<CameraIcon
|
|
1296
|
-
icon={verkadaCamera.icon}
|
|
1297
|
-
size={16}
|
|
1298
|
-
/>
|
|
1299
|
-
</div>
|
|
1300
1578
|
</div>
|
|
1301
|
-
)}
|
|
1302
|
-
<div className={styles.sidebarCameraText}>
|
|
1303
|
-
<h4 className={styles.cameraName}>
|
|
1304
|
-
{camera.customName || camera.name}
|
|
1305
|
-
</h4>
|
|
1306
|
-
{verkadaCamera && (
|
|
1307
|
-
<span
|
|
1308
|
-
className={`${styles.cameraType} ${styles[verkadaCamera.environment]}`}
|
|
1309
|
-
>
|
|
1310
|
-
{verkadaCamera.id}
|
|
1311
|
-
</span>
|
|
1312
|
-
)}
|
|
1313
1579
|
</div>
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
}
|
|
1324
|
-
title="Edit camera"
|
|
1325
|
-
>
|
|
1326
|
-
<Edit3 size={16} />
|
|
1327
|
-
</button>
|
|
1328
|
-
<button
|
|
1329
|
-
className={styles.iconBtn}
|
|
1330
|
-
onClick={() => removeCamera(camera.id)}
|
|
1331
|
-
title="Remove camera"
|
|
1580
|
+
)}
|
|
1581
|
+
<div
|
|
1582
|
+
className={
|
|
1583
|
+
styles.sidebarCameraText
|
|
1584
|
+
}
|
|
1585
|
+
>
|
|
1586
|
+
<h4
|
|
1587
|
+
className={
|
|
1588
|
+
styles.cameraName
|
|
1589
|
+
}
|
|
1332
1590
|
>
|
|
1333
|
-
|
|
1334
|
-
|
|
1591
|
+
{camera.customName ||
|
|
1592
|
+
camera.name}
|
|
1593
|
+
</h4>
|
|
1594
|
+
{verkadaCamera && (
|
|
1595
|
+
<span
|
|
1596
|
+
className={`${
|
|
1597
|
+
styles.cameraType
|
|
1598
|
+
} ${
|
|
1599
|
+
styles[
|
|
1600
|
+
verkadaCamera
|
|
1601
|
+
.environment
|
|
1602
|
+
]
|
|
1603
|
+
}`}
|
|
1604
|
+
>
|
|
1605
|
+
{verkadaCamera.id}
|
|
1606
|
+
</span>
|
|
1607
|
+
)}
|
|
1335
1608
|
</div>
|
|
1336
1609
|
</div>
|
|
1337
1610
|
|
|
1338
|
-
{
|
|
1611
|
+
{/* Compact action buttons */}
|
|
1612
|
+
<div className={styles.cameraActions}>
|
|
1613
|
+
<button
|
|
1614
|
+
className={styles.iconBtn}
|
|
1615
|
+
onClick={() => {
|
|
1616
|
+
setSelectedCamera(camera);
|
|
1617
|
+
setShowModal(true);
|
|
1618
|
+
}}
|
|
1619
|
+
title="Edit camera"
|
|
1620
|
+
>
|
|
1621
|
+
<Edit3 size={16} />
|
|
1622
|
+
</button>
|
|
1623
|
+
<button
|
|
1624
|
+
className={styles.iconBtn}
|
|
1625
|
+
onClick={() =>
|
|
1626
|
+
removeCamera(camera.id)
|
|
1627
|
+
}
|
|
1628
|
+
title="Remove camera"
|
|
1629
|
+
>
|
|
1630
|
+
<Trash2 size={16} />
|
|
1631
|
+
</button>
|
|
1632
|
+
</div>
|
|
1633
|
+
</div>
|
|
1634
|
+
|
|
1635
|
+
{verkadaCamera ? (
|
|
1636
|
+
<div className={styles.cameraDetails}>
|
|
1339
1637
|
<div
|
|
1340
|
-
className={
|
|
1638
|
+
className={
|
|
1639
|
+
modalStyles.cameraModelName
|
|
1640
|
+
}
|
|
1641
|
+
>
|
|
1642
|
+
<strong>
|
|
1643
|
+
{verkadaCamera.name}
|
|
1644
|
+
</strong>
|
|
1645
|
+
</div>
|
|
1646
|
+
|
|
1647
|
+
<div
|
|
1648
|
+
className={
|
|
1649
|
+
modalStyles.cameraSpecGrid
|
|
1650
|
+
}
|
|
1341
1651
|
>
|
|
1342
1652
|
<div
|
|
1343
1653
|
className={
|
|
1344
|
-
modalStyles.
|
|
1654
|
+
modalStyles.specItem
|
|
1345
1655
|
}
|
|
1346
1656
|
>
|
|
1347
|
-
<
|
|
1348
|
-
{
|
|
1349
|
-
|
|
1657
|
+
<span
|
|
1658
|
+
className={
|
|
1659
|
+
modalStyles.specLabel
|
|
1660
|
+
}
|
|
1661
|
+
>
|
|
1662
|
+
Resolution:
|
|
1663
|
+
</span>
|
|
1664
|
+
<span
|
|
1665
|
+
className={
|
|
1666
|
+
modalStyles.specValue
|
|
1667
|
+
}
|
|
1668
|
+
>
|
|
1669
|
+
{
|
|
1670
|
+
verkadaCamera.resolution
|
|
1671
|
+
}
|
|
1672
|
+
</span>
|
|
1350
1673
|
</div>
|
|
1351
|
-
|
|
1352
1674
|
<div
|
|
1353
1675
|
className={
|
|
1354
|
-
modalStyles.
|
|
1676
|
+
modalStyles.specItem
|
|
1355
1677
|
}
|
|
1356
1678
|
>
|
|
1357
|
-
<
|
|
1679
|
+
<span
|
|
1358
1680
|
className={
|
|
1359
|
-
modalStyles.
|
|
1681
|
+
modalStyles.specLabel
|
|
1360
1682
|
}
|
|
1361
1683
|
>
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
}
|
|
1366
|
-
>
|
|
1367
|
-
Resolution:
|
|
1368
|
-
</span>
|
|
1369
|
-
<span
|
|
1370
|
-
className={
|
|
1371
|
-
modalStyles.specValue
|
|
1372
|
-
}
|
|
1373
|
-
>
|
|
1374
|
-
{
|
|
1375
|
-
verkadaCamera.resolution
|
|
1376
|
-
}
|
|
1377
|
-
</span>
|
|
1378
|
-
</div>
|
|
1379
|
-
<div
|
|
1684
|
+
FOV:
|
|
1685
|
+
</span>
|
|
1686
|
+
<span
|
|
1380
1687
|
className={
|
|
1381
|
-
modalStyles.
|
|
1688
|
+
modalStyles.specValue
|
|
1382
1689
|
}
|
|
1383
1690
|
>
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
modalStyles.specValue
|
|
1394
|
-
}
|
|
1395
|
-
>
|
|
1396
|
-
{verkadaCamera.fov}°
|
|
1397
|
-
</span>
|
|
1398
|
-
</div>
|
|
1399
|
-
<div
|
|
1691
|
+
{verkadaCamera.fov}°
|
|
1692
|
+
</span>
|
|
1693
|
+
</div>
|
|
1694
|
+
<div
|
|
1695
|
+
className={
|
|
1696
|
+
modalStyles.specItem
|
|
1697
|
+
}
|
|
1698
|
+
>
|
|
1699
|
+
<span
|
|
1400
1700
|
className={
|
|
1401
|
-
modalStyles.
|
|
1701
|
+
modalStyles.specLabel
|
|
1402
1702
|
}
|
|
1403
1703
|
>
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
modalStyles[
|
|
1416
|
-
verkadaCamera
|
|
1417
|
-
.environment
|
|
1418
|
-
]
|
|
1419
|
-
}`}
|
|
1420
|
-
>
|
|
1421
|
-
{
|
|
1422
|
-
verkadaCamera.environment
|
|
1423
|
-
}
|
|
1424
|
-
</span>
|
|
1425
|
-
</div>
|
|
1426
|
-
<div
|
|
1427
|
-
className={
|
|
1428
|
-
modalStyles.specItem
|
|
1429
|
-
}
|
|
1704
|
+
Environment:
|
|
1705
|
+
</span>
|
|
1706
|
+
<span
|
|
1707
|
+
className={`${
|
|
1708
|
+
modalStyles.specValue
|
|
1709
|
+
} ${
|
|
1710
|
+
modalStyles[
|
|
1711
|
+
verkadaCamera
|
|
1712
|
+
.environment
|
|
1713
|
+
]
|
|
1714
|
+
}`}
|
|
1430
1715
|
>
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
>
|
|
1443
|
-
{
|
|
1444
|
-
verkadaCamera.irRange
|
|
1445
|
-
}
|
|
1446
|
-
m
|
|
1447
|
-
</span>
|
|
1448
|
-
</div>
|
|
1449
|
-
<div
|
|
1716
|
+
{
|
|
1717
|
+
verkadaCamera.environment
|
|
1718
|
+
}
|
|
1719
|
+
</span>
|
|
1720
|
+
</div>
|
|
1721
|
+
<div
|
|
1722
|
+
className={
|
|
1723
|
+
modalStyles.specItem
|
|
1724
|
+
}
|
|
1725
|
+
>
|
|
1726
|
+
<span
|
|
1450
1727
|
className={
|
|
1451
|
-
modalStyles.
|
|
1728
|
+
modalStyles.specLabel
|
|
1452
1729
|
}
|
|
1453
1730
|
>
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
}
|
|
1458
|
-
>
|
|
1459
|
-
Zoom:
|
|
1460
|
-
</span>
|
|
1461
|
-
<span
|
|
1462
|
-
className={
|
|
1463
|
-
modalStyles.specValue
|
|
1464
|
-
}
|
|
1465
|
-
>
|
|
1466
|
-
{verkadaCamera.zoom}
|
|
1467
|
-
</span>
|
|
1468
|
-
</div>
|
|
1469
|
-
<div
|
|
1731
|
+
IR Range:
|
|
1732
|
+
</span>
|
|
1733
|
+
<span
|
|
1470
1734
|
className={
|
|
1471
|
-
modalStyles.
|
|
1735
|
+
modalStyles.specValue
|
|
1472
1736
|
}
|
|
1473
1737
|
>
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
modalStyles.specLabel
|
|
1477
|
-
}
|
|
1478
|
-
>
|
|
1479
|
-
Rating:
|
|
1480
|
-
</span>
|
|
1481
|
-
<span
|
|
1482
|
-
className={
|
|
1483
|
-
modalStyles.specValue
|
|
1484
|
-
}
|
|
1485
|
-
>
|
|
1486
|
-
{
|
|
1487
|
-
verkadaCamera.rating
|
|
1488
|
-
}
|
|
1489
|
-
</span>
|
|
1490
|
-
</div>
|
|
1491
|
-
{verkadaCamera.ptz && (
|
|
1492
|
-
<>
|
|
1493
|
-
<div
|
|
1494
|
-
className={
|
|
1495
|
-
modalStyles.specItem
|
|
1496
|
-
}
|
|
1497
|
-
>
|
|
1498
|
-
<span
|
|
1499
|
-
className={
|
|
1500
|
-
modalStyles.specLabel
|
|
1501
|
-
}
|
|
1502
|
-
>
|
|
1503
|
-
Pan:
|
|
1504
|
-
</span>
|
|
1505
|
-
<span
|
|
1506
|
-
className={
|
|
1507
|
-
modalStyles.specValue
|
|
1508
|
-
}
|
|
1509
|
-
>
|
|
1510
|
-
{
|
|
1511
|
-
verkadaCamera.pan
|
|
1512
|
-
}
|
|
1513
|
-
</span>
|
|
1514
|
-
</div>
|
|
1515
|
-
<div
|
|
1516
|
-
className={
|
|
1517
|
-
modalStyles.specItem
|
|
1518
|
-
}
|
|
1519
|
-
>
|
|
1520
|
-
<span
|
|
1521
|
-
className={
|
|
1522
|
-
modalStyles.specLabel
|
|
1523
|
-
}
|
|
1524
|
-
>
|
|
1525
|
-
Tilt:
|
|
1526
|
-
</span>
|
|
1527
|
-
<span
|
|
1528
|
-
className={
|
|
1529
|
-
modalStyles.specValue
|
|
1530
|
-
}
|
|
1531
|
-
>
|
|
1532
|
-
{
|
|
1533
|
-
verkadaCamera.tilt
|
|
1534
|
-
}
|
|
1535
|
-
</span>
|
|
1536
|
-
</div>
|
|
1537
|
-
</>
|
|
1538
|
-
)}
|
|
1738
|
+
{verkadaCamera.irRange}m
|
|
1739
|
+
</span>
|
|
1539
1740
|
</div>
|
|
1540
|
-
|
|
1541
1741
|
<div
|
|
1542
1742
|
className={
|
|
1543
|
-
modalStyles.
|
|
1743
|
+
modalStyles.specItem
|
|
1544
1744
|
}
|
|
1545
1745
|
>
|
|
1546
1746
|
<span
|
|
@@ -1548,153 +1748,231 @@ const CameraPlacement = () => {
|
|
|
1548
1748
|
modalStyles.specLabel
|
|
1549
1749
|
}
|
|
1550
1750
|
>
|
|
1551
|
-
|
|
1751
|
+
Zoom:
|
|
1552
1752
|
</span>
|
|
1553
|
-
<
|
|
1753
|
+
<span
|
|
1554
1754
|
className={
|
|
1555
|
-
modalStyles.
|
|
1755
|
+
modalStyles.specValue
|
|
1556
1756
|
}
|
|
1557
1757
|
>
|
|
1558
|
-
{verkadaCamera.
|
|
1559
|
-
|
|
1560
|
-
feature,
|
|
1561
|
-
index
|
|
1562
|
-
) => (
|
|
1563
|
-
<span
|
|
1564
|
-
key={index}
|
|
1565
|
-
className={
|
|
1566
|
-
modalStyles.featureTag
|
|
1567
|
-
}
|
|
1568
|
-
>
|
|
1569
|
-
{feature}
|
|
1570
|
-
</span>
|
|
1571
|
-
)
|
|
1572
|
-
)}
|
|
1573
|
-
</div>
|
|
1758
|
+
{verkadaCamera.zoom}
|
|
1759
|
+
</span>
|
|
1574
1760
|
</div>
|
|
1575
|
-
|
|
1576
1761
|
<div
|
|
1577
1762
|
className={
|
|
1578
|
-
modalStyles.
|
|
1763
|
+
modalStyles.specItem
|
|
1579
1764
|
}
|
|
1580
1765
|
>
|
|
1581
|
-
<
|
|
1582
|
-
Direction:{' '}
|
|
1583
|
-
{camera.direction}°
|
|
1584
|
-
</label>
|
|
1585
|
-
<input
|
|
1586
|
-
type="range"
|
|
1587
|
-
min="0"
|
|
1588
|
-
max="360"
|
|
1589
|
-
value={camera.direction}
|
|
1590
|
-
onChange={(e) =>
|
|
1591
|
-
updateCamera(
|
|
1592
|
-
camera.id,
|
|
1593
|
-
{
|
|
1594
|
-
direction:
|
|
1595
|
-
parseInt(
|
|
1596
|
-
e
|
|
1597
|
-
.target
|
|
1598
|
-
.value
|
|
1599
|
-
),
|
|
1600
|
-
}
|
|
1601
|
-
)
|
|
1602
|
-
}
|
|
1766
|
+
<span
|
|
1603
1767
|
className={
|
|
1604
|
-
modalStyles.
|
|
1768
|
+
modalStyles.specLabel
|
|
1605
1769
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1770
|
+
>
|
|
1771
|
+
Rating:
|
|
1772
|
+
</span>
|
|
1773
|
+
<span
|
|
1608
1774
|
className={
|
|
1609
|
-
modalStyles.
|
|
1775
|
+
modalStyles.specValue
|
|
1610
1776
|
}
|
|
1611
1777
|
>
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
West
|
|
1615
|
-
</div>
|
|
1778
|
+
{verkadaCamera.rating}
|
|
1779
|
+
</span>
|
|
1616
1780
|
</div>
|
|
1781
|
+
{verkadaCamera.ptz && (
|
|
1782
|
+
<>
|
|
1783
|
+
<div
|
|
1784
|
+
className={
|
|
1785
|
+
modalStyles.specItem
|
|
1786
|
+
}
|
|
1787
|
+
>
|
|
1788
|
+
<span
|
|
1789
|
+
className={
|
|
1790
|
+
modalStyles.specLabel
|
|
1791
|
+
}
|
|
1792
|
+
>
|
|
1793
|
+
Pan:
|
|
1794
|
+
</span>
|
|
1795
|
+
<span
|
|
1796
|
+
className={
|
|
1797
|
+
modalStyles.specValue
|
|
1798
|
+
}
|
|
1799
|
+
>
|
|
1800
|
+
{
|
|
1801
|
+
verkadaCamera.pan
|
|
1802
|
+
}
|
|
1803
|
+
</span>
|
|
1804
|
+
</div>
|
|
1805
|
+
<div
|
|
1806
|
+
className={
|
|
1807
|
+
modalStyles.specItem
|
|
1808
|
+
}
|
|
1809
|
+
>
|
|
1810
|
+
<span
|
|
1811
|
+
className={
|
|
1812
|
+
modalStyles.specLabel
|
|
1813
|
+
}
|
|
1814
|
+
>
|
|
1815
|
+
Tilt:
|
|
1816
|
+
</span>
|
|
1817
|
+
<span
|
|
1818
|
+
className={
|
|
1819
|
+
modalStyles.specValue
|
|
1820
|
+
}
|
|
1821
|
+
>
|
|
1822
|
+
{
|
|
1823
|
+
verkadaCamera.tilt
|
|
1824
|
+
}
|
|
1825
|
+
</span>
|
|
1826
|
+
</div>
|
|
1827
|
+
</>
|
|
1828
|
+
)}
|
|
1617
1829
|
</div>
|
|
1618
|
-
|
|
1830
|
+
|
|
1619
1831
|
<div
|
|
1620
|
-
className={
|
|
1832
|
+
className={
|
|
1833
|
+
modalStyles.cameraFeatures
|
|
1834
|
+
}
|
|
1621
1835
|
>
|
|
1622
|
-
<
|
|
1836
|
+
<span
|
|
1623
1837
|
className={
|
|
1624
|
-
modalStyles.
|
|
1838
|
+
modalStyles.specLabel
|
|
1625
1839
|
}
|
|
1626
1840
|
>
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
camera
|
|
1630
|
-
</div>
|
|
1841
|
+
Features:
|
|
1842
|
+
</span>
|
|
1631
1843
|
<div
|
|
1632
1844
|
className={
|
|
1633
|
-
modalStyles.
|
|
1845
|
+
modalStyles.featuresList
|
|
1634
1846
|
}
|
|
1635
1847
|
>
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
min="0"
|
|
1643
|
-
max="360"
|
|
1644
|
-
value={camera.direction}
|
|
1645
|
-
onChange={(e) =>
|
|
1646
|
-
updateCamera(
|
|
1647
|
-
camera.id,
|
|
1648
|
-
{
|
|
1649
|
-
direction:
|
|
1650
|
-
parseInt(
|
|
1651
|
-
e
|
|
1652
|
-
.target
|
|
1653
|
-
.value
|
|
1654
|
-
),
|
|
1848
|
+
{verkadaCamera.features.map(
|
|
1849
|
+
(feature, index) => (
|
|
1850
|
+
<span
|
|
1851
|
+
key={index}
|
|
1852
|
+
className={
|
|
1853
|
+
modalStyles.featureTag
|
|
1655
1854
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
/>
|
|
1662
|
-
<div
|
|
1663
|
-
className={
|
|
1664
|
-
modalStyles.directionHelper
|
|
1665
|
-
}
|
|
1666
|
-
>
|
|
1667
|
-
0° = North, 90° = East,
|
|
1668
|
-
180° = South, 270° =
|
|
1669
|
-
West
|
|
1670
|
-
</div>
|
|
1855
|
+
>
|
|
1856
|
+
{feature}
|
|
1857
|
+
</span>
|
|
1858
|
+
)
|
|
1859
|
+
)}
|
|
1671
1860
|
</div>
|
|
1672
1861
|
</div>
|
|
1673
|
-
)}
|
|
1674
1862
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
{
|
|
1682
|
-
{camera.
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1863
|
+
<div
|
|
1864
|
+
className={
|
|
1865
|
+
modalStyles.directionControl
|
|
1866
|
+
}
|
|
1867
|
+
>
|
|
1868
|
+
<label>
|
|
1869
|
+
Direction:{' '}
|
|
1870
|
+
{camera.direction}°
|
|
1871
|
+
</label>
|
|
1872
|
+
<input
|
|
1873
|
+
type="range"
|
|
1874
|
+
min="0"
|
|
1875
|
+
max="360"
|
|
1876
|
+
value={camera.direction}
|
|
1877
|
+
onChange={(e) =>
|
|
1878
|
+
updateCamera(
|
|
1879
|
+
camera.id,
|
|
1880
|
+
{
|
|
1881
|
+
direction:
|
|
1882
|
+
parseInt(
|
|
1883
|
+
e.target
|
|
1884
|
+
.value
|
|
1885
|
+
),
|
|
1886
|
+
}
|
|
1887
|
+
)
|
|
1888
|
+
}
|
|
1889
|
+
className={
|
|
1890
|
+
modalStyles.directionSlider
|
|
1891
|
+
}
|
|
1892
|
+
/>
|
|
1893
|
+
<div
|
|
1894
|
+
className={
|
|
1895
|
+
modalStyles.directionHelper
|
|
1896
|
+
}
|
|
1897
|
+
>
|
|
1898
|
+
0° = North, 90° = East, 180°
|
|
1899
|
+
= South, 270° = West
|
|
1900
|
+
</div>
|
|
1901
|
+
</div>
|
|
1902
|
+
</div>
|
|
1903
|
+
) : (
|
|
1904
|
+
<div className={styles.cameraDetails}>
|
|
1905
|
+
<div
|
|
1906
|
+
className={modalStyles.noModel}
|
|
1907
|
+
>
|
|
1908
|
+
No Verkada model selected -
|
|
1909
|
+
Click "Edit" to choose a camera
|
|
1910
|
+
</div>
|
|
1911
|
+
<div
|
|
1912
|
+
className={
|
|
1913
|
+
modalStyles.directionControl
|
|
1914
|
+
}
|
|
1915
|
+
>
|
|
1916
|
+
<label>
|
|
1917
|
+
Direction:{' '}
|
|
1918
|
+
{camera.direction}°
|
|
1919
|
+
</label>
|
|
1920
|
+
<input
|
|
1921
|
+
type="range"
|
|
1922
|
+
min="0"
|
|
1923
|
+
max="360"
|
|
1924
|
+
value={camera.direction}
|
|
1925
|
+
onChange={(e) =>
|
|
1926
|
+
updateCamera(
|
|
1927
|
+
camera.id,
|
|
1928
|
+
{
|
|
1929
|
+
direction:
|
|
1930
|
+
parseInt(
|
|
1931
|
+
e.target
|
|
1932
|
+
.value
|
|
1933
|
+
),
|
|
1934
|
+
}
|
|
1935
|
+
)
|
|
1936
|
+
}
|
|
1937
|
+
className={
|
|
1938
|
+
modalStyles.directionSlider
|
|
1939
|
+
}
|
|
1940
|
+
/>
|
|
1941
|
+
<div
|
|
1942
|
+
className={
|
|
1943
|
+
modalStyles.directionHelper
|
|
1944
|
+
}
|
|
1945
|
+
>
|
|
1946
|
+
0° = North, 90° = East, 180°
|
|
1947
|
+
= South, 270° = West
|
|
1948
|
+
</div>
|
|
1949
|
+
</div>
|
|
1689
1950
|
</div>
|
|
1951
|
+
)}
|
|
1690
1952
|
|
|
1953
|
+
<div className={styles.cameraCoords}>
|
|
1954
|
+
<span className={styles.coordLabel}>
|
|
1955
|
+
Location:
|
|
1956
|
+
</span>
|
|
1957
|
+
{mode === 'map' ? (
|
|
1958
|
+
<>
|
|
1959
|
+
{camera.lat?.toFixed(6)},{' '}
|
|
1960
|
+
{camera.lng?.toFixed(6)}
|
|
1961
|
+
</>
|
|
1962
|
+
) : (
|
|
1963
|
+
<>
|
|
1964
|
+
X: {Math.round(camera.x)}, Y:{' '}
|
|
1965
|
+
{Math.round(camera.y)}
|
|
1966
|
+
</>
|
|
1967
|
+
)}
|
|
1691
1968
|
</div>
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
)
|
|
1695
|
-
|
|
1969
|
+
</div>
|
|
1970
|
+
);
|
|
1971
|
+
})
|
|
1972
|
+
)}
|
|
1696
1973
|
</div>
|
|
1697
|
-
|
|
1974
|
+
</div>
|
|
1975
|
+
|
|
1698
1976
|
{showModal && selectedCamera && (
|
|
1699
1977
|
<CameraSelectionModal
|
|
1700
1978
|
camera={selectedCamera}
|
|
@@ -1714,9 +1992,11 @@ const CameraPlacement = () => {
|
|
|
1714
1992
|
const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
1715
1993
|
// Determine if this is editing an existing camera or adding a new one
|
|
1716
1994
|
const isUpdating = Boolean(camera.verkadaModel);
|
|
1717
|
-
|
|
1995
|
+
|
|
1718
1996
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
1719
|
-
const [selectedCamera, setSelectedCamera] = useState(
|
|
1997
|
+
const [selectedCamera, setSelectedCamera] = useState(
|
|
1998
|
+
camera.verkadaModel ? getCameraById(camera.verkadaModel) : null
|
|
1999
|
+
);
|
|
1720
2000
|
|
|
1721
2001
|
// Filter cameras based on selected category
|
|
1722
2002
|
const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
|
|
@@ -1724,7 +2004,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1724
2004
|
return cam.type === selectedCategory;
|
|
1725
2005
|
});
|
|
1726
2006
|
|
|
1727
|
-
|
|
1728
2007
|
const handleCameraSelect = (cameraModel) => {
|
|
1729
2008
|
setSelectedCamera(cameraModel);
|
|
1730
2009
|
};
|
|
@@ -1745,29 +2024,43 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1745
2024
|
<div className={styles.cameraSelectionModal}>
|
|
1746
2025
|
<div className={styles.modalHeader}>
|
|
1747
2026
|
<h3>Select Verkada Camera</h3>
|
|
1748
|
-
<button className={styles.closeBtn} onClick={onClose}
|
|
2027
|
+
<button className={styles.closeBtn} onClick={onClose}>
|
|
2028
|
+
×
|
|
2029
|
+
</button>
|
|
1749
2030
|
</div>
|
|
1750
2031
|
|
|
1751
2032
|
<div className={styles.modalContent}>
|
|
1752
2033
|
<div className={styles.topSection}>
|
|
1753
|
-
<div className={styles.categoryFilter}>
|
|
2034
|
+
<div className={styles.categoryFilter}>
|
|
1754
2035
|
<label>Filter by Category:</label>
|
|
1755
2036
|
<div className={styles.categoryButtons}>
|
|
1756
2037
|
<button
|
|
1757
|
-
className={
|
|
2038
|
+
className={
|
|
2039
|
+
selectedCategory === 'all'
|
|
2040
|
+
? styles.active
|
|
2041
|
+
: ''
|
|
2042
|
+
}
|
|
1758
2043
|
onClick={() => setSelectedCategory('all')}
|
|
1759
2044
|
>
|
|
1760
2045
|
All Cameras
|
|
1761
2046
|
</button>
|
|
1762
|
-
{Object.entries(CAMERA_CATEGORIES).map(
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2047
|
+
{Object.entries(CAMERA_CATEGORIES).map(
|
|
2048
|
+
([key, category]) => (
|
|
2049
|
+
<button
|
|
2050
|
+
key={key}
|
|
2051
|
+
className={
|
|
2052
|
+
selectedCategory === key
|
|
2053
|
+
? styles.active
|
|
2054
|
+
: ''
|
|
2055
|
+
}
|
|
2056
|
+
onClick={() =>
|
|
2057
|
+
setSelectedCategory(key)
|
|
2058
|
+
}
|
|
2059
|
+
>
|
|
2060
|
+
{category.name}
|
|
2061
|
+
</button>
|
|
2062
|
+
)
|
|
2063
|
+
)}
|
|
1771
2064
|
</div>
|
|
1772
2065
|
</div>
|
|
1773
2066
|
</div>
|
|
@@ -1776,13 +2069,19 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1776
2069
|
{filteredCameras.map((cam) => (
|
|
1777
2070
|
<div
|
|
1778
2071
|
key={cam.id}
|
|
1779
|
-
className={`${styles.cameraCard} ${
|
|
2072
|
+
className={`${styles.cameraCard} ${
|
|
2073
|
+
selectedCamera?.id === cam.id
|
|
2074
|
+
? styles.selectedCard
|
|
2075
|
+
: ''
|
|
2076
|
+
}`}
|
|
1780
2077
|
onClick={() => handleCameraSelect(cam)}
|
|
1781
2078
|
>
|
|
1782
2079
|
<div className={styles.cardHeader}>
|
|
1783
|
-
<div
|
|
1784
|
-
|
|
1785
|
-
|
|
2080
|
+
<div
|
|
2081
|
+
className={styles.cameraImageContainer}
|
|
2082
|
+
>
|
|
2083
|
+
<img
|
|
2084
|
+
src={cam.image}
|
|
1786
2085
|
alt={cam.name}
|
|
1787
2086
|
className={styles.cameraImage}
|
|
1788
2087
|
onLoad={(e) => {
|
|
@@ -1791,49 +2090,83 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1791
2090
|
onError={(e) => {
|
|
1792
2091
|
// Fallback to icon if image fails to load
|
|
1793
2092
|
e.target.style.display = 'none';
|
|
1794
|
-
e.target.nextSibling.style.display =
|
|
2093
|
+
e.target.nextSibling.style.display =
|
|
2094
|
+
'flex';
|
|
1795
2095
|
}}
|
|
1796
|
-
style={{opacity: '0.3'}}
|
|
2096
|
+
style={{ opacity: '0.3' }}
|
|
1797
2097
|
/>
|
|
1798
|
-
<div
|
|
1799
|
-
|
|
2098
|
+
<div
|
|
2099
|
+
className={styles.cameraIcon}
|
|
2100
|
+
style={{ display: 'none' }}
|
|
2101
|
+
>
|
|
2102
|
+
<CameraIcon
|
|
2103
|
+
icon={cam.icon}
|
|
2104
|
+
size={24}
|
|
2105
|
+
/>
|
|
1800
2106
|
</div>
|
|
1801
2107
|
</div>
|
|
1802
2108
|
<div className={styles.cameraTitle}>
|
|
1803
2109
|
<h4>{cam.name}</h4>
|
|
1804
|
-
<span
|
|
2110
|
+
<span
|
|
2111
|
+
className={`${
|
|
2112
|
+
styles.environmentBadge
|
|
2113
|
+
} ${styles[cam.environment]}`}
|
|
2114
|
+
>
|
|
1805
2115
|
{cam.environment}
|
|
1806
2116
|
</span>
|
|
1807
2117
|
</div>
|
|
1808
2118
|
</div>
|
|
1809
|
-
|
|
2119
|
+
|
|
1810
2120
|
<div className={styles.cardBody}>
|
|
1811
2121
|
<div className={styles.specRow}>
|
|
1812
|
-
<span className={styles.specLabel}>
|
|
1813
|
-
|
|
2122
|
+
<span className={styles.specLabel}>
|
|
2123
|
+
Resolution:
|
|
2124
|
+
</span>
|
|
2125
|
+
<span className={styles.specValue}>
|
|
2126
|
+
{cam.resolution}
|
|
2127
|
+
</span>
|
|
1814
2128
|
</div>
|
|
1815
2129
|
<div className={styles.specRow}>
|
|
1816
|
-
<span className={styles.specLabel}>
|
|
1817
|
-
|
|
2130
|
+
<span className={styles.specLabel}>
|
|
2131
|
+
FOV:
|
|
2132
|
+
</span>
|
|
2133
|
+
<span className={styles.specValue}>
|
|
2134
|
+
{cam.fov}°
|
|
2135
|
+
</span>
|
|
1818
2136
|
</div>
|
|
1819
2137
|
<div className={styles.specRow}>
|
|
1820
|
-
<span className={styles.specLabel}>
|
|
1821
|
-
|
|
2138
|
+
<span className={styles.specLabel}>
|
|
2139
|
+
Range:
|
|
2140
|
+
</span>
|
|
2141
|
+
<span className={styles.specValue}>
|
|
2142
|
+
{cam.irRange}m IR
|
|
2143
|
+
</span>
|
|
1822
2144
|
</div>
|
|
1823
2145
|
{cam.ptz && (
|
|
1824
|
-
<div className={styles.ptzBadge}>
|
|
2146
|
+
<div className={styles.ptzBadge}>
|
|
2147
|
+
PTZ
|
|
2148
|
+
</div>
|
|
1825
2149
|
)}
|
|
1826
2150
|
</div>
|
|
1827
2151
|
|
|
1828
2152
|
<div className={styles.cardFooter}>
|
|
1829
2153
|
<div className={styles.features}>
|
|
1830
|
-
{cam.features
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
2154
|
+
{cam.features
|
|
2155
|
+
?.slice(0, 2)
|
|
2156
|
+
.map((feature, index) => (
|
|
2157
|
+
<span
|
|
2158
|
+
key={index}
|
|
2159
|
+
className={
|
|
2160
|
+
styles.featureBadge
|
|
2161
|
+
}
|
|
2162
|
+
>
|
|
2163
|
+
{feature}
|
|
2164
|
+
</span>
|
|
2165
|
+
))}
|
|
1835
2166
|
{cam.features?.length > 2 && (
|
|
1836
|
-
<span
|
|
2167
|
+
<span
|
|
2168
|
+
className={styles.moreFeatures}
|
|
2169
|
+
>
|
|
1837
2170
|
+{cam.features.length - 2} more
|
|
1838
2171
|
</span>
|
|
1839
2172
|
)}
|
|
@@ -1854,8 +2187,8 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1854
2187
|
<button onClick={onClose} className={styles.cancelBtn}>
|
|
1855
2188
|
Cancel
|
|
1856
2189
|
</button>
|
|
1857
|
-
<button
|
|
1858
|
-
onClick={handleSave}
|
|
2190
|
+
<button
|
|
2191
|
+
onClick={handleSave}
|
|
1859
2192
|
disabled={!selectedCamera}
|
|
1860
2193
|
className={styles.saveBtn}
|
|
1861
2194
|
>
|