@visns-studio/visns-components 5.15.6 → 5.15.7
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,21 @@ 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 {
|
|
7
|
+
Edit3,
|
|
8
|
+
Trash2,
|
|
9
|
+
Upload,
|
|
10
|
+
Save,
|
|
11
|
+
FolderOpen,
|
|
12
|
+
Eye,
|
|
13
|
+
EyeOff,
|
|
14
|
+
Download,
|
|
15
|
+
FileImage,
|
|
16
|
+
Lightbulb,
|
|
17
|
+
Mail,
|
|
18
|
+
Users,
|
|
19
|
+
FileText,
|
|
20
|
+
} from 'lucide-react';
|
|
7
21
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
8
22
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
9
23
|
import styles from './styles/CameraPlacement.module.scss';
|
|
@@ -24,64 +38,149 @@ const CameraPlacement = () => {
|
|
|
24
38
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
25
39
|
const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
|
|
26
40
|
const [uploadedImage, setUploadedImage] = useState(null);
|
|
27
|
-
const [addCameraMode, setAddCameraMode] = useState(false);
|
|
28
41
|
const mapContainer = useRef(null);
|
|
29
42
|
const mapRef = useRef(null);
|
|
30
43
|
const geocoderRef = useRef(null);
|
|
31
44
|
const imageContainer = useRef(null);
|
|
32
45
|
const fileInputRef = useRef(null);
|
|
46
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
47
|
+
const [isRotating, setIsRotating] = useState(false);
|
|
33
48
|
|
|
34
49
|
// Mapbox configuration
|
|
35
|
-
const MAPBOX_ACCESS_TOKEN =
|
|
50
|
+
const MAPBOX_ACCESS_TOKEN =
|
|
51
|
+
'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
|
|
36
52
|
|
|
37
53
|
// Handle image upload
|
|
38
|
-
const handleImageUpload = useCallback(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
const handleImageUpload = useCallback(
|
|
55
|
+
(event) => {
|
|
56
|
+
const file = event.target.files[0];
|
|
57
|
+
if (file && file.type.startsWith('image/')) {
|
|
58
|
+
const reader = new FileReader();
|
|
59
|
+
reader.onload = (e) => {
|
|
60
|
+
setUploadedImage(e.target.result);
|
|
61
|
+
setMode('image');
|
|
62
|
+
// Reset cameras when switching to image mode
|
|
63
|
+
setCameras([]);
|
|
64
|
+
// Cleanup map if it exists
|
|
65
|
+
if (map) {
|
|
66
|
+
try {
|
|
67
|
+
map.remove();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.warn('Error removing map:', error);
|
|
70
|
+
}
|
|
71
|
+
setMap(null);
|
|
53
72
|
}
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
};
|
|
74
|
+
reader.readAsDataURL(file);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
[map]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Handle image click for camera placement (only when image is uploaded)
|
|
81
|
+
const handleImageClick = useCallback(
|
|
82
|
+
(event) => {
|
|
83
|
+
if (
|
|
84
|
+
mode !== 'image' ||
|
|
85
|
+
!imageContainer.current ||
|
|
86
|
+
!uploadedImage ||
|
|
87
|
+
isRotating
|
|
88
|
+
)
|
|
89
|
+
return;
|
|
90
|
+
|
|
91
|
+
// Check if we clicked on a camera marker or rotation handle
|
|
92
|
+
const clickedElement = event.target;
|
|
93
|
+
if (
|
|
94
|
+
clickedElement.closest('.camera-marker-container') ||
|
|
95
|
+
clickedElement.closest(`.${styles.rotationHandle}`) ||
|
|
96
|
+
clickedElement.closest(`.${styles.circularMarker}`)
|
|
97
|
+
) {
|
|
98
|
+
return; // Don't add camera if clicking on existing camera elements
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rect = imageContainer.current.getBoundingClientRect();
|
|
102
|
+
const x = event.clientX - rect.left;
|
|
103
|
+
const y = event.clientY - rect.top;
|
|
104
|
+
|
|
105
|
+
const newCamera = {
|
|
106
|
+
id: Date.now(),
|
|
107
|
+
x: x, // Image coordinates instead of lat/lng
|
|
108
|
+
y: y,
|
|
109
|
+
name: `Camera ${cameras.length + 1}`,
|
|
110
|
+
verkadaModel: null,
|
|
111
|
+
customName: '',
|
|
112
|
+
direction: 0,
|
|
56
113
|
};
|
|
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
114
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
115
|
+
setCameras((prev) => [...prev, newCamera]);
|
|
116
|
+
setSelectedCamera(newCamera);
|
|
117
|
+
setShowModal(true);
|
|
118
|
+
},
|
|
119
|
+
[mode, cameras.length, uploadedImage, isRotating]
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Drag and drop handlers
|
|
123
|
+
const handleDragOver = useCallback((event) => {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
event.stopPropagation();
|
|
126
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
127
|
+
setIsDragOver(true);
|
|
128
|
+
}, []);
|
|
84
129
|
|
|
130
|
+
const handleDragEnter = useCallback((event) => {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
setIsDragOver(true);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const handleDragLeave = useCallback((event) => {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
// Only set to false if we're leaving the main container
|
|
140
|
+
if (event.currentTarget === event.target) {
|
|
141
|
+
setIsDragOver(false);
|
|
142
|
+
}
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const handleDrop = useCallback(
|
|
146
|
+
(event) => {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
event.stopPropagation();
|
|
149
|
+
setIsDragOver(false);
|
|
150
|
+
|
|
151
|
+
const files = event.dataTransfer.files;
|
|
152
|
+
if (files && files.length > 0) {
|
|
153
|
+
const file = files[0];
|
|
154
|
+
if (file.type.startsWith('image/')) {
|
|
155
|
+
const reader = new FileReader();
|
|
156
|
+
reader.onload = (e) => {
|
|
157
|
+
setUploadedImage(e.target.result);
|
|
158
|
+
setMode('image');
|
|
159
|
+
// Reset cameras when switching to image mode
|
|
160
|
+
setCameras([]);
|
|
161
|
+
// Cleanup map if it exists
|
|
162
|
+
if (map) {
|
|
163
|
+
try {
|
|
164
|
+
map.remove();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.warn('Error removing map:', error);
|
|
167
|
+
}
|
|
168
|
+
setMap(null);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
reader.readAsDataURL(file);
|
|
172
|
+
} else {
|
|
173
|
+
Swal.fire({
|
|
174
|
+
icon: 'error',
|
|
175
|
+
title: 'Invalid File Type',
|
|
176
|
+
text: 'Please upload an image file (JPG, PNG, GIF, etc.)',
|
|
177
|
+
confirmButtonColor: '#ef4444',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
[map]
|
|
183
|
+
);
|
|
85
184
|
|
|
86
185
|
// Initialize Mapbox GL map
|
|
87
186
|
useEffect(() => {
|
|
@@ -195,21 +294,42 @@ const CameraPlacement = () => {
|
|
|
195
294
|
|
|
196
295
|
const removeCamera = useCallback(
|
|
197
296
|
(cameraId) => {
|
|
198
|
-
|
|
297
|
+
// Find camera info for confirmation message
|
|
298
|
+
const camera = cameras.find((cam) => cam.id === cameraId);
|
|
299
|
+
const cameraName = camera
|
|
300
|
+
? camera.customName || camera.name
|
|
301
|
+
: 'this camera';
|
|
199
302
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
303
|
+
Swal.fire({
|
|
304
|
+
title: 'Delete Camera?',
|
|
305
|
+
text: `Are you sure you want to delete ${cameraName}? This action cannot be undone.`,
|
|
306
|
+
icon: 'warning',
|
|
307
|
+
showCancelButton: true,
|
|
308
|
+
confirmButtonColor: '#ef4444',
|
|
309
|
+
cancelButtonColor: '#6b7280',
|
|
310
|
+
confirmButtonText: 'Yes, delete it!',
|
|
311
|
+
cancelButtonText: 'Cancel',
|
|
312
|
+
}).then((result) => {
|
|
313
|
+
if (result.isConfirmed) {
|
|
314
|
+
setCameras((prev) =>
|
|
315
|
+
prev.filter((cam) => cam.id !== cameraId)
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Remove FOV layer and source from map
|
|
319
|
+
if (map) {
|
|
320
|
+
const layerId = `fov-layer-${cameraId}`;
|
|
321
|
+
const sourceId = `fov-${cameraId}`;
|
|
322
|
+
if (map.getLayer(layerId)) {
|
|
323
|
+
map.removeLayer(layerId);
|
|
324
|
+
}
|
|
325
|
+
if (map.getSource(sourceId)) {
|
|
326
|
+
map.removeSource(sourceId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
209
329
|
}
|
|
210
|
-
}
|
|
330
|
+
});
|
|
211
331
|
},
|
|
212
|
-
[map]
|
|
332
|
+
[map, cameras]
|
|
213
333
|
);
|
|
214
334
|
|
|
215
335
|
// Save placement as JSON file
|
|
@@ -219,7 +339,7 @@ const CameraPlacement = () => {
|
|
|
219
339
|
icon: 'warning',
|
|
220
340
|
title: 'No Data to Save',
|
|
221
341
|
text: 'Please add cameras or upload an image first.',
|
|
222
|
-
confirmButtonColor: '#8b5cf6'
|
|
342
|
+
confirmButtonColor: '#8b5cf6',
|
|
223
343
|
});
|
|
224
344
|
return;
|
|
225
345
|
}
|
|
@@ -227,12 +347,14 @@ const CameraPlacement = () => {
|
|
|
227
347
|
const saveData = {
|
|
228
348
|
version: '1.0',
|
|
229
349
|
timestamp: new Date().toISOString(),
|
|
230
|
-
image: uploadedImage
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
350
|
+
image: uploadedImage
|
|
351
|
+
? {
|
|
352
|
+
src: uploadedImage,
|
|
353
|
+
name: 'uploaded-image',
|
|
354
|
+
}
|
|
355
|
+
: null,
|
|
234
356
|
mode: mode,
|
|
235
|
-
cameras: cameras.map(camera => ({
|
|
357
|
+
cameras: cameras.map((camera) => ({
|
|
236
358
|
id: camera.id,
|
|
237
359
|
name: camera.name,
|
|
238
360
|
customName: camera.customName,
|
|
@@ -240,15 +362,19 @@ const CameraPlacement = () => {
|
|
|
240
362
|
y: camera.y,
|
|
241
363
|
direction: camera.direction,
|
|
242
364
|
verkadaModel: camera.verkadaModel,
|
|
243
|
-
timestamp: camera.timestamp
|
|
244
|
-
}))
|
|
365
|
+
timestamp: camera.timestamp,
|
|
366
|
+
})),
|
|
245
367
|
};
|
|
246
368
|
|
|
247
369
|
const dataStr = JSON.stringify(saveData, null, 2);
|
|
248
|
-
const dataUri =
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
370
|
+
const dataUri =
|
|
371
|
+
'data:application/json;charset=utf-8,' +
|
|
372
|
+
encodeURIComponent(dataStr);
|
|
373
|
+
|
|
374
|
+
const exportFileDefaultName = `camera-placement-${
|
|
375
|
+
new Date().toISOString().split('T')[0]
|
|
376
|
+
}.json`;
|
|
377
|
+
|
|
252
378
|
const linkElement = document.createElement('a');
|
|
253
379
|
linkElement.setAttribute('href', dataUri);
|
|
254
380
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
@@ -262,7 +388,7 @@ const CameraPlacement = () => {
|
|
|
262
388
|
timer: 3000,
|
|
263
389
|
showConfirmButton: false,
|
|
264
390
|
toast: true,
|
|
265
|
-
position: 'top-end'
|
|
391
|
+
position: 'top-end',
|
|
266
392
|
});
|
|
267
393
|
}, [cameras, uploadedImage, mode]);
|
|
268
394
|
|
|
@@ -271,60 +397,61 @@ const CameraPlacement = () => {
|
|
|
271
397
|
const input = document.createElement('input');
|
|
272
398
|
input.type = 'file';
|
|
273
399
|
input.accept = '.json';
|
|
274
|
-
|
|
400
|
+
|
|
275
401
|
input.onchange = (event) => {
|
|
276
402
|
const file = event.target.files[0];
|
|
277
403
|
if (!file) return;
|
|
278
|
-
|
|
404
|
+
|
|
279
405
|
const reader = new FileReader();
|
|
280
406
|
reader.onload = (e) => {
|
|
281
407
|
try {
|
|
282
408
|
const saveData = JSON.parse(e.target.result);
|
|
283
|
-
|
|
409
|
+
|
|
284
410
|
// Validate data structure
|
|
285
411
|
if (!saveData.version || !Array.isArray(saveData.cameras)) {
|
|
286
412
|
Swal.fire({
|
|
287
413
|
icon: 'error',
|
|
288
414
|
title: 'Invalid File',
|
|
289
415
|
text: 'Please select a valid camera placement file.',
|
|
290
|
-
confirmButtonColor: '#f59e0b'
|
|
416
|
+
confirmButtonColor: '#f59e0b',
|
|
291
417
|
});
|
|
292
418
|
return;
|
|
293
419
|
}
|
|
294
|
-
|
|
420
|
+
|
|
295
421
|
// Load image if present
|
|
296
422
|
if (saveData.image && saveData.image.src) {
|
|
297
423
|
setUploadedImage(saveData.image.src);
|
|
298
424
|
}
|
|
299
|
-
|
|
425
|
+
|
|
300
426
|
// Set mode
|
|
301
427
|
if (saveData.mode) {
|
|
302
428
|
setMode(saveData.mode);
|
|
303
429
|
}
|
|
304
|
-
|
|
430
|
+
|
|
305
431
|
// Load cameras
|
|
306
432
|
setCameras(saveData.cameras || []);
|
|
307
|
-
|
|
433
|
+
|
|
308
434
|
Swal.fire({
|
|
309
435
|
icon: 'success',
|
|
310
436
|
title: 'File Loaded!',
|
|
311
|
-
text: `Successfully loaded ${
|
|
312
|
-
|
|
437
|
+
text: `Successfully loaded ${
|
|
438
|
+
saveData.cameras?.length || 0
|
|
439
|
+
} cameras from ${file.name}`,
|
|
440
|
+
confirmButtonColor: '#f59e0b',
|
|
313
441
|
});
|
|
314
|
-
|
|
315
442
|
} catch (error) {
|
|
316
443
|
console.error('Error loading file:', error);
|
|
317
444
|
Swal.fire({
|
|
318
445
|
icon: 'error',
|
|
319
446
|
title: 'File Error',
|
|
320
|
-
text:
|
|
321
|
-
confirmButtonColor: '#f59e0b'
|
|
447
|
+
text: "Error reading file. Please ensure it's a valid JSON file.",
|
|
448
|
+
confirmButtonColor: '#f59e0b',
|
|
322
449
|
});
|
|
323
450
|
}
|
|
324
451
|
};
|
|
325
452
|
reader.readAsText(file);
|
|
326
453
|
};
|
|
327
|
-
|
|
454
|
+
|
|
328
455
|
input.click();
|
|
329
456
|
}, []);
|
|
330
457
|
|
|
@@ -335,18 +462,20 @@ const CameraPlacement = () => {
|
|
|
335
462
|
icon: 'warning',
|
|
336
463
|
title: 'No Cameras',
|
|
337
464
|
text: 'Please add cameras first before exporting.',
|
|
338
|
-
confirmButtonColor: '#10b981'
|
|
465
|
+
confirmButtonColor: '#10b981',
|
|
339
466
|
});
|
|
340
467
|
return;
|
|
341
468
|
}
|
|
342
469
|
|
|
343
|
-
const imageContainer = document.querySelector(
|
|
470
|
+
const imageContainer = document.querySelector(
|
|
471
|
+
`.${styles.imageContainer}`
|
|
472
|
+
);
|
|
344
473
|
if (!imageContainer) {
|
|
345
474
|
Swal.fire({
|
|
346
475
|
icon: 'warning',
|
|
347
476
|
title: 'No Image',
|
|
348
477
|
text: 'Please upload an image first before exporting.',
|
|
349
|
-
confirmButtonColor: '#10b981'
|
|
478
|
+
confirmButtonColor: '#10b981',
|
|
350
479
|
});
|
|
351
480
|
return;
|
|
352
481
|
}
|
|
@@ -357,7 +486,7 @@ const CameraPlacement = () => {
|
|
|
357
486
|
allowTaint: true,
|
|
358
487
|
scale: 1.2,
|
|
359
488
|
backgroundColor: '#f8fafc',
|
|
360
|
-
logging: false
|
|
489
|
+
logging: false,
|
|
361
490
|
});
|
|
362
491
|
|
|
363
492
|
// Calculate dimensions for professional report layout
|
|
@@ -366,10 +495,17 @@ const CameraPlacement = () => {
|
|
|
366
495
|
const summaryHeight = 120; // Space for project summary
|
|
367
496
|
const cameraHeight = 180; // More space per camera card
|
|
368
497
|
const footerHeight = 300; // Space for legend and notes
|
|
369
|
-
const totalCameraListHeight =
|
|
370
|
-
|
|
498
|
+
const totalCameraListHeight =
|
|
499
|
+
headerHeight +
|
|
500
|
+
summaryHeight +
|
|
501
|
+
cameras.length * cameraHeight +
|
|
502
|
+
footerHeight;
|
|
503
|
+
|
|
371
504
|
// Use the larger of image height or camera list height
|
|
372
|
-
const finalHeight = Math.max(
|
|
505
|
+
const finalHeight = Math.max(
|
|
506
|
+
imageCanvas.height,
|
|
507
|
+
totalCameraListHeight
|
|
508
|
+
);
|
|
373
509
|
const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
|
|
374
510
|
|
|
375
511
|
// Create the composite container with calculated dimensions
|
|
@@ -382,7 +518,8 @@ const CameraPlacement = () => {
|
|
|
382
518
|
exportContainer.style.backgroundColor = '#ffffff';
|
|
383
519
|
exportContainer.style.display = 'flex';
|
|
384
520
|
exportContainer.style.gap = '10px';
|
|
385
|
-
exportContainer.style.fontFamily =
|
|
521
|
+
exportContainer.style.fontFamily =
|
|
522
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
|
|
386
523
|
document.body.appendChild(exportContainer);
|
|
387
524
|
|
|
388
525
|
// Left side: Image
|
|
@@ -402,7 +539,7 @@ const CameraPlacement = () => {
|
|
|
402
539
|
scaledCanvas.width = imageCanvas.width;
|
|
403
540
|
scaledCanvas.height = imageCanvas.height;
|
|
404
541
|
ctx.drawImage(imageCanvas, 0, 0);
|
|
405
|
-
|
|
542
|
+
|
|
406
543
|
imageDiv.appendChild(scaledCanvas);
|
|
407
544
|
exportContainer.appendChild(imageDiv);
|
|
408
545
|
|
|
@@ -426,50 +563,88 @@ const CameraPlacement = () => {
|
|
|
426
563
|
</div>
|
|
427
564
|
</div>
|
|
428
565
|
<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(
|
|
566
|
+
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString(
|
|
567
|
+
'en-US',
|
|
568
|
+
{
|
|
569
|
+
weekday: 'long',
|
|
570
|
+
year: 'numeric',
|
|
571
|
+
month: 'long',
|
|
572
|
+
day: 'numeric',
|
|
573
|
+
}
|
|
574
|
+
)}</div>
|
|
430
575
|
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
|
|
431
576
|
</div>
|
|
432
577
|
</div>
|
|
433
578
|
|
|
434
579
|
|
|
435
|
-
${cameras
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
580
|
+
${cameras
|
|
581
|
+
.map((camera, index) => {
|
|
582
|
+
const verkadaCamera = camera.verkadaModel
|
|
583
|
+
? getCameraById(camera.verkadaModel)
|
|
584
|
+
: null;
|
|
585
|
+
// Generate sequential camera number
|
|
586
|
+
const cameraNumber = index + 1;
|
|
587
|
+
|
|
588
|
+
return `
|
|
441
589
|
<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
590
|
<!-- Camera Header -->
|
|
443
|
-
<div style="background: ${
|
|
591
|
+
<div style="background: ${
|
|
592
|
+
verkadaCamera?.environment === 'indoor'
|
|
593
|
+
? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)'
|
|
594
|
+
: 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'
|
|
595
|
+
}; color: white; padding: 16px 20px; position: relative;">
|
|
444
596
|
<div style="display: flex; align-items: center; gap: 16px;">
|
|
445
597
|
<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
598
|
<div style="flex: 1;">
|
|
447
599
|
<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;">${
|
|
600
|
+
<div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${
|
|
601
|
+
verkadaCamera
|
|
602
|
+
? verkadaCamera.name
|
|
603
|
+
: 'Model Not Selected'
|
|
604
|
+
}</div>
|
|
449
605
|
</div>
|
|
450
|
-
${
|
|
606
|
+
${
|
|
607
|
+
verkadaCamera?.ptz
|
|
608
|
+
? '<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>'
|
|
609
|
+
: ''
|
|
610
|
+
}
|
|
451
611
|
</div>
|
|
452
612
|
</div>
|
|
453
613
|
|
|
454
614
|
<!-- Camera Details -->
|
|
455
615
|
<div style="padding: 20px;">
|
|
456
|
-
${
|
|
616
|
+
${
|
|
617
|
+
verkadaCamera
|
|
618
|
+
? `
|
|
457
619
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
|
|
458
620
|
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
|
|
459
621
|
<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;">${
|
|
622
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
|
|
623
|
+
verkadaCamera.resolution
|
|
624
|
+
}</div>
|
|
461
625
|
</div>
|
|
462
626
|
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
|
|
463
627
|
<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;">${
|
|
628
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
|
|
629
|
+
verkadaCamera.fov
|
|
630
|
+
}°</div>
|
|
465
631
|
</div>
|
|
466
|
-
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${
|
|
632
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${
|
|
633
|
+
verkadaCamera.environment ===
|
|
634
|
+
'indoor'
|
|
635
|
+
? '#10b981'
|
|
636
|
+
: '#f59e0b'
|
|
637
|
+
};">
|
|
467
638
|
<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;">${
|
|
639
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${
|
|
640
|
+
verkadaCamera.environment
|
|
641
|
+
}</div>
|
|
469
642
|
</div>
|
|
470
643
|
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
|
|
471
644
|
<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;">${
|
|
645
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
|
|
646
|
+
verkadaCamera.irRange
|
|
647
|
+
}m</div>
|
|
473
648
|
</div>
|
|
474
649
|
</div>
|
|
475
650
|
|
|
@@ -479,37 +654,74 @@ const CameraPlacement = () => {
|
|
|
479
654
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
480
655
|
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
481
656
|
<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;">${
|
|
657
|
+
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${
|
|
658
|
+
camera.direction
|
|
659
|
+
? `${camera.direction}° from North`
|
|
660
|
+
: 'Not set'
|
|
661
|
+
}</div>
|
|
483
662
|
</div>
|
|
484
663
|
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
485
664
|
<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;">${
|
|
665
|
+
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${
|
|
666
|
+
mode === 'map'
|
|
667
|
+
? `${camera.lat?.toFixed(
|
|
668
|
+
6
|
|
669
|
+
)}, ${camera.lng?.toFixed(
|
|
670
|
+
6
|
|
671
|
+
)}`
|
|
672
|
+
: `X: ${Math.round(
|
|
673
|
+
camera.x
|
|
674
|
+
)}, Y: ${Math.round(
|
|
675
|
+
camera.y
|
|
676
|
+
)}`
|
|
677
|
+
}</div>
|
|
487
678
|
</div>
|
|
488
679
|
</div>
|
|
489
680
|
</div>
|
|
490
681
|
|
|
491
682
|
<!-- Features -->
|
|
492
|
-
${
|
|
683
|
+
${
|
|
684
|
+
verkadaCamera.features &&
|
|
685
|
+
verkadaCamera.features.length > 0
|
|
686
|
+
? `
|
|
493
687
|
<div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
|
|
494
688
|
<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
689
|
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
|
|
496
|
-
${verkadaCamera.features
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
690
|
+
${verkadaCamera.features
|
|
691
|
+
.slice(0, 6)
|
|
692
|
+
.map(
|
|
693
|
+
(feature) =>
|
|
694
|
+
`<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>`
|
|
695
|
+
)
|
|
696
|
+
.join('')}
|
|
697
|
+
${
|
|
698
|
+
verkadaCamera.features
|
|
699
|
+
.length > 6
|
|
700
|
+
? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${
|
|
701
|
+
verkadaCamera
|
|
702
|
+
.features
|
|
703
|
+
.length - 6
|
|
704
|
+
} more</span>`
|
|
705
|
+
: ''
|
|
706
|
+
}
|
|
500
707
|
</div>
|
|
501
708
|
</div>
|
|
502
|
-
`
|
|
503
|
-
|
|
709
|
+
`
|
|
710
|
+
: ''
|
|
711
|
+
}
|
|
712
|
+
`
|
|
713
|
+
: `
|
|
504
714
|
<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
715
|
<div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
|
|
506
716
|
<div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
|
|
507
717
|
</div>
|
|
508
|
-
`
|
|
718
|
+
`
|
|
719
|
+
}
|
|
509
720
|
</div>
|
|
510
721
|
</div>
|
|
511
722
|
`;
|
|
512
|
-
|
|
723
|
+
})
|
|
724
|
+
.join('')}
|
|
513
725
|
|
|
514
726
|
<!-- Report Footer -->
|
|
515
727
|
<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);">
|
|
@@ -525,28 +737,40 @@ const CameraPlacement = () => {
|
|
|
525
737
|
<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
738
|
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
|
|
527
739
|
</div>
|
|
528
|
-
${
|
|
740
|
+
${
|
|
741
|
+
cameras.some(
|
|
742
|
+
(c) => getCameraById(c.verkadaModel)?.ptz
|
|
743
|
+
)
|
|
744
|
+
? `
|
|
529
745
|
<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
746
|
<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
747
|
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
|
|
532
|
-
</div>`
|
|
748
|
+
</div>`
|
|
749
|
+
: ''
|
|
750
|
+
}
|
|
533
751
|
</div>
|
|
534
752
|
</div>
|
|
535
753
|
|
|
536
754
|
<!-- Technical Notes -->
|
|
537
755
|
<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;"
|
|
756
|
+
<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
757
|
<div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
|
|
540
758
|
<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
759
|
<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> ${
|
|
760
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${
|
|
761
|
+
mode === 'map'
|
|
762
|
+
? 'Geographic coordinates (Latitude, Longitude) in decimal degrees'
|
|
763
|
+
: 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'
|
|
764
|
+
}</div>
|
|
543
765
|
<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
766
|
</div>
|
|
545
767
|
</div>
|
|
546
768
|
|
|
547
769
|
<!-- Report ID & Disclaimer -->
|
|
548
770
|
<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()
|
|
771
|
+
<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()
|
|
772
|
+
.toString(36)
|
|
773
|
+
.toUpperCase()}</span></div>
|
|
550
774
|
<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
775
|
</div>
|
|
552
776
|
</div>
|
|
@@ -556,7 +780,7 @@ const CameraPlacement = () => {
|
|
|
556
780
|
exportContainer.appendChild(cameraListDiv);
|
|
557
781
|
|
|
558
782
|
// Wait for rendering
|
|
559
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
783
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
560
784
|
|
|
561
785
|
// Capture the composite with exact dimensions
|
|
562
786
|
const finalCanvas = await html2canvas(exportContainer, {
|
|
@@ -566,7 +790,7 @@ const CameraPlacement = () => {
|
|
|
566
790
|
backgroundColor: '#ffffff',
|
|
567
791
|
logging: false,
|
|
568
792
|
width: finalWidth,
|
|
569
|
-
height: finalHeight
|
|
793
|
+
height: finalHeight,
|
|
570
794
|
});
|
|
571
795
|
|
|
572
796
|
// Clean up
|
|
@@ -574,12 +798,14 @@ const CameraPlacement = () => {
|
|
|
574
798
|
|
|
575
799
|
// Download
|
|
576
800
|
const link = document.createElement('a');
|
|
577
|
-
const timestamp = new Date()
|
|
801
|
+
const timestamp = new Date()
|
|
802
|
+
.toISOString()
|
|
803
|
+
.slice(0, 19)
|
|
804
|
+
.replace(/[:.]/g, '-');
|
|
578
805
|
link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
|
|
579
806
|
link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
|
|
580
807
|
link.click();
|
|
581
808
|
|
|
582
|
-
|
|
583
809
|
// Show success message
|
|
584
810
|
Swal.fire({
|
|
585
811
|
icon: 'success',
|
|
@@ -588,16 +814,15 @@ const CameraPlacement = () => {
|
|
|
588
814
|
timer: 3000,
|
|
589
815
|
showConfirmButton: false,
|
|
590
816
|
toast: true,
|
|
591
|
-
position: 'top-end'
|
|
817
|
+
position: 'top-end',
|
|
592
818
|
});
|
|
593
|
-
|
|
594
819
|
} catch (error) {
|
|
595
820
|
console.error('Error exporting:', error);
|
|
596
821
|
Swal.fire({
|
|
597
822
|
icon: 'error',
|
|
598
823
|
title: 'Export Failed',
|
|
599
824
|
text: 'Export failed. Please try again or take a manual screenshot.',
|
|
600
|
-
confirmButtonColor: '#10b981'
|
|
825
|
+
confirmButtonColor: '#10b981',
|
|
601
826
|
});
|
|
602
827
|
}
|
|
603
828
|
}, [cameras]);
|
|
@@ -615,12 +840,15 @@ const CameraPlacement = () => {
|
|
|
615
840
|
try {
|
|
616
841
|
const style = map.getStyle();
|
|
617
842
|
if (style && style.sources) {
|
|
618
|
-
existingSources = Object.keys(style.sources).filter(
|
|
619
|
-
|
|
843
|
+
existingSources = Object.keys(style.sources).filter((id) =>
|
|
844
|
+
id.startsWith('fov-')
|
|
620
845
|
);
|
|
621
846
|
}
|
|
622
847
|
} catch (error) {
|
|
623
|
-
console.warn(
|
|
848
|
+
console.warn(
|
|
849
|
+
'Could not access map style, skipping FOV layer cleanup:',
|
|
850
|
+
error
|
|
851
|
+
);
|
|
624
852
|
}
|
|
625
853
|
existingSources.forEach((sourceId) => {
|
|
626
854
|
try {
|
|
@@ -632,7 +860,10 @@ const CameraPlacement = () => {
|
|
|
632
860
|
map.removeSource(sourceId);
|
|
633
861
|
}
|
|
634
862
|
} catch (error) {
|
|
635
|
-
console.warn(
|
|
863
|
+
console.warn(
|
|
864
|
+
`Could not remove layer/source ${sourceId}:`,
|
|
865
|
+
error
|
|
866
|
+
);
|
|
636
867
|
}
|
|
637
868
|
});
|
|
638
869
|
|
|
@@ -679,6 +910,7 @@ const CameraPlacement = () => {
|
|
|
679
910
|
const handleRotationStart = (e) => {
|
|
680
911
|
e.stopPropagation();
|
|
681
912
|
isRotating = true;
|
|
913
|
+
setIsRotating(true);
|
|
682
914
|
const rect = markerContainer.getBoundingClientRect();
|
|
683
915
|
const centerX = rect.left + rect.width / 2;
|
|
684
916
|
const centerY = rect.top + rect.height / 2;
|
|
@@ -712,6 +944,7 @@ const CameraPlacement = () => {
|
|
|
712
944
|
|
|
713
945
|
const handleRotationEnd = () => {
|
|
714
946
|
isRotating = false;
|
|
947
|
+
setIsRotating(false);
|
|
715
948
|
markerContainer.classList.remove('rotating');
|
|
716
949
|
document.removeEventListener('mousemove', handleRotationMove);
|
|
717
950
|
document.removeEventListener('mouseup', handleRotationEnd);
|
|
@@ -858,167 +1091,232 @@ const CameraPlacement = () => {
|
|
|
858
1091
|
});
|
|
859
1092
|
}, [map, cameras, mode]);
|
|
860
1093
|
|
|
1094
|
+
// Icon button with tooltip component
|
|
1095
|
+
const IconButton = ({
|
|
1096
|
+
icon: Icon,
|
|
1097
|
+
onClick,
|
|
1098
|
+
disabled = false,
|
|
1099
|
+
tooltip,
|
|
1100
|
+
className = '',
|
|
1101
|
+
}) => (
|
|
1102
|
+
<button
|
|
1103
|
+
onClick={onClick}
|
|
1104
|
+
disabled={disabled}
|
|
1105
|
+
className={`${styles.iconButton} ${className}`}
|
|
1106
|
+
title={tooltip}
|
|
1107
|
+
>
|
|
1108
|
+
<Icon size={20} />
|
|
1109
|
+
</button>
|
|
1110
|
+
);
|
|
861
1111
|
|
|
862
1112
|
return (
|
|
863
1113
|
<div className={styles.cameraPlacement}>
|
|
864
1114
|
<div className={styles.header}>
|
|
865
|
-
<
|
|
866
|
-
|
|
867
|
-
<div className={styles.
|
|
868
|
-
<
|
|
1115
|
+
<div className={styles.headerContent}>
|
|
1116
|
+
<h1 className={styles.title}>Site Layout Planner</h1>
|
|
1117
|
+
<div className={styles.controls}>
|
|
1118
|
+
<IconButton
|
|
1119
|
+
icon={Upload}
|
|
869
1120
|
onClick={() => fileInputRef.current?.click()}
|
|
1121
|
+
tooltip="Upload Image"
|
|
870
1122
|
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
|
|
1123
|
+
/>
|
|
1124
|
+
<IconButton
|
|
1125
|
+
icon={Save}
|
|
885
1126
|
onClick={saveAsFile}
|
|
886
|
-
className={styles.saveFileBtn}
|
|
887
1127
|
disabled={!cameras.length && !uploadedImage}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1128
|
+
tooltip="Save camera placement as JSON file"
|
|
1129
|
+
className={styles.saveFileBtn}
|
|
1130
|
+
/>
|
|
1131
|
+
<IconButton
|
|
1132
|
+
icon={FolderOpen}
|
|
893
1133
|
onClick={loadFromFile}
|
|
1134
|
+
tooltip="Load camera placement from JSON file"
|
|
894
1135
|
className={styles.loadFileBtn}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
</button>
|
|
899
|
-
<button
|
|
1136
|
+
/>
|
|
1137
|
+
<IconButton
|
|
1138
|
+
icon={sidebarCollapsed ? Eye : EyeOff}
|
|
900
1139
|
onClick={() =>
|
|
901
1140
|
setSidebarCollapsed(!sidebarCollapsed)
|
|
902
1141
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1142
|
+
tooltip={`${
|
|
1143
|
+
sidebarCollapsed ? 'Show' : 'Hide'
|
|
1144
|
+
} Cameras`}
|
|
1145
|
+
/>
|
|
1146
|
+
<IconButton
|
|
1147
|
+
icon={Download}
|
|
908
1148
|
onClick={exportMap}
|
|
909
1149
|
disabled={mode === 'image' && !uploadedImage}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
1150
|
+
tooltip={`Export ${
|
|
1151
|
+
mode === 'map' ? 'Map' : 'Image'
|
|
1152
|
+
}`}
|
|
1153
|
+
className={styles.exportBtn}
|
|
1154
|
+
/>
|
|
913
1155
|
</div>
|
|
914
1156
|
</div>
|
|
915
1157
|
</div>
|
|
916
1158
|
|
|
917
1159
|
<div className={styles.mainContent}>
|
|
918
|
-
<div
|
|
919
|
-
className={`${styles.imageContainer} ${
|
|
920
|
-
|
|
1160
|
+
<div
|
|
1161
|
+
className={`${styles.imageContainer} ${
|
|
1162
|
+
uploadedImage ? styles.addMode : ''
|
|
1163
|
+
} ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
|
|
1164
|
+
isDragOver ? styles.dragOver : ''
|
|
1165
|
+
}`}
|
|
1166
|
+
ref={imageContainer}
|
|
921
1167
|
onClick={handleImageClick}
|
|
1168
|
+
onDragOver={handleDragOver}
|
|
1169
|
+
onDragEnter={handleDragEnter}
|
|
1170
|
+
onDragLeave={handleDragLeave}
|
|
1171
|
+
onDrop={handleDrop}
|
|
922
1172
|
>
|
|
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
|
-
|
|
1173
|
+
{uploadedImage ? (
|
|
1174
|
+
<div className={styles.imageWrapper}>
|
|
1175
|
+
<img
|
|
1176
|
+
src={uploadedImage}
|
|
1177
|
+
alt="Floor plan or site layout"
|
|
1178
|
+
className={styles.uploadedImage}
|
|
1179
|
+
/>
|
|
1180
|
+
{/* Render FOV triangles first (behind markers) */}
|
|
1181
|
+
{cameras.map((camera) => {
|
|
1182
|
+
const verkadaCamera = camera.verkadaModel
|
|
1183
|
+
? getCameraById(camera.verkadaModel)
|
|
1184
|
+
: null;
|
|
1185
|
+
if (!verkadaCamera) return null;
|
|
1186
|
+
|
|
1187
|
+
let fov = verkadaCamera.ptz
|
|
1188
|
+
? 90
|
|
1189
|
+
: verkadaCamera.fov;
|
|
1190
|
+
|
|
1191
|
+
// Handle special FOV cases
|
|
1192
|
+
if (fov >= 360)
|
|
1193
|
+
fov = 120; // PTZ cameras showing max practical FOV
|
|
1194
|
+
else if (fov >= 270)
|
|
1195
|
+
fov = 150; // Multi-sensor cameras
|
|
1196
|
+
else if (fov >= 180) fov = 130; // Fisheye cameras
|
|
1197
|
+
|
|
1198
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
1199
|
+
|
|
1200
|
+
// Calculate distance based on FOV - wider angles get shorter distances
|
|
1201
|
+
// Base distance of 100px for 60° FOV, scaled inversely with bounds
|
|
1202
|
+
const baseFov = 60; // Reference FOV angle
|
|
1203
|
+
const baseDistance = verkadaCamera.ptz
|
|
1204
|
+
? 120
|
|
1205
|
+
: 100;
|
|
1206
|
+
let distance = baseDistance * (baseFov / fov);
|
|
1207
|
+
|
|
1208
|
+
// Apply bounds: min 30px, max 200px
|
|
1209
|
+
distance = Math.max(
|
|
1210
|
+
30,
|
|
1211
|
+
Math.min(200, distance)
|
|
1212
|
+
);
|
|
1213
|
+
const directionRad =
|
|
1214
|
+
(camera.direction * Math.PI) / 180;
|
|
1215
|
+
|
|
1216
|
+
// Calculate FOV triangle points in pixels
|
|
1217
|
+
const point1X =
|
|
1218
|
+
camera.x +
|
|
1219
|
+
distance *
|
|
1220
|
+
Math.cos(directionRad - fovRad / 2);
|
|
1221
|
+
const point1Y =
|
|
1222
|
+
camera.y +
|
|
1223
|
+
distance *
|
|
1224
|
+
Math.sin(directionRad - fovRad / 2);
|
|
1225
|
+
const point2X =
|
|
1226
|
+
camera.x +
|
|
1227
|
+
distance *
|
|
1228
|
+
Math.cos(directionRad + fovRad / 2);
|
|
1229
|
+
const point2Y =
|
|
1230
|
+
camera.y +
|
|
1231
|
+
distance *
|
|
1232
|
+
Math.sin(directionRad + fovRad / 2);
|
|
1233
|
+
|
|
1234
|
+
const fovColor =
|
|
1235
|
+
verkadaCamera.environment === 'indoor'
|
|
1236
|
+
? '#10b981'
|
|
1237
|
+
: '#f59e0b';
|
|
1238
|
+
|
|
1239
|
+
// Create SVG path for the triangle
|
|
1240
|
+
const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
|
|
1241
|
+
|
|
1242
|
+
return (
|
|
1243
|
+
<svg
|
|
1244
|
+
key={`fov-${camera.id}`}
|
|
1245
|
+
className={styles.fovTriangle}
|
|
1246
|
+
style={{
|
|
1247
|
+
position: 'absolute',
|
|
1248
|
+
top: 0,
|
|
1249
|
+
left: 0,
|
|
1250
|
+
width: '100%',
|
|
1251
|
+
height: '100%',
|
|
1252
|
+
pointerEvents: 'none',
|
|
1253
|
+
zIndex: 1,
|
|
1254
|
+
}}
|
|
1255
|
+
>
|
|
1256
|
+
<path
|
|
1257
|
+
d={pathData}
|
|
1258
|
+
fill={fovColor}
|
|
1259
|
+
fillOpacity="0.25"
|
|
1260
|
+
stroke={fovColor}
|
|
1261
|
+
strokeOpacity="0.5"
|
|
1262
|
+
strokeWidth="2"
|
|
1263
|
+
/>
|
|
1264
|
+
</svg>
|
|
1265
|
+
);
|
|
1266
|
+
})}
|
|
953
1267
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1268
|
+
{/* Render camera markers on image */}
|
|
1269
|
+
{cameras.map((camera) => {
|
|
1270
|
+
const verkadaCamera = camera.verkadaModel
|
|
1271
|
+
? getCameraById(camera.verkadaModel)
|
|
1272
|
+
: null;
|
|
1273
|
+
const environment = verkadaCamera
|
|
1274
|
+
? verkadaCamera.environment
|
|
1275
|
+
: 'outdoor';
|
|
1276
|
+
const cameraNumber =
|
|
1277
|
+
(camera.customName || camera.name).replace(
|
|
1278
|
+
/\D/g,
|
|
1279
|
+
''
|
|
1280
|
+
) || camera.id;
|
|
959
1281
|
|
|
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
|
-
strokeOpacity="0.5"
|
|
985
|
-
strokeWidth="2"
|
|
986
|
-
/>
|
|
987
|
-
</svg>
|
|
988
|
-
);
|
|
989
|
-
})}
|
|
990
|
-
|
|
991
|
-
{/* Render camera markers on image */}
|
|
992
|
-
{cameras.map((camera) => {
|
|
993
|
-
const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
|
|
994
|
-
const environment = verkadaCamera ? verkadaCamera.environment : 'outdoor';
|
|
995
|
-
const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
|
|
996
|
-
|
|
997
|
-
return (
|
|
998
|
-
<div
|
|
999
|
-
key={camera.id}
|
|
1000
|
-
className={`camera-marker-container ${styles.imageMarker}`}
|
|
1001
|
-
style={{
|
|
1002
|
-
position: 'absolute',
|
|
1003
|
-
left: camera.x - 20, // Adjusted for smaller circular marker
|
|
1004
|
-
top: camera.y - 20, // Adjusted for smaller circular marker
|
|
1005
|
-
transform: 'none',
|
|
1006
|
-
zIndex: 2,
|
|
1007
|
-
}}
|
|
1008
|
-
onMouseEnter={(e) => {
|
|
1009
|
-
// Only show tooltip if not hovering over rotation handle
|
|
1010
|
-
if (verkadaCamera && !e.target.closest(`.${styles.rotationHandle}`)) {
|
|
1011
|
-
// Add delay to prevent tooltip from showing during quick rotations
|
|
1012
|
-
const targetElement = e.currentTarget;
|
|
1013
|
-
targetElement._tooltipTimeout = setTimeout(() => {
|
|
1282
|
+
return (
|
|
1283
|
+
<div
|
|
1284
|
+
key={camera.id}
|
|
1285
|
+
className={`camera-marker-container ${styles.imageMarker}`}
|
|
1286
|
+
style={{
|
|
1287
|
+
position: 'absolute',
|
|
1288
|
+
left: camera.x - 20, // Adjusted for smaller circular marker
|
|
1289
|
+
top: camera.y - 20, // Adjusted for smaller circular marker
|
|
1290
|
+
transform: 'none',
|
|
1291
|
+
zIndex: 2,
|
|
1292
|
+
}}
|
|
1293
|
+
onMouseEnter={(e) => {
|
|
1294
|
+
// Only show tooltip if not hovering over rotation handle
|
|
1295
|
+
if (
|
|
1296
|
+
verkadaCamera &&
|
|
1297
|
+
!e.target.closest(
|
|
1298
|
+
`.${styles.rotationHandle}`
|
|
1299
|
+
)
|
|
1300
|
+
) {
|
|
1301
|
+
// Add delay to prevent tooltip from showing during quick rotations
|
|
1302
|
+
const targetElement =
|
|
1303
|
+
e.currentTarget;
|
|
1304
|
+
targetElement._tooltipTimeout =
|
|
1305
|
+
setTimeout(() => {
|
|
1014
1306
|
// Check if element still exists and is in DOM
|
|
1015
|
-
if (
|
|
1307
|
+
if (
|
|
1308
|
+
!targetElement ||
|
|
1309
|
+
!targetElement.parentNode
|
|
1310
|
+
) {
|
|
1016
1311
|
return;
|
|
1017
1312
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1313
|
+
|
|
1314
|
+
const tooltip =
|
|
1315
|
+
document.createElement(
|
|
1316
|
+
'div'
|
|
1317
|
+
);
|
|
1318
|
+
tooltip.className =
|
|
1319
|
+
styles.cameraTooltip;
|
|
1022
1320
|
tooltip.innerHTML = `
|
|
1023
1321
|
<div class="${styles.tooltipImage}">
|
|
1024
1322
|
<img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
|
|
@@ -1032,515 +1330,633 @@ const CameraPlacement = () => {
|
|
|
1032
1330
|
</div>
|
|
1033
1331
|
</div>
|
|
1034
1332
|
`;
|
|
1035
|
-
|
|
1333
|
+
|
|
1036
1334
|
// Position tooltip away from rotation handle
|
|
1037
|
-
tooltip.style.left =
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1335
|
+
tooltip.style.left =
|
|
1336
|
+
'50px';
|
|
1337
|
+
tooltip.style.top =
|
|
1338
|
+
'-10px';
|
|
1339
|
+
|
|
1340
|
+
targetElement.appendChild(
|
|
1341
|
+
tooltip
|
|
1342
|
+
);
|
|
1343
|
+
targetElement._tooltip =
|
|
1344
|
+
tooltip;
|
|
1043
1345
|
}, 300); // 300ms delay
|
|
1346
|
+
}
|
|
1347
|
+
}}
|
|
1348
|
+
onMouseLeave={(e) => {
|
|
1349
|
+
// Clear timeout if leaving before tooltip shows
|
|
1350
|
+
if (
|
|
1351
|
+
e.currentTarget._tooltipTimeout
|
|
1352
|
+
) {
|
|
1353
|
+
clearTimeout(
|
|
1354
|
+
e.currentTarget
|
|
1355
|
+
._tooltipTimeout
|
|
1356
|
+
);
|
|
1357
|
+
e.currentTarget._tooltipTimeout =
|
|
1358
|
+
null;
|
|
1359
|
+
}
|
|
1360
|
+
// Remove tooltip if it exists
|
|
1361
|
+
if (e.currentTarget._tooltip) {
|
|
1362
|
+
e.currentTarget.removeChild(
|
|
1363
|
+
e.currentTarget._tooltip
|
|
1364
|
+
);
|
|
1365
|
+
e.currentTarget._tooltip = null;
|
|
1366
|
+
}
|
|
1367
|
+
}}
|
|
1368
|
+
onMouseDown={(e) => {
|
|
1369
|
+
e.stopPropagation();
|
|
1370
|
+
let isDragging = false;
|
|
1371
|
+
let startTime = Date.now();
|
|
1372
|
+
const startX = e.clientX;
|
|
1373
|
+
const startY = e.clientY;
|
|
1374
|
+
const containerRect =
|
|
1375
|
+
imageContainer.current.getBoundingClientRect();
|
|
1376
|
+
|
|
1377
|
+
const handleMouseMove = (
|
|
1378
|
+
moveEvent
|
|
1379
|
+
) => {
|
|
1380
|
+
const deltaX = Math.abs(
|
|
1381
|
+
moveEvent.clientX - startX
|
|
1382
|
+
);
|
|
1383
|
+
const deltaY = Math.abs(
|
|
1384
|
+
moveEvent.clientY - startY
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
// Start dragging if moved more than 5px
|
|
1388
|
+
if (
|
|
1389
|
+
!isDragging &&
|
|
1390
|
+
(deltaX > 5 || deltaY > 5)
|
|
1391
|
+
) {
|
|
1392
|
+
isDragging = true;
|
|
1044
1393
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1394
|
+
|
|
1395
|
+
if (isDragging) {
|
|
1396
|
+
const newX =
|
|
1397
|
+
moveEvent.clientX -
|
|
1398
|
+
containerRect.left;
|
|
1399
|
+
const newY =
|
|
1400
|
+
moveEvent.clientY -
|
|
1401
|
+
containerRect.top;
|
|
1402
|
+
|
|
1403
|
+
// Update camera position
|
|
1404
|
+
updateCamera(camera.id, {
|
|
1405
|
+
x: newX,
|
|
1406
|
+
y: newY,
|
|
1407
|
+
});
|
|
1051
1408
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
const handleMouseUp = () => {
|
|
1412
|
+
const clickDuration =
|
|
1413
|
+
Date.now() - startTime;
|
|
1414
|
+
|
|
1415
|
+
// If it was a quick click without dragging, show modal
|
|
1416
|
+
if (
|
|
1417
|
+
!isDragging &&
|
|
1418
|
+
clickDuration < 200
|
|
1419
|
+
) {
|
|
1420
|
+
setSelectedCamera(camera);
|
|
1421
|
+
setShowModal(true);
|
|
1056
1422
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1423
|
+
|
|
1424
|
+
document.removeEventListener(
|
|
1425
|
+
'mousemove',
|
|
1426
|
+
handleMouseMove
|
|
1427
|
+
);
|
|
1428
|
+
document.removeEventListener(
|
|
1429
|
+
'mouseup',
|
|
1430
|
+
handleMouseUp
|
|
1431
|
+
);
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
document.addEventListener(
|
|
1435
|
+
'mousemove',
|
|
1436
|
+
handleMouseMove
|
|
1437
|
+
);
|
|
1438
|
+
document.addEventListener(
|
|
1439
|
+
'mouseup',
|
|
1440
|
+
handleMouseUp
|
|
1441
|
+
);
|
|
1442
|
+
}}
|
|
1443
|
+
>
|
|
1444
|
+
<div
|
|
1445
|
+
className={`${
|
|
1446
|
+
styles.circularMarker
|
|
1447
|
+
} ${environment} ${
|
|
1448
|
+
verkadaCamera?.ptz
|
|
1449
|
+
? styles.ptzMarker
|
|
1450
|
+
: ''
|
|
1451
|
+
}`}
|
|
1452
|
+
>
|
|
1453
|
+
<div
|
|
1454
|
+
className={styles.markerNumber}
|
|
1455
|
+
>
|
|
1456
|
+
{cameraNumber}
|
|
1457
|
+
</div>
|
|
1458
|
+
<div
|
|
1459
|
+
className={
|
|
1460
|
+
styles.rotationHandle
|
|
1461
|
+
}
|
|
1462
|
+
style={{
|
|
1463
|
+
transform: `rotate(${
|
|
1464
|
+
camera.direction || 0
|
|
1465
|
+
}deg)`,
|
|
1466
|
+
}}
|
|
1467
|
+
onMouseEnter={(e) => {
|
|
1468
|
+
// Hide tooltip when hovering over rotation handle
|
|
1469
|
+
const container =
|
|
1470
|
+
e.currentTarget
|
|
1471
|
+
.parentElement
|
|
1472
|
+
.parentElement;
|
|
1473
|
+
if (
|
|
1474
|
+
container._tooltipTimeout
|
|
1475
|
+
) {
|
|
1476
|
+
clearTimeout(
|
|
1477
|
+
container._tooltipTimeout
|
|
1478
|
+
);
|
|
1479
|
+
container._tooltipTimeout =
|
|
1480
|
+
null;
|
|
1081
1481
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
if (!isDragging && clickDuration < 200) {
|
|
1089
|
-
setSelectedCamera(camera);
|
|
1090
|
-
setShowModal(true);
|
|
1482
|
+
if (container._tooltip) {
|
|
1483
|
+
container.removeChild(
|
|
1484
|
+
container._tooltip
|
|
1485
|
+
);
|
|
1486
|
+
container._tooltip =
|
|
1487
|
+
null;
|
|
1091
1488
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1489
|
+
}}
|
|
1490
|
+
onMouseDown={(e) => {
|
|
1491
|
+
e.stopPropagation();
|
|
1492
|
+
|
|
1493
|
+
// Store references to prevent React event pooling issues
|
|
1494
|
+
const rotationHandle =
|
|
1495
|
+
e.currentTarget;
|
|
1496
|
+
const startMouseX =
|
|
1497
|
+
e.clientX;
|
|
1498
|
+
const startMouseY =
|
|
1499
|
+
e.clientY;
|
|
1500
|
+
|
|
1501
|
+
let isRotating = false;
|
|
1502
|
+
let startAngle = 0;
|
|
1503
|
+
let currentRotation =
|
|
1504
|
+
camera.direction || 0;
|
|
1505
|
+
|
|
1506
|
+
// Get the center of the camera marker
|
|
1507
|
+
const markerContainer =
|
|
1508
|
+
rotationHandle.parentElement;
|
|
1509
|
+
const markerRect =
|
|
1510
|
+
markerContainer.getBoundingClientRect();
|
|
1511
|
+
const centerX =
|
|
1512
|
+
markerRect.left +
|
|
1513
|
+
markerRect.width / 2;
|
|
1514
|
+
const centerY =
|
|
1515
|
+
markerRect.top +
|
|
1516
|
+
markerRect.height / 2;
|
|
1517
|
+
|
|
1518
|
+
// Calculate initial angle from center to mouse position
|
|
1519
|
+
const startDeltaX =
|
|
1520
|
+
startMouseX - centerX;
|
|
1521
|
+
const startDeltaY =
|
|
1522
|
+
startMouseY - centerY;
|
|
1523
|
+
startAngle =
|
|
1524
|
+
(Math.atan2(
|
|
1525
|
+
startDeltaX,
|
|
1526
|
+
-startDeltaY
|
|
1527
|
+
) *
|
|
1528
|
+
180) /
|
|
1529
|
+
Math.PI;
|
|
1530
|
+
|
|
1531
|
+
const handleRotationMove = (
|
|
1532
|
+
moveEvent
|
|
1533
|
+
) => {
|
|
1534
|
+
const deltaX = Math.abs(
|
|
1535
|
+
moveEvent.clientX -
|
|
1536
|
+
startMouseX
|
|
1537
|
+
);
|
|
1538
|
+
const deltaY = Math.abs(
|
|
1539
|
+
moveEvent.clientY -
|
|
1540
|
+
startMouseY
|
|
1541
|
+
);
|
|
1542
|
+
|
|
1543
|
+
// Start rotating if mouse has moved more than 5 pixels
|
|
1544
|
+
if (
|
|
1545
|
+
!isRotating &&
|
|
1546
|
+
(deltaX > 5 ||
|
|
1547
|
+
deltaY > 5)
|
|
1548
|
+
) {
|
|
1549
|
+
isRotating = true;
|
|
1550
|
+
setIsRotating(true);
|
|
1551
|
+
markerContainer.classList.add(
|
|
1552
|
+
'rotating'
|
|
1553
|
+
);
|
|
1114
1554
|
}
|
|
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
|
-
|
|
1555
|
+
|
|
1556
|
+
if (isRotating) {
|
|
1557
|
+
// Calculate current angle from center to mouse position
|
|
1558
|
+
const currentDeltaX =
|
|
1559
|
+
moveEvent.clientX -
|
|
1560
|
+
centerX;
|
|
1561
|
+
const currentDeltaY =
|
|
1562
|
+
moveEvent.clientY -
|
|
1563
|
+
centerY;
|
|
1564
|
+
const currentAngle =
|
|
1565
|
+
(Math.atan2(
|
|
1566
|
+
currentDeltaX,
|
|
1567
|
+
-currentDeltaY
|
|
1568
|
+
) *
|
|
1569
|
+
180) /
|
|
1570
|
+
Math.PI;
|
|
1571
|
+
|
|
1572
|
+
// Calculate rotation delta
|
|
1573
|
+
let angleDelta =
|
|
1574
|
+
currentAngle -
|
|
1575
|
+
startAngle;
|
|
1576
|
+
|
|
1577
|
+
// Handle angle wrapping
|
|
1578
|
+
if (
|
|
1579
|
+
angleDelta > 180
|
|
1580
|
+
)
|
|
1581
|
+
angleDelta -= 360;
|
|
1582
|
+
if (
|
|
1583
|
+
angleDelta <
|
|
1584
|
+
-180
|
|
1585
|
+
)
|
|
1586
|
+
angleDelta += 360;
|
|
1587
|
+
|
|
1588
|
+
// Calculate new rotation
|
|
1589
|
+
let newRotation =
|
|
1590
|
+
(currentRotation +
|
|
1591
|
+
angleDelta +
|
|
1592
|
+
360) %
|
|
1593
|
+
360;
|
|
1594
|
+
newRotation =
|
|
1595
|
+
Math.round(
|
|
1596
|
+
newRotation
|
|
1597
|
+
);
|
|
1598
|
+
|
|
1599
|
+
// Update rotation handle visual using stored reference
|
|
1600
|
+
if (
|
|
1601
|
+
rotationHandle &&
|
|
1602
|
+
rotationHandle.style
|
|
1603
|
+
) {
|
|
1604
|
+
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
1605
|
+
} else {
|
|
1606
|
+
console.error(
|
|
1607
|
+
'Rotation handle is null or has no style property'
|
|
1608
|
+
);
|
|
1155
1609
|
}
|
|
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');
|
|
1610
|
+
|
|
1611
|
+
// Update camera direction
|
|
1612
|
+
updateCamera(
|
|
1613
|
+
camera.id,
|
|
1614
|
+
{
|
|
1615
|
+
direction:
|
|
1616
|
+
newRotation,
|
|
1180
1617
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
const handleRotationEnd = () => {
|
|
1188
|
-
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
const handleRotationEnd =
|
|
1623
|
+
() => {
|
|
1189
1624
|
if (isRotating) {
|
|
1190
|
-
markerContainer.classList.remove(
|
|
1625
|
+
markerContainer.classList.remove(
|
|
1626
|
+
'rotating'
|
|
1627
|
+
);
|
|
1191
1628
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1629
|
+
setIsRotating(
|
|
1630
|
+
false
|
|
1631
|
+
);
|
|
1632
|
+
document.removeEventListener(
|
|
1633
|
+
'mousemove',
|
|
1634
|
+
handleRotationMove
|
|
1635
|
+
);
|
|
1636
|
+
document.removeEventListener(
|
|
1637
|
+
'mouseup',
|
|
1638
|
+
handleRotationEnd
|
|
1639
|
+
);
|
|
1194
1640
|
};
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1641
|
+
|
|
1642
|
+
document.addEventListener(
|
|
1643
|
+
'mousemove',
|
|
1644
|
+
handleRotationMove
|
|
1645
|
+
);
|
|
1646
|
+
document.addEventListener(
|
|
1647
|
+
'mouseup',
|
|
1648
|
+
handleRotationEnd
|
|
1649
|
+
);
|
|
1650
|
+
}}
|
|
1651
|
+
/>
|
|
1201
1652
|
</div>
|
|
1202
|
-
|
|
1203
|
-
|
|
1653
|
+
</div>
|
|
1654
|
+
);
|
|
1655
|
+
})}
|
|
1656
|
+
</div>
|
|
1657
|
+
) : (
|
|
1658
|
+
<div className={styles.uploadPlaceholder}>
|
|
1659
|
+
<div className={styles.uploadIcon}>
|
|
1660
|
+
<FileImage size={48} />
|
|
1204
1661
|
</div>
|
|
1205
|
-
|
|
1206
|
-
<
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1662
|
+
<h3>Upload an Image</h3>
|
|
1663
|
+
<p>
|
|
1664
|
+
Drag and drop an image file here, or click the
|
|
1665
|
+
upload button to add a floor plan, site layout,
|
|
1666
|
+
or any image where you want to place cameras.
|
|
1667
|
+
</p>
|
|
1668
|
+
<button
|
|
1669
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1670
|
+
className={styles.uploadBtn}
|
|
1671
|
+
>
|
|
1672
|
+
Choose Image File
|
|
1673
|
+
</button>
|
|
1674
|
+
<div className={styles.dragDropHint}>
|
|
1675
|
+
<Lightbulb
|
|
1676
|
+
size={16}
|
|
1677
|
+
className={styles.hintIcon}
|
|
1678
|
+
/>
|
|
1679
|
+
<span>
|
|
1680
|
+
You can also drag and drop image files
|
|
1681
|
+
directly onto this area
|
|
1682
|
+
</span>
|
|
1216
1683
|
</div>
|
|
1217
|
-
|
|
1218
|
-
|
|
1684
|
+
</div>
|
|
1685
|
+
)}
|
|
1219
1686
|
</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
|
-
/>
|
|
1687
|
+
</div>
|
|
1229
1688
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1689
|
+
{/* Hidden file input */}
|
|
1690
|
+
<input
|
|
1691
|
+
type="file"
|
|
1692
|
+
ref={fileInputRef}
|
|
1693
|
+
onChange={handleImageUpload}
|
|
1694
|
+
accept="image/*"
|
|
1695
|
+
style={{ display: 'none' }}
|
|
1696
|
+
/>
|
|
1697
|
+
|
|
1698
|
+
<div
|
|
1699
|
+
className={`${styles.sidebar} ${
|
|
1700
|
+
sidebarCollapsed ? styles.collapsed : ''
|
|
1701
|
+
}`}
|
|
1702
|
+
>
|
|
1703
|
+
<div className={styles.sidebarHeader}>
|
|
1704
|
+
<h3 className={styles.sidebarTitle}>Camera List</h3>
|
|
1705
|
+
<p className={styles.cameraCount}>
|
|
1706
|
+
{cameras.length} camera
|
|
1707
|
+
{cameras.length !== 1 ? 's' : ''} placed
|
|
1708
|
+
</p>
|
|
1709
|
+
</div>
|
|
1242
1710
|
|
|
1243
|
-
|
|
1244
|
-
|
|
1711
|
+
<div className={styles.camerasList}>
|
|
1712
|
+
{cameras.length === 0 ? (
|
|
1713
|
+
<div
|
|
1714
|
+
style={{
|
|
1715
|
+
textAlign: 'center',
|
|
1716
|
+
color: '#6c757d',
|
|
1717
|
+
padding: '40px 20px',
|
|
1718
|
+
fontStyle: 'italic',
|
|
1719
|
+
}}
|
|
1720
|
+
>
|
|
1721
|
+
<div>
|
|
1722
|
+
Upload an image first, then click anywhere on it
|
|
1723
|
+
to place cameras
|
|
1724
|
+
</div>
|
|
1245
1725
|
<div
|
|
1246
1726
|
style={{
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
padding: '40px 20px',
|
|
1250
|
-
fontStyle: 'italic',
|
|
1727
|
+
fontSize: '12px',
|
|
1728
|
+
marginTop: '8px',
|
|
1251
1729
|
}}
|
|
1252
1730
|
>
|
|
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>
|
|
1731
|
+
Once placed, you can drag cameras to reposition
|
|
1732
|
+
them and use the rotation handle to adjust
|
|
1733
|
+
direction
|
|
1264
1734
|
</div>
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1735
|
+
</div>
|
|
1736
|
+
) : (
|
|
1737
|
+
cameras.map((camera) => {
|
|
1738
|
+
const verkadaCamera = camera.verkadaModel
|
|
1739
|
+
? getCameraById(camera.verkadaModel)
|
|
1740
|
+
: null;
|
|
1270
1741
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1742
|
+
return (
|
|
1743
|
+
<div
|
|
1744
|
+
key={camera.id}
|
|
1745
|
+
className={styles.cameraItem}
|
|
1746
|
+
>
|
|
1747
|
+
<div className={styles.cameraHeader}>
|
|
1748
|
+
<div
|
|
1749
|
+
className={styles.sidebarCameraInfo}
|
|
1750
|
+
>
|
|
1751
|
+
{verkadaCamera && (
|
|
1752
|
+
<div
|
|
1753
|
+
className={
|
|
1754
|
+
styles.sidebarCameraImageContainer
|
|
1755
|
+
}
|
|
1756
|
+
>
|
|
1757
|
+
<img
|
|
1758
|
+
src={
|
|
1759
|
+
verkadaCamera.image
|
|
1760
|
+
}
|
|
1761
|
+
alt={verkadaCamera.name}
|
|
1762
|
+
className={
|
|
1763
|
+
styles.sidebarCameraImage
|
|
1764
|
+
}
|
|
1765
|
+
onLoad={(e) => {
|
|
1766
|
+
e.target.style.opacity =
|
|
1767
|
+
'1';
|
|
1768
|
+
}}
|
|
1769
|
+
onError={(e) => {
|
|
1770
|
+
// Fallback to icon if image fails to load
|
|
1771
|
+
e.target.style.display =
|
|
1772
|
+
'none';
|
|
1773
|
+
e.target.nextSibling.style.display =
|
|
1774
|
+
'flex';
|
|
1775
|
+
}}
|
|
1776
|
+
style={{
|
|
1777
|
+
opacity: '0.3',
|
|
1778
|
+
}}
|
|
1779
|
+
/>
|
|
1780
|
+
<div
|
|
1781
|
+
className={
|
|
1782
|
+
styles.sidebarCameraIcon
|
|
1783
|
+
}
|
|
1784
|
+
style={{
|
|
1785
|
+
display: 'none',
|
|
1786
|
+
}}
|
|
1787
|
+
>
|
|
1788
|
+
<CameraIcon
|
|
1789
|
+
icon={
|
|
1790
|
+
verkadaCamera.icon
|
|
1791
|
+
}
|
|
1792
|
+
size={16}
|
|
1293
1793
|
/>
|
|
1294
|
-
<div className={styles.sidebarCameraIcon} style={{display: 'none'}}>
|
|
1295
|
-
<CameraIcon
|
|
1296
|
-
icon={verkadaCamera.icon}
|
|
1297
|
-
size={16}
|
|
1298
|
-
/>
|
|
1299
|
-
</div>
|
|
1300
1794
|
</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
1795
|
</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"
|
|
1796
|
+
)}
|
|
1797
|
+
<div
|
|
1798
|
+
className={
|
|
1799
|
+
styles.sidebarCameraText
|
|
1800
|
+
}
|
|
1801
|
+
>
|
|
1802
|
+
<h4
|
|
1803
|
+
className={
|
|
1804
|
+
styles.cameraName
|
|
1805
|
+
}
|
|
1332
1806
|
>
|
|
1333
|
-
|
|
1334
|
-
|
|
1807
|
+
{camera.customName ||
|
|
1808
|
+
camera.name}
|
|
1809
|
+
</h4>
|
|
1810
|
+
{verkadaCamera && (
|
|
1811
|
+
<span
|
|
1812
|
+
className={`${
|
|
1813
|
+
styles.cameraType
|
|
1814
|
+
} ${
|
|
1815
|
+
styles[
|
|
1816
|
+
verkadaCamera
|
|
1817
|
+
.environment
|
|
1818
|
+
]
|
|
1819
|
+
}`}
|
|
1820
|
+
>
|
|
1821
|
+
{verkadaCamera.id}
|
|
1822
|
+
</span>
|
|
1823
|
+
)}
|
|
1335
1824
|
</div>
|
|
1336
1825
|
</div>
|
|
1337
1826
|
|
|
1338
|
-
{
|
|
1827
|
+
{/* Compact action buttons */}
|
|
1828
|
+
<div className={styles.cameraActions}>
|
|
1829
|
+
<button
|
|
1830
|
+
className={styles.iconBtn}
|
|
1831
|
+
onClick={() => {
|
|
1832
|
+
setSelectedCamera(camera);
|
|
1833
|
+
setShowModal(true);
|
|
1834
|
+
}}
|
|
1835
|
+
title="Edit camera"
|
|
1836
|
+
>
|
|
1837
|
+
<Edit3 size={16} />
|
|
1838
|
+
</button>
|
|
1839
|
+
<button
|
|
1840
|
+
className={styles.iconBtn}
|
|
1841
|
+
onClick={() =>
|
|
1842
|
+
removeCamera(camera.id)
|
|
1843
|
+
}
|
|
1844
|
+
title="Remove camera"
|
|
1845
|
+
>
|
|
1846
|
+
<Trash2 size={16} />
|
|
1847
|
+
</button>
|
|
1848
|
+
</div>
|
|
1849
|
+
</div>
|
|
1850
|
+
|
|
1851
|
+
{verkadaCamera ? (
|
|
1852
|
+
<div className={styles.cameraDetails}>
|
|
1853
|
+
<div
|
|
1854
|
+
className={
|
|
1855
|
+
modalStyles.cameraModelName
|
|
1856
|
+
}
|
|
1857
|
+
>
|
|
1858
|
+
<strong>
|
|
1859
|
+
{verkadaCamera.name}
|
|
1860
|
+
</strong>
|
|
1861
|
+
</div>
|
|
1862
|
+
|
|
1339
1863
|
<div
|
|
1340
|
-
className={
|
|
1864
|
+
className={
|
|
1865
|
+
modalStyles.cameraSpecGrid
|
|
1866
|
+
}
|
|
1341
1867
|
>
|
|
1342
1868
|
<div
|
|
1343
1869
|
className={
|
|
1344
|
-
modalStyles.
|
|
1870
|
+
modalStyles.specItem
|
|
1345
1871
|
}
|
|
1346
1872
|
>
|
|
1347
|
-
<
|
|
1348
|
-
{
|
|
1349
|
-
|
|
1873
|
+
<span
|
|
1874
|
+
className={
|
|
1875
|
+
modalStyles.specLabel
|
|
1876
|
+
}
|
|
1877
|
+
>
|
|
1878
|
+
Resolution:
|
|
1879
|
+
</span>
|
|
1880
|
+
<span
|
|
1881
|
+
className={
|
|
1882
|
+
modalStyles.specValue
|
|
1883
|
+
}
|
|
1884
|
+
>
|
|
1885
|
+
{
|
|
1886
|
+
verkadaCamera.resolution
|
|
1887
|
+
}
|
|
1888
|
+
</span>
|
|
1350
1889
|
</div>
|
|
1351
|
-
|
|
1352
1890
|
<div
|
|
1353
1891
|
className={
|
|
1354
|
-
modalStyles.
|
|
1892
|
+
modalStyles.specItem
|
|
1355
1893
|
}
|
|
1356
1894
|
>
|
|
1357
|
-
<
|
|
1895
|
+
<span
|
|
1358
1896
|
className={
|
|
1359
|
-
modalStyles.
|
|
1897
|
+
modalStyles.specLabel
|
|
1360
1898
|
}
|
|
1361
1899
|
>
|
|
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
|
|
1900
|
+
FOV:
|
|
1901
|
+
</span>
|
|
1902
|
+
<span
|
|
1380
1903
|
className={
|
|
1381
|
-
modalStyles.
|
|
1904
|
+
modalStyles.specValue
|
|
1382
1905
|
}
|
|
1383
1906
|
>
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
modalStyles.specValue
|
|
1394
|
-
}
|
|
1395
|
-
>
|
|
1396
|
-
{verkadaCamera.fov}°
|
|
1397
|
-
</span>
|
|
1398
|
-
</div>
|
|
1399
|
-
<div
|
|
1907
|
+
{verkadaCamera.fov}°
|
|
1908
|
+
</span>
|
|
1909
|
+
</div>
|
|
1910
|
+
<div
|
|
1911
|
+
className={
|
|
1912
|
+
modalStyles.specItem
|
|
1913
|
+
}
|
|
1914
|
+
>
|
|
1915
|
+
<span
|
|
1400
1916
|
className={
|
|
1401
|
-
modalStyles.
|
|
1917
|
+
modalStyles.specLabel
|
|
1402
1918
|
}
|
|
1403
1919
|
>
|
|
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
|
-
}
|
|
1920
|
+
Environment:
|
|
1921
|
+
</span>
|
|
1922
|
+
<span
|
|
1923
|
+
className={`${
|
|
1924
|
+
modalStyles.specValue
|
|
1925
|
+
} ${
|
|
1926
|
+
modalStyles[
|
|
1927
|
+
verkadaCamera
|
|
1928
|
+
.environment
|
|
1929
|
+
]
|
|
1930
|
+
}`}
|
|
1430
1931
|
>
|
|
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
|
|
1932
|
+
{
|
|
1933
|
+
verkadaCamera.environment
|
|
1934
|
+
}
|
|
1935
|
+
</span>
|
|
1936
|
+
</div>
|
|
1937
|
+
<div
|
|
1938
|
+
className={
|
|
1939
|
+
modalStyles.specItem
|
|
1940
|
+
}
|
|
1941
|
+
>
|
|
1942
|
+
<span
|
|
1450
1943
|
className={
|
|
1451
|
-
modalStyles.
|
|
1944
|
+
modalStyles.specLabel
|
|
1452
1945
|
}
|
|
1453
1946
|
>
|
|
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
|
|
1947
|
+
IR Range:
|
|
1948
|
+
</span>
|
|
1949
|
+
<span
|
|
1470
1950
|
className={
|
|
1471
|
-
modalStyles.
|
|
1951
|
+
modalStyles.specValue
|
|
1472
1952
|
}
|
|
1473
1953
|
>
|
|
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
|
-
)}
|
|
1954
|
+
{verkadaCamera.irRange}m
|
|
1955
|
+
</span>
|
|
1539
1956
|
</div>
|
|
1540
|
-
|
|
1541
1957
|
<div
|
|
1542
1958
|
className={
|
|
1543
|
-
modalStyles.
|
|
1959
|
+
modalStyles.specItem
|
|
1544
1960
|
}
|
|
1545
1961
|
>
|
|
1546
1962
|
<span
|
|
@@ -1548,153 +1964,231 @@ const CameraPlacement = () => {
|
|
|
1548
1964
|
modalStyles.specLabel
|
|
1549
1965
|
}
|
|
1550
1966
|
>
|
|
1551
|
-
|
|
1967
|
+
Zoom:
|
|
1552
1968
|
</span>
|
|
1553
|
-
<
|
|
1969
|
+
<span
|
|
1554
1970
|
className={
|
|
1555
|
-
modalStyles.
|
|
1971
|
+
modalStyles.specValue
|
|
1556
1972
|
}
|
|
1557
1973
|
>
|
|
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>
|
|
1974
|
+
{verkadaCamera.zoom}
|
|
1975
|
+
</span>
|
|
1574
1976
|
</div>
|
|
1575
|
-
|
|
1576
1977
|
<div
|
|
1577
1978
|
className={
|
|
1578
|
-
modalStyles.
|
|
1979
|
+
modalStyles.specItem
|
|
1579
1980
|
}
|
|
1580
1981
|
>
|
|
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
|
-
}
|
|
1982
|
+
<span
|
|
1603
1983
|
className={
|
|
1604
|
-
modalStyles.
|
|
1984
|
+
modalStyles.specLabel
|
|
1605
1985
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1986
|
+
>
|
|
1987
|
+
Rating:
|
|
1988
|
+
</span>
|
|
1989
|
+
<span
|
|
1608
1990
|
className={
|
|
1609
|
-
modalStyles.
|
|
1991
|
+
modalStyles.specValue
|
|
1610
1992
|
}
|
|
1611
1993
|
>
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
West
|
|
1615
|
-
</div>
|
|
1994
|
+
{verkadaCamera.rating}
|
|
1995
|
+
</span>
|
|
1616
1996
|
</div>
|
|
1997
|
+
{verkadaCamera.ptz && (
|
|
1998
|
+
<>
|
|
1999
|
+
<div
|
|
2000
|
+
className={
|
|
2001
|
+
modalStyles.specItem
|
|
2002
|
+
}
|
|
2003
|
+
>
|
|
2004
|
+
<span
|
|
2005
|
+
className={
|
|
2006
|
+
modalStyles.specLabel
|
|
2007
|
+
}
|
|
2008
|
+
>
|
|
2009
|
+
Pan:
|
|
2010
|
+
</span>
|
|
2011
|
+
<span
|
|
2012
|
+
className={
|
|
2013
|
+
modalStyles.specValue
|
|
2014
|
+
}
|
|
2015
|
+
>
|
|
2016
|
+
{
|
|
2017
|
+
verkadaCamera.pan
|
|
2018
|
+
}
|
|
2019
|
+
</span>
|
|
2020
|
+
</div>
|
|
2021
|
+
<div
|
|
2022
|
+
className={
|
|
2023
|
+
modalStyles.specItem
|
|
2024
|
+
}
|
|
2025
|
+
>
|
|
2026
|
+
<span
|
|
2027
|
+
className={
|
|
2028
|
+
modalStyles.specLabel
|
|
2029
|
+
}
|
|
2030
|
+
>
|
|
2031
|
+
Tilt:
|
|
2032
|
+
</span>
|
|
2033
|
+
<span
|
|
2034
|
+
className={
|
|
2035
|
+
modalStyles.specValue
|
|
2036
|
+
}
|
|
2037
|
+
>
|
|
2038
|
+
{
|
|
2039
|
+
verkadaCamera.tilt
|
|
2040
|
+
}
|
|
2041
|
+
</span>
|
|
2042
|
+
</div>
|
|
2043
|
+
</>
|
|
2044
|
+
)}
|
|
1617
2045
|
</div>
|
|
1618
|
-
|
|
2046
|
+
|
|
1619
2047
|
<div
|
|
1620
|
-
className={
|
|
2048
|
+
className={
|
|
2049
|
+
modalStyles.cameraFeatures
|
|
2050
|
+
}
|
|
1621
2051
|
>
|
|
1622
|
-
<
|
|
2052
|
+
<span
|
|
1623
2053
|
className={
|
|
1624
|
-
modalStyles.
|
|
2054
|
+
modalStyles.specLabel
|
|
1625
2055
|
}
|
|
1626
2056
|
>
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
camera
|
|
1630
|
-
</div>
|
|
2057
|
+
Features:
|
|
2058
|
+
</span>
|
|
1631
2059
|
<div
|
|
1632
2060
|
className={
|
|
1633
|
-
modalStyles.
|
|
2061
|
+
modalStyles.featuresList
|
|
1634
2062
|
}
|
|
1635
2063
|
>
|
|
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
|
-
),
|
|
2064
|
+
{verkadaCamera.features.map(
|
|
2065
|
+
(feature, index) => (
|
|
2066
|
+
<span
|
|
2067
|
+
key={index}
|
|
2068
|
+
className={
|
|
2069
|
+
modalStyles.featureTag
|
|
1655
2070
|
}
|
|
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>
|
|
2071
|
+
>
|
|
2072
|
+
{feature}
|
|
2073
|
+
</span>
|
|
2074
|
+
)
|
|
2075
|
+
)}
|
|
1671
2076
|
</div>
|
|
1672
2077
|
</div>
|
|
1673
|
-
)}
|
|
1674
2078
|
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
{
|
|
1682
|
-
{camera.
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
2079
|
+
<div
|
|
2080
|
+
className={
|
|
2081
|
+
modalStyles.directionControl
|
|
2082
|
+
}
|
|
2083
|
+
>
|
|
2084
|
+
<label>
|
|
2085
|
+
Direction:{' '}
|
|
2086
|
+
{camera.direction}°
|
|
2087
|
+
</label>
|
|
2088
|
+
<input
|
|
2089
|
+
type="range"
|
|
2090
|
+
min="0"
|
|
2091
|
+
max="360"
|
|
2092
|
+
value={camera.direction}
|
|
2093
|
+
onChange={(e) =>
|
|
2094
|
+
updateCamera(
|
|
2095
|
+
camera.id,
|
|
2096
|
+
{
|
|
2097
|
+
direction:
|
|
2098
|
+
parseInt(
|
|
2099
|
+
e.target
|
|
2100
|
+
.value
|
|
2101
|
+
),
|
|
2102
|
+
}
|
|
2103
|
+
)
|
|
2104
|
+
}
|
|
2105
|
+
className={
|
|
2106
|
+
modalStyles.directionSlider
|
|
2107
|
+
}
|
|
2108
|
+
/>
|
|
2109
|
+
<div
|
|
2110
|
+
className={
|
|
2111
|
+
modalStyles.directionHelper
|
|
2112
|
+
}
|
|
2113
|
+
>
|
|
2114
|
+
0° = North, 90° = East, 180°
|
|
2115
|
+
= South, 270° = West
|
|
2116
|
+
</div>
|
|
2117
|
+
</div>
|
|
1689
2118
|
</div>
|
|
2119
|
+
) : (
|
|
2120
|
+
<div className={styles.cameraDetails}>
|
|
2121
|
+
<div
|
|
2122
|
+
className={modalStyles.noModel}
|
|
2123
|
+
>
|
|
2124
|
+
No Verkada model selected -
|
|
2125
|
+
Click "Edit" to choose a camera
|
|
2126
|
+
</div>
|
|
2127
|
+
<div
|
|
2128
|
+
className={
|
|
2129
|
+
modalStyles.directionControl
|
|
2130
|
+
}
|
|
2131
|
+
>
|
|
2132
|
+
<label>
|
|
2133
|
+
Direction:{' '}
|
|
2134
|
+
{camera.direction}°
|
|
2135
|
+
</label>
|
|
2136
|
+
<input
|
|
2137
|
+
type="range"
|
|
2138
|
+
min="0"
|
|
2139
|
+
max="360"
|
|
2140
|
+
value={camera.direction}
|
|
2141
|
+
onChange={(e) =>
|
|
2142
|
+
updateCamera(
|
|
2143
|
+
camera.id,
|
|
2144
|
+
{
|
|
2145
|
+
direction:
|
|
2146
|
+
parseInt(
|
|
2147
|
+
e.target
|
|
2148
|
+
.value
|
|
2149
|
+
),
|
|
2150
|
+
}
|
|
2151
|
+
)
|
|
2152
|
+
}
|
|
2153
|
+
className={
|
|
2154
|
+
modalStyles.directionSlider
|
|
2155
|
+
}
|
|
2156
|
+
/>
|
|
2157
|
+
<div
|
|
2158
|
+
className={
|
|
2159
|
+
modalStyles.directionHelper
|
|
2160
|
+
}
|
|
2161
|
+
>
|
|
2162
|
+
0° = North, 90° = East, 180°
|
|
2163
|
+
= South, 270° = West
|
|
2164
|
+
</div>
|
|
2165
|
+
</div>
|
|
2166
|
+
</div>
|
|
2167
|
+
)}
|
|
1690
2168
|
|
|
2169
|
+
<div className={styles.cameraCoords}>
|
|
2170
|
+
<span className={styles.coordLabel}>
|
|
2171
|
+
Location:
|
|
2172
|
+
</span>
|
|
2173
|
+
{mode === 'map' ? (
|
|
2174
|
+
<>
|
|
2175
|
+
{camera.lat?.toFixed(6)},{' '}
|
|
2176
|
+
{camera.lng?.toFixed(6)}
|
|
2177
|
+
</>
|
|
2178
|
+
) : (
|
|
2179
|
+
<>
|
|
2180
|
+
X: {Math.round(camera.x)}, Y:{' '}
|
|
2181
|
+
{Math.round(camera.y)}
|
|
2182
|
+
</>
|
|
2183
|
+
)}
|
|
1691
2184
|
</div>
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
)
|
|
1695
|
-
|
|
2185
|
+
</div>
|
|
2186
|
+
);
|
|
2187
|
+
})
|
|
2188
|
+
)}
|
|
1696
2189
|
</div>
|
|
1697
|
-
|
|
2190
|
+
</div>
|
|
2191
|
+
|
|
1698
2192
|
{showModal && selectedCamera && (
|
|
1699
2193
|
<CameraSelectionModal
|
|
1700
2194
|
camera={selectedCamera}
|
|
@@ -1714,9 +2208,11 @@ const CameraPlacement = () => {
|
|
|
1714
2208
|
const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
1715
2209
|
// Determine if this is editing an existing camera or adding a new one
|
|
1716
2210
|
const isUpdating = Boolean(camera.verkadaModel);
|
|
1717
|
-
|
|
2211
|
+
|
|
1718
2212
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
1719
|
-
const [selectedCamera, setSelectedCamera] = useState(
|
|
2213
|
+
const [selectedCamera, setSelectedCamera] = useState(
|
|
2214
|
+
camera.verkadaModel ? getCameraById(camera.verkadaModel) : null
|
|
2215
|
+
);
|
|
1720
2216
|
|
|
1721
2217
|
// Filter cameras based on selected category
|
|
1722
2218
|
const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
|
|
@@ -1724,7 +2220,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1724
2220
|
return cam.type === selectedCategory;
|
|
1725
2221
|
});
|
|
1726
2222
|
|
|
1727
|
-
|
|
1728
2223
|
const handleCameraSelect = (cameraModel) => {
|
|
1729
2224
|
setSelectedCamera(cameraModel);
|
|
1730
2225
|
};
|
|
@@ -1745,29 +2240,43 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1745
2240
|
<div className={styles.cameraSelectionModal}>
|
|
1746
2241
|
<div className={styles.modalHeader}>
|
|
1747
2242
|
<h3>Select Verkada Camera</h3>
|
|
1748
|
-
<button className={styles.closeBtn} onClick={onClose}
|
|
2243
|
+
<button className={styles.closeBtn} onClick={onClose}>
|
|
2244
|
+
×
|
|
2245
|
+
</button>
|
|
1749
2246
|
</div>
|
|
1750
2247
|
|
|
1751
2248
|
<div className={styles.modalContent}>
|
|
1752
2249
|
<div className={styles.topSection}>
|
|
1753
|
-
<div className={styles.categoryFilter}>
|
|
2250
|
+
<div className={styles.categoryFilter}>
|
|
1754
2251
|
<label>Filter by Category:</label>
|
|
1755
2252
|
<div className={styles.categoryButtons}>
|
|
1756
2253
|
<button
|
|
1757
|
-
className={
|
|
2254
|
+
className={
|
|
2255
|
+
selectedCategory === 'all'
|
|
2256
|
+
? styles.active
|
|
2257
|
+
: ''
|
|
2258
|
+
}
|
|
1758
2259
|
onClick={() => setSelectedCategory('all')}
|
|
1759
2260
|
>
|
|
1760
2261
|
All Cameras
|
|
1761
2262
|
</button>
|
|
1762
|
-
{Object.entries(CAMERA_CATEGORIES).map(
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
2263
|
+
{Object.entries(CAMERA_CATEGORIES).map(
|
|
2264
|
+
([key, category]) => (
|
|
2265
|
+
<button
|
|
2266
|
+
key={key}
|
|
2267
|
+
className={
|
|
2268
|
+
selectedCategory === key
|
|
2269
|
+
? styles.active
|
|
2270
|
+
: ''
|
|
2271
|
+
}
|
|
2272
|
+
onClick={() =>
|
|
2273
|
+
setSelectedCategory(key)
|
|
2274
|
+
}
|
|
2275
|
+
>
|
|
2276
|
+
{category.name}
|
|
2277
|
+
</button>
|
|
2278
|
+
)
|
|
2279
|
+
)}
|
|
1771
2280
|
</div>
|
|
1772
2281
|
</div>
|
|
1773
2282
|
</div>
|
|
@@ -1776,13 +2285,19 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1776
2285
|
{filteredCameras.map((cam) => (
|
|
1777
2286
|
<div
|
|
1778
2287
|
key={cam.id}
|
|
1779
|
-
className={`${styles.cameraCard} ${
|
|
2288
|
+
className={`${styles.cameraCard} ${
|
|
2289
|
+
selectedCamera?.id === cam.id
|
|
2290
|
+
? styles.selectedCard
|
|
2291
|
+
: ''
|
|
2292
|
+
}`}
|
|
1780
2293
|
onClick={() => handleCameraSelect(cam)}
|
|
1781
2294
|
>
|
|
1782
2295
|
<div className={styles.cardHeader}>
|
|
1783
|
-
<div
|
|
1784
|
-
|
|
1785
|
-
|
|
2296
|
+
<div
|
|
2297
|
+
className={styles.cameraImageContainer}
|
|
2298
|
+
>
|
|
2299
|
+
<img
|
|
2300
|
+
src={cam.image}
|
|
1786
2301
|
alt={cam.name}
|
|
1787
2302
|
className={styles.cameraImage}
|
|
1788
2303
|
onLoad={(e) => {
|
|
@@ -1791,49 +2306,83 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1791
2306
|
onError={(e) => {
|
|
1792
2307
|
// Fallback to icon if image fails to load
|
|
1793
2308
|
e.target.style.display = 'none';
|
|
1794
|
-
e.target.nextSibling.style.display =
|
|
2309
|
+
e.target.nextSibling.style.display =
|
|
2310
|
+
'flex';
|
|
1795
2311
|
}}
|
|
1796
|
-
style={{opacity: '0.3'}}
|
|
2312
|
+
style={{ opacity: '0.3' }}
|
|
1797
2313
|
/>
|
|
1798
|
-
<div
|
|
1799
|
-
|
|
2314
|
+
<div
|
|
2315
|
+
className={styles.cameraIcon}
|
|
2316
|
+
style={{ display: 'none' }}
|
|
2317
|
+
>
|
|
2318
|
+
<CameraIcon
|
|
2319
|
+
icon={cam.icon}
|
|
2320
|
+
size={24}
|
|
2321
|
+
/>
|
|
1800
2322
|
</div>
|
|
1801
2323
|
</div>
|
|
1802
2324
|
<div className={styles.cameraTitle}>
|
|
1803
2325
|
<h4>{cam.name}</h4>
|
|
1804
|
-
<span
|
|
2326
|
+
<span
|
|
2327
|
+
className={`${
|
|
2328
|
+
styles.environmentBadge
|
|
2329
|
+
} ${styles[cam.environment]}`}
|
|
2330
|
+
>
|
|
1805
2331
|
{cam.environment}
|
|
1806
2332
|
</span>
|
|
1807
2333
|
</div>
|
|
1808
2334
|
</div>
|
|
1809
|
-
|
|
2335
|
+
|
|
1810
2336
|
<div className={styles.cardBody}>
|
|
1811
2337
|
<div className={styles.specRow}>
|
|
1812
|
-
<span className={styles.specLabel}>
|
|
1813
|
-
|
|
2338
|
+
<span className={styles.specLabel}>
|
|
2339
|
+
Resolution:
|
|
2340
|
+
</span>
|
|
2341
|
+
<span className={styles.specValue}>
|
|
2342
|
+
{cam.resolution}
|
|
2343
|
+
</span>
|
|
1814
2344
|
</div>
|
|
1815
2345
|
<div className={styles.specRow}>
|
|
1816
|
-
<span className={styles.specLabel}>
|
|
1817
|
-
|
|
2346
|
+
<span className={styles.specLabel}>
|
|
2347
|
+
FOV:
|
|
2348
|
+
</span>
|
|
2349
|
+
<span className={styles.specValue}>
|
|
2350
|
+
{cam.fov}°
|
|
2351
|
+
</span>
|
|
1818
2352
|
</div>
|
|
1819
2353
|
<div className={styles.specRow}>
|
|
1820
|
-
<span className={styles.specLabel}>
|
|
1821
|
-
|
|
2354
|
+
<span className={styles.specLabel}>
|
|
2355
|
+
Range:
|
|
2356
|
+
</span>
|
|
2357
|
+
<span className={styles.specValue}>
|
|
2358
|
+
{cam.irRange}m IR
|
|
2359
|
+
</span>
|
|
1822
2360
|
</div>
|
|
1823
2361
|
{cam.ptz && (
|
|
1824
|
-
<div className={styles.ptzBadge}>
|
|
2362
|
+
<div className={styles.ptzBadge}>
|
|
2363
|
+
PTZ
|
|
2364
|
+
</div>
|
|
1825
2365
|
)}
|
|
1826
2366
|
</div>
|
|
1827
2367
|
|
|
1828
2368
|
<div className={styles.cardFooter}>
|
|
1829
2369
|
<div className={styles.features}>
|
|
1830
|
-
{cam.features
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
2370
|
+
{cam.features
|
|
2371
|
+
?.slice(0, 2)
|
|
2372
|
+
.map((feature, index) => (
|
|
2373
|
+
<span
|
|
2374
|
+
key={index}
|
|
2375
|
+
className={
|
|
2376
|
+
styles.featureBadge
|
|
2377
|
+
}
|
|
2378
|
+
>
|
|
2379
|
+
{feature}
|
|
2380
|
+
</span>
|
|
2381
|
+
))}
|
|
1835
2382
|
{cam.features?.length > 2 && (
|
|
1836
|
-
<span
|
|
2383
|
+
<span
|
|
2384
|
+
className={styles.moreFeatures}
|
|
2385
|
+
>
|
|
1837
2386
|
+{cam.features.length - 2} more
|
|
1838
2387
|
</span>
|
|
1839
2388
|
)}
|
|
@@ -1854,8 +2403,8 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1854
2403
|
<button onClick={onClose} className={styles.cancelBtn}>
|
|
1855
2404
|
Cancel
|
|
1856
2405
|
</button>
|
|
1857
|
-
<button
|
|
1858
|
-
onClick={handleSave}
|
|
2406
|
+
<button
|
|
2407
|
+
onClick={handleSave}
|
|
1859
2408
|
disabled={!selectedCamera}
|
|
1860
2409
|
className={styles.saveBtn}
|
|
1861
2410
|
>
|