@visns-studio/visns-components 5.15.5 → 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,6 +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 {
|
|
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';
|
|
6
21
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
7
22
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
8
23
|
import styles from './styles/CameraPlacement.module.scss';
|
|
@@ -23,63 +38,149 @@ const CameraPlacement = () => {
|
|
|
23
38
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
24
39
|
const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
|
|
25
40
|
const [uploadedImage, setUploadedImage] = useState(null);
|
|
26
|
-
const [addCameraMode, setAddCameraMode] = useState(false);
|
|
27
41
|
const mapContainer = useRef(null);
|
|
28
42
|
const mapRef = useRef(null);
|
|
29
43
|
const geocoderRef = useRef(null);
|
|
30
44
|
const imageContainer = useRef(null);
|
|
31
45
|
const fileInputRef = useRef(null);
|
|
46
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
47
|
+
const [isRotating, setIsRotating] = useState(false);
|
|
32
48
|
|
|
33
49
|
// Mapbox configuration
|
|
34
|
-
const MAPBOX_ACCESS_TOKEN =
|
|
50
|
+
const MAPBOX_ACCESS_TOKEN =
|
|
51
|
+
'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
|
|
35
52
|
|
|
36
53
|
// Handle image upload
|
|
37
|
-
const handleImageUpload = useCallback(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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);
|
|
52
72
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
55
113
|
};
|
|
56
|
-
|
|
114
|
+
|
|
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
|
+
}, []);
|
|
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);
|
|
57
142
|
}
|
|
58
|
-
}, [
|
|
59
|
-
|
|
60
|
-
// Handle image click for camera placement (only when in add camera mode)
|
|
61
|
-
const handleImageClick = useCallback((event) => {
|
|
62
|
-
if (mode !== 'image' || !imageContainer.current || !addCameraMode) return;
|
|
63
|
-
|
|
64
|
-
const rect = imageContainer.current.getBoundingClientRect();
|
|
65
|
-
const x = event.clientX - rect.left;
|
|
66
|
-
const y = event.clientY - rect.top;
|
|
67
|
-
|
|
68
|
-
const newCamera = {
|
|
69
|
-
id: Date.now(),
|
|
70
|
-
x: x, // Image coordinates instead of lat/lng
|
|
71
|
-
y: y,
|
|
72
|
-
name: `Camera ${cameras.length + 1}`,
|
|
73
|
-
verkadaModel: null,
|
|
74
|
-
customName: '',
|
|
75
|
-
direction: 0,
|
|
76
|
-
};
|
|
143
|
+
}, []);
|
|
77
144
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
);
|
|
83
184
|
|
|
84
185
|
// Initialize Mapbox GL map
|
|
85
186
|
useEffect(() => {
|
|
@@ -183,30 +284,52 @@ const CameraPlacement = () => {
|
|
|
183
284
|
}, [map, handleMapClick]);
|
|
184
285
|
|
|
185
286
|
const updateCamera = useCallback((cameraId, updates) => {
|
|
186
|
-
setCameras((prev) =>
|
|
187
|
-
prev.map((cam) =>
|
|
287
|
+
setCameras((prev) => {
|
|
288
|
+
const updated = prev.map((cam) =>
|
|
188
289
|
cam.id === cameraId ? { ...cam, ...updates } : cam
|
|
189
|
-
)
|
|
190
|
-
|
|
290
|
+
);
|
|
291
|
+
return updated;
|
|
292
|
+
});
|
|
191
293
|
}, []);
|
|
192
294
|
|
|
193
295
|
const removeCamera = useCallback(
|
|
194
296
|
(cameraId) => {
|
|
195
|
-
|
|
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';
|
|
196
302
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|
|
206
329
|
}
|
|
207
|
-
}
|
|
330
|
+
});
|
|
208
331
|
},
|
|
209
|
-
[map]
|
|
332
|
+
[map, cameras]
|
|
210
333
|
);
|
|
211
334
|
|
|
212
335
|
// Save placement as JSON file
|
|
@@ -216,7 +339,7 @@ const CameraPlacement = () => {
|
|
|
216
339
|
icon: 'warning',
|
|
217
340
|
title: 'No Data to Save',
|
|
218
341
|
text: 'Please add cameras or upload an image first.',
|
|
219
|
-
confirmButtonColor: '#8b5cf6'
|
|
342
|
+
confirmButtonColor: '#8b5cf6',
|
|
220
343
|
});
|
|
221
344
|
return;
|
|
222
345
|
}
|
|
@@ -224,12 +347,14 @@ const CameraPlacement = () => {
|
|
|
224
347
|
const saveData = {
|
|
225
348
|
version: '1.0',
|
|
226
349
|
timestamp: new Date().toISOString(),
|
|
227
|
-
image: uploadedImage
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
350
|
+
image: uploadedImage
|
|
351
|
+
? {
|
|
352
|
+
src: uploadedImage,
|
|
353
|
+
name: 'uploaded-image',
|
|
354
|
+
}
|
|
355
|
+
: null,
|
|
231
356
|
mode: mode,
|
|
232
|
-
cameras: cameras.map(camera => ({
|
|
357
|
+
cameras: cameras.map((camera) => ({
|
|
233
358
|
id: camera.id,
|
|
234
359
|
name: camera.name,
|
|
235
360
|
customName: camera.customName,
|
|
@@ -237,15 +362,19 @@ const CameraPlacement = () => {
|
|
|
237
362
|
y: camera.y,
|
|
238
363
|
direction: camera.direction,
|
|
239
364
|
verkadaModel: camera.verkadaModel,
|
|
240
|
-
timestamp: camera.timestamp
|
|
241
|
-
}))
|
|
365
|
+
timestamp: camera.timestamp,
|
|
366
|
+
})),
|
|
242
367
|
};
|
|
243
368
|
|
|
244
369
|
const dataStr = JSON.stringify(saveData, null, 2);
|
|
245
|
-
const dataUri =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
|
|
249
378
|
const linkElement = document.createElement('a');
|
|
250
379
|
linkElement.setAttribute('href', dataUri);
|
|
251
380
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
@@ -259,7 +388,7 @@ const CameraPlacement = () => {
|
|
|
259
388
|
timer: 3000,
|
|
260
389
|
showConfirmButton: false,
|
|
261
390
|
toast: true,
|
|
262
|
-
position: 'top-end'
|
|
391
|
+
position: 'top-end',
|
|
263
392
|
});
|
|
264
393
|
}, [cameras, uploadedImage, mode]);
|
|
265
394
|
|
|
@@ -268,60 +397,61 @@ const CameraPlacement = () => {
|
|
|
268
397
|
const input = document.createElement('input');
|
|
269
398
|
input.type = 'file';
|
|
270
399
|
input.accept = '.json';
|
|
271
|
-
|
|
400
|
+
|
|
272
401
|
input.onchange = (event) => {
|
|
273
402
|
const file = event.target.files[0];
|
|
274
403
|
if (!file) return;
|
|
275
|
-
|
|
404
|
+
|
|
276
405
|
const reader = new FileReader();
|
|
277
406
|
reader.onload = (e) => {
|
|
278
407
|
try {
|
|
279
408
|
const saveData = JSON.parse(e.target.result);
|
|
280
|
-
|
|
409
|
+
|
|
281
410
|
// Validate data structure
|
|
282
411
|
if (!saveData.version || !Array.isArray(saveData.cameras)) {
|
|
283
412
|
Swal.fire({
|
|
284
413
|
icon: 'error',
|
|
285
414
|
title: 'Invalid File',
|
|
286
415
|
text: 'Please select a valid camera placement file.',
|
|
287
|
-
confirmButtonColor: '#f59e0b'
|
|
416
|
+
confirmButtonColor: '#f59e0b',
|
|
288
417
|
});
|
|
289
418
|
return;
|
|
290
419
|
}
|
|
291
|
-
|
|
420
|
+
|
|
292
421
|
// Load image if present
|
|
293
422
|
if (saveData.image && saveData.image.src) {
|
|
294
423
|
setUploadedImage(saveData.image.src);
|
|
295
424
|
}
|
|
296
|
-
|
|
425
|
+
|
|
297
426
|
// Set mode
|
|
298
427
|
if (saveData.mode) {
|
|
299
428
|
setMode(saveData.mode);
|
|
300
429
|
}
|
|
301
|
-
|
|
430
|
+
|
|
302
431
|
// Load cameras
|
|
303
432
|
setCameras(saveData.cameras || []);
|
|
304
|
-
|
|
433
|
+
|
|
305
434
|
Swal.fire({
|
|
306
435
|
icon: 'success',
|
|
307
436
|
title: 'File Loaded!',
|
|
308
|
-
text: `Successfully loaded ${
|
|
309
|
-
|
|
437
|
+
text: `Successfully loaded ${
|
|
438
|
+
saveData.cameras?.length || 0
|
|
439
|
+
} cameras from ${file.name}`,
|
|
440
|
+
confirmButtonColor: '#f59e0b',
|
|
310
441
|
});
|
|
311
|
-
|
|
312
442
|
} catch (error) {
|
|
313
443
|
console.error('Error loading file:', error);
|
|
314
444
|
Swal.fire({
|
|
315
445
|
icon: 'error',
|
|
316
446
|
title: 'File Error',
|
|
317
|
-
text:
|
|
318
|
-
confirmButtonColor: '#f59e0b'
|
|
447
|
+
text: "Error reading file. Please ensure it's a valid JSON file.",
|
|
448
|
+
confirmButtonColor: '#f59e0b',
|
|
319
449
|
});
|
|
320
450
|
}
|
|
321
451
|
};
|
|
322
452
|
reader.readAsText(file);
|
|
323
453
|
};
|
|
324
|
-
|
|
454
|
+
|
|
325
455
|
input.click();
|
|
326
456
|
}, []);
|
|
327
457
|
|
|
@@ -332,18 +462,20 @@ const CameraPlacement = () => {
|
|
|
332
462
|
icon: 'warning',
|
|
333
463
|
title: 'No Cameras',
|
|
334
464
|
text: 'Please add cameras first before exporting.',
|
|
335
|
-
confirmButtonColor: '#10b981'
|
|
465
|
+
confirmButtonColor: '#10b981',
|
|
336
466
|
});
|
|
337
467
|
return;
|
|
338
468
|
}
|
|
339
469
|
|
|
340
|
-
const imageContainer = document.querySelector(
|
|
470
|
+
const imageContainer = document.querySelector(
|
|
471
|
+
`.${styles.imageContainer}`
|
|
472
|
+
);
|
|
341
473
|
if (!imageContainer) {
|
|
342
474
|
Swal.fire({
|
|
343
475
|
icon: 'warning',
|
|
344
476
|
title: 'No Image',
|
|
345
477
|
text: 'Please upload an image first before exporting.',
|
|
346
|
-
confirmButtonColor: '#10b981'
|
|
478
|
+
confirmButtonColor: '#10b981',
|
|
347
479
|
});
|
|
348
480
|
return;
|
|
349
481
|
}
|
|
@@ -354,17 +486,26 @@ const CameraPlacement = () => {
|
|
|
354
486
|
allowTaint: true,
|
|
355
487
|
scale: 1.2,
|
|
356
488
|
backgroundColor: '#f8fafc',
|
|
357
|
-
logging: false
|
|
489
|
+
logging: false,
|
|
358
490
|
});
|
|
359
491
|
|
|
360
|
-
// Calculate dimensions
|
|
361
|
-
const cameraListWidth =
|
|
362
|
-
const headerHeight =
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
492
|
+
// Calculate dimensions for professional report layout
|
|
493
|
+
const cameraListWidth = 550; // Fixed width for professional report
|
|
494
|
+
const headerHeight = 140; // Space for report header
|
|
495
|
+
const summaryHeight = 120; // Space for project summary
|
|
496
|
+
const cameraHeight = 180; // More space per camera card
|
|
497
|
+
const footerHeight = 300; // Space for legend and notes
|
|
498
|
+
const totalCameraListHeight =
|
|
499
|
+
headerHeight +
|
|
500
|
+
summaryHeight +
|
|
501
|
+
cameras.length * cameraHeight +
|
|
502
|
+
footerHeight;
|
|
503
|
+
|
|
366
504
|
// Use the larger of image height or camera list height
|
|
367
|
-
const finalHeight = Math.max(
|
|
505
|
+
const finalHeight = Math.max(
|
|
506
|
+
imageCanvas.height,
|
|
507
|
+
totalCameraListHeight
|
|
508
|
+
);
|
|
368
509
|
const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
|
|
369
510
|
|
|
370
511
|
// Create the composite container with calculated dimensions
|
|
@@ -377,7 +518,8 @@ const CameraPlacement = () => {
|
|
|
377
518
|
exportContainer.style.backgroundColor = '#ffffff';
|
|
378
519
|
exportContainer.style.display = 'flex';
|
|
379
520
|
exportContainer.style.gap = '10px';
|
|
380
|
-
exportContainer.style.fontFamily =
|
|
521
|
+
exportContainer.style.fontFamily =
|
|
522
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
|
|
381
523
|
document.body.appendChild(exportContainer);
|
|
382
524
|
|
|
383
525
|
// Left side: Image
|
|
@@ -397,7 +539,7 @@ const CameraPlacement = () => {
|
|
|
397
539
|
scaledCanvas.width = imageCanvas.width;
|
|
398
540
|
scaledCanvas.height = imageCanvas.height;
|
|
399
541
|
ctx.drawImage(imageCanvas, 0, 0);
|
|
400
|
-
|
|
542
|
+
|
|
401
543
|
imageDiv.appendChild(scaledCanvas);
|
|
402
544
|
exportContainer.appendChild(imageDiv);
|
|
403
545
|
|
|
@@ -410,48 +552,235 @@ const CameraPlacement = () => {
|
|
|
410
552
|
cameraListDiv.style.overflow = 'visible';
|
|
411
553
|
cameraListDiv.style.boxSizing = 'border-box';
|
|
412
554
|
|
|
413
|
-
// Generate
|
|
555
|
+
// Generate professional report-style HTML
|
|
414
556
|
const cameraListHTML = `
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
<
|
|
418
|
-
<
|
|
557
|
+
<!-- Report Header -->
|
|
558
|
+
<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);">
|
|
559
|
+
<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>
|
|
560
|
+
<div style="text-align: center; margin-bottom: 24px;">
|
|
561
|
+
<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;">
|
|
562
|
+
PROJECT LAYOUT & SPECIFICATIONS
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
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;">
|
|
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>
|
|
575
|
+
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
|
|
576
|
+
</div>
|
|
419
577
|
</div>
|
|
420
578
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
579
|
+
|
|
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 `
|
|
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;">
|
|
590
|
+
<!-- Camera Header -->
|
|
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;">
|
|
596
|
+
<div style="display: flex; align-items: center; gap: 16px;">
|
|
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>
|
|
598
|
+
<div style="flex: 1;">
|
|
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>
|
|
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>
|
|
605
|
+
</div>
|
|
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
|
+
}
|
|
432
611
|
</div>
|
|
433
612
|
</div>
|
|
434
613
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
<div style="
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
614
|
+
<!-- Camera Details -->
|
|
615
|
+
<div style="padding: 20px;">
|
|
616
|
+
${
|
|
617
|
+
verkadaCamera
|
|
618
|
+
? `
|
|
619
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
|
|
620
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
|
|
621
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
|
|
622
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
|
|
623
|
+
verkadaCamera.resolution
|
|
624
|
+
}</div>
|
|
625
|
+
</div>
|
|
626
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
|
|
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>
|
|
628
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
|
|
629
|
+
verkadaCamera.fov
|
|
630
|
+
}°</div>
|
|
631
|
+
</div>
|
|
632
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${
|
|
633
|
+
verkadaCamera.environment ===
|
|
634
|
+
'indoor'
|
|
635
|
+
? '#10b981'
|
|
636
|
+
: '#f59e0b'
|
|
637
|
+
};">
|
|
638
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Environment</div>
|
|
639
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${
|
|
640
|
+
verkadaCamera.environment
|
|
641
|
+
}</div>
|
|
642
|
+
</div>
|
|
643
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
|
|
644
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
|
|
645
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${
|
|
646
|
+
verkadaCamera.irRange
|
|
647
|
+
}m</div>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
|
|
651
|
+
<!-- Position & Direction -->
|
|
652
|
+
<div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
|
|
653
|
+
<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>
|
|
654
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
655
|
+
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
656
|
+
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</div>
|
|
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>
|
|
662
|
+
</div>
|
|
663
|
+
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
664
|
+
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</div>
|
|
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>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<!-- Features -->
|
|
683
|
+
${
|
|
684
|
+
verkadaCamera.features &&
|
|
685
|
+
verkadaCamera.features.length > 0
|
|
686
|
+
? `
|
|
687
|
+
<div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
|
|
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>
|
|
689
|
+
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
|
|
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
|
+
}
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
`
|
|
710
|
+
: ''
|
|
711
|
+
}
|
|
712
|
+
`
|
|
713
|
+
: `
|
|
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;">
|
|
715
|
+
<div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
|
|
716
|
+
<div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
|
|
717
|
+
</div>
|
|
718
|
+
`
|
|
719
|
+
}
|
|
720
|
+
</div>
|
|
445
721
|
</div>
|
|
446
722
|
`;
|
|
447
|
-
|
|
723
|
+
})
|
|
724
|
+
.join('')}
|
|
725
|
+
|
|
726
|
+
<!-- Report Footer -->
|
|
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);">
|
|
728
|
+
<!-- Legend Section -->
|
|
729
|
+
<div style="margin-bottom: 24px;">
|
|
730
|
+
<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>
|
|
731
|
+
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
|
|
732
|
+
<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);">
|
|
733
|
+
<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>
|
|
734
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
|
|
735
|
+
</div>
|
|
736
|
+
<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);">
|
|
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>
|
|
738
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
|
|
739
|
+
</div>
|
|
740
|
+
${
|
|
741
|
+
cameras.some(
|
|
742
|
+
(c) => getCameraById(c.verkadaModel)?.ptz
|
|
743
|
+
)
|
|
744
|
+
? `
|
|
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);">
|
|
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>
|
|
747
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
|
|
748
|
+
</div>`
|
|
749
|
+
: ''
|
|
750
|
+
}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<!-- Technical Notes -->
|
|
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);">
|
|
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>
|
|
757
|
+
<div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
|
|
769
|
+
<!-- Report ID & Disclaimer -->
|
|
770
|
+
<div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
|
|
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>
|
|
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>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
448
777
|
`;
|
|
449
778
|
|
|
450
779
|
cameraListDiv.innerHTML = cameraListHTML;
|
|
451
780
|
exportContainer.appendChild(cameraListDiv);
|
|
452
781
|
|
|
453
782
|
// Wait for rendering
|
|
454
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
783
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
455
784
|
|
|
456
785
|
// Capture the composite with exact dimensions
|
|
457
786
|
const finalCanvas = await html2canvas(exportContainer, {
|
|
@@ -461,7 +790,7 @@ const CameraPlacement = () => {
|
|
|
461
790
|
backgroundColor: '#ffffff',
|
|
462
791
|
logging: false,
|
|
463
792
|
width: finalWidth,
|
|
464
|
-
height: finalHeight
|
|
793
|
+
height: finalHeight,
|
|
465
794
|
});
|
|
466
795
|
|
|
467
796
|
// Clean up
|
|
@@ -469,30 +798,31 @@ const CameraPlacement = () => {
|
|
|
469
798
|
|
|
470
799
|
// Download
|
|
471
800
|
const link = document.createElement('a');
|
|
472
|
-
|
|
473
|
-
|
|
801
|
+
const timestamp = new Date()
|
|
802
|
+
.toISOString()
|
|
803
|
+
.slice(0, 19)
|
|
804
|
+
.replace(/[:.]/g, '-');
|
|
805
|
+
link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
|
|
806
|
+
link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
|
|
474
807
|
link.click();
|
|
475
808
|
|
|
476
|
-
console.log('Export completed successfully');
|
|
477
|
-
|
|
478
809
|
// Show success message
|
|
479
810
|
Swal.fire({
|
|
480
811
|
icon: 'success',
|
|
481
812
|
title: 'Export Complete!',
|
|
482
|
-
text: `
|
|
813
|
+
text: `Professional camera installation report exported as ${link.download}`,
|
|
483
814
|
timer: 3000,
|
|
484
815
|
showConfirmButton: false,
|
|
485
816
|
toast: true,
|
|
486
|
-
position: 'top-end'
|
|
817
|
+
position: 'top-end',
|
|
487
818
|
});
|
|
488
|
-
|
|
489
819
|
} catch (error) {
|
|
490
820
|
console.error('Error exporting:', error);
|
|
491
821
|
Swal.fire({
|
|
492
822
|
icon: 'error',
|
|
493
823
|
title: 'Export Failed',
|
|
494
824
|
text: 'Export failed. Please try again or take a manual screenshot.',
|
|
495
|
-
confirmButtonColor: '#10b981'
|
|
825
|
+
confirmButtonColor: '#10b981',
|
|
496
826
|
});
|
|
497
827
|
}
|
|
498
828
|
}, [cameras]);
|
|
@@ -510,12 +840,15 @@ const CameraPlacement = () => {
|
|
|
510
840
|
try {
|
|
511
841
|
const style = map.getStyle();
|
|
512
842
|
if (style && style.sources) {
|
|
513
|
-
existingSources = Object.keys(style.sources).filter(
|
|
514
|
-
|
|
843
|
+
existingSources = Object.keys(style.sources).filter((id) =>
|
|
844
|
+
id.startsWith('fov-')
|
|
515
845
|
);
|
|
516
846
|
}
|
|
517
847
|
} catch (error) {
|
|
518
|
-
console.warn(
|
|
848
|
+
console.warn(
|
|
849
|
+
'Could not access map style, skipping FOV layer cleanup:',
|
|
850
|
+
error
|
|
851
|
+
);
|
|
519
852
|
}
|
|
520
853
|
existingSources.forEach((sourceId) => {
|
|
521
854
|
try {
|
|
@@ -527,7 +860,10 @@ const CameraPlacement = () => {
|
|
|
527
860
|
map.removeSource(sourceId);
|
|
528
861
|
}
|
|
529
862
|
} catch (error) {
|
|
530
|
-
console.warn(
|
|
863
|
+
console.warn(
|
|
864
|
+
`Could not remove layer/source ${sourceId}:`,
|
|
865
|
+
error
|
|
866
|
+
);
|
|
531
867
|
}
|
|
532
868
|
});
|
|
533
869
|
|
|
@@ -574,6 +910,7 @@ const CameraPlacement = () => {
|
|
|
574
910
|
const handleRotationStart = (e) => {
|
|
575
911
|
e.stopPropagation();
|
|
576
912
|
isRotating = true;
|
|
913
|
+
setIsRotating(true);
|
|
577
914
|
const rect = markerContainer.getBoundingClientRect();
|
|
578
915
|
const centerX = rect.left + rect.width / 2;
|
|
579
916
|
const centerY = rect.top + rect.height / 2;
|
|
@@ -607,6 +944,7 @@ const CameraPlacement = () => {
|
|
|
607
944
|
|
|
608
945
|
const handleRotationEnd = () => {
|
|
609
946
|
isRotating = false;
|
|
947
|
+
setIsRotating(false);
|
|
610
948
|
markerContainer.classList.remove('rotating');
|
|
611
949
|
document.removeEventListener('mousemove', handleRotationMove);
|
|
612
950
|
document.removeEventListener('mouseup', handleRotationEnd);
|
|
@@ -753,552 +1091,872 @@ const CameraPlacement = () => {
|
|
|
753
1091
|
});
|
|
754
1092
|
}, [map, cameras, mode]);
|
|
755
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
|
+
);
|
|
756
1111
|
|
|
757
1112
|
return (
|
|
758
1113
|
<div className={styles.cameraPlacement}>
|
|
759
1114
|
<div className={styles.header}>
|
|
760
|
-
<
|
|
761
|
-
|
|
762
|
-
<div className={styles.
|
|
763
|
-
<
|
|
1115
|
+
<div className={styles.headerContent}>
|
|
1116
|
+
<h1 className={styles.title}>Site Layout Planner</h1>
|
|
1117
|
+
<div className={styles.controls}>
|
|
1118
|
+
<IconButton
|
|
1119
|
+
icon={Upload}
|
|
764
1120
|
onClick={() => fileInputRef.current?.click()}
|
|
1121
|
+
tooltip="Upload Image"
|
|
765
1122
|
className={styles.uploadBtn}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
{uploadedImage && (
|
|
770
|
-
<button
|
|
771
|
-
onClick={() => setAddCameraMode(!addCameraMode)}
|
|
772
|
-
className={`${styles.addCameraBtn} ${addCameraMode ? styles.activeControl : ''}`}
|
|
773
|
-
>
|
|
774
|
-
{addCameraMode ? 'Cancel Add' : 'Add Camera'}
|
|
775
|
-
</button>
|
|
776
|
-
)}
|
|
777
|
-
</div>
|
|
778
|
-
<div className={styles.rightControls}>
|
|
779
|
-
<button
|
|
1123
|
+
/>
|
|
1124
|
+
<IconButton
|
|
1125
|
+
icon={Save}
|
|
780
1126
|
onClick={saveAsFile}
|
|
781
|
-
className={styles.saveFileBtn}
|
|
782
1127
|
disabled={!cameras.length && !uploadedImage}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1128
|
+
tooltip="Save camera placement as JSON file"
|
|
1129
|
+
className={styles.saveFileBtn}
|
|
1130
|
+
/>
|
|
1131
|
+
<IconButton
|
|
1132
|
+
icon={FolderOpen}
|
|
788
1133
|
onClick={loadFromFile}
|
|
1134
|
+
tooltip="Load camera placement from JSON file"
|
|
789
1135
|
className={styles.loadFileBtn}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
</button>
|
|
794
|
-
<button
|
|
1136
|
+
/>
|
|
1137
|
+
<IconButton
|
|
1138
|
+
icon={sidebarCollapsed ? Eye : EyeOff}
|
|
795
1139
|
onClick={() =>
|
|
796
1140
|
setSidebarCollapsed(!sidebarCollapsed)
|
|
797
1141
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1142
|
+
tooltip={`${
|
|
1143
|
+
sidebarCollapsed ? 'Show' : 'Hide'
|
|
1144
|
+
} Cameras`}
|
|
1145
|
+
/>
|
|
1146
|
+
<IconButton
|
|
1147
|
+
icon={Download}
|
|
803
1148
|
onClick={exportMap}
|
|
804
1149
|
disabled={mode === 'image' && !uploadedImage}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1150
|
+
tooltip={`Export ${
|
|
1151
|
+
mode === 'map' ? 'Map' : 'Image'
|
|
1152
|
+
}`}
|
|
1153
|
+
className={styles.exportBtn}
|
|
1154
|
+
/>
|
|
808
1155
|
</div>
|
|
809
1156
|
</div>
|
|
810
1157
|
</div>
|
|
811
1158
|
|
|
812
1159
|
<div className={styles.mainContent}>
|
|
813
|
-
<div
|
|
814
|
-
className={`${styles.imageContainer} ${
|
|
815
|
-
|
|
1160
|
+
<div
|
|
1161
|
+
className={`${styles.imageContainer} ${
|
|
1162
|
+
uploadedImage ? styles.addMode : ''
|
|
1163
|
+
} ${sidebarCollapsed ? styles.sidebarCollapsed : ''} ${
|
|
1164
|
+
isDragOver ? styles.dragOver : ''
|
|
1165
|
+
}`}
|
|
1166
|
+
ref={imageContainer}
|
|
816
1167
|
onClick={handleImageClick}
|
|
1168
|
+
onDragOver={handleDragOver}
|
|
1169
|
+
onDragEnter={handleDragEnter}
|
|
1170
|
+
onDragLeave={handleDragLeave}
|
|
1171
|
+
onDrop={handleDrop}
|
|
817
1172
|
>
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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`;
|
|
848
1241
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
+
})}
|
|
854
1267
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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;
|
|
1281
|
+
|
|
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(() => {
|
|
1306
|
+
// Check if element still exists and is in DOM
|
|
1307
|
+
if (
|
|
1308
|
+
!targetElement ||
|
|
1309
|
+
!targetElement.parentNode
|
|
1310
|
+
) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const tooltip =
|
|
1315
|
+
document.createElement(
|
|
1316
|
+
'div'
|
|
1317
|
+
);
|
|
1318
|
+
tooltip.className =
|
|
1319
|
+
styles.cameraTooltip;
|
|
1320
|
+
tooltip.innerHTML = `
|
|
1321
|
+
<div class="${styles.tooltipImage}">
|
|
1322
|
+
<img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
|
|
1323
|
+
</div>
|
|
1324
|
+
<div class="${styles.tooltipContent}">
|
|
1325
|
+
<div class="${styles.tooltipTitle}">${verkadaCamera.name}</div>
|
|
1326
|
+
<div class="${styles.tooltipSpecs}">
|
|
1327
|
+
<div>Resolution: ${verkadaCamera.resolution}</div>
|
|
1328
|
+
<div>FOV: ${verkadaCamera.fov}°</div>
|
|
1329
|
+
<div>Environment: ${verkadaCamera.environment}</div>
|
|
1330
|
+
</div>
|
|
1331
|
+
</div>
|
|
1332
|
+
`;
|
|
1333
|
+
|
|
1334
|
+
// Position tooltip away from rotation handle
|
|
1335
|
+
tooltip.style.left =
|
|
1336
|
+
'50px';
|
|
1337
|
+
tooltip.style.top =
|
|
1338
|
+
'-10px';
|
|
1339
|
+
|
|
1340
|
+
targetElement.appendChild(
|
|
1341
|
+
tooltip
|
|
1342
|
+
);
|
|
1343
|
+
targetElement._tooltip =
|
|
1344
|
+
tooltip;
|
|
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;
|
|
1393
|
+
}
|
|
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
|
+
});
|
|
1408
|
+
}
|
|
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);
|
|
1422
|
+
}
|
|
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
|
+
>
|
|
893
1444
|
<div
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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;
|
|
926
1481
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (!isDragging && clickDuration < 200) {
|
|
934
|
-
setSelectedCamera(camera);
|
|
935
|
-
setShowModal(true);
|
|
1482
|
+
if (container._tooltip) {
|
|
1483
|
+
container.removeChild(
|
|
1484
|
+
container._tooltip
|
|
1485
|
+
);
|
|
1486
|
+
container._tooltip =
|
|
1487
|
+
null;
|
|
936
1488
|
}
|
|
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
|
-
|
|
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'
|
|
970
1553
|
);
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1554
|
+
}
|
|
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
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
978
1611
|
// Update camera direction
|
|
979
|
-
updateCamera(
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1612
|
+
updateCamera(
|
|
1613
|
+
camera.id,
|
|
1614
|
+
{
|
|
1615
|
+
direction:
|
|
1616
|
+
newRotation,
|
|
1617
|
+
}
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
const handleRotationEnd =
|
|
1623
|
+
() => {
|
|
1624
|
+
if (isRotating) {
|
|
1625
|
+
markerContainer.classList.remove(
|
|
1626
|
+
'rotating'
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
setIsRotating(
|
|
1630
|
+
false
|
|
1631
|
+
);
|
|
1632
|
+
document.removeEventListener(
|
|
1633
|
+
'mousemove',
|
|
1634
|
+
handleRotationMove
|
|
1635
|
+
);
|
|
1636
|
+
document.removeEventListener(
|
|
1637
|
+
'mouseup',
|
|
1638
|
+
handleRotationEnd
|
|
1639
|
+
);
|
|
985
1640
|
};
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1641
|
+
|
|
1642
|
+
document.addEventListener(
|
|
1643
|
+
'mousemove',
|
|
1644
|
+
handleRotationMove
|
|
1645
|
+
);
|
|
1646
|
+
document.addEventListener(
|
|
1647
|
+
'mouseup',
|
|
1648
|
+
handleRotationEnd
|
|
1649
|
+
);
|
|
1650
|
+
}}
|
|
1651
|
+
/>
|
|
992
1652
|
</div>
|
|
993
|
-
|
|
994
|
-
|
|
1653
|
+
</div>
|
|
1654
|
+
);
|
|
1655
|
+
})}
|
|
1656
|
+
</div>
|
|
1657
|
+
) : (
|
|
1658
|
+
<div className={styles.uploadPlaceholder}>
|
|
1659
|
+
<div className={styles.uploadIcon}>
|
|
1660
|
+
<FileImage size={48} />
|
|
995
1661
|
</div>
|
|
996
|
-
|
|
997
|
-
<
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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>
|
|
1007
1683
|
</div>
|
|
1008
|
-
|
|
1009
|
-
|
|
1684
|
+
</div>
|
|
1685
|
+
)}
|
|
1010
1686
|
</div>
|
|
1011
|
-
|
|
1012
|
-
{/* Hidden file input */}
|
|
1013
|
-
<input
|
|
1014
|
-
type="file"
|
|
1015
|
-
ref={fileInputRef}
|
|
1016
|
-
onChange={handleImageUpload}
|
|
1017
|
-
accept="image/*"
|
|
1018
|
-
style={{ display: 'none' }}
|
|
1019
|
-
/>
|
|
1687
|
+
</div>
|
|
1020
1688
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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>
|
|
1033
1710
|
|
|
1034
|
-
|
|
1035
|
-
|
|
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>
|
|
1036
1725
|
<div
|
|
1037
1726
|
style={{
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
padding: '40px 20px',
|
|
1041
|
-
fontStyle: 'italic',
|
|
1727
|
+
fontSize: '12px',
|
|
1728
|
+
marginTop: '8px',
|
|
1042
1729
|
}}
|
|
1043
1730
|
>
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
<div
|
|
1048
|
-
style={{
|
|
1049
|
-
fontSize: '12px',
|
|
1050
|
-
marginTop: '8px',
|
|
1051
|
-
}}
|
|
1052
|
-
>
|
|
1053
|
-
Once placed, you can drag cameras to reposition them and use the rotation handle to adjust direction
|
|
1054
|
-
</div>
|
|
1731
|
+
Once placed, you can drag cameras to reposition
|
|
1732
|
+
them and use the rotation handle to adjust
|
|
1733
|
+
direction
|
|
1055
1734
|
</div>
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1735
|
+
</div>
|
|
1736
|
+
) : (
|
|
1737
|
+
cameras.map((camera) => {
|
|
1738
|
+
const verkadaCamera = camera.verkadaModel
|
|
1739
|
+
? getCameraById(camera.verkadaModel)
|
|
1740
|
+
: null;
|
|
1061
1741
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
)}{' '}
|
|
1077
|
-
{camera.customName ||
|
|
1078
|
-
camera.name}
|
|
1079
|
-
</h4>
|
|
1080
|
-
{verkadaCamera && (
|
|
1081
|
-
<span
|
|
1082
|
-
className={`${
|
|
1083
|
-
styles.cameraType
|
|
1084
|
-
} ${
|
|
1085
|
-
styles[
|
|
1086
|
-
verkadaCamera
|
|
1087
|
-
.environment
|
|
1088
|
-
]
|
|
1089
|
-
}`}
|
|
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
|
+
}
|
|
1090
1756
|
>
|
|
1091
|
-
|
|
1092
|
-
|
|
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}
|
|
1793
|
+
/>
|
|
1794
|
+
</div>
|
|
1795
|
+
</div>
|
|
1093
1796
|
)}
|
|
1797
|
+
<div
|
|
1798
|
+
className={
|
|
1799
|
+
styles.sidebarCameraText
|
|
1800
|
+
}
|
|
1801
|
+
>
|
|
1802
|
+
<h4
|
|
1803
|
+
className={
|
|
1804
|
+
styles.cameraName
|
|
1805
|
+
}
|
|
1806
|
+
>
|
|
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
|
+
)}
|
|
1824
|
+
</div>
|
|
1825
|
+
</div>
|
|
1826
|
+
|
|
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>
|
|
1094
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>
|
|
1095
1862
|
|
|
1096
|
-
{verkadaCamera ? (
|
|
1097
1863
|
<div
|
|
1098
|
-
className={
|
|
1864
|
+
className={
|
|
1865
|
+
modalStyles.cameraSpecGrid
|
|
1866
|
+
}
|
|
1099
1867
|
>
|
|
1100
1868
|
<div
|
|
1101
1869
|
className={
|
|
1102
|
-
modalStyles.
|
|
1870
|
+
modalStyles.specItem
|
|
1103
1871
|
}
|
|
1104
1872
|
>
|
|
1105
|
-
<
|
|
1106
|
-
{
|
|
1107
|
-
|
|
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>
|
|
1108
1889
|
</div>
|
|
1109
|
-
|
|
1110
1890
|
<div
|
|
1111
1891
|
className={
|
|
1112
|
-
modalStyles.
|
|
1892
|
+
modalStyles.specItem
|
|
1113
1893
|
}
|
|
1114
1894
|
>
|
|
1115
|
-
<
|
|
1895
|
+
<span
|
|
1116
1896
|
className={
|
|
1117
|
-
modalStyles.
|
|
1897
|
+
modalStyles.specLabel
|
|
1118
1898
|
}
|
|
1119
1899
|
>
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
1124
|
-
>
|
|
1125
|
-
Resolution:
|
|
1126
|
-
</span>
|
|
1127
|
-
<span
|
|
1128
|
-
className={
|
|
1129
|
-
modalStyles.specValue
|
|
1130
|
-
}
|
|
1131
|
-
>
|
|
1132
|
-
{
|
|
1133
|
-
verkadaCamera.resolution
|
|
1134
|
-
}
|
|
1135
|
-
</span>
|
|
1136
|
-
</div>
|
|
1137
|
-
<div
|
|
1900
|
+
FOV:
|
|
1901
|
+
</span>
|
|
1902
|
+
<span
|
|
1138
1903
|
className={
|
|
1139
|
-
modalStyles.
|
|
1904
|
+
modalStyles.specValue
|
|
1140
1905
|
}
|
|
1141
1906
|
>
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
modalStyles.specValue
|
|
1152
|
-
}
|
|
1153
|
-
>
|
|
1154
|
-
{verkadaCamera.fov}°
|
|
1155
|
-
</span>
|
|
1156
|
-
</div>
|
|
1157
|
-
<div
|
|
1907
|
+
{verkadaCamera.fov}°
|
|
1908
|
+
</span>
|
|
1909
|
+
</div>
|
|
1910
|
+
<div
|
|
1911
|
+
className={
|
|
1912
|
+
modalStyles.specItem
|
|
1913
|
+
}
|
|
1914
|
+
>
|
|
1915
|
+
<span
|
|
1158
1916
|
className={
|
|
1159
|
-
modalStyles.
|
|
1917
|
+
modalStyles.specLabel
|
|
1160
1918
|
}
|
|
1161
1919
|
>
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
modalStyles[
|
|
1174
|
-
verkadaCamera
|
|
1175
|
-
.environment
|
|
1176
|
-
]
|
|
1177
|
-
}`}
|
|
1178
|
-
>
|
|
1179
|
-
{
|
|
1180
|
-
verkadaCamera.environment
|
|
1181
|
-
}
|
|
1182
|
-
</span>
|
|
1183
|
-
</div>
|
|
1184
|
-
<div
|
|
1185
|
-
className={
|
|
1186
|
-
modalStyles.specItem
|
|
1187
|
-
}
|
|
1920
|
+
Environment:
|
|
1921
|
+
</span>
|
|
1922
|
+
<span
|
|
1923
|
+
className={`${
|
|
1924
|
+
modalStyles.specValue
|
|
1925
|
+
} ${
|
|
1926
|
+
modalStyles[
|
|
1927
|
+
verkadaCamera
|
|
1928
|
+
.environment
|
|
1929
|
+
]
|
|
1930
|
+
}`}
|
|
1188
1931
|
>
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
>
|
|
1201
|
-
{
|
|
1202
|
-
verkadaCamera.irRange
|
|
1203
|
-
}
|
|
1204
|
-
m
|
|
1205
|
-
</span>
|
|
1206
|
-
</div>
|
|
1207
|
-
<div
|
|
1932
|
+
{
|
|
1933
|
+
verkadaCamera.environment
|
|
1934
|
+
}
|
|
1935
|
+
</span>
|
|
1936
|
+
</div>
|
|
1937
|
+
<div
|
|
1938
|
+
className={
|
|
1939
|
+
modalStyles.specItem
|
|
1940
|
+
}
|
|
1941
|
+
>
|
|
1942
|
+
<span
|
|
1208
1943
|
className={
|
|
1209
|
-
modalStyles.
|
|
1944
|
+
modalStyles.specLabel
|
|
1210
1945
|
}
|
|
1211
1946
|
>
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
}
|
|
1216
|
-
>
|
|
1217
|
-
Zoom:
|
|
1218
|
-
</span>
|
|
1219
|
-
<span
|
|
1220
|
-
className={
|
|
1221
|
-
modalStyles.specValue
|
|
1222
|
-
}
|
|
1223
|
-
>
|
|
1224
|
-
{verkadaCamera.zoom}
|
|
1225
|
-
</span>
|
|
1226
|
-
</div>
|
|
1227
|
-
<div
|
|
1947
|
+
IR Range:
|
|
1948
|
+
</span>
|
|
1949
|
+
<span
|
|
1228
1950
|
className={
|
|
1229
|
-
modalStyles.
|
|
1951
|
+
modalStyles.specValue
|
|
1230
1952
|
}
|
|
1231
1953
|
>
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
modalStyles.specLabel
|
|
1235
|
-
}
|
|
1236
|
-
>
|
|
1237
|
-
Rating:
|
|
1238
|
-
</span>
|
|
1239
|
-
<span
|
|
1240
|
-
className={
|
|
1241
|
-
modalStyles.specValue
|
|
1242
|
-
}
|
|
1243
|
-
>
|
|
1244
|
-
{
|
|
1245
|
-
verkadaCamera.rating
|
|
1246
|
-
}
|
|
1247
|
-
</span>
|
|
1248
|
-
</div>
|
|
1249
|
-
{verkadaCamera.ptz && (
|
|
1250
|
-
<>
|
|
1251
|
-
<div
|
|
1252
|
-
className={
|
|
1253
|
-
modalStyles.specItem
|
|
1254
|
-
}
|
|
1255
|
-
>
|
|
1256
|
-
<span
|
|
1257
|
-
className={
|
|
1258
|
-
modalStyles.specLabel
|
|
1259
|
-
}
|
|
1260
|
-
>
|
|
1261
|
-
Pan:
|
|
1262
|
-
</span>
|
|
1263
|
-
<span
|
|
1264
|
-
className={
|
|
1265
|
-
modalStyles.specValue
|
|
1266
|
-
}
|
|
1267
|
-
>
|
|
1268
|
-
{
|
|
1269
|
-
verkadaCamera.pan
|
|
1270
|
-
}
|
|
1271
|
-
</span>
|
|
1272
|
-
</div>
|
|
1273
|
-
<div
|
|
1274
|
-
className={
|
|
1275
|
-
modalStyles.specItem
|
|
1276
|
-
}
|
|
1277
|
-
>
|
|
1278
|
-
<span
|
|
1279
|
-
className={
|
|
1280
|
-
modalStyles.specLabel
|
|
1281
|
-
}
|
|
1282
|
-
>
|
|
1283
|
-
Tilt:
|
|
1284
|
-
</span>
|
|
1285
|
-
<span
|
|
1286
|
-
className={
|
|
1287
|
-
modalStyles.specValue
|
|
1288
|
-
}
|
|
1289
|
-
>
|
|
1290
|
-
{
|
|
1291
|
-
verkadaCamera.tilt
|
|
1292
|
-
}
|
|
1293
|
-
</span>
|
|
1294
|
-
</div>
|
|
1295
|
-
</>
|
|
1296
|
-
)}
|
|
1954
|
+
{verkadaCamera.irRange}m
|
|
1955
|
+
</span>
|
|
1297
1956
|
</div>
|
|
1298
|
-
|
|
1299
1957
|
<div
|
|
1300
1958
|
className={
|
|
1301
|
-
modalStyles.
|
|
1959
|
+
modalStyles.specItem
|
|
1302
1960
|
}
|
|
1303
1961
|
>
|
|
1304
1962
|
<span
|
|
@@ -1306,172 +1964,231 @@ const CameraPlacement = () => {
|
|
|
1306
1964
|
modalStyles.specLabel
|
|
1307
1965
|
}
|
|
1308
1966
|
>
|
|
1309
|
-
|
|
1967
|
+
Zoom:
|
|
1310
1968
|
</span>
|
|
1311
|
-
<
|
|
1969
|
+
<span
|
|
1312
1970
|
className={
|
|
1313
|
-
modalStyles.
|
|
1971
|
+
modalStyles.specValue
|
|
1314
1972
|
}
|
|
1315
1973
|
>
|
|
1316
|
-
{verkadaCamera.
|
|
1317
|
-
|
|
1318
|
-
feature,
|
|
1319
|
-
index
|
|
1320
|
-
) => (
|
|
1321
|
-
<span
|
|
1322
|
-
key={index}
|
|
1323
|
-
className={
|
|
1324
|
-
modalStyles.featureTag
|
|
1325
|
-
}
|
|
1326
|
-
>
|
|
1327
|
-
{feature}
|
|
1328
|
-
</span>
|
|
1329
|
-
)
|
|
1330
|
-
)}
|
|
1331
|
-
</div>
|
|
1974
|
+
{verkadaCamera.zoom}
|
|
1975
|
+
</span>
|
|
1332
1976
|
</div>
|
|
1333
|
-
|
|
1334
1977
|
<div
|
|
1335
1978
|
className={
|
|
1336
|
-
modalStyles.
|
|
1979
|
+
modalStyles.specItem
|
|
1337
1980
|
}
|
|
1338
1981
|
>
|
|
1339
|
-
<
|
|
1340
|
-
Direction:{' '}
|
|
1341
|
-
{camera.direction}°
|
|
1342
|
-
</label>
|
|
1343
|
-
<input
|
|
1344
|
-
type="range"
|
|
1345
|
-
min="0"
|
|
1346
|
-
max="360"
|
|
1347
|
-
value={camera.direction}
|
|
1348
|
-
onChange={(e) =>
|
|
1349
|
-
updateCamera(
|
|
1350
|
-
camera.id,
|
|
1351
|
-
{
|
|
1352
|
-
direction:
|
|
1353
|
-
parseInt(
|
|
1354
|
-
e
|
|
1355
|
-
.target
|
|
1356
|
-
.value
|
|
1357
|
-
),
|
|
1358
|
-
}
|
|
1359
|
-
)
|
|
1360
|
-
}
|
|
1982
|
+
<span
|
|
1361
1983
|
className={
|
|
1362
|
-
modalStyles.
|
|
1984
|
+
modalStyles.specLabel
|
|
1363
1985
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1986
|
+
>
|
|
1987
|
+
Rating:
|
|
1988
|
+
</span>
|
|
1989
|
+
<span
|
|
1366
1990
|
className={
|
|
1367
|
-
modalStyles.
|
|
1991
|
+
modalStyles.specValue
|
|
1368
1992
|
}
|
|
1369
1993
|
>
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
West
|
|
1373
|
-
</div>
|
|
1994
|
+
{verkadaCamera.rating}
|
|
1995
|
+
</span>
|
|
1374
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
|
+
)}
|
|
1375
2045
|
</div>
|
|
1376
|
-
|
|
2046
|
+
|
|
1377
2047
|
<div
|
|
1378
|
-
className={
|
|
2048
|
+
className={
|
|
2049
|
+
modalStyles.cameraFeatures
|
|
2050
|
+
}
|
|
1379
2051
|
>
|
|
1380
|
-
<
|
|
2052
|
+
<span
|
|
1381
2053
|
className={
|
|
1382
|
-
modalStyles.
|
|
2054
|
+
modalStyles.specLabel
|
|
1383
2055
|
}
|
|
1384
2056
|
>
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
camera
|
|
1388
|
-
</div>
|
|
2057
|
+
Features:
|
|
2058
|
+
</span>
|
|
1389
2059
|
<div
|
|
1390
2060
|
className={
|
|
1391
|
-
modalStyles.
|
|
2061
|
+
modalStyles.featuresList
|
|
1392
2062
|
}
|
|
1393
2063
|
>
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
min="0"
|
|
1401
|
-
max="360"
|
|
1402
|
-
value={camera.direction}
|
|
1403
|
-
onChange={(e) =>
|
|
1404
|
-
updateCamera(
|
|
1405
|
-
camera.id,
|
|
1406
|
-
{
|
|
1407
|
-
direction:
|
|
1408
|
-
parseInt(
|
|
1409
|
-
e
|
|
1410
|
-
.target
|
|
1411
|
-
.value
|
|
1412
|
-
),
|
|
2064
|
+
{verkadaCamera.features.map(
|
|
2065
|
+
(feature, index) => (
|
|
2066
|
+
<span
|
|
2067
|
+
key={index}
|
|
2068
|
+
className={
|
|
2069
|
+
modalStyles.featureTag
|
|
1413
2070
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
/>
|
|
1420
|
-
<div
|
|
1421
|
-
className={
|
|
1422
|
-
modalStyles.directionHelper
|
|
1423
|
-
}
|
|
1424
|
-
>
|
|
1425
|
-
0° = North, 90° = East,
|
|
1426
|
-
180° = South, 270° =
|
|
1427
|
-
West
|
|
1428
|
-
</div>
|
|
2071
|
+
>
|
|
2072
|
+
{feature}
|
|
2073
|
+
</span>
|
|
2074
|
+
)
|
|
2075
|
+
)}
|
|
1429
2076
|
</div>
|
|
1430
2077
|
</div>
|
|
1431
|
-
)}
|
|
1432
2078
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
{
|
|
1440
|
-
{camera.
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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>
|
|
1447
2118
|
</div>
|
|
1448
|
-
|
|
1449
|
-
<div className={styles.
|
|
1450
|
-
<
|
|
1451
|
-
className={
|
|
1452
|
-
onClick={() => {
|
|
1453
|
-
setSelectedCamera(camera);
|
|
1454
|
-
setShowModal(true);
|
|
1455
|
-
}}
|
|
2119
|
+
) : (
|
|
2120
|
+
<div className={styles.cameraDetails}>
|
|
2121
|
+
<div
|
|
2122
|
+
className={modalStyles.noModel}
|
|
1456
2123
|
>
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
2124
|
+
No Verkada model selected -
|
|
2125
|
+
Click "Edit" to choose a camera
|
|
2126
|
+
</div>
|
|
2127
|
+
<div
|
|
2128
|
+
className={
|
|
2129
|
+
modalStyles.directionControl
|
|
1463
2130
|
}
|
|
1464
2131
|
>
|
|
1465
|
-
|
|
1466
|
-
|
|
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>
|
|
1467
2166
|
</div>
|
|
2167
|
+
)}
|
|
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
|
+
)}
|
|
1468
2184
|
</div>
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
)
|
|
1472
|
-
|
|
2185
|
+
</div>
|
|
2186
|
+
);
|
|
2187
|
+
})
|
|
2188
|
+
)}
|
|
1473
2189
|
</div>
|
|
1474
|
-
|
|
2190
|
+
</div>
|
|
2191
|
+
|
|
1475
2192
|
{showModal && selectedCamera && (
|
|
1476
2193
|
<CameraSelectionModal
|
|
1477
2194
|
camera={selectedCamera}
|
|
@@ -1479,7 +2196,9 @@ const CameraPlacement = () => {
|
|
|
1479
2196
|
updateCamera(selectedCamera.id, updates);
|
|
1480
2197
|
setShowModal(false);
|
|
1481
2198
|
}}
|
|
1482
|
-
onClose={() =>
|
|
2199
|
+
onClose={() => {
|
|
2200
|
+
setShowModal(false);
|
|
2201
|
+
}}
|
|
1483
2202
|
/>
|
|
1484
2203
|
)}
|
|
1485
2204
|
</div>
|
|
@@ -1487,9 +2206,13 @@ const CameraPlacement = () => {
|
|
|
1487
2206
|
};
|
|
1488
2207
|
|
|
1489
2208
|
const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
1490
|
-
|
|
2209
|
+
// Determine if this is editing an existing camera or adding a new one
|
|
2210
|
+
const isUpdating = Boolean(camera.verkadaModel);
|
|
2211
|
+
|
|
1491
2212
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
1492
|
-
const [selectedCamera, setSelectedCamera] = useState(
|
|
2213
|
+
const [selectedCamera, setSelectedCamera] = useState(
|
|
2214
|
+
camera.verkadaModel ? getCameraById(camera.verkadaModel) : null
|
|
2215
|
+
);
|
|
1493
2216
|
|
|
1494
2217
|
// Filter cameras based on selected category
|
|
1495
2218
|
const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
|
|
@@ -1508,7 +2231,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1508
2231
|
}
|
|
1509
2232
|
|
|
1510
2233
|
onSave({
|
|
1511
|
-
customName: customName.trim(),
|
|
1512
2234
|
verkadaModel: selectedCamera.id,
|
|
1513
2235
|
});
|
|
1514
2236
|
};
|
|
@@ -1518,39 +2240,43 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1518
2240
|
<div className={styles.cameraSelectionModal}>
|
|
1519
2241
|
<div className={styles.modalHeader}>
|
|
1520
2242
|
<h3>Select Verkada Camera</h3>
|
|
1521
|
-
<button className={styles.closeBtn} onClick={onClose}
|
|
2243
|
+
<button className={styles.closeBtn} onClick={onClose}>
|
|
2244
|
+
×
|
|
2245
|
+
</button>
|
|
1522
2246
|
</div>
|
|
1523
2247
|
|
|
1524
2248
|
<div className={styles.modalContent}>
|
|
1525
2249
|
<div className={styles.topSection}>
|
|
1526
|
-
<div className={styles.formGroup}>
|
|
1527
|
-
<label>Custom Name:</label>
|
|
1528
|
-
<input
|
|
1529
|
-
type="text"
|
|
1530
|
-
value={customName}
|
|
1531
|
-
onChange={(e) => setCustomName(e.target.value)}
|
|
1532
|
-
placeholder="Enter camera name (optional)"
|
|
1533
|
-
/>
|
|
1534
|
-
</div>
|
|
1535
|
-
|
|
1536
2250
|
<div className={styles.categoryFilter}>
|
|
1537
2251
|
<label>Filter by Category:</label>
|
|
1538
2252
|
<div className={styles.categoryButtons}>
|
|
1539
2253
|
<button
|
|
1540
|
-
className={
|
|
2254
|
+
className={
|
|
2255
|
+
selectedCategory === 'all'
|
|
2256
|
+
? styles.active
|
|
2257
|
+
: ''
|
|
2258
|
+
}
|
|
1541
2259
|
onClick={() => setSelectedCategory('all')}
|
|
1542
2260
|
>
|
|
1543
2261
|
All Cameras
|
|
1544
2262
|
</button>
|
|
1545
|
-
{Object.entries(CAMERA_CATEGORIES).map(
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
+
)}
|
|
1554
2280
|
</div>
|
|
1555
2281
|
</div>
|
|
1556
2282
|
</div>
|
|
@@ -1559,48 +2285,104 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1559
2285
|
{filteredCameras.map((cam) => (
|
|
1560
2286
|
<div
|
|
1561
2287
|
key={cam.id}
|
|
1562
|
-
className={`${styles.cameraCard} ${
|
|
2288
|
+
className={`${styles.cameraCard} ${
|
|
2289
|
+
selectedCamera?.id === cam.id
|
|
2290
|
+
? styles.selectedCard
|
|
2291
|
+
: ''
|
|
2292
|
+
}`}
|
|
1563
2293
|
onClick={() => handleCameraSelect(cam)}
|
|
1564
2294
|
>
|
|
1565
2295
|
<div className={styles.cardHeader}>
|
|
1566
|
-
<div
|
|
1567
|
-
|
|
2296
|
+
<div
|
|
2297
|
+
className={styles.cameraImageContainer}
|
|
2298
|
+
>
|
|
2299
|
+
<img
|
|
2300
|
+
src={cam.image}
|
|
2301
|
+
alt={cam.name}
|
|
2302
|
+
className={styles.cameraImage}
|
|
2303
|
+
onLoad={(e) => {
|
|
2304
|
+
e.target.style.opacity = '1';
|
|
2305
|
+
}}
|
|
2306
|
+
onError={(e) => {
|
|
2307
|
+
// Fallback to icon if image fails to load
|
|
2308
|
+
e.target.style.display = 'none';
|
|
2309
|
+
e.target.nextSibling.style.display =
|
|
2310
|
+
'flex';
|
|
2311
|
+
}}
|
|
2312
|
+
style={{ opacity: '0.3' }}
|
|
2313
|
+
/>
|
|
2314
|
+
<div
|
|
2315
|
+
className={styles.cameraIcon}
|
|
2316
|
+
style={{ display: 'none' }}
|
|
2317
|
+
>
|
|
2318
|
+
<CameraIcon
|
|
2319
|
+
icon={cam.icon}
|
|
2320
|
+
size={24}
|
|
2321
|
+
/>
|
|
2322
|
+
</div>
|
|
1568
2323
|
</div>
|
|
1569
2324
|
<div className={styles.cameraTitle}>
|
|
1570
2325
|
<h4>{cam.name}</h4>
|
|
1571
|
-
<span
|
|
2326
|
+
<span
|
|
2327
|
+
className={`${
|
|
2328
|
+
styles.environmentBadge
|
|
2329
|
+
} ${styles[cam.environment]}`}
|
|
2330
|
+
>
|
|
1572
2331
|
{cam.environment}
|
|
1573
2332
|
</span>
|
|
1574
2333
|
</div>
|
|
1575
2334
|
</div>
|
|
1576
|
-
|
|
2335
|
+
|
|
1577
2336
|
<div className={styles.cardBody}>
|
|
1578
2337
|
<div className={styles.specRow}>
|
|
1579
|
-
<span className={styles.specLabel}>
|
|
1580
|
-
|
|
2338
|
+
<span className={styles.specLabel}>
|
|
2339
|
+
Resolution:
|
|
2340
|
+
</span>
|
|
2341
|
+
<span className={styles.specValue}>
|
|
2342
|
+
{cam.resolution}
|
|
2343
|
+
</span>
|
|
1581
2344
|
</div>
|
|
1582
2345
|
<div className={styles.specRow}>
|
|
1583
|
-
<span className={styles.specLabel}>
|
|
1584
|
-
|
|
2346
|
+
<span className={styles.specLabel}>
|
|
2347
|
+
FOV:
|
|
2348
|
+
</span>
|
|
2349
|
+
<span className={styles.specValue}>
|
|
2350
|
+
{cam.fov}°
|
|
2351
|
+
</span>
|
|
1585
2352
|
</div>
|
|
1586
2353
|
<div className={styles.specRow}>
|
|
1587
|
-
<span className={styles.specLabel}>
|
|
1588
|
-
|
|
2354
|
+
<span className={styles.specLabel}>
|
|
2355
|
+
Range:
|
|
2356
|
+
</span>
|
|
2357
|
+
<span className={styles.specValue}>
|
|
2358
|
+
{cam.irRange}m IR
|
|
2359
|
+
</span>
|
|
1589
2360
|
</div>
|
|
1590
2361
|
{cam.ptz && (
|
|
1591
|
-
<div className={styles.ptzBadge}>
|
|
2362
|
+
<div className={styles.ptzBadge}>
|
|
2363
|
+
PTZ
|
|
2364
|
+
</div>
|
|
1592
2365
|
)}
|
|
1593
2366
|
</div>
|
|
1594
2367
|
|
|
1595
2368
|
<div className={styles.cardFooter}>
|
|
1596
2369
|
<div className={styles.features}>
|
|
1597
|
-
{cam.features
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
+
))}
|
|
1602
2382
|
{cam.features?.length > 2 && (
|
|
1603
|
-
<span
|
|
2383
|
+
<span
|
|
2384
|
+
className={styles.moreFeatures}
|
|
2385
|
+
>
|
|
1604
2386
|
+{cam.features.length - 2} more
|
|
1605
2387
|
</span>
|
|
1606
2388
|
)}
|
|
@@ -1621,12 +2403,12 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1621
2403
|
<button onClick={onClose} className={styles.cancelBtn}>
|
|
1622
2404
|
Cancel
|
|
1623
2405
|
</button>
|
|
1624
|
-
<button
|
|
1625
|
-
onClick={handleSave}
|
|
2406
|
+
<button
|
|
2407
|
+
onClick={handleSave}
|
|
1626
2408
|
disabled={!selectedCamera}
|
|
1627
2409
|
className={styles.saveBtn}
|
|
1628
2410
|
>
|
|
1629
|
-
Add Camera
|
|
2411
|
+
{isUpdating ? 'Update Camera' : 'Add Camera'}
|
|
1630
2412
|
</button>
|
|
1631
2413
|
</div>
|
|
1632
2414
|
</div>
|