@visns-studio/visns-components 5.15.3 → 5.15.5

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.
@@ -0,0 +1,1638 @@
1
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
2
+ import mapboxgl from 'mapbox-gl';
3
+ import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
4
+ import html2canvas from 'html2canvas';
5
+ import Swal from 'sweetalert2';
6
+ import 'mapbox-gl/dist/mapbox-gl.css';
7
+ import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
8
+ import styles from './styles/CameraPlacement.module.scss';
9
+ import modalStyles from './styles/CameraPlacementModal.module.scss';
10
+ import './styles/CameraMarkers.scss';
11
+ import {
12
+ VERKADA_CAMERAS,
13
+ CAMERA_CATEGORIES,
14
+ getCameraById,
15
+ } from './data/verkadaCameras';
16
+ import CameraIcon from './utils/CameraIcon';
17
+
18
+ const CameraPlacement = () => {
19
+ const [cameras, setCameras] = useState([]);
20
+ const [selectedCamera, setSelectedCamera] = useState(null);
21
+ const [showModal, setShowModal] = useState(false);
22
+ const [map, setMap] = useState(null);
23
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
24
+ const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
25
+ const [uploadedImage, setUploadedImage] = useState(null);
26
+ const [addCameraMode, setAddCameraMode] = useState(false);
27
+ const mapContainer = useRef(null);
28
+ const mapRef = useRef(null);
29
+ const geocoderRef = useRef(null);
30
+ const imageContainer = useRef(null);
31
+ const fileInputRef = useRef(null);
32
+
33
+ // Mapbox configuration
34
+ const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
35
+
36
+ // Handle image upload
37
+ const handleImageUpload = useCallback((event) => {
38
+ const file = event.target.files[0];
39
+ if (file && file.type.startsWith('image/')) {
40
+ const reader = new FileReader();
41
+ reader.onload = (e) => {
42
+ setUploadedImage(e.target.result);
43
+ setMode('image');
44
+ // Reset cameras when switching to image mode
45
+ setCameras([]);
46
+ // Cleanup map if it exists
47
+ if (map) {
48
+ try {
49
+ map.remove();
50
+ } catch (error) {
51
+ console.warn('Error removing map:', error);
52
+ }
53
+ setMap(null);
54
+ }
55
+ };
56
+ reader.readAsDataURL(file);
57
+ }
58
+ }, [map]);
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
+ };
77
+
78
+ setCameras((prev) => [...prev, newCamera]);
79
+ setSelectedCamera(newCamera);
80
+ setShowModal(true);
81
+ setAddCameraMode(false); // Exit add camera mode after placing
82
+ }, [mode, cameras.length, addCameraMode]);
83
+
84
+ // Initialize Mapbox GL map
85
+ useEffect(() => {
86
+ if (!mapContainer.current || mode !== 'map') return;
87
+
88
+ // Set Mapbox access token
89
+ mapboxgl.accessToken = MAPBOX_ACCESS_TOKEN;
90
+
91
+ const mapInstance = new mapboxgl.Map({
92
+ container: mapContainer.current,
93
+ style: 'mapbox://styles/mapbox/satellite-streets-v12', // Start with satellite imagery
94
+ center: [115.8605, -31.9505], // Perth, Australia default
95
+ zoom: 16,
96
+ maxZoom: 22, // Allow higher zoom for better detail
97
+ minZoom: 10,
98
+ bearing: 0,
99
+ pitch: 0,
100
+ preserveDrawingBuffer: true, // Required for canvas export
101
+ });
102
+
103
+ // Add Mapbox geocoder
104
+ const geocoder = new MapboxGeocoder({
105
+ accessToken: MAPBOX_ACCESS_TOKEN,
106
+ mapboxgl: mapboxgl,
107
+ placeholder: 'Search for addresses...',
108
+ proximity: {
109
+ longitude: 144.9631,
110
+ latitude: -37.8136,
111
+ },
112
+ });
113
+
114
+ mapInstance.on('load', () => {
115
+ setMap(mapInstance);
116
+ mapRef.current = mapInstance;
117
+
118
+ // Add zoom controls to the map using Mapbox GL
119
+ const zoomControl = new mapboxgl.NavigationControl({
120
+ showCompass: false, // Hide compass, only show zoom buttons
121
+ showZoom: true,
122
+ });
123
+ mapInstance.addControl(zoomControl, 'top-right');
124
+
125
+ // Add fullscreen control using Mapbox GL
126
+ const fullscreenControl = new mapboxgl.FullscreenControl();
127
+ mapInstance.addControl(fullscreenControl, 'top-right');
128
+
129
+ // Add Mapbox geocoder to the map
130
+ if (geocoderRef.current) {
131
+ geocoderRef.current.appendChild(geocoder.onAdd(mapInstance));
132
+ }
133
+
134
+ // Handle geocoder result for Mapbox
135
+ geocoder.on('result', (e) => {
136
+ // Mapbox geocoder result format
137
+ const coordinates = e.result.center;
138
+ if (coordinates) {
139
+ mapInstance.flyTo({
140
+ center: coordinates,
141
+ zoom: Math.min(Math.max(mapInstance.getZoom(), 18), 20), // Allow zoom up to 20 for better detail
142
+ });
143
+ }
144
+ });
145
+ });
146
+
147
+ return () => {
148
+ mapInstance.remove();
149
+ };
150
+ }, [mode]);
151
+
152
+ // Handle map click to add camera
153
+ const handleMapClick = useCallback(
154
+ (e) => {
155
+ if (!map || mode !== 'map') return;
156
+
157
+ const { lng, lat } = e.lngLat;
158
+ const newCamera = {
159
+ id: Date.now(),
160
+ lng,
161
+ lat,
162
+ name: `Camera ${cameras.length + 1}`,
163
+ verkadaModel: null, // Will be selected in modal
164
+ customName: '',
165
+ direction: 0,
166
+ };
167
+
168
+ setCameras((prev) => [...prev, newCamera]);
169
+ setSelectedCamera(newCamera);
170
+ setShowModal(true);
171
+ },
172
+ [map, cameras.length, mode]
173
+ );
174
+
175
+ // Add click listener to map
176
+ useEffect(() => {
177
+ if (!map) return;
178
+
179
+ map.on('click', handleMapClick);
180
+ return () => {
181
+ map.off('click', handleMapClick);
182
+ };
183
+ }, [map, handleMapClick]);
184
+
185
+ const updateCamera = useCallback((cameraId, updates) => {
186
+ setCameras((prev) =>
187
+ prev.map((cam) =>
188
+ cam.id === cameraId ? { ...cam, ...updates } : cam
189
+ )
190
+ );
191
+ }, []);
192
+
193
+ const removeCamera = useCallback(
194
+ (cameraId) => {
195
+ setCameras((prev) => prev.filter((cam) => cam.id !== cameraId));
196
+
197
+ // Remove FOV layer and source from map
198
+ if (map) {
199
+ const layerId = `fov-layer-${cameraId}`;
200
+ const sourceId = `fov-${cameraId}`;
201
+ if (map.getLayer(layerId)) {
202
+ map.removeLayer(layerId);
203
+ }
204
+ if (map.getSource(sourceId)) {
205
+ map.removeSource(sourceId);
206
+ }
207
+ }
208
+ },
209
+ [map]
210
+ );
211
+
212
+ // Save placement as JSON file
213
+ const saveAsFile = useCallback(() => {
214
+ if (!cameras.length && !uploadedImage) {
215
+ Swal.fire({
216
+ icon: 'warning',
217
+ title: 'No Data to Save',
218
+ text: 'Please add cameras or upload an image first.',
219
+ confirmButtonColor: '#8b5cf6'
220
+ });
221
+ return;
222
+ }
223
+
224
+ const saveData = {
225
+ version: '1.0',
226
+ timestamp: new Date().toISOString(),
227
+ image: uploadedImage ? {
228
+ src: uploadedImage,
229
+ name: 'uploaded-image'
230
+ } : null,
231
+ mode: mode,
232
+ cameras: cameras.map(camera => ({
233
+ id: camera.id,
234
+ name: camera.name,
235
+ customName: camera.customName,
236
+ x: camera.x,
237
+ y: camera.y,
238
+ direction: camera.direction,
239
+ verkadaModel: camera.verkadaModel,
240
+ timestamp: camera.timestamp
241
+ }))
242
+ };
243
+
244
+ const dataStr = JSON.stringify(saveData, null, 2);
245
+ const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
246
+
247
+ const exportFileDefaultName = `camera-placement-${new Date().toISOString().split('T')[0]}.json`;
248
+
249
+ const linkElement = document.createElement('a');
250
+ linkElement.setAttribute('href', dataUri);
251
+ linkElement.setAttribute('download', exportFileDefaultName);
252
+ linkElement.click();
253
+
254
+ // Show success message
255
+ Swal.fire({
256
+ icon: 'success',
257
+ title: 'File Saved!',
258
+ text: `Camera placement saved as ${exportFileDefaultName}`,
259
+ timer: 3000,
260
+ showConfirmButton: false,
261
+ toast: true,
262
+ position: 'top-end'
263
+ });
264
+ }, [cameras, uploadedImage, mode]);
265
+
266
+ // Load placement from JSON file
267
+ const loadFromFile = useCallback(() => {
268
+ const input = document.createElement('input');
269
+ input.type = 'file';
270
+ input.accept = '.json';
271
+
272
+ input.onchange = (event) => {
273
+ const file = event.target.files[0];
274
+ if (!file) return;
275
+
276
+ const reader = new FileReader();
277
+ reader.onload = (e) => {
278
+ try {
279
+ const saveData = JSON.parse(e.target.result);
280
+
281
+ // Validate data structure
282
+ if (!saveData.version || !Array.isArray(saveData.cameras)) {
283
+ Swal.fire({
284
+ icon: 'error',
285
+ title: 'Invalid File',
286
+ text: 'Please select a valid camera placement file.',
287
+ confirmButtonColor: '#f59e0b'
288
+ });
289
+ return;
290
+ }
291
+
292
+ // Load image if present
293
+ if (saveData.image && saveData.image.src) {
294
+ setUploadedImage(saveData.image.src);
295
+ }
296
+
297
+ // Set mode
298
+ if (saveData.mode) {
299
+ setMode(saveData.mode);
300
+ }
301
+
302
+ // Load cameras
303
+ setCameras(saveData.cameras || []);
304
+
305
+ Swal.fire({
306
+ icon: 'success',
307
+ title: 'File Loaded!',
308
+ text: `Successfully loaded ${saveData.cameras?.length || 0} cameras from ${file.name}`,
309
+ confirmButtonColor: '#f59e0b'
310
+ });
311
+
312
+ } catch (error) {
313
+ console.error('Error loading file:', error);
314
+ Swal.fire({
315
+ icon: 'error',
316
+ title: 'File Error',
317
+ text: 'Error reading file. Please ensure it\'s a valid JSON file.',
318
+ confirmButtonColor: '#f59e0b'
319
+ });
320
+ }
321
+ };
322
+ reader.readAsText(file);
323
+ };
324
+
325
+ input.click();
326
+ }, []);
327
+
328
+ const exportMap = useCallback(async () => {
329
+ try {
330
+ if (!cameras.length) {
331
+ Swal.fire({
332
+ icon: 'warning',
333
+ title: 'No Cameras',
334
+ text: 'Please add cameras first before exporting.',
335
+ confirmButtonColor: '#10b981'
336
+ });
337
+ return;
338
+ }
339
+
340
+ const imageContainer = document.querySelector(`.${styles.imageContainer}`);
341
+ if (!imageContainer) {
342
+ Swal.fire({
343
+ icon: 'warning',
344
+ title: 'No Image',
345
+ text: 'Please upload an image first before exporting.',
346
+ confirmButtonColor: '#10b981'
347
+ });
348
+ return;
349
+ }
350
+
351
+ // First, take a screenshot of just the image container to get its natural size
352
+ const imageCanvas = await html2canvas(imageContainer, {
353
+ useCORS: true,
354
+ allowTaint: true,
355
+ scale: 1.2,
356
+ backgroundColor: '#f8fafc',
357
+ logging: false
358
+ });
359
+
360
+ // Calculate dimensions based on camera count (more cameras = more height needed)
361
+ const cameraListWidth = 450; // Wider to show more info
362
+ const headerHeight = 80;
363
+ const cameraHeight = 85; // Height per camera entry
364
+ const totalCameraListHeight = headerHeight + (cameras.length * cameraHeight) + 40; // padding
365
+
366
+ // Use the larger of image height or camera list height
367
+ const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
368
+ const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
369
+
370
+ // Create the composite container with calculated dimensions
371
+ const exportContainer = document.createElement('div');
372
+ exportContainer.style.position = 'absolute';
373
+ exportContainer.style.top = '-10000px';
374
+ exportContainer.style.left = '-10000px';
375
+ exportContainer.style.width = finalWidth + 'px';
376
+ exportContainer.style.height = finalHeight + 'px';
377
+ exportContainer.style.backgroundColor = '#ffffff';
378
+ exportContainer.style.display = 'flex';
379
+ exportContainer.style.gap = '10px';
380
+ exportContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
381
+ document.body.appendChild(exportContainer);
382
+
383
+ // Left side: Image
384
+ const imageDiv = document.createElement('div');
385
+ imageDiv.style.width = imageCanvas.width + 'px';
386
+ imageDiv.style.height = finalHeight + 'px';
387
+ imageDiv.style.background = '#f8fafc';
388
+ imageDiv.style.display = 'flex';
389
+ imageDiv.style.alignItems = 'center';
390
+ imageDiv.style.justifyContent = 'center';
391
+ imageDiv.style.borderRight = '2px solid #e5e7eb';
392
+ imageDiv.style.flexShrink = '0';
393
+
394
+ // Scale the canvas to fit if needed
395
+ const scaledCanvas = document.createElement('canvas');
396
+ const ctx = scaledCanvas.getContext('2d');
397
+ scaledCanvas.width = imageCanvas.width;
398
+ scaledCanvas.height = imageCanvas.height;
399
+ ctx.drawImage(imageCanvas, 0, 0);
400
+
401
+ imageDiv.appendChild(scaledCanvas);
402
+ exportContainer.appendChild(imageDiv);
403
+
404
+ // Right side: Camera list
405
+ const cameraListDiv = document.createElement('div');
406
+ cameraListDiv.style.width = cameraListWidth + 'px';
407
+ cameraListDiv.style.height = finalHeight + 'px';
408
+ cameraListDiv.style.padding = '20px';
409
+ cameraListDiv.style.background = '#ffffff';
410
+ cameraListDiv.style.overflow = 'visible';
411
+ cameraListDiv.style.boxSizing = 'border-box';
412
+
413
+ // Generate optimized camera list HTML
414
+ const cameraListHTML = `
415
+ <div style="margin-bottom: 20px; border-bottom: 2px solid #e5e7eb; padding-bottom: 12px;">
416
+ <h2 style="margin: 0 0 6px 0; font-size: 18px; font-weight: 700; color: #111827;">Camera Reference</h2>
417
+ <p style="margin: 0; font-size: 12px; color: #6b7280;">Generated ${new Date().toLocaleDateString()}</p>
418
+ <p style="margin: 2px 0 0 0; font-size: 12px; color: #6b7280;">${cameras.length} camera${cameras.length !== 1 ? 's' : ''} placed</p>
419
+ </div>
420
+
421
+ ${cameras.map((camera) => {
422
+ const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
423
+ const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
424
+
425
+ return `
426
+ <div style="margin-bottom: 12px; padding: 10px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; page-break-inside: avoid;">
427
+ <div style="display: flex; align-items: center; margin-bottom: 6px;">
428
+ <div style="width: 22px; height: 22px; background: ${verkadaCamera?.environment === 'indoor' ? '#10b981' : '#f59e0b'}; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; margin-right: 8px; flex-shrink: 0;">${cameraNumber}</div>
429
+ <div style="flex: 1; min-width: 0;">
430
+ <div style="font-size: 13px; font-weight: 600; color: #111827; margin-bottom: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${camera.customName || camera.name}</div>
431
+ <div style="font-size: 10px; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${verkadaCamera ? verkadaCamera.name : 'No Model Selected'}</div>
432
+ </div>
433
+ </div>
434
+
435
+ ${verkadaCamera ? `
436
+ <div style="font-size: 10px; line-height: 1.5;">
437
+ <div style="margin-bottom: 4px;"><strong>Resolution:</strong> ${verkadaCamera.resolution} | <strong>FOV:</strong> ${verkadaCamera.fov}°</div>
438
+ <div style="margin-bottom: 4px;"><strong>Environment:</strong> ${verkadaCamera.environment} | <strong>Range:</strong> ${verkadaCamera.irRange}m IR</div>
439
+ ${camera.direction ? `<div style="margin-bottom: 4px;"><strong>Direction:</strong> ${camera.direction}°</div>` : ''}
440
+ <div style="color: #6b7280; margin-top: 6px; font-size: 9px; line-height: 1.4;">${verkadaCamera.features?.slice(0, 3).join(', ') || 'No features'}</div>
441
+ </div>
442
+ ` : `
443
+ <div style="font-size: 10px; color: #dc2626; font-style: italic; margin-top: 4px;">Camera model not selected</div>
444
+ `}
445
+ </div>
446
+ `;
447
+ }).join('')}
448
+ `;
449
+
450
+ cameraListDiv.innerHTML = cameraListHTML;
451
+ exportContainer.appendChild(cameraListDiv);
452
+
453
+ // Wait for rendering
454
+ await new Promise(resolve => setTimeout(resolve, 100));
455
+
456
+ // Capture the composite with exact dimensions
457
+ const finalCanvas = await html2canvas(exportContainer, {
458
+ useCORS: true,
459
+ allowTaint: true,
460
+ scale: 1,
461
+ backgroundColor: '#ffffff',
462
+ logging: false,
463
+ width: finalWidth,
464
+ height: finalHeight
465
+ });
466
+
467
+ // Clean up
468
+ document.body.removeChild(exportContainer);
469
+
470
+ // Download
471
+ const link = document.createElement('a');
472
+ link.download = `camera-layout-${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}.png`;
473
+ link.href = finalCanvas.toDataURL('image/png', 0.9);
474
+ link.click();
475
+
476
+ console.log('Export completed successfully');
477
+
478
+ // Show success message
479
+ Swal.fire({
480
+ icon: 'success',
481
+ title: 'Export Complete!',
482
+ text: `Camera layout exported as ${link.download}`,
483
+ timer: 3000,
484
+ showConfirmButton: false,
485
+ toast: true,
486
+ position: 'top-end'
487
+ });
488
+
489
+ } catch (error) {
490
+ console.error('Error exporting:', error);
491
+ Swal.fire({
492
+ icon: 'error',
493
+ title: 'Export Failed',
494
+ text: 'Export failed. Please try again or take a manual screenshot.',
495
+ confirmButtonColor: '#10b981'
496
+ });
497
+ }
498
+ }, [cameras]);
499
+
500
+ // Update cameras with markers on map
501
+ useEffect(() => {
502
+ if (!map || mode !== 'map') return;
503
+
504
+ // Clear existing markers
505
+ const existingMarkers = document.querySelectorAll('.camera-map-marker');
506
+ existingMarkers.forEach((marker) => marker.remove());
507
+
508
+ // Clear existing FOV layers
509
+ let existingSources = [];
510
+ try {
511
+ const style = map.getStyle();
512
+ if (style && style.sources) {
513
+ existingSources = Object.keys(style.sources).filter(
514
+ (id) => id.startsWith('fov-')
515
+ );
516
+ }
517
+ } catch (error) {
518
+ console.warn('Could not access map style, skipping FOV layer cleanup:', error);
519
+ }
520
+ existingSources.forEach((sourceId) => {
521
+ try {
522
+ const layerId = `fov-layer-${sourceId.replace('fov-', '')}`;
523
+ if (map.getLayer(layerId)) {
524
+ map.removeLayer(layerId);
525
+ }
526
+ if (map.getSource(sourceId)) {
527
+ map.removeSource(sourceId);
528
+ }
529
+ } catch (error) {
530
+ console.warn(`Could not remove layer/source ${sourceId}:`, error);
531
+ }
532
+ });
533
+
534
+ // Add camera markers and FOV
535
+ cameras.forEach((camera, index) => {
536
+ const verkadaCamera = camera.verkadaModel
537
+ ? getCameraById(camera.verkadaModel)
538
+ : null;
539
+
540
+ // Create main marker container
541
+ const markerContainer = document.createElement('div');
542
+ markerContainer.className = 'camera-marker-container';
543
+
544
+ // Create marker element
545
+ const markerEl = document.createElement('div');
546
+ const cameraType = verkadaCamera ? verkadaCamera.type : 'unknown';
547
+ const environment = verkadaCamera
548
+ ? verkadaCamera.environment
549
+ : 'outdoor';
550
+
551
+ markerEl.className = `camera-map-marker ${cameraType} ${environment}`;
552
+
553
+ // Extract camera number from name or use index + 1
554
+ const cameraNumber =
555
+ (camera.customName || camera.name).replace(/\D/g, '') ||
556
+ index + 1;
557
+
558
+ markerEl.innerHTML = `
559
+ <div class="camera-marker-content">
560
+ <div class="camera-number">${cameraNumber}</div>
561
+ </div>
562
+ `;
563
+
564
+ // Create rotation handle
565
+ const rotationHandle = document.createElement('div');
566
+ rotationHandle.className = 'rotation-handle';
567
+ rotationHandle.innerHTML = '<div class="rotation-icon"></div>';
568
+
569
+ // Add rotation functionality
570
+ let isRotating = false;
571
+ let startAngle = 0;
572
+ let currentRotation = camera.direction || 0;
573
+
574
+ const handleRotationStart = (e) => {
575
+ e.stopPropagation();
576
+ isRotating = true;
577
+ const rect = markerContainer.getBoundingClientRect();
578
+ const centerX = rect.left + rect.width / 2;
579
+ const centerY = rect.top + rect.height / 2;
580
+ startAngle =
581
+ (Math.atan2(e.clientY - centerY, e.clientX - centerX) *
582
+ 180) /
583
+ Math.PI;
584
+ markerContainer.classList.add('rotating');
585
+ document.addEventListener('mousemove', handleRotationMove);
586
+ document.addEventListener('mouseup', handleRotationEnd);
587
+ };
588
+
589
+ const handleRotationMove = (e) => {
590
+ if (!isRotating) return;
591
+ const rect = markerContainer.getBoundingClientRect();
592
+ const centerX = rect.left + rect.width / 2;
593
+ const centerY = rect.top + rect.height / 2;
594
+ const currentAngle =
595
+ (Math.atan2(e.clientY - centerY, e.clientX - centerX) *
596
+ 180) /
597
+ Math.PI;
598
+ const deltaAngle = currentAngle - startAngle;
599
+ const newRotation = (currentRotation + deltaAngle + 360) % 360;
600
+
601
+ // Update visual rotation
602
+ rotationHandle.style.transform = `rotate(${newRotation}deg)`;
603
+
604
+ // Update camera direction in real-time
605
+ updateCamera(camera.id, { direction: Math.round(newRotation) });
606
+ };
607
+
608
+ const handleRotationEnd = () => {
609
+ isRotating = false;
610
+ markerContainer.classList.remove('rotating');
611
+ document.removeEventListener('mousemove', handleRotationMove);
612
+ document.removeEventListener('mouseup', handleRotationEnd);
613
+
614
+ // Get final rotation value
615
+ const transform = rotationHandle.style.transform;
616
+ const match = transform.match(/rotate\(([^)]+)deg\)/);
617
+ if (match) {
618
+ currentRotation = parseFloat(match[1]);
619
+ updateCamera(camera.id, {
620
+ direction: Math.round(currentRotation),
621
+ });
622
+ }
623
+ };
624
+
625
+ // Add event listeners
626
+ rotationHandle.addEventListener('mousedown', handleRotationStart);
627
+
628
+ // Set initial rotation
629
+ rotationHandle.style.transform = `rotate(${currentRotation}deg)`;
630
+
631
+ // Click handler for main marker (not rotation handle)
632
+ markerEl.onclick = () => {
633
+ if (!isRotating) {
634
+ setSelectedCamera(camera);
635
+ setShowModal(true);
636
+ }
637
+ };
638
+
639
+ // Assemble marker structure
640
+ markerContainer.appendChild(markerEl);
641
+ markerContainer.appendChild(rotationHandle);
642
+
643
+ // Add marker to map with drag functionality using Mapbox GL
644
+ const marker = new mapboxgl.Marker({
645
+ element: markerContainer,
646
+ draggable: true,
647
+ anchor: 'center', // Ensure marker is centered on coordinates
648
+ })
649
+ .setLngLat([camera.lng, camera.lat])
650
+ .addTo(map);
651
+
652
+ // Handle drag start
653
+ marker.on('dragstart', () => {
654
+ markerContainer.classList.add('dragging');
655
+ // Hide rotation handle during drag
656
+ rotationHandle.style.opacity = '0.2';
657
+ });
658
+
659
+ // Handle drag (real-time coordinate updates)
660
+ marker.on('drag', () => {
661
+ const { lng, lat } = marker.getLngLat();
662
+ // Update coordinates in real-time (optional - can be removed if performance is an issue)
663
+ updateCamera(camera.id, {
664
+ lng: parseFloat(lng.toFixed(6)),
665
+ lat: parseFloat(lat.toFixed(6)),
666
+ });
667
+ });
668
+
669
+ // Handle drag end to finalize camera position
670
+ marker.on('dragend', () => {
671
+ markerContainer.classList.remove('dragging');
672
+ rotationHandle.style.opacity = '0.9';
673
+
674
+ const { lng, lat } = marker.getLngLat();
675
+ updateCamera(camera.id, {
676
+ lng: parseFloat(lng.toFixed(6)),
677
+ lat: parseFloat(lat.toFixed(6)),
678
+ });
679
+ });
680
+
681
+ // Prevent rotation handle from triggering drag
682
+ rotationHandle.addEventListener('mousedown', (e) => {
683
+ e.stopPropagation();
684
+ marker.setDraggable(false);
685
+ });
686
+
687
+ // Re-enable dragging after rotation ends
688
+ const enableDragAfterRotation = () => {
689
+ setTimeout(() => {
690
+ marker.setDraggable(true);
691
+ }, 100);
692
+ };
693
+
694
+ document.addEventListener('mouseup', enableDragAfterRotation);
695
+
696
+ // Add FOV visualization if camera model is selected
697
+ if (verkadaCamera) {
698
+ const fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov; // Use 90 degrees for PTZ as default view
699
+ const fovRad = (fov * Math.PI) / 180;
700
+ const distance = verkadaCamera.ptz ? 0.001 : 0.0005; // Larger range for PTZ
701
+ const directionRad = (camera.direction * Math.PI) / 180;
702
+
703
+ // Calculate FOV triangle points
704
+ const point1Lng =
705
+ camera.lng + distance * Math.cos(directionRad - fovRad / 2);
706
+ const point1Lat =
707
+ camera.lat + distance * Math.sin(directionRad - fovRad / 2);
708
+ const point2Lng =
709
+ camera.lng + distance * Math.cos(directionRad + fovRad / 2);
710
+ const point2Lat =
711
+ camera.lat + distance * Math.sin(directionRad + fovRad / 2);
712
+
713
+ const fovPolygon = {
714
+ type: 'Feature',
715
+ geometry: {
716
+ type: 'Polygon',
717
+ coordinates: [
718
+ [
719
+ [camera.lng, camera.lat],
720
+ [point1Lng, point1Lat],
721
+ [point2Lng, point2Lat],
722
+ [camera.lng, camera.lat],
723
+ ],
724
+ ],
725
+ },
726
+ };
727
+
728
+ // Add FOV polygon to map
729
+ const sourceId = `fov-${camera.id}`;
730
+ const layerId = `fov-layer-${camera.id}`;
731
+
732
+ map.addSource(sourceId, {
733
+ type: 'geojson',
734
+ data: fovPolygon,
735
+ });
736
+
737
+ // Color based on camera type and environment
738
+ const fovColor =
739
+ verkadaCamera.environment === 'indoor'
740
+ ? '#10b981'
741
+ : '#f59e0b';
742
+
743
+ map.addLayer({
744
+ id: layerId,
745
+ type: 'fill',
746
+ source: sourceId,
747
+ paint: {
748
+ 'fill-color': fovColor,
749
+ 'fill-opacity': 0.25,
750
+ },
751
+ });
752
+ }
753
+ });
754
+ }, [map, cameras, mode]);
755
+
756
+
757
+ return (
758
+ <div className={styles.cameraPlacement}>
759
+ <div className={styles.header}>
760
+ <h1 className={styles.title}>Camera Placement Tool</h1>
761
+ <div className={styles.controls}>
762
+ <div className={styles.leftControls}>
763
+ <button
764
+ onClick={() => fileInputRef.current?.click()}
765
+ className={styles.uploadBtn}
766
+ >
767
+ Upload Image
768
+ </button>
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
780
+ onClick={saveAsFile}
781
+ className={styles.saveFileBtn}
782
+ disabled={!cameras.length && !uploadedImage}
783
+ title="Save camera placement as JSON file"
784
+ >
785
+ Save File
786
+ </button>
787
+ <button
788
+ onClick={loadFromFile}
789
+ className={styles.loadFileBtn}
790
+ title="Load camera placement from JSON file"
791
+ >
792
+ Load File
793
+ </button>
794
+ <button
795
+ onClick={() =>
796
+ setSidebarCollapsed(!sidebarCollapsed)
797
+ }
798
+ >
799
+ {sidebarCollapsed ? 'Show' : 'Hide'} Cameras
800
+ </button>
801
+ <button
802
+ className={styles.exportBtn}
803
+ onClick={exportMap}
804
+ disabled={mode === 'image' && !uploadedImage}
805
+ >
806
+ Export {mode === 'map' ? 'Map' : 'Image'}
807
+ </button>
808
+ </div>
809
+ </div>
810
+ </div>
811
+
812
+ <div className={styles.mainContent}>
813
+ <div
814
+ className={`${styles.imageContainer} ${addCameraMode ? styles.addMode : ''} ${sidebarCollapsed ? styles.sidebarCollapsed : ''}`}
815
+ ref={imageContainer}
816
+ onClick={handleImageClick}
817
+ >
818
+ {uploadedImage ? (
819
+ <div className={styles.imageWrapper}>
820
+ <img
821
+ src={uploadedImage}
822
+ alt="Floor plan or site layout"
823
+ className={styles.uploadedImage}
824
+ />
825
+ {/* Render FOV triangles first (behind markers) */}
826
+ {cameras.map((camera) => {
827
+ const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
828
+ if (!verkadaCamera) return null;
829
+
830
+ let fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov;
831
+
832
+ // Handle special FOV cases
833
+ if (fov >= 360) fov = 120; // PTZ cameras showing max practical FOV
834
+ else if (fov >= 270) fov = 150; // Multi-sensor cameras
835
+ else if (fov >= 180) fov = 130; // Fisheye cameras
836
+
837
+ const fovRad = (fov * Math.PI) / 180;
838
+
839
+ // Calculate distance based on FOV - wider angles get shorter distances
840
+ // Base distance of 100px for 60° FOV, scaled inversely with bounds
841
+ const baseFov = 60; // Reference FOV angle
842
+ const baseDistance = verkadaCamera.ptz ? 120 : 100;
843
+ let distance = baseDistance * (baseFov / fov);
844
+
845
+ // Apply bounds: min 30px, max 200px
846
+ distance = Math.max(30, Math.min(200, distance));
847
+ const directionRad = (camera.direction * Math.PI) / 180;
848
+
849
+ // Calculate FOV triangle points in pixels
850
+ const point1X = camera.x + distance * Math.cos(directionRad - fovRad / 2);
851
+ const point1Y = camera.y + distance * Math.sin(directionRad - fovRad / 2);
852
+ const point2X = camera.x + distance * Math.cos(directionRad + fovRad / 2);
853
+ const point2Y = camera.y + distance * Math.sin(directionRad + fovRad / 2);
854
+
855
+ const fovColor = verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b';
856
+
857
+ // Create SVG path for the triangle
858
+ const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
859
+
860
+ return (
861
+ <svg
862
+ key={`fov-${camera.id}`}
863
+ className={styles.fovTriangle}
864
+ style={{
865
+ position: 'absolute',
866
+ top: 0,
867
+ left: 0,
868
+ width: '100%',
869
+ height: '100%',
870
+ pointerEvents: 'none',
871
+ zIndex: 1,
872
+ }}
873
+ >
874
+ <path
875
+ d={pathData}
876
+ fill={fovColor}
877
+ fillOpacity="0.25"
878
+ stroke={fovColor}
879
+ strokeOpacity="0.5"
880
+ strokeWidth="2"
881
+ />
882
+ </svg>
883
+ );
884
+ })}
885
+
886
+ {/* Render camera markers on image */}
887
+ {cameras.map((camera) => {
888
+ const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
889
+ const environment = verkadaCamera ? verkadaCamera.environment : 'outdoor';
890
+ const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
891
+
892
+ return (
893
+ <div
894
+ key={camera.id}
895
+ className={`camera-marker-container ${styles.imageMarker}`}
896
+ style={{
897
+ position: 'absolute',
898
+ left: camera.x - 20, // Adjusted for smaller circular marker
899
+ top: camera.y - 20, // Adjusted for smaller circular marker
900
+ transform: 'none',
901
+ zIndex: 2,
902
+ }}
903
+ onMouseDown={(e) => {
904
+ e.stopPropagation();
905
+ let isDragging = false;
906
+ let startTime = Date.now();
907
+ const startX = e.clientX;
908
+ const startY = e.clientY;
909
+ const containerRect = imageContainer.current.getBoundingClientRect();
910
+
911
+ const handleMouseMove = (moveEvent) => {
912
+ const deltaX = Math.abs(moveEvent.clientX - startX);
913
+ const deltaY = Math.abs(moveEvent.clientY - startY);
914
+
915
+ // Start dragging if moved more than 5px
916
+ if (!isDragging && (deltaX > 5 || deltaY > 5)) {
917
+ isDragging = true;
918
+ }
919
+
920
+ if (isDragging) {
921
+ const newX = moveEvent.clientX - containerRect.left;
922
+ const newY = moveEvent.clientY - containerRect.top;
923
+
924
+ // Update camera position
925
+ updateCamera(camera.id, { x: newX, y: newY });
926
+ }
927
+ };
928
+
929
+ const handleMouseUp = () => {
930
+ const clickDuration = Date.now() - startTime;
931
+
932
+ // If it was a quick click without dragging, show modal
933
+ if (!isDragging && clickDuration < 200) {
934
+ setSelectedCamera(camera);
935
+ setShowModal(true);
936
+ }
937
+
938
+ document.removeEventListener('mousemove', handleMouseMove);
939
+ document.removeEventListener('mouseup', handleMouseUp);
940
+ };
941
+
942
+ document.addEventListener('mousemove', handleMouseMove);
943
+ document.addEventListener('mouseup', handleMouseUp);
944
+ }}
945
+ >
946
+ <div className={`${styles.circularMarker} ${environment} ${verkadaCamera?.ptz ? styles.ptzMarker : ''}`}>
947
+ <div className={styles.markerNumber}>{cameraNumber}</div>
948
+ <div
949
+ className={styles.rotationHandle}
950
+ style={{
951
+ transform: `rotate(${camera.direction || 0}deg)`,
952
+ }}
953
+ onMouseDown={(e) => {
954
+ e.stopPropagation();
955
+ let isRotating = false;
956
+ let currentRotation = camera.direction || 0;
957
+
958
+ const markerRect = e.currentTarget.parentElement.getBoundingClientRect();
959
+ const centerX = markerRect.left + markerRect.width / 2;
960
+ const centerY = markerRect.top + markerRect.height / 2;
961
+
962
+ const handleRotationMove = (moveEvent) => {
963
+ if (!isRotating) {
964
+ isRotating = true;
965
+ }
966
+
967
+ const angle = Math.atan2(
968
+ moveEvent.clientY - centerY,
969
+ moveEvent.clientX - centerX
970
+ );
971
+
972
+ const degrees = ((angle * 180) / Math.PI + 90 + 360) % 360;
973
+ currentRotation = Math.round(degrees);
974
+
975
+ // Update rotation handle visual
976
+ e.currentTarget.style.transform = `rotate(${currentRotation}deg)`;
977
+
978
+ // Update camera direction
979
+ updateCamera(camera.id, { direction: currentRotation });
980
+ };
981
+
982
+ const handleRotationEnd = () => {
983
+ document.removeEventListener('mousemove', handleRotationMove);
984
+ document.removeEventListener('mouseup', handleRotationEnd);
985
+ };
986
+
987
+ document.addEventListener('mousemove', handleRotationMove);
988
+ document.addEventListener('mouseup', handleRotationEnd);
989
+ }}
990
+ />
991
+ </div>
992
+ </div>
993
+ );
994
+ })}
995
+ </div>
996
+ ) : (
997
+ <div className={styles.uploadPlaceholder}>
998
+ <div className={styles.uploadIcon}>📁</div>
999
+ <h3>Upload an Image</h3>
1000
+ <p>Click "Upload Image" to add a floor plan, site layout, or any image where you want to place cameras.</p>
1001
+ <button
1002
+ onClick={() => fileInputRef.current?.click()}
1003
+ className={styles.uploadBtn}
1004
+ >
1005
+ Choose Image File
1006
+ </button>
1007
+ </div>
1008
+ )}
1009
+ </div>
1010
+ </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
+ />
1020
+
1021
+ <div
1022
+ className={`${styles.sidebar} ${
1023
+ sidebarCollapsed ? styles.collapsed : ''
1024
+ }`}
1025
+ >
1026
+ <div className={styles.sidebarHeader}>
1027
+ <h3 className={styles.sidebarTitle}>Camera List</h3>
1028
+ <p className={styles.cameraCount}>
1029
+ {cameras.length} camera
1030
+ {cameras.length !== 1 ? 's' : ''} placed
1031
+ </p>
1032
+ </div>
1033
+
1034
+ <div className={styles.camerasList}>
1035
+ {cameras.length === 0 ? (
1036
+ <div
1037
+ style={{
1038
+ textAlign: 'center',
1039
+ color: '#6c757d',
1040
+ padding: '40px 20px',
1041
+ fontStyle: 'italic',
1042
+ }}
1043
+ >
1044
+ <div>
1045
+ Click "Add Camera" to place your first camera
1046
+ </div>
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>
1055
+ </div>
1056
+ ) : (
1057
+ cameras.map((camera) => {
1058
+ const verkadaCamera = camera.verkadaModel
1059
+ ? getCameraById(camera.verkadaModel)
1060
+ : null;
1061
+
1062
+ return (
1063
+ <div
1064
+ key={camera.id}
1065
+ className={styles.cameraItem}
1066
+ >
1067
+ <div className={styles.cameraHeader}>
1068
+ <h4 className={styles.cameraName}>
1069
+ {verkadaCamera && (
1070
+ <CameraIcon
1071
+ icon={
1072
+ verkadaCamera.icon
1073
+ }
1074
+ size={16}
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
+ }`}
1090
+ >
1091
+ {verkadaCamera.id}
1092
+ </span>
1093
+ )}
1094
+ </div>
1095
+
1096
+ {verkadaCamera ? (
1097
+ <div
1098
+ className={styles.cameraDetails}
1099
+ >
1100
+ <div
1101
+ className={
1102
+ modalStyles.cameraModelName
1103
+ }
1104
+ >
1105
+ <strong>
1106
+ {verkadaCamera.name}
1107
+ </strong>
1108
+ </div>
1109
+
1110
+ <div
1111
+ className={
1112
+ modalStyles.cameraSpecGrid
1113
+ }
1114
+ >
1115
+ <div
1116
+ className={
1117
+ modalStyles.specItem
1118
+ }
1119
+ >
1120
+ <span
1121
+ className={
1122
+ modalStyles.specLabel
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
1138
+ className={
1139
+ modalStyles.specItem
1140
+ }
1141
+ >
1142
+ <span
1143
+ className={
1144
+ modalStyles.specLabel
1145
+ }
1146
+ >
1147
+ FOV:
1148
+ </span>
1149
+ <span
1150
+ className={
1151
+ modalStyles.specValue
1152
+ }
1153
+ >
1154
+ {verkadaCamera.fov}°
1155
+ </span>
1156
+ </div>
1157
+ <div
1158
+ className={
1159
+ modalStyles.specItem
1160
+ }
1161
+ >
1162
+ <span
1163
+ className={
1164
+ modalStyles.specLabel
1165
+ }
1166
+ >
1167
+ Environment:
1168
+ </span>
1169
+ <span
1170
+ className={`${
1171
+ modalStyles.specValue
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
+ }
1188
+ >
1189
+ <span
1190
+ className={
1191
+ modalStyles.specLabel
1192
+ }
1193
+ >
1194
+ IR Range:
1195
+ </span>
1196
+ <span
1197
+ className={
1198
+ modalStyles.specValue
1199
+ }
1200
+ >
1201
+ {
1202
+ verkadaCamera.irRange
1203
+ }
1204
+ m
1205
+ </span>
1206
+ </div>
1207
+ <div
1208
+ className={
1209
+ modalStyles.specItem
1210
+ }
1211
+ >
1212
+ <span
1213
+ className={
1214
+ modalStyles.specLabel
1215
+ }
1216
+ >
1217
+ Zoom:
1218
+ </span>
1219
+ <span
1220
+ className={
1221
+ modalStyles.specValue
1222
+ }
1223
+ >
1224
+ {verkadaCamera.zoom}
1225
+ </span>
1226
+ </div>
1227
+ <div
1228
+ className={
1229
+ modalStyles.specItem
1230
+ }
1231
+ >
1232
+ <span
1233
+ className={
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
+ )}
1297
+ </div>
1298
+
1299
+ <div
1300
+ className={
1301
+ modalStyles.cameraFeatures
1302
+ }
1303
+ >
1304
+ <span
1305
+ className={
1306
+ modalStyles.specLabel
1307
+ }
1308
+ >
1309
+ Features:
1310
+ </span>
1311
+ <div
1312
+ className={
1313
+ modalStyles.featuresList
1314
+ }
1315
+ >
1316
+ {verkadaCamera.features.map(
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>
1332
+ </div>
1333
+
1334
+ <div
1335
+ className={
1336
+ modalStyles.directionControl
1337
+ }
1338
+ >
1339
+ <label>
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
+ }
1361
+ className={
1362
+ modalStyles.directionSlider
1363
+ }
1364
+ />
1365
+ <div
1366
+ className={
1367
+ modalStyles.directionHelper
1368
+ }
1369
+ >
1370
+ 0° = North, 90° = East,
1371
+ 180° = South, 270° =
1372
+ West
1373
+ </div>
1374
+ </div>
1375
+ </div>
1376
+ ) : (
1377
+ <div
1378
+ className={styles.cameraDetails}
1379
+ >
1380
+ <div
1381
+ className={
1382
+ modalStyles.noModel
1383
+ }
1384
+ >
1385
+ No Verkada model selected -
1386
+ Click "Edit" to choose a
1387
+ camera
1388
+ </div>
1389
+ <div
1390
+ className={
1391
+ modalStyles.directionControl
1392
+ }
1393
+ >
1394
+ <label>
1395
+ Direction:{' '}
1396
+ {camera.direction}°
1397
+ </label>
1398
+ <input
1399
+ type="range"
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
+ ),
1413
+ }
1414
+ )
1415
+ }
1416
+ className={
1417
+ modalStyles.directionSlider
1418
+ }
1419
+ />
1420
+ <div
1421
+ className={
1422
+ modalStyles.directionHelper
1423
+ }
1424
+ >
1425
+ 0° = North, 90° = East,
1426
+ 180° = South, 270° =
1427
+ West
1428
+ </div>
1429
+ </div>
1430
+ </div>
1431
+ )}
1432
+
1433
+ <div className={styles.cameraCoords}>
1434
+ <span className={styles.coordLabel}>
1435
+ Location:
1436
+ </span>
1437
+ {mode === 'map' ? (
1438
+ <>
1439
+ {camera.lat?.toFixed(6)},{' '}
1440
+ {camera.lng?.toFixed(6)}
1441
+ </>
1442
+ ) : (
1443
+ <>
1444
+ X: {Math.round(camera.x)}, Y: {Math.round(camera.y)}
1445
+ </>
1446
+ )}
1447
+ </div>
1448
+
1449
+ <div className={styles.cameraActions}>
1450
+ <button
1451
+ className={styles.editBtn}
1452
+ onClick={() => {
1453
+ setSelectedCamera(camera);
1454
+ setShowModal(true);
1455
+ }}
1456
+ >
1457
+ Edit
1458
+ </button>
1459
+ <button
1460
+ className={styles.removeBtn}
1461
+ onClick={() =>
1462
+ removeCamera(camera.id)
1463
+ }
1464
+ >
1465
+ Remove
1466
+ </button>
1467
+ </div>
1468
+ </div>
1469
+ );
1470
+ })
1471
+ )}
1472
+ </div>
1473
+ </div>
1474
+
1475
+ {showModal && selectedCamera && (
1476
+ <CameraSelectionModal
1477
+ camera={selectedCamera}
1478
+ onSave={(updates) => {
1479
+ updateCamera(selectedCamera.id, updates);
1480
+ setShowModal(false);
1481
+ }}
1482
+ onClose={() => setShowModal(false)}
1483
+ />
1484
+ )}
1485
+ </div>
1486
+ );
1487
+ };
1488
+
1489
+ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
1490
+ const [customName, setCustomName] = useState(camera.customName || '');
1491
+ const [selectedCategory, setSelectedCategory] = useState('all');
1492
+ const [selectedCamera, setSelectedCamera] = useState(null);
1493
+
1494
+ // Filter cameras based on selected category
1495
+ const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
1496
+ if (selectedCategory === 'all') return true;
1497
+ return cam.type === selectedCategory;
1498
+ });
1499
+
1500
+ const handleCameraSelect = (cameraModel) => {
1501
+ setSelectedCamera(cameraModel);
1502
+ };
1503
+
1504
+ const handleSave = () => {
1505
+ if (!selectedCamera) {
1506
+ alert('Please select a camera model before saving.');
1507
+ return;
1508
+ }
1509
+
1510
+ onSave({
1511
+ customName: customName.trim(),
1512
+ verkadaModel: selectedCamera.id,
1513
+ });
1514
+ };
1515
+
1516
+ return (
1517
+ <div className={styles.modalOverlay}>
1518
+ <div className={styles.cameraSelectionModal}>
1519
+ <div className={styles.modalHeader}>
1520
+ <h3>Select Verkada Camera</h3>
1521
+ <button className={styles.closeBtn} onClick={onClose}>×</button>
1522
+ </div>
1523
+
1524
+ <div className={styles.modalContent}>
1525
+ <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
+ <div className={styles.categoryFilter}>
1537
+ <label>Filter by Category:</label>
1538
+ <div className={styles.categoryButtons}>
1539
+ <button
1540
+ className={selectedCategory === 'all' ? styles.active : ''}
1541
+ onClick={() => setSelectedCategory('all')}
1542
+ >
1543
+ All Cameras
1544
+ </button>
1545
+ {Object.entries(CAMERA_CATEGORIES).map(([key, category]) => (
1546
+ <button
1547
+ key={key}
1548
+ className={selectedCategory === key ? styles.active : ''}
1549
+ onClick={() => setSelectedCategory(key)}
1550
+ >
1551
+ {category.name}
1552
+ </button>
1553
+ ))}
1554
+ </div>
1555
+ </div>
1556
+ </div>
1557
+
1558
+ <div className={styles.cameraGrid}>
1559
+ {filteredCameras.map((cam) => (
1560
+ <div
1561
+ key={cam.id}
1562
+ className={`${styles.cameraCard} ${selectedCamera?.id === cam.id ? styles.selectedCard : ''}`}
1563
+ onClick={() => handleCameraSelect(cam)}
1564
+ >
1565
+ <div className={styles.cardHeader}>
1566
+ <div className={styles.cameraIcon}>
1567
+ <CameraIcon icon={cam.icon} size={24} />
1568
+ </div>
1569
+ <div className={styles.cameraTitle}>
1570
+ <h4>{cam.name}</h4>
1571
+ <span className={`${styles.environmentBadge} ${styles[cam.environment]}`}>
1572
+ {cam.environment}
1573
+ </span>
1574
+ </div>
1575
+ </div>
1576
+
1577
+ <div className={styles.cardBody}>
1578
+ <div className={styles.specRow}>
1579
+ <span className={styles.specLabel}>Resolution:</span>
1580
+ <span className={styles.specValue}>{cam.resolution}</span>
1581
+ </div>
1582
+ <div className={styles.specRow}>
1583
+ <span className={styles.specLabel}>FOV:</span>
1584
+ <span className={styles.specValue}>{cam.fov}°</span>
1585
+ </div>
1586
+ <div className={styles.specRow}>
1587
+ <span className={styles.specLabel}>Range:</span>
1588
+ <span className={styles.specValue}>{cam.irRange}m IR</span>
1589
+ </div>
1590
+ {cam.ptz && (
1591
+ <div className={styles.ptzBadge}>PTZ</div>
1592
+ )}
1593
+ </div>
1594
+
1595
+ <div className={styles.cardFooter}>
1596
+ <div className={styles.features}>
1597
+ {cam.features?.slice(0, 2).map((feature, index) => (
1598
+ <span key={index} className={styles.featureBadge}>
1599
+ {feature}
1600
+ </span>
1601
+ ))}
1602
+ {cam.features?.length > 2 && (
1603
+ <span className={styles.moreFeatures}>
1604
+ +{cam.features.length - 2} more
1605
+ </span>
1606
+ )}
1607
+ </div>
1608
+ </div>
1609
+ </div>
1610
+ ))}
1611
+ </div>
1612
+ </div>
1613
+
1614
+ <div className={styles.modalActions}>
1615
+ <div className={styles.selectedInfo}>
1616
+ {selectedCamera && (
1617
+ <span>Selected: {selectedCamera.name}</span>
1618
+ )}
1619
+ </div>
1620
+ <div className={styles.actionButtons}>
1621
+ <button onClick={onClose} className={styles.cancelBtn}>
1622
+ Cancel
1623
+ </button>
1624
+ <button
1625
+ onClick={handleSave}
1626
+ disabled={!selectedCamera}
1627
+ className={styles.saveBtn}
1628
+ >
1629
+ Add Camera
1630
+ </button>
1631
+ </div>
1632
+ </div>
1633
+ </div>
1634
+ </div>
1635
+ );
1636
+ };
1637
+
1638
+ export default CameraPlacement;