@visns-studio/visns-components 5.15.5 → 5.15.6
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,7 @@ 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 { Edit3, Trash2 } from 'lucide-react';
|
|
6
7
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
7
8
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
8
9
|
import styles from './styles/CameraPlacement.module.scss';
|
|
@@ -81,6 +82,7 @@ const CameraPlacement = () => {
|
|
|
81
82
|
setAddCameraMode(false); // Exit add camera mode after placing
|
|
82
83
|
}, [mode, cameras.length, addCameraMode]);
|
|
83
84
|
|
|
85
|
+
|
|
84
86
|
// Initialize Mapbox GL map
|
|
85
87
|
useEffect(() => {
|
|
86
88
|
if (!mapContainer.current || mode !== 'map') return;
|
|
@@ -183,11 +185,12 @@ const CameraPlacement = () => {
|
|
|
183
185
|
}, [map, handleMapClick]);
|
|
184
186
|
|
|
185
187
|
const updateCamera = useCallback((cameraId, updates) => {
|
|
186
|
-
setCameras((prev) =>
|
|
187
|
-
prev.map((cam) =>
|
|
188
|
+
setCameras((prev) => {
|
|
189
|
+
const updated = prev.map((cam) =>
|
|
188
190
|
cam.id === cameraId ? { ...cam, ...updates } : cam
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
+
);
|
|
192
|
+
return updated;
|
|
193
|
+
});
|
|
191
194
|
}, []);
|
|
192
195
|
|
|
193
196
|
const removeCamera = useCallback(
|
|
@@ -357,11 +360,13 @@ const CameraPlacement = () => {
|
|
|
357
360
|
logging: false
|
|
358
361
|
});
|
|
359
362
|
|
|
360
|
-
// Calculate dimensions
|
|
361
|
-
const cameraListWidth =
|
|
362
|
-
const headerHeight =
|
|
363
|
-
const
|
|
364
|
-
const
|
|
363
|
+
// Calculate dimensions for professional report layout
|
|
364
|
+
const cameraListWidth = 550; // Fixed width for professional report
|
|
365
|
+
const headerHeight = 140; // Space for report header
|
|
366
|
+
const summaryHeight = 120; // Space for project summary
|
|
367
|
+
const cameraHeight = 180; // More space per camera card
|
|
368
|
+
const footerHeight = 300; // Space for legend and notes
|
|
369
|
+
const totalCameraListHeight = headerHeight + summaryHeight + (cameras.length * cameraHeight) + footerHeight;
|
|
365
370
|
|
|
366
371
|
// Use the larger of image height or camera list height
|
|
367
372
|
const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
|
|
@@ -410,41 +415,141 @@ const CameraPlacement = () => {
|
|
|
410
415
|
cameraListDiv.style.overflow = 'visible';
|
|
411
416
|
cameraListDiv.style.boxSizing = 'border-box';
|
|
412
417
|
|
|
413
|
-
// Generate
|
|
418
|
+
// Generate professional report-style HTML
|
|
414
419
|
const cameraListHTML = `
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
<
|
|
418
|
-
<
|
|
420
|
+
<!-- Report Header -->
|
|
421
|
+
<div style="background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; padding: 32px; margin: -20px -20px 0 -20px; border-radius: 16px 16px 0 0; box-shadow: 0 6px 20px rgba(0,0,0,0.15);">
|
|
422
|
+
<h1 style="margin: 0 0 16px 0; font-size: 36px; font-weight: 800; text-align: center; color: #ffffff; text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.6px; line-height: 1.1;">Security Camera Installation Report</h1>
|
|
423
|
+
<div style="text-align: center; margin-bottom: 24px;">
|
|
424
|
+
<div style="display: inline-block; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); padding: 12px 24px; border-radius: 25px; font-size: 14px; font-weight: 600; letter-spacing: 1px; color: #e5e7eb;">
|
|
425
|
+
PROJECT LAYOUT & SPECIFICATIONS
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; border-top: 1px solid rgba(255,255,255,0.15); padding-top: 20px; letter-spacing: 0.3px;">
|
|
429
|
+
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</div>
|
|
430
|
+
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
|
|
431
|
+
</div>
|
|
419
432
|
</div>
|
|
420
433
|
|
|
421
|
-
|
|
434
|
+
|
|
435
|
+
${cameras.map((camera, index) => {
|
|
422
436
|
const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
|
|
423
|
-
|
|
437
|
+
// Generate sequential camera number
|
|
438
|
+
const cameraNumber = index + 1;
|
|
424
439
|
|
|
425
440
|
return `
|
|
426
|
-
<div style="margin-bottom:
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
<div style="
|
|
430
|
-
<div style="
|
|
431
|
-
<div style="
|
|
441
|
+
<div style="margin-bottom: 20px; background: white; border: 1px solid #d1d5db; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); page-break-inside: avoid;">
|
|
442
|
+
<!-- Camera Header -->
|
|
443
|
+
<div style="background: ${verkadaCamera?.environment === 'indoor' ? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' : 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'}; color: white; padding: 16px 20px; position: relative;">
|
|
444
|
+
<div style="display: flex; align-items: center; gap: 16px;">
|
|
445
|
+
<div style="width: 40px; height: 40px; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.25); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 16px; flex-shrink: 0; text-shadow: 0 1px 3px rgba(0,0,0,0.3);">${cameraNumber}</div>
|
|
446
|
+
<div style="flex: 1;">
|
|
447
|
+
<h4 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.3px; text-shadow: 0 1px 3px rgba(0,0,0,0.2);">Camera ${cameraNumber}</h4>
|
|
448
|
+
<div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${verkadaCamera ? verkadaCamera.name : 'Model Not Selected'}</div>
|
|
449
|
+
</div>
|
|
450
|
+
${verkadaCamera?.ptz ? '<div style="background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); padding: 6px 12px; border-radius: 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px;">PTZ</div>' : ''}
|
|
432
451
|
</div>
|
|
433
452
|
</div>
|
|
434
453
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
<div style="
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
454
|
+
<!-- Camera Details -->
|
|
455
|
+
<div style="padding: 20px;">
|
|
456
|
+
${verkadaCamera ? `
|
|
457
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
|
|
458
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
|
|
459
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
|
|
460
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.resolution}</div>
|
|
461
|
+
</div>
|
|
462
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
|
|
463
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Field of View</div>
|
|
464
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.fov}°</div>
|
|
465
|
+
</div>
|
|
466
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b'};">
|
|
467
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Environment</div>
|
|
468
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${verkadaCamera.environment}</div>
|
|
469
|
+
</div>
|
|
470
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
|
|
471
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
|
|
472
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.irRange}m</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
<!-- Position & Direction -->
|
|
477
|
+
<div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
|
|
478
|
+
<div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; text-align: center; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px;">Installation Details</div>
|
|
479
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
480
|
+
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
481
|
+
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</div>
|
|
482
|
+
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${camera.direction ? `${camera.direction}° from North` : 'Not set'}</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
485
|
+
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</div>
|
|
486
|
+
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${mode === 'map' ? `${camera.lat?.toFixed(6)}, ${camera.lng?.toFixed(6)}` : `X: ${Math.round(camera.x)}, Y: ${Math.round(camera.y)}`}</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<!-- Features -->
|
|
492
|
+
${verkadaCamera.features && verkadaCamera.features.length > 0 ? `
|
|
493
|
+
<div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
|
|
494
|
+
<div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 10px; text-align: center;">Key Features</div>
|
|
495
|
+
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
|
|
496
|
+
${verkadaCamera.features.slice(0, 6).map(feature =>
|
|
497
|
+
`<span style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); color: #1e40af; border: 1px solid #93c5fd; padding: 6px 12px; border-radius: 18px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;">${feature}</span>`
|
|
498
|
+
).join('')}
|
|
499
|
+
${verkadaCamera.features.length > 6 ? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${verkadaCamera.features.length - 6} more</span>` : ''}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
` : ''}
|
|
503
|
+
` : `
|
|
504
|
+
<div style="text-align: center; padding: 30px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #fca5a5; border-radius: 12px; margin: 10px 0;">
|
|
505
|
+
<div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
|
|
506
|
+
<div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
|
|
507
|
+
</div>
|
|
508
|
+
`}
|
|
509
|
+
</div>
|
|
445
510
|
</div>
|
|
446
511
|
`;
|
|
447
512
|
}).join('')}
|
|
513
|
+
|
|
514
|
+
<!-- Report Footer -->
|
|
515
|
+
<div style="margin-top: 32px; padding: 24px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); border-radius: 16px; border: 1px solid #cbd5e1; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
|
|
516
|
+
<!-- Legend Section -->
|
|
517
|
+
<div style="margin-bottom: 24px;">
|
|
518
|
+
<h3 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 800; color: #0f172a; text-align: center; letter-spacing: -0.4px;">Camera Types & Legend</h3>
|
|
519
|
+
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
|
|
520
|
+
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
521
|
+
<div style="width: 24px; height: 24px; background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(13,148,136,0.3);"></div>
|
|
522
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
|
|
523
|
+
</div>
|
|
524
|
+
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
525
|
+
<div style="width: 24px; height: 24px; background: linear-gradient(135deg, #ea580c 0%, #dc2626 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(234,88,12,0.3);"></div>
|
|
526
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
|
|
527
|
+
</div>
|
|
528
|
+
${cameras.some(c => getCameraById(c.verkadaModel)?.ptz) ? `
|
|
529
|
+
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
530
|
+
<div style="width: 24px; height: 24px; background: #6b7280; border-radius: 50%; border: 3px solid #ef4444; box-shadow: 0 2px 6px rgba(239,68,68,0.3);"></div>
|
|
531
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
|
|
532
|
+
</div>` : ''}
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
<!-- Technical Notes -->
|
|
537
|
+
<div style="background: white; padding: 20px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
|
|
538
|
+
<h4 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: #0f172a; text-align: center; letter-spacing: -0.2px;">📋 Installation Notes</h4>
|
|
539
|
+
<div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
|
|
540
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #3b82f6;"><strong style="color: #1e40af;">Direction Reference:</strong> All camera directions are measured clockwise from North (0° = North, 90° = East, 180° = South, 270° = West)</div>
|
|
541
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #8b5cf6;"><strong style="color: #7c3aed;">Field of View:</strong> FOV angles represent the horizontal viewing angle. Actual coverage may vary based on mounting height and obstacles</div>
|
|
542
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${mode === 'map' ? 'Geographic coordinates (Latitude, Longitude) in decimal degrees' : 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'}</div>
|
|
543
|
+
<div style="padding: 12px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border-radius: 8px; border-left: 3px solid #ef4444;"><strong style="color: #dc2626;">Important:</strong> This layout should be verified on-site during installation. Actual camera placement may need adjustment based on physical constraints, lighting conditions, and site requirements</div>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<!-- Report ID & Disclaimer -->
|
|
548
|
+
<div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
|
|
549
|
+
<div style="font-size: 12px; color: #475569; margin-bottom: 8px; letter-spacing: 0.5px; font-weight: 600;">Report ID: <span style="font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px;">${Date.now().toString(36).toUpperCase()}</span></div>
|
|
550
|
+
<div style="font-size: 11px; color: #64748b; line-height: 1.6; letter-spacing: 0.3px; max-width: 400px; margin: 0 auto;">This report was generated automatically and should be reviewed by qualified security installation professionals</div>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
448
553
|
`;
|
|
449
554
|
|
|
450
555
|
cameraListDiv.innerHTML = cameraListHTML;
|
|
@@ -469,17 +574,17 @@ const CameraPlacement = () => {
|
|
|
469
574
|
|
|
470
575
|
// Download
|
|
471
576
|
const link = document.createElement('a');
|
|
472
|
-
|
|
473
|
-
link.
|
|
577
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
|
|
578
|
+
link.download = `Security-Camera-Installation-Report-${timestamp}.png`;
|
|
579
|
+
link.href = finalCanvas.toDataURL('image/png', 1.0); // Maximum quality
|
|
474
580
|
link.click();
|
|
475
581
|
|
|
476
|
-
console.log('Export completed successfully');
|
|
477
582
|
|
|
478
583
|
// Show success message
|
|
479
584
|
Swal.fire({
|
|
480
585
|
icon: 'success',
|
|
481
586
|
title: 'Export Complete!',
|
|
482
|
-
text: `
|
|
587
|
+
text: `Professional camera installation report exported as ${link.download}`,
|
|
483
588
|
timer: 3000,
|
|
484
589
|
showConfirmButton: false,
|
|
485
590
|
toast: true,
|
|
@@ -900,6 +1005,56 @@ const CameraPlacement = () => {
|
|
|
900
1005
|
transform: 'none',
|
|
901
1006
|
zIndex: 2,
|
|
902
1007
|
}}
|
|
1008
|
+
onMouseEnter={(e) => {
|
|
1009
|
+
// Only show tooltip if not hovering over rotation handle
|
|
1010
|
+
if (verkadaCamera && !e.target.closest(`.${styles.rotationHandle}`)) {
|
|
1011
|
+
// Add delay to prevent tooltip from showing during quick rotations
|
|
1012
|
+
const targetElement = e.currentTarget;
|
|
1013
|
+
targetElement._tooltipTimeout = setTimeout(() => {
|
|
1014
|
+
// Check if element still exists and is in DOM
|
|
1015
|
+
if (!targetElement || !targetElement.parentNode) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
const tooltip = document.createElement('div');
|
|
1021
|
+
tooltip.className = styles.cameraTooltip;
|
|
1022
|
+
tooltip.innerHTML = `
|
|
1023
|
+
<div class="${styles.tooltipImage}">
|
|
1024
|
+
<img src="${verkadaCamera.image}" alt="${verkadaCamera.name}" />
|
|
1025
|
+
</div>
|
|
1026
|
+
<div class="${styles.tooltipContent}">
|
|
1027
|
+
<div class="${styles.tooltipTitle}">${verkadaCamera.name}</div>
|
|
1028
|
+
<div class="${styles.tooltipSpecs}">
|
|
1029
|
+
<div>Resolution: ${verkadaCamera.resolution}</div>
|
|
1030
|
+
<div>FOV: ${verkadaCamera.fov}°</div>
|
|
1031
|
+
<div>Environment: ${verkadaCamera.environment}</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
</div>
|
|
1034
|
+
`;
|
|
1035
|
+
|
|
1036
|
+
// Position tooltip away from rotation handle
|
|
1037
|
+
tooltip.style.left = '50px';
|
|
1038
|
+
tooltip.style.top = '-10px';
|
|
1039
|
+
|
|
1040
|
+
targetElement.appendChild(tooltip);
|
|
1041
|
+
targetElement._tooltip = tooltip;
|
|
1042
|
+
|
|
1043
|
+
}, 300); // 300ms delay
|
|
1044
|
+
}
|
|
1045
|
+
}}
|
|
1046
|
+
onMouseLeave={(e) => {
|
|
1047
|
+
// Clear timeout if leaving before tooltip shows
|
|
1048
|
+
if (e.currentTarget._tooltipTimeout) {
|
|
1049
|
+
clearTimeout(e.currentTarget._tooltipTimeout);
|
|
1050
|
+
e.currentTarget._tooltipTimeout = null;
|
|
1051
|
+
}
|
|
1052
|
+
// Remove tooltip if it exists
|
|
1053
|
+
if (e.currentTarget._tooltip) {
|
|
1054
|
+
e.currentTarget.removeChild(e.currentTarget._tooltip);
|
|
1055
|
+
e.currentTarget._tooltip = null;
|
|
1056
|
+
}
|
|
1057
|
+
}}
|
|
903
1058
|
onMouseDown={(e) => {
|
|
904
1059
|
e.stopPropagation();
|
|
905
1060
|
let isDragging = false;
|
|
@@ -950,36 +1105,90 @@ const CameraPlacement = () => {
|
|
|
950
1105
|
style={{
|
|
951
1106
|
transform: `rotate(${camera.direction || 0}deg)`,
|
|
952
1107
|
}}
|
|
1108
|
+
onMouseEnter={(e) => {
|
|
1109
|
+
// Hide tooltip when hovering over rotation handle
|
|
1110
|
+
const container = e.currentTarget.parentElement.parentElement;
|
|
1111
|
+
if (container._tooltipTimeout) {
|
|
1112
|
+
clearTimeout(container._tooltipTimeout);
|
|
1113
|
+
container._tooltipTimeout = null;
|
|
1114
|
+
}
|
|
1115
|
+
if (container._tooltip) {
|
|
1116
|
+
container.removeChild(container._tooltip);
|
|
1117
|
+
container._tooltip = null;
|
|
1118
|
+
}
|
|
1119
|
+
}}
|
|
953
1120
|
onMouseDown={(e) => {
|
|
1121
|
+
|
|
954
1122
|
e.stopPropagation();
|
|
1123
|
+
|
|
1124
|
+
// Store references to prevent React event pooling issues
|
|
1125
|
+
const rotationHandle = e.currentTarget;
|
|
1126
|
+
const startMouseX = e.clientX;
|
|
1127
|
+
const startMouseY = e.clientY;
|
|
1128
|
+
|
|
955
1129
|
let isRotating = false;
|
|
1130
|
+
let startAngle = 0;
|
|
956
1131
|
let currentRotation = camera.direction || 0;
|
|
957
1132
|
|
|
958
|
-
|
|
1133
|
+
// Get the center of the camera marker
|
|
1134
|
+
const markerContainer = rotationHandle.parentElement;
|
|
1135
|
+
const markerRect = markerContainer.getBoundingClientRect();
|
|
959
1136
|
const centerX = markerRect.left + markerRect.width / 2;
|
|
960
1137
|
const centerY = markerRect.top + markerRect.height / 2;
|
|
961
1138
|
|
|
1139
|
+
|
|
1140
|
+
// Calculate initial angle from center to mouse position
|
|
1141
|
+
const startDeltaX = startMouseX - centerX;
|
|
1142
|
+
const startDeltaY = startMouseY - centerY;
|
|
1143
|
+
startAngle = Math.atan2(startDeltaX, -startDeltaY) * 180 / Math.PI;
|
|
1144
|
+
|
|
1145
|
+
|
|
962
1146
|
const handleRotationMove = (moveEvent) => {
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
const angle = Math.atan2(
|
|
968
|
-
moveEvent.clientY - centerY,
|
|
969
|
-
moveEvent.clientX - centerX
|
|
970
|
-
);
|
|
1147
|
+
const deltaX = Math.abs(moveEvent.clientX - startMouseX);
|
|
1148
|
+
const deltaY = Math.abs(moveEvent.clientY - startMouseY);
|
|
971
1149
|
|
|
972
|
-
const degrees = ((angle * 180) / Math.PI + 90 + 360) % 360;
|
|
973
|
-
currentRotation = Math.round(degrees);
|
|
974
1150
|
|
|
975
|
-
//
|
|
976
|
-
|
|
1151
|
+
// Start rotating if mouse has moved more than 5 pixels
|
|
1152
|
+
if (!isRotating && (deltaX > 5 || deltaY > 5)) {
|
|
1153
|
+
isRotating = true;
|
|
1154
|
+
markerContainer.classList.add('rotating');
|
|
1155
|
+
}
|
|
977
1156
|
|
|
978
|
-
|
|
979
|
-
|
|
1157
|
+
if (isRotating) {
|
|
1158
|
+
// Calculate current angle from center to mouse position
|
|
1159
|
+
const currentDeltaX = moveEvent.clientX - centerX;
|
|
1160
|
+
const currentDeltaY = moveEvent.clientY - centerY;
|
|
1161
|
+
const currentAngle = Math.atan2(currentDeltaX, -currentDeltaY) * 180 / Math.PI;
|
|
1162
|
+
|
|
1163
|
+
// Calculate rotation delta
|
|
1164
|
+
let angleDelta = currentAngle - startAngle;
|
|
1165
|
+
|
|
1166
|
+
// Handle angle wrapping
|
|
1167
|
+
if (angleDelta > 180) angleDelta -= 360;
|
|
1168
|
+
if (angleDelta < -180) angleDelta += 360;
|
|
1169
|
+
|
|
1170
|
+
// Calculate new rotation
|
|
1171
|
+
let newRotation = (currentRotation + angleDelta + 360) % 360;
|
|
1172
|
+
newRotation = Math.round(newRotation);
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
// Update rotation handle visual using stored reference
|
|
1176
|
+
if (rotationHandle && rotationHandle.style) {
|
|
1177
|
+
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
1178
|
+
} else {
|
|
1179
|
+
console.error('🔄 Rotation handle is null or has no style property');
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Update camera direction
|
|
1183
|
+
updateCamera(camera.id, { direction: newRotation });
|
|
1184
|
+
}
|
|
980
1185
|
};
|
|
981
1186
|
|
|
982
1187
|
const handleRotationEnd = () => {
|
|
1188
|
+
|
|
1189
|
+
if (isRotating) {
|
|
1190
|
+
markerContainer.classList.remove('rotating');
|
|
1191
|
+
}
|
|
983
1192
|
document.removeEventListener('mousemove', handleRotationMove);
|
|
984
1193
|
document.removeEventListener('mouseup', handleRotationEnd);
|
|
985
1194
|
};
|
|
@@ -1065,32 +1274,65 @@ const CameraPlacement = () => {
|
|
|
1065
1274
|
className={styles.cameraItem}
|
|
1066
1275
|
>
|
|
1067
1276
|
<div className={styles.cameraHeader}>
|
|
1068
|
-
<
|
|
1277
|
+
<div className={styles.sidebarCameraInfo}>
|
|
1069
1278
|
{verkadaCamera && (
|
|
1070
|
-
<
|
|
1071
|
-
|
|
1072
|
-
verkadaCamera.
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
styles
|
|
1086
|
-
|
|
1087
|
-
.
|
|
1088
|
-
|
|
1089
|
-
|
|
1279
|
+
<div className={styles.sidebarCameraImageContainer}>
|
|
1280
|
+
<img
|
|
1281
|
+
src={verkadaCamera.image}
|
|
1282
|
+
alt={verkadaCamera.name}
|
|
1283
|
+
className={styles.sidebarCameraImage}
|
|
1284
|
+
onLoad={(e) => {
|
|
1285
|
+
e.target.style.opacity = '1';
|
|
1286
|
+
}}
|
|
1287
|
+
onError={(e) => {
|
|
1288
|
+
// Fallback to icon if image fails to load
|
|
1289
|
+
e.target.style.display = 'none';
|
|
1290
|
+
e.target.nextSibling.style.display = 'flex';
|
|
1291
|
+
}}
|
|
1292
|
+
style={{opacity: '0.3'}}
|
|
1293
|
+
/>
|
|
1294
|
+
<div className={styles.sidebarCameraIcon} style={{display: 'none'}}>
|
|
1295
|
+
<CameraIcon
|
|
1296
|
+
icon={verkadaCamera.icon}
|
|
1297
|
+
size={16}
|
|
1298
|
+
/>
|
|
1299
|
+
</div>
|
|
1300
|
+
</div>
|
|
1301
|
+
)}
|
|
1302
|
+
<div className={styles.sidebarCameraText}>
|
|
1303
|
+
<h4 className={styles.cameraName}>
|
|
1304
|
+
{camera.customName || camera.name}
|
|
1305
|
+
</h4>
|
|
1306
|
+
{verkadaCamera && (
|
|
1307
|
+
<span
|
|
1308
|
+
className={`${styles.cameraType} ${styles[verkadaCamera.environment]}`}
|
|
1309
|
+
>
|
|
1310
|
+
{verkadaCamera.id}
|
|
1311
|
+
</span>
|
|
1312
|
+
)}
|
|
1313
|
+
</div>
|
|
1314
|
+
</div>
|
|
1315
|
+
|
|
1316
|
+
{/* Compact action buttons */}
|
|
1317
|
+
<div className={styles.cameraActions}>
|
|
1318
|
+
<button
|
|
1319
|
+
className={styles.iconBtn}
|
|
1320
|
+
onClick={() => {
|
|
1321
|
+
setSelectedCamera(camera);
|
|
1322
|
+
setShowModal(true);
|
|
1323
|
+
}}
|
|
1324
|
+
title="Edit camera"
|
|
1090
1325
|
>
|
|
1091
|
-
{
|
|
1092
|
-
</
|
|
1093
|
-
|
|
1326
|
+
<Edit3 size={16} />
|
|
1327
|
+
</button>
|
|
1328
|
+
<button
|
|
1329
|
+
className={styles.iconBtn}
|
|
1330
|
+
onClick={() => removeCamera(camera.id)}
|
|
1331
|
+
title="Remove camera"
|
|
1332
|
+
>
|
|
1333
|
+
<Trash2 size={16} />
|
|
1334
|
+
</button>
|
|
1335
|
+
</div>
|
|
1094
1336
|
</div>
|
|
1095
1337
|
|
|
1096
1338
|
{verkadaCamera ? (
|
|
@@ -1446,25 +1688,6 @@ const CameraPlacement = () => {
|
|
|
1446
1688
|
)}
|
|
1447
1689
|
</div>
|
|
1448
1690
|
|
|
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
1691
|
</div>
|
|
1469
1692
|
);
|
|
1470
1693
|
})
|
|
@@ -1479,7 +1702,9 @@ const CameraPlacement = () => {
|
|
|
1479
1702
|
updateCamera(selectedCamera.id, updates);
|
|
1480
1703
|
setShowModal(false);
|
|
1481
1704
|
}}
|
|
1482
|
-
onClose={() =>
|
|
1705
|
+
onClose={() => {
|
|
1706
|
+
setShowModal(false);
|
|
1707
|
+
}}
|
|
1483
1708
|
/>
|
|
1484
1709
|
)}
|
|
1485
1710
|
</div>
|
|
@@ -1487,9 +1712,11 @@ const CameraPlacement = () => {
|
|
|
1487
1712
|
};
|
|
1488
1713
|
|
|
1489
1714
|
const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
1490
|
-
|
|
1715
|
+
// Determine if this is editing an existing camera or adding a new one
|
|
1716
|
+
const isUpdating = Boolean(camera.verkadaModel);
|
|
1717
|
+
|
|
1491
1718
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
1492
|
-
const [selectedCamera, setSelectedCamera] = useState(null);
|
|
1719
|
+
const [selectedCamera, setSelectedCamera] = useState(camera.verkadaModel ? getCameraById(camera.verkadaModel) : null);
|
|
1493
1720
|
|
|
1494
1721
|
// Filter cameras based on selected category
|
|
1495
1722
|
const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
|
|
@@ -1497,6 +1724,7 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1497
1724
|
return cam.type === selectedCategory;
|
|
1498
1725
|
});
|
|
1499
1726
|
|
|
1727
|
+
|
|
1500
1728
|
const handleCameraSelect = (cameraModel) => {
|
|
1501
1729
|
setSelectedCamera(cameraModel);
|
|
1502
1730
|
};
|
|
@@ -1508,7 +1736,6 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1508
1736
|
}
|
|
1509
1737
|
|
|
1510
1738
|
onSave({
|
|
1511
|
-
customName: customName.trim(),
|
|
1512
1739
|
verkadaModel: selectedCamera.id,
|
|
1513
1740
|
});
|
|
1514
1741
|
};
|
|
@@ -1523,17 +1750,7 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1523
1750
|
|
|
1524
1751
|
<div className={styles.modalContent}>
|
|
1525
1752
|
<div className={styles.topSection}>
|
|
1526
|
-
|
|
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}>
|
|
1753
|
+
<div className={styles.categoryFilter}>
|
|
1537
1754
|
<label>Filter by Category:</label>
|
|
1538
1755
|
<div className={styles.categoryButtons}>
|
|
1539
1756
|
<button
|
|
@@ -1563,8 +1780,24 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1563
1780
|
onClick={() => handleCameraSelect(cam)}
|
|
1564
1781
|
>
|
|
1565
1782
|
<div className={styles.cardHeader}>
|
|
1566
|
-
<div className={styles.
|
|
1567
|
-
<
|
|
1783
|
+
<div className={styles.cameraImageContainer}>
|
|
1784
|
+
<img
|
|
1785
|
+
src={cam.image}
|
|
1786
|
+
alt={cam.name}
|
|
1787
|
+
className={styles.cameraImage}
|
|
1788
|
+
onLoad={(e) => {
|
|
1789
|
+
e.target.style.opacity = '1';
|
|
1790
|
+
}}
|
|
1791
|
+
onError={(e) => {
|
|
1792
|
+
// Fallback to icon if image fails to load
|
|
1793
|
+
e.target.style.display = 'none';
|
|
1794
|
+
e.target.nextSibling.style.display = 'flex';
|
|
1795
|
+
}}
|
|
1796
|
+
style={{opacity: '0.3'}}
|
|
1797
|
+
/>
|
|
1798
|
+
<div className={styles.cameraIcon} style={{display: 'none'}}>
|
|
1799
|
+
<CameraIcon icon={cam.icon} size={24} />
|
|
1800
|
+
</div>
|
|
1568
1801
|
</div>
|
|
1569
1802
|
<div className={styles.cameraTitle}>
|
|
1570
1803
|
<h4>{cam.name}</h4>
|
|
@@ -1626,7 +1859,7 @@ const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
|
1626
1859
|
disabled={!selectedCamera}
|
|
1627
1860
|
className={styles.saveBtn}
|
|
1628
1861
|
>
|
|
1629
|
-
Add Camera
|
|
1862
|
+
{isUpdating ? 'Update Camera' : 'Add Camera'}
|
|
1630
1863
|
</button>
|
|
1631
1864
|
</div>
|
|
1632
1865
|
</div>
|