@visns-studio/visns-components 5.15.4 → 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.
- package/README.md +42 -1
- package/package.json +4 -1
- package/src/components/CameraPlacement.jsx +1871 -0
- package/src/components/data/verkadaCameras.js +818 -0
- package/src/components/styles/CameraMarkers.scss +200 -0
- package/src/components/styles/CameraPlacement.module.scss +1495 -0
- package/src/components/styles/CameraPlacementModal.module.scss +185 -0
- package/src/components/utils/CameraIcon.jsx +18 -0
- package/src/index.js +2 -0
|
@@ -0,0 +1,1871 @@
|
|
|
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 { Edit3, Trash2 } from 'lucide-react';
|
|
7
|
+
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
8
|
+
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
9
|
+
import styles from './styles/CameraPlacement.module.scss';
|
|
10
|
+
import modalStyles from './styles/CameraPlacementModal.module.scss';
|
|
11
|
+
import './styles/CameraMarkers.scss';
|
|
12
|
+
import {
|
|
13
|
+
VERKADA_CAMERAS,
|
|
14
|
+
CAMERA_CATEGORIES,
|
|
15
|
+
getCameraById,
|
|
16
|
+
} from './data/verkadaCameras';
|
|
17
|
+
import CameraIcon from './utils/CameraIcon';
|
|
18
|
+
|
|
19
|
+
const CameraPlacement = () => {
|
|
20
|
+
const [cameras, setCameras] = useState([]);
|
|
21
|
+
const [selectedCamera, setSelectedCamera] = useState(null);
|
|
22
|
+
const [showModal, setShowModal] = useState(false);
|
|
23
|
+
const [map, setMap] = useState(null);
|
|
24
|
+
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
25
|
+
const [mode, setMode] = useState('image'); // 'image' or 'map' - default to image
|
|
26
|
+
const [uploadedImage, setUploadedImage] = useState(null);
|
|
27
|
+
const [addCameraMode, setAddCameraMode] = useState(false);
|
|
28
|
+
const mapContainer = useRef(null);
|
|
29
|
+
const mapRef = useRef(null);
|
|
30
|
+
const geocoderRef = useRef(null);
|
|
31
|
+
const imageContainer = useRef(null);
|
|
32
|
+
const fileInputRef = useRef(null);
|
|
33
|
+
|
|
34
|
+
// Mapbox configuration
|
|
35
|
+
const MAPBOX_ACCESS_TOKEN = 'pk.eyJ1IjoidmlzbnMtc3R1ZGlvIiwiYSI6ImNrbGlraW5vZDBhMDYycHBsdmk5b3ljbGQifQ.ZGUBk0PWac5GahUeVHdTpg';
|
|
36
|
+
|
|
37
|
+
// Handle image upload
|
|
38
|
+
const handleImageUpload = useCallback((event) => {
|
|
39
|
+
const file = event.target.files[0];
|
|
40
|
+
if (file && file.type.startsWith('image/')) {
|
|
41
|
+
const reader = new FileReader();
|
|
42
|
+
reader.onload = (e) => {
|
|
43
|
+
setUploadedImage(e.target.result);
|
|
44
|
+
setMode('image');
|
|
45
|
+
// Reset cameras when switching to image mode
|
|
46
|
+
setCameras([]);
|
|
47
|
+
// Cleanup map if it exists
|
|
48
|
+
if (map) {
|
|
49
|
+
try {
|
|
50
|
+
map.remove();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.warn('Error removing map:', error);
|
|
53
|
+
}
|
|
54
|
+
setMap(null);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
reader.readAsDataURL(file);
|
|
58
|
+
}
|
|
59
|
+
}, [map]);
|
|
60
|
+
|
|
61
|
+
// Handle image click for camera placement (only when in add camera mode)
|
|
62
|
+
const handleImageClick = useCallback((event) => {
|
|
63
|
+
if (mode !== 'image' || !imageContainer.current || !addCameraMode) return;
|
|
64
|
+
|
|
65
|
+
const rect = imageContainer.current.getBoundingClientRect();
|
|
66
|
+
const x = event.clientX - rect.left;
|
|
67
|
+
const y = event.clientY - rect.top;
|
|
68
|
+
|
|
69
|
+
const newCamera = {
|
|
70
|
+
id: Date.now(),
|
|
71
|
+
x: x, // Image coordinates instead of lat/lng
|
|
72
|
+
y: y,
|
|
73
|
+
name: `Camera ${cameras.length + 1}`,
|
|
74
|
+
verkadaModel: null,
|
|
75
|
+
customName: '',
|
|
76
|
+
direction: 0,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
setCameras((prev) => [...prev, newCamera]);
|
|
80
|
+
setSelectedCamera(newCamera);
|
|
81
|
+
setShowModal(true);
|
|
82
|
+
setAddCameraMode(false); // Exit add camera mode after placing
|
|
83
|
+
}, [mode, cameras.length, addCameraMode]);
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// Initialize Mapbox GL map
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!mapContainer.current || mode !== 'map') return;
|
|
89
|
+
|
|
90
|
+
// Set Mapbox access token
|
|
91
|
+
mapboxgl.accessToken = MAPBOX_ACCESS_TOKEN;
|
|
92
|
+
|
|
93
|
+
const mapInstance = new mapboxgl.Map({
|
|
94
|
+
container: mapContainer.current,
|
|
95
|
+
style: 'mapbox://styles/mapbox/satellite-streets-v12', // Start with satellite imagery
|
|
96
|
+
center: [115.8605, -31.9505], // Perth, Australia default
|
|
97
|
+
zoom: 16,
|
|
98
|
+
maxZoom: 22, // Allow higher zoom for better detail
|
|
99
|
+
minZoom: 10,
|
|
100
|
+
bearing: 0,
|
|
101
|
+
pitch: 0,
|
|
102
|
+
preserveDrawingBuffer: true, // Required for canvas export
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Add Mapbox geocoder
|
|
106
|
+
const geocoder = new MapboxGeocoder({
|
|
107
|
+
accessToken: MAPBOX_ACCESS_TOKEN,
|
|
108
|
+
mapboxgl: mapboxgl,
|
|
109
|
+
placeholder: 'Search for addresses...',
|
|
110
|
+
proximity: {
|
|
111
|
+
longitude: 144.9631,
|
|
112
|
+
latitude: -37.8136,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
mapInstance.on('load', () => {
|
|
117
|
+
setMap(mapInstance);
|
|
118
|
+
mapRef.current = mapInstance;
|
|
119
|
+
|
|
120
|
+
// Add zoom controls to the map using Mapbox GL
|
|
121
|
+
const zoomControl = new mapboxgl.NavigationControl({
|
|
122
|
+
showCompass: false, // Hide compass, only show zoom buttons
|
|
123
|
+
showZoom: true,
|
|
124
|
+
});
|
|
125
|
+
mapInstance.addControl(zoomControl, 'top-right');
|
|
126
|
+
|
|
127
|
+
// Add fullscreen control using Mapbox GL
|
|
128
|
+
const fullscreenControl = new mapboxgl.FullscreenControl();
|
|
129
|
+
mapInstance.addControl(fullscreenControl, 'top-right');
|
|
130
|
+
|
|
131
|
+
// Add Mapbox geocoder to the map
|
|
132
|
+
if (geocoderRef.current) {
|
|
133
|
+
geocoderRef.current.appendChild(geocoder.onAdd(mapInstance));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Handle geocoder result for Mapbox
|
|
137
|
+
geocoder.on('result', (e) => {
|
|
138
|
+
// Mapbox geocoder result format
|
|
139
|
+
const coordinates = e.result.center;
|
|
140
|
+
if (coordinates) {
|
|
141
|
+
mapInstance.flyTo({
|
|
142
|
+
center: coordinates,
|
|
143
|
+
zoom: Math.min(Math.max(mapInstance.getZoom(), 18), 20), // Allow zoom up to 20 for better detail
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return () => {
|
|
150
|
+
mapInstance.remove();
|
|
151
|
+
};
|
|
152
|
+
}, [mode]);
|
|
153
|
+
|
|
154
|
+
// Handle map click to add camera
|
|
155
|
+
const handleMapClick = useCallback(
|
|
156
|
+
(e) => {
|
|
157
|
+
if (!map || mode !== 'map') return;
|
|
158
|
+
|
|
159
|
+
const { lng, lat } = e.lngLat;
|
|
160
|
+
const newCamera = {
|
|
161
|
+
id: Date.now(),
|
|
162
|
+
lng,
|
|
163
|
+
lat,
|
|
164
|
+
name: `Camera ${cameras.length + 1}`,
|
|
165
|
+
verkadaModel: null, // Will be selected in modal
|
|
166
|
+
customName: '',
|
|
167
|
+
direction: 0,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
setCameras((prev) => [...prev, newCamera]);
|
|
171
|
+
setSelectedCamera(newCamera);
|
|
172
|
+
setShowModal(true);
|
|
173
|
+
},
|
|
174
|
+
[map, cameras.length, mode]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Add click listener to map
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!map) return;
|
|
180
|
+
|
|
181
|
+
map.on('click', handleMapClick);
|
|
182
|
+
return () => {
|
|
183
|
+
map.off('click', handleMapClick);
|
|
184
|
+
};
|
|
185
|
+
}, [map, handleMapClick]);
|
|
186
|
+
|
|
187
|
+
const updateCamera = useCallback((cameraId, updates) => {
|
|
188
|
+
setCameras((prev) => {
|
|
189
|
+
const updated = prev.map((cam) =>
|
|
190
|
+
cam.id === cameraId ? { ...cam, ...updates } : cam
|
|
191
|
+
);
|
|
192
|
+
return updated;
|
|
193
|
+
});
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
const removeCamera = useCallback(
|
|
197
|
+
(cameraId) => {
|
|
198
|
+
setCameras((prev) => prev.filter((cam) => cam.id !== cameraId));
|
|
199
|
+
|
|
200
|
+
// Remove FOV layer and source from map
|
|
201
|
+
if (map) {
|
|
202
|
+
const layerId = `fov-layer-${cameraId}`;
|
|
203
|
+
const sourceId = `fov-${cameraId}`;
|
|
204
|
+
if (map.getLayer(layerId)) {
|
|
205
|
+
map.removeLayer(layerId);
|
|
206
|
+
}
|
|
207
|
+
if (map.getSource(sourceId)) {
|
|
208
|
+
map.removeSource(sourceId);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
[map]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Save placement as JSON file
|
|
216
|
+
const saveAsFile = useCallback(() => {
|
|
217
|
+
if (!cameras.length && !uploadedImage) {
|
|
218
|
+
Swal.fire({
|
|
219
|
+
icon: 'warning',
|
|
220
|
+
title: 'No Data to Save',
|
|
221
|
+
text: 'Please add cameras or upload an image first.',
|
|
222
|
+
confirmButtonColor: '#8b5cf6'
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const saveData = {
|
|
228
|
+
version: '1.0',
|
|
229
|
+
timestamp: new Date().toISOString(),
|
|
230
|
+
image: uploadedImage ? {
|
|
231
|
+
src: uploadedImage,
|
|
232
|
+
name: 'uploaded-image'
|
|
233
|
+
} : null,
|
|
234
|
+
mode: mode,
|
|
235
|
+
cameras: cameras.map(camera => ({
|
|
236
|
+
id: camera.id,
|
|
237
|
+
name: camera.name,
|
|
238
|
+
customName: camera.customName,
|
|
239
|
+
x: camera.x,
|
|
240
|
+
y: camera.y,
|
|
241
|
+
direction: camera.direction,
|
|
242
|
+
verkadaModel: camera.verkadaModel,
|
|
243
|
+
timestamp: camera.timestamp
|
|
244
|
+
}))
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const dataStr = JSON.stringify(saveData, null, 2);
|
|
248
|
+
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
|
249
|
+
|
|
250
|
+
const exportFileDefaultName = `camera-placement-${new Date().toISOString().split('T')[0]}.json`;
|
|
251
|
+
|
|
252
|
+
const linkElement = document.createElement('a');
|
|
253
|
+
linkElement.setAttribute('href', dataUri);
|
|
254
|
+
linkElement.setAttribute('download', exportFileDefaultName);
|
|
255
|
+
linkElement.click();
|
|
256
|
+
|
|
257
|
+
// Show success message
|
|
258
|
+
Swal.fire({
|
|
259
|
+
icon: 'success',
|
|
260
|
+
title: 'File Saved!',
|
|
261
|
+
text: `Camera placement saved as ${exportFileDefaultName}`,
|
|
262
|
+
timer: 3000,
|
|
263
|
+
showConfirmButton: false,
|
|
264
|
+
toast: true,
|
|
265
|
+
position: 'top-end'
|
|
266
|
+
});
|
|
267
|
+
}, [cameras, uploadedImage, mode]);
|
|
268
|
+
|
|
269
|
+
// Load placement from JSON file
|
|
270
|
+
const loadFromFile = useCallback(() => {
|
|
271
|
+
const input = document.createElement('input');
|
|
272
|
+
input.type = 'file';
|
|
273
|
+
input.accept = '.json';
|
|
274
|
+
|
|
275
|
+
input.onchange = (event) => {
|
|
276
|
+
const file = event.target.files[0];
|
|
277
|
+
if (!file) return;
|
|
278
|
+
|
|
279
|
+
const reader = new FileReader();
|
|
280
|
+
reader.onload = (e) => {
|
|
281
|
+
try {
|
|
282
|
+
const saveData = JSON.parse(e.target.result);
|
|
283
|
+
|
|
284
|
+
// Validate data structure
|
|
285
|
+
if (!saveData.version || !Array.isArray(saveData.cameras)) {
|
|
286
|
+
Swal.fire({
|
|
287
|
+
icon: 'error',
|
|
288
|
+
title: 'Invalid File',
|
|
289
|
+
text: 'Please select a valid camera placement file.',
|
|
290
|
+
confirmButtonColor: '#f59e0b'
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Load image if present
|
|
296
|
+
if (saveData.image && saveData.image.src) {
|
|
297
|
+
setUploadedImage(saveData.image.src);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Set mode
|
|
301
|
+
if (saveData.mode) {
|
|
302
|
+
setMode(saveData.mode);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Load cameras
|
|
306
|
+
setCameras(saveData.cameras || []);
|
|
307
|
+
|
|
308
|
+
Swal.fire({
|
|
309
|
+
icon: 'success',
|
|
310
|
+
title: 'File Loaded!',
|
|
311
|
+
text: `Successfully loaded ${saveData.cameras?.length || 0} cameras from ${file.name}`,
|
|
312
|
+
confirmButtonColor: '#f59e0b'
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error('Error loading file:', error);
|
|
317
|
+
Swal.fire({
|
|
318
|
+
icon: 'error',
|
|
319
|
+
title: 'File Error',
|
|
320
|
+
text: 'Error reading file. Please ensure it\'s a valid JSON file.',
|
|
321
|
+
confirmButtonColor: '#f59e0b'
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
reader.readAsText(file);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
input.click();
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
const exportMap = useCallback(async () => {
|
|
332
|
+
try {
|
|
333
|
+
if (!cameras.length) {
|
|
334
|
+
Swal.fire({
|
|
335
|
+
icon: 'warning',
|
|
336
|
+
title: 'No Cameras',
|
|
337
|
+
text: 'Please add cameras first before exporting.',
|
|
338
|
+
confirmButtonColor: '#10b981'
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const imageContainer = document.querySelector(`.${styles.imageContainer}`);
|
|
344
|
+
if (!imageContainer) {
|
|
345
|
+
Swal.fire({
|
|
346
|
+
icon: 'warning',
|
|
347
|
+
title: 'No Image',
|
|
348
|
+
text: 'Please upload an image first before exporting.',
|
|
349
|
+
confirmButtonColor: '#10b981'
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// First, take a screenshot of just the image container to get its natural size
|
|
355
|
+
const imageCanvas = await html2canvas(imageContainer, {
|
|
356
|
+
useCORS: true,
|
|
357
|
+
allowTaint: true,
|
|
358
|
+
scale: 1.2,
|
|
359
|
+
backgroundColor: '#f8fafc',
|
|
360
|
+
logging: false
|
|
361
|
+
});
|
|
362
|
+
|
|
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;
|
|
370
|
+
|
|
371
|
+
// Use the larger of image height or camera list height
|
|
372
|
+
const finalHeight = Math.max(imageCanvas.height, totalCameraListHeight);
|
|
373
|
+
const finalWidth = imageCanvas.width + cameraListWidth + 20; // small gap
|
|
374
|
+
|
|
375
|
+
// Create the composite container with calculated dimensions
|
|
376
|
+
const exportContainer = document.createElement('div');
|
|
377
|
+
exportContainer.style.position = 'absolute';
|
|
378
|
+
exportContainer.style.top = '-10000px';
|
|
379
|
+
exportContainer.style.left = '-10000px';
|
|
380
|
+
exportContainer.style.width = finalWidth + 'px';
|
|
381
|
+
exportContainer.style.height = finalHeight + 'px';
|
|
382
|
+
exportContainer.style.backgroundColor = '#ffffff';
|
|
383
|
+
exportContainer.style.display = 'flex';
|
|
384
|
+
exportContainer.style.gap = '10px';
|
|
385
|
+
exportContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif';
|
|
386
|
+
document.body.appendChild(exportContainer);
|
|
387
|
+
|
|
388
|
+
// Left side: Image
|
|
389
|
+
const imageDiv = document.createElement('div');
|
|
390
|
+
imageDiv.style.width = imageCanvas.width + 'px';
|
|
391
|
+
imageDiv.style.height = finalHeight + 'px';
|
|
392
|
+
imageDiv.style.background = '#f8fafc';
|
|
393
|
+
imageDiv.style.display = 'flex';
|
|
394
|
+
imageDiv.style.alignItems = 'center';
|
|
395
|
+
imageDiv.style.justifyContent = 'center';
|
|
396
|
+
imageDiv.style.borderRight = '2px solid #e5e7eb';
|
|
397
|
+
imageDiv.style.flexShrink = '0';
|
|
398
|
+
|
|
399
|
+
// Scale the canvas to fit if needed
|
|
400
|
+
const scaledCanvas = document.createElement('canvas');
|
|
401
|
+
const ctx = scaledCanvas.getContext('2d');
|
|
402
|
+
scaledCanvas.width = imageCanvas.width;
|
|
403
|
+
scaledCanvas.height = imageCanvas.height;
|
|
404
|
+
ctx.drawImage(imageCanvas, 0, 0);
|
|
405
|
+
|
|
406
|
+
imageDiv.appendChild(scaledCanvas);
|
|
407
|
+
exportContainer.appendChild(imageDiv);
|
|
408
|
+
|
|
409
|
+
// Right side: Camera list
|
|
410
|
+
const cameraListDiv = document.createElement('div');
|
|
411
|
+
cameraListDiv.style.width = cameraListWidth + 'px';
|
|
412
|
+
cameraListDiv.style.height = finalHeight + 'px';
|
|
413
|
+
cameraListDiv.style.padding = '20px';
|
|
414
|
+
cameraListDiv.style.background = '#ffffff';
|
|
415
|
+
cameraListDiv.style.overflow = 'visible';
|
|
416
|
+
cameraListDiv.style.boxSizing = 'border-box';
|
|
417
|
+
|
|
418
|
+
// Generate professional report-style HTML
|
|
419
|
+
const cameraListHTML = `
|
|
420
|
+
<!-- Report Header -->
|
|
421
|
+
<div style="background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; padding: 32px; margin: -20px -20px 0 -20px; border-radius: 16px 16px 0 0; box-shadow: 0 6px 20px rgba(0,0,0,0.15);">
|
|
422
|
+
<h1 style="margin: 0 0 16px 0; font-size: 36px; font-weight: 800; text-align: center; color: #ffffff; text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.6px; line-height: 1.1;">Security Camera Installation Report</h1>
|
|
423
|
+
<div style="text-align: center; margin-bottom: 24px;">
|
|
424
|
+
<div style="display: inline-block; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); padding: 12px 24px; border-radius: 25px; font-size: 14px; font-weight: 600; letter-spacing: 1px; color: #e5e7eb;">
|
|
425
|
+
PROJECT LAYOUT & SPECIFICATIONS
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; border-top: 1px solid rgba(255,255,255,0.15); padding-top: 20px; letter-spacing: 0.3px;">
|
|
429
|
+
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Generated:</strong> ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</div>
|
|
430
|
+
<div style="color: #d1d5db; font-weight: 500;"><strong style="color: #f3f4f6; font-weight: 600;">Time:</strong> ${new Date().toLocaleTimeString()}</div>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
${cameras.map((camera, index) => {
|
|
436
|
+
const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
|
|
437
|
+
// Generate sequential camera number
|
|
438
|
+
const cameraNumber = index + 1;
|
|
439
|
+
|
|
440
|
+
return `
|
|
441
|
+
<div style="margin-bottom: 20px; background: white; border: 1px solid #d1d5db; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08); page-break-inside: avoid;">
|
|
442
|
+
<!-- Camera Header -->
|
|
443
|
+
<div style="background: ${verkadaCamera?.environment === 'indoor' ? 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' : 'linear-gradient(135deg, #ea580c 0%, #dc2626 100%)'}; color: white; padding: 16px 20px; position: relative;">
|
|
444
|
+
<div style="display: flex; align-items: center; gap: 16px;">
|
|
445
|
+
<div style="width: 40px; height: 40px; background: rgba(255,255,255,0.15); border: 2px solid rgba(255,255,255,0.25); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 16px; flex-shrink: 0; text-shadow: 0 1px 3px rgba(0,0,0,0.3);">${cameraNumber}</div>
|
|
446
|
+
<div style="flex: 1;">
|
|
447
|
+
<h4 style="margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.3px; text-shadow: 0 1px 3px rgba(0,0,0,0.2);">Camera ${cameraNumber}</h4>
|
|
448
|
+
<div style="font-size: 13px; opacity: 0.95; margin-top: 4px; letter-spacing: 0.2px;">${verkadaCamera ? verkadaCamera.name : 'Model Not Selected'}</div>
|
|
449
|
+
</div>
|
|
450
|
+
${verkadaCamera?.ptz ? '<div style="background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); padding: 6px 12px; border-radius: 16px; font-size: 11px; font-weight: 600; letter-spacing: 0.5px;">PTZ</div>' : ''}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<!-- Camera Details -->
|
|
455
|
+
<div style="padding: 20px;">
|
|
456
|
+
${verkadaCamera ? `
|
|
457
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
|
|
458
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #3b82f6;">
|
|
459
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Resolution</div>
|
|
460
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.resolution}</div>
|
|
461
|
+
</div>
|
|
462
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #8b5cf6;">
|
|
463
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Field of View</div>
|
|
464
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.fov}°</div>
|
|
465
|
+
</div>
|
|
466
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid ${verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b'};">
|
|
467
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">Environment</div>
|
|
468
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; text-transform: capitalize; line-height: 1.3;">${verkadaCamera.environment}</div>
|
|
469
|
+
</div>
|
|
470
|
+
<div style="background: #f8fafc; padding: 12px; border-radius: 10px; border-left: 3px solid #ef4444;">
|
|
471
|
+
<div style="font-size: 12px; color: #475569; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 4px;">IR Range</div>
|
|
472
|
+
<div style="font-size: 15px; font-weight: 700; color: #0f172a; line-height: 1.3;">${verkadaCamera.irRange}m</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
<!-- Position & Direction -->
|
|
477
|
+
<div style="background: linear-gradient(135deg, #fefefe 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
|
|
478
|
+
<div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 12px; text-align: center; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px;">Installation Details</div>
|
|
479
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
|
480
|
+
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
481
|
+
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Direction</div>
|
|
482
|
+
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4;">${camera.direction ? `${camera.direction}° from North` : 'Not set'}</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid #e2e8f0;">
|
|
485
|
+
<div style="font-size: 11px; color: #64748b; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">Coordinates</div>
|
|
486
|
+
<div style="font-size: 14px; font-weight: 600; color: #0f172a; line-height: 1.4; font-family: monospace;">${mode === 'map' ? `${camera.lat?.toFixed(6)}, ${camera.lng?.toFixed(6)}` : `X: ${Math.round(camera.x)}, Y: ${Math.round(camera.y)}`}</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<!-- Features -->
|
|
492
|
+
${verkadaCamera.features && verkadaCamera.features.length > 0 ? `
|
|
493
|
+
<div style="background: white; padding: 14px; border-radius: 12px; border: 1px solid #e2e8f0;">
|
|
494
|
+
<div style="font-size: 13px; color: #334155; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 10px; text-align: center;">Key Features</div>
|
|
495
|
+
<div style="display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;">
|
|
496
|
+
${verkadaCamera.features.slice(0, 6).map(feature =>
|
|
497
|
+
`<span style="background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); color: #1e40af; border: 1px solid #93c5fd; padding: 6px 12px; border-radius: 18px; font-size: 11px; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;">${feature}</span>`
|
|
498
|
+
).join('')}
|
|
499
|
+
${verkadaCamera.features.length > 6 ? `<span style="color: #64748b; font-size: 11px; align-self: center; padding: 4px 8px; border-radius: 12px; background: #f1f5f9; font-weight: 500;">+${verkadaCamera.features.length - 6} more</span>` : ''}
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
` : ''}
|
|
503
|
+
` : `
|
|
504
|
+
<div style="text-align: center; padding: 30px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border: 1px solid #fca5a5; border-radius: 12px; margin: 10px 0;">
|
|
505
|
+
<div style="font-size: 16px; font-weight: 700; margin-bottom: 8px; color: #dc2626; letter-spacing: 0.2px;">⚠ No Camera Model Selected</div>
|
|
506
|
+
<div style="font-size: 13px; color: #7f1d1d; line-height: 1.5; letter-spacing: 0.2px;">Please configure this camera before installation</div>
|
|
507
|
+
</div>
|
|
508
|
+
`}
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
`;
|
|
512
|
+
}).join('')}
|
|
513
|
+
|
|
514
|
+
<!-- Report Footer -->
|
|
515
|
+
<div style="margin-top: 32px; padding: 24px; background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); border-radius: 16px; border: 1px solid #cbd5e1; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
|
|
516
|
+
<!-- Legend Section -->
|
|
517
|
+
<div style="margin-bottom: 24px;">
|
|
518
|
+
<h3 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 800; color: #0f172a; text-align: center; letter-spacing: -0.4px;">Camera Types & Legend</h3>
|
|
519
|
+
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;">
|
|
520
|
+
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
521
|
+
<div style="width: 24px; height: 24px; background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(13,148,136,0.3);"></div>
|
|
522
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Indoor Camera</span>
|
|
523
|
+
</div>
|
|
524
|
+
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
525
|
+
<div style="width: 24px; height: 24px; background: linear-gradient(135deg, #ea580c 0%, #dc2626 100%); border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 6px rgba(234,88,12,0.3);"></div>
|
|
526
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">Outdoor Camera</span>
|
|
527
|
+
</div>
|
|
528
|
+
${cameras.some(c => getCameraById(c.verkadaModel)?.ptz) ? `
|
|
529
|
+
<div style="display: flex; align-items: center; gap: 12px; background: white; padding: 12px 16px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
|
|
530
|
+
<div style="width: 24px; height: 24px; background: #6b7280; border-radius: 50%; border: 3px solid #ef4444; box-shadow: 0 2px 6px rgba(239,68,68,0.3);"></div>
|
|
531
|
+
<span style="font-size: 13px; font-weight: 600; color: #1e293b; letter-spacing: 0.2px;">PTZ Camera</span>
|
|
532
|
+
</div>` : ''}
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
<!-- Technical Notes -->
|
|
537
|
+
<div style="background: white; padding: 20px; border-radius: 12px; border: 1px solid #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,0.04);">
|
|
538
|
+
<h4 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 700; color: #0f172a; text-align: center; letter-spacing: -0.2px;">📋 Installation Notes</h4>
|
|
539
|
+
<div style="font-size: 13px; line-height: 1.8; color: #374151; letter-spacing: 0.2px;">
|
|
540
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #3b82f6;"><strong style="color: #1e40af;">Direction Reference:</strong> All camera directions are measured clockwise from North (0° = North, 90° = East, 180° = South, 270° = West)</div>
|
|
541
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #8b5cf6;"><strong style="color: #7c3aed;">Field of View:</strong> FOV angles represent the horizontal viewing angle. Actual coverage may vary based on mounting height and obstacles</div>
|
|
542
|
+
<div style="margin-bottom: 12px; padding: 10px; background: #f8fafc; border-radius: 8px; border-left: 3px solid #10b981;"><strong style="color: #059669;">Coordinates:</strong> ${mode === 'map' ? 'Geographic coordinates (Latitude, Longitude) in decimal degrees' : 'Pixel coordinates relative to uploaded image (X, Y from top-left corner)'}</div>
|
|
543
|
+
<div style="padding: 12px; background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); border-radius: 8px; border-left: 3px solid #ef4444;"><strong style="color: #dc2626;">Important:</strong> This layout should be verified on-site during installation. Actual camera placement may need adjustment based on physical constraints, lighting conditions, and site requirements</div>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<!-- Report ID & Disclaimer -->
|
|
548
|
+
<div style="text-align: center; margin-top: 20px; padding-top: 16px; border-top: 2px solid #cbd5e1;">
|
|
549
|
+
<div style="font-size: 12px; color: #475569; margin-bottom: 8px; letter-spacing: 0.5px; font-weight: 600;">Report ID: <span style="font-family: monospace; background: #f1f5f9; padding: 2px 6px; border-radius: 4px;">${Date.now().toString(36).toUpperCase()}</span></div>
|
|
550
|
+
<div style="font-size: 11px; color: #64748b; line-height: 1.6; letter-spacing: 0.3px; max-width: 400px; margin: 0 auto;">This report was generated automatically and should be reviewed by qualified security installation professionals</div>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
`;
|
|
554
|
+
|
|
555
|
+
cameraListDiv.innerHTML = cameraListHTML;
|
|
556
|
+
exportContainer.appendChild(cameraListDiv);
|
|
557
|
+
|
|
558
|
+
// Wait for rendering
|
|
559
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
560
|
+
|
|
561
|
+
// Capture the composite with exact dimensions
|
|
562
|
+
const finalCanvas = await html2canvas(exportContainer, {
|
|
563
|
+
useCORS: true,
|
|
564
|
+
allowTaint: true,
|
|
565
|
+
scale: 1,
|
|
566
|
+
backgroundColor: '#ffffff',
|
|
567
|
+
logging: false,
|
|
568
|
+
width: finalWidth,
|
|
569
|
+
height: finalHeight
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Clean up
|
|
573
|
+
document.body.removeChild(exportContainer);
|
|
574
|
+
|
|
575
|
+
// Download
|
|
576
|
+
const link = document.createElement('a');
|
|
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
|
|
580
|
+
link.click();
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
// Show success message
|
|
584
|
+
Swal.fire({
|
|
585
|
+
icon: 'success',
|
|
586
|
+
title: 'Export Complete!',
|
|
587
|
+
text: `Professional camera installation report exported as ${link.download}`,
|
|
588
|
+
timer: 3000,
|
|
589
|
+
showConfirmButton: false,
|
|
590
|
+
toast: true,
|
|
591
|
+
position: 'top-end'
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
} catch (error) {
|
|
595
|
+
console.error('Error exporting:', error);
|
|
596
|
+
Swal.fire({
|
|
597
|
+
icon: 'error',
|
|
598
|
+
title: 'Export Failed',
|
|
599
|
+
text: 'Export failed. Please try again or take a manual screenshot.',
|
|
600
|
+
confirmButtonColor: '#10b981'
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}, [cameras]);
|
|
604
|
+
|
|
605
|
+
// Update cameras with markers on map
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
if (!map || mode !== 'map') return;
|
|
608
|
+
|
|
609
|
+
// Clear existing markers
|
|
610
|
+
const existingMarkers = document.querySelectorAll('.camera-map-marker');
|
|
611
|
+
existingMarkers.forEach((marker) => marker.remove());
|
|
612
|
+
|
|
613
|
+
// Clear existing FOV layers
|
|
614
|
+
let existingSources = [];
|
|
615
|
+
try {
|
|
616
|
+
const style = map.getStyle();
|
|
617
|
+
if (style && style.sources) {
|
|
618
|
+
existingSources = Object.keys(style.sources).filter(
|
|
619
|
+
(id) => id.startsWith('fov-')
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
} catch (error) {
|
|
623
|
+
console.warn('Could not access map style, skipping FOV layer cleanup:', error);
|
|
624
|
+
}
|
|
625
|
+
existingSources.forEach((sourceId) => {
|
|
626
|
+
try {
|
|
627
|
+
const layerId = `fov-layer-${sourceId.replace('fov-', '')}`;
|
|
628
|
+
if (map.getLayer(layerId)) {
|
|
629
|
+
map.removeLayer(layerId);
|
|
630
|
+
}
|
|
631
|
+
if (map.getSource(sourceId)) {
|
|
632
|
+
map.removeSource(sourceId);
|
|
633
|
+
}
|
|
634
|
+
} catch (error) {
|
|
635
|
+
console.warn(`Could not remove layer/source ${sourceId}:`, error);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Add camera markers and FOV
|
|
640
|
+
cameras.forEach((camera, index) => {
|
|
641
|
+
const verkadaCamera = camera.verkadaModel
|
|
642
|
+
? getCameraById(camera.verkadaModel)
|
|
643
|
+
: null;
|
|
644
|
+
|
|
645
|
+
// Create main marker container
|
|
646
|
+
const markerContainer = document.createElement('div');
|
|
647
|
+
markerContainer.className = 'camera-marker-container';
|
|
648
|
+
|
|
649
|
+
// Create marker element
|
|
650
|
+
const markerEl = document.createElement('div');
|
|
651
|
+
const cameraType = verkadaCamera ? verkadaCamera.type : 'unknown';
|
|
652
|
+
const environment = verkadaCamera
|
|
653
|
+
? verkadaCamera.environment
|
|
654
|
+
: 'outdoor';
|
|
655
|
+
|
|
656
|
+
markerEl.className = `camera-map-marker ${cameraType} ${environment}`;
|
|
657
|
+
|
|
658
|
+
// Extract camera number from name or use index + 1
|
|
659
|
+
const cameraNumber =
|
|
660
|
+
(camera.customName || camera.name).replace(/\D/g, '') ||
|
|
661
|
+
index + 1;
|
|
662
|
+
|
|
663
|
+
markerEl.innerHTML = `
|
|
664
|
+
<div class="camera-marker-content">
|
|
665
|
+
<div class="camera-number">${cameraNumber}</div>
|
|
666
|
+
</div>
|
|
667
|
+
`;
|
|
668
|
+
|
|
669
|
+
// Create rotation handle
|
|
670
|
+
const rotationHandle = document.createElement('div');
|
|
671
|
+
rotationHandle.className = 'rotation-handle';
|
|
672
|
+
rotationHandle.innerHTML = '<div class="rotation-icon"></div>';
|
|
673
|
+
|
|
674
|
+
// Add rotation functionality
|
|
675
|
+
let isRotating = false;
|
|
676
|
+
let startAngle = 0;
|
|
677
|
+
let currentRotation = camera.direction || 0;
|
|
678
|
+
|
|
679
|
+
const handleRotationStart = (e) => {
|
|
680
|
+
e.stopPropagation();
|
|
681
|
+
isRotating = true;
|
|
682
|
+
const rect = markerContainer.getBoundingClientRect();
|
|
683
|
+
const centerX = rect.left + rect.width / 2;
|
|
684
|
+
const centerY = rect.top + rect.height / 2;
|
|
685
|
+
startAngle =
|
|
686
|
+
(Math.atan2(e.clientY - centerY, e.clientX - centerX) *
|
|
687
|
+
180) /
|
|
688
|
+
Math.PI;
|
|
689
|
+
markerContainer.classList.add('rotating');
|
|
690
|
+
document.addEventListener('mousemove', handleRotationMove);
|
|
691
|
+
document.addEventListener('mouseup', handleRotationEnd);
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const handleRotationMove = (e) => {
|
|
695
|
+
if (!isRotating) return;
|
|
696
|
+
const rect = markerContainer.getBoundingClientRect();
|
|
697
|
+
const centerX = rect.left + rect.width / 2;
|
|
698
|
+
const centerY = rect.top + rect.height / 2;
|
|
699
|
+
const currentAngle =
|
|
700
|
+
(Math.atan2(e.clientY - centerY, e.clientX - centerX) *
|
|
701
|
+
180) /
|
|
702
|
+
Math.PI;
|
|
703
|
+
const deltaAngle = currentAngle - startAngle;
|
|
704
|
+
const newRotation = (currentRotation + deltaAngle + 360) % 360;
|
|
705
|
+
|
|
706
|
+
// Update visual rotation
|
|
707
|
+
rotationHandle.style.transform = `rotate(${newRotation}deg)`;
|
|
708
|
+
|
|
709
|
+
// Update camera direction in real-time
|
|
710
|
+
updateCamera(camera.id, { direction: Math.round(newRotation) });
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const handleRotationEnd = () => {
|
|
714
|
+
isRotating = false;
|
|
715
|
+
markerContainer.classList.remove('rotating');
|
|
716
|
+
document.removeEventListener('mousemove', handleRotationMove);
|
|
717
|
+
document.removeEventListener('mouseup', handleRotationEnd);
|
|
718
|
+
|
|
719
|
+
// Get final rotation value
|
|
720
|
+
const transform = rotationHandle.style.transform;
|
|
721
|
+
const match = transform.match(/rotate\(([^)]+)deg\)/);
|
|
722
|
+
if (match) {
|
|
723
|
+
currentRotation = parseFloat(match[1]);
|
|
724
|
+
updateCamera(camera.id, {
|
|
725
|
+
direction: Math.round(currentRotation),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// Add event listeners
|
|
731
|
+
rotationHandle.addEventListener('mousedown', handleRotationStart);
|
|
732
|
+
|
|
733
|
+
// Set initial rotation
|
|
734
|
+
rotationHandle.style.transform = `rotate(${currentRotation}deg)`;
|
|
735
|
+
|
|
736
|
+
// Click handler for main marker (not rotation handle)
|
|
737
|
+
markerEl.onclick = () => {
|
|
738
|
+
if (!isRotating) {
|
|
739
|
+
setSelectedCamera(camera);
|
|
740
|
+
setShowModal(true);
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// Assemble marker structure
|
|
745
|
+
markerContainer.appendChild(markerEl);
|
|
746
|
+
markerContainer.appendChild(rotationHandle);
|
|
747
|
+
|
|
748
|
+
// Add marker to map with drag functionality using Mapbox GL
|
|
749
|
+
const marker = new mapboxgl.Marker({
|
|
750
|
+
element: markerContainer,
|
|
751
|
+
draggable: true,
|
|
752
|
+
anchor: 'center', // Ensure marker is centered on coordinates
|
|
753
|
+
})
|
|
754
|
+
.setLngLat([camera.lng, camera.lat])
|
|
755
|
+
.addTo(map);
|
|
756
|
+
|
|
757
|
+
// Handle drag start
|
|
758
|
+
marker.on('dragstart', () => {
|
|
759
|
+
markerContainer.classList.add('dragging');
|
|
760
|
+
// Hide rotation handle during drag
|
|
761
|
+
rotationHandle.style.opacity = '0.2';
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// Handle drag (real-time coordinate updates)
|
|
765
|
+
marker.on('drag', () => {
|
|
766
|
+
const { lng, lat } = marker.getLngLat();
|
|
767
|
+
// Update coordinates in real-time (optional - can be removed if performance is an issue)
|
|
768
|
+
updateCamera(camera.id, {
|
|
769
|
+
lng: parseFloat(lng.toFixed(6)),
|
|
770
|
+
lat: parseFloat(lat.toFixed(6)),
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Handle drag end to finalize camera position
|
|
775
|
+
marker.on('dragend', () => {
|
|
776
|
+
markerContainer.classList.remove('dragging');
|
|
777
|
+
rotationHandle.style.opacity = '0.9';
|
|
778
|
+
|
|
779
|
+
const { lng, lat } = marker.getLngLat();
|
|
780
|
+
updateCamera(camera.id, {
|
|
781
|
+
lng: parseFloat(lng.toFixed(6)),
|
|
782
|
+
lat: parseFloat(lat.toFixed(6)),
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// Prevent rotation handle from triggering drag
|
|
787
|
+
rotationHandle.addEventListener('mousedown', (e) => {
|
|
788
|
+
e.stopPropagation();
|
|
789
|
+
marker.setDraggable(false);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Re-enable dragging after rotation ends
|
|
793
|
+
const enableDragAfterRotation = () => {
|
|
794
|
+
setTimeout(() => {
|
|
795
|
+
marker.setDraggable(true);
|
|
796
|
+
}, 100);
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
document.addEventListener('mouseup', enableDragAfterRotation);
|
|
800
|
+
|
|
801
|
+
// Add FOV visualization if camera model is selected
|
|
802
|
+
if (verkadaCamera) {
|
|
803
|
+
const fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov; // Use 90 degrees for PTZ as default view
|
|
804
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
805
|
+
const distance = verkadaCamera.ptz ? 0.001 : 0.0005; // Larger range for PTZ
|
|
806
|
+
const directionRad = (camera.direction * Math.PI) / 180;
|
|
807
|
+
|
|
808
|
+
// Calculate FOV triangle points
|
|
809
|
+
const point1Lng =
|
|
810
|
+
camera.lng + distance * Math.cos(directionRad - fovRad / 2);
|
|
811
|
+
const point1Lat =
|
|
812
|
+
camera.lat + distance * Math.sin(directionRad - fovRad / 2);
|
|
813
|
+
const point2Lng =
|
|
814
|
+
camera.lng + distance * Math.cos(directionRad + fovRad / 2);
|
|
815
|
+
const point2Lat =
|
|
816
|
+
camera.lat + distance * Math.sin(directionRad + fovRad / 2);
|
|
817
|
+
|
|
818
|
+
const fovPolygon = {
|
|
819
|
+
type: 'Feature',
|
|
820
|
+
geometry: {
|
|
821
|
+
type: 'Polygon',
|
|
822
|
+
coordinates: [
|
|
823
|
+
[
|
|
824
|
+
[camera.lng, camera.lat],
|
|
825
|
+
[point1Lng, point1Lat],
|
|
826
|
+
[point2Lng, point2Lat],
|
|
827
|
+
[camera.lng, camera.lat],
|
|
828
|
+
],
|
|
829
|
+
],
|
|
830
|
+
},
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
// Add FOV polygon to map
|
|
834
|
+
const sourceId = `fov-${camera.id}`;
|
|
835
|
+
const layerId = `fov-layer-${camera.id}`;
|
|
836
|
+
|
|
837
|
+
map.addSource(sourceId, {
|
|
838
|
+
type: 'geojson',
|
|
839
|
+
data: fovPolygon,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// Color based on camera type and environment
|
|
843
|
+
const fovColor =
|
|
844
|
+
verkadaCamera.environment === 'indoor'
|
|
845
|
+
? '#10b981'
|
|
846
|
+
: '#f59e0b';
|
|
847
|
+
|
|
848
|
+
map.addLayer({
|
|
849
|
+
id: layerId,
|
|
850
|
+
type: 'fill',
|
|
851
|
+
source: sourceId,
|
|
852
|
+
paint: {
|
|
853
|
+
'fill-color': fovColor,
|
|
854
|
+
'fill-opacity': 0.25,
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}, [map, cameras, mode]);
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
return (
|
|
863
|
+
<div className={styles.cameraPlacement}>
|
|
864
|
+
<div className={styles.header}>
|
|
865
|
+
<h1 className={styles.title}>Camera Placement Tool</h1>
|
|
866
|
+
<div className={styles.controls}>
|
|
867
|
+
<div className={styles.leftControls}>
|
|
868
|
+
<button
|
|
869
|
+
onClick={() => fileInputRef.current?.click()}
|
|
870
|
+
className={styles.uploadBtn}
|
|
871
|
+
>
|
|
872
|
+
Upload Image
|
|
873
|
+
</button>
|
|
874
|
+
{uploadedImage && (
|
|
875
|
+
<button
|
|
876
|
+
onClick={() => setAddCameraMode(!addCameraMode)}
|
|
877
|
+
className={`${styles.addCameraBtn} ${addCameraMode ? styles.activeControl : ''}`}
|
|
878
|
+
>
|
|
879
|
+
{addCameraMode ? 'Cancel Add' : 'Add Camera'}
|
|
880
|
+
</button>
|
|
881
|
+
)}
|
|
882
|
+
</div>
|
|
883
|
+
<div className={styles.rightControls}>
|
|
884
|
+
<button
|
|
885
|
+
onClick={saveAsFile}
|
|
886
|
+
className={styles.saveFileBtn}
|
|
887
|
+
disabled={!cameras.length && !uploadedImage}
|
|
888
|
+
title="Save camera placement as JSON file"
|
|
889
|
+
>
|
|
890
|
+
Save File
|
|
891
|
+
</button>
|
|
892
|
+
<button
|
|
893
|
+
onClick={loadFromFile}
|
|
894
|
+
className={styles.loadFileBtn}
|
|
895
|
+
title="Load camera placement from JSON file"
|
|
896
|
+
>
|
|
897
|
+
Load File
|
|
898
|
+
</button>
|
|
899
|
+
<button
|
|
900
|
+
onClick={() =>
|
|
901
|
+
setSidebarCollapsed(!sidebarCollapsed)
|
|
902
|
+
}
|
|
903
|
+
>
|
|
904
|
+
{sidebarCollapsed ? 'Show' : 'Hide'} Cameras
|
|
905
|
+
</button>
|
|
906
|
+
<button
|
|
907
|
+
className={styles.exportBtn}
|
|
908
|
+
onClick={exportMap}
|
|
909
|
+
disabled={mode === 'image' && !uploadedImage}
|
|
910
|
+
>
|
|
911
|
+
Export {mode === 'map' ? 'Map' : 'Image'}
|
|
912
|
+
</button>
|
|
913
|
+
</div>
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
|
|
917
|
+
<div className={styles.mainContent}>
|
|
918
|
+
<div
|
|
919
|
+
className={`${styles.imageContainer} ${addCameraMode ? styles.addMode : ''} ${sidebarCollapsed ? styles.sidebarCollapsed : ''}`}
|
|
920
|
+
ref={imageContainer}
|
|
921
|
+
onClick={handleImageClick}
|
|
922
|
+
>
|
|
923
|
+
{uploadedImage ? (
|
|
924
|
+
<div className={styles.imageWrapper}>
|
|
925
|
+
<img
|
|
926
|
+
src={uploadedImage}
|
|
927
|
+
alt="Floor plan or site layout"
|
|
928
|
+
className={styles.uploadedImage}
|
|
929
|
+
/>
|
|
930
|
+
{/* Render FOV triangles first (behind markers) */}
|
|
931
|
+
{cameras.map((camera) => {
|
|
932
|
+
const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
|
|
933
|
+
if (!verkadaCamera) return null;
|
|
934
|
+
|
|
935
|
+
let fov = verkadaCamera.ptz ? 90 : verkadaCamera.fov;
|
|
936
|
+
|
|
937
|
+
// Handle special FOV cases
|
|
938
|
+
if (fov >= 360) fov = 120; // PTZ cameras showing max practical FOV
|
|
939
|
+
else if (fov >= 270) fov = 150; // Multi-sensor cameras
|
|
940
|
+
else if (fov >= 180) fov = 130; // Fisheye cameras
|
|
941
|
+
|
|
942
|
+
const fovRad = (fov * Math.PI) / 180;
|
|
943
|
+
|
|
944
|
+
// Calculate distance based on FOV - wider angles get shorter distances
|
|
945
|
+
// Base distance of 100px for 60° FOV, scaled inversely with bounds
|
|
946
|
+
const baseFov = 60; // Reference FOV angle
|
|
947
|
+
const baseDistance = verkadaCamera.ptz ? 120 : 100;
|
|
948
|
+
let distance = baseDistance * (baseFov / fov);
|
|
949
|
+
|
|
950
|
+
// Apply bounds: min 30px, max 200px
|
|
951
|
+
distance = Math.max(30, Math.min(200, distance));
|
|
952
|
+
const directionRad = (camera.direction * Math.PI) / 180;
|
|
953
|
+
|
|
954
|
+
// Calculate FOV triangle points in pixels
|
|
955
|
+
const point1X = camera.x + distance * Math.cos(directionRad - fovRad / 2);
|
|
956
|
+
const point1Y = camera.y + distance * Math.sin(directionRad - fovRad / 2);
|
|
957
|
+
const point2X = camera.x + distance * Math.cos(directionRad + fovRad / 2);
|
|
958
|
+
const point2Y = camera.y + distance * Math.sin(directionRad + fovRad / 2);
|
|
959
|
+
|
|
960
|
+
const fovColor = verkadaCamera.environment === 'indoor' ? '#10b981' : '#f59e0b';
|
|
961
|
+
|
|
962
|
+
// Create SVG path for the triangle
|
|
963
|
+
const pathData = `M ${camera.x} ${camera.y} L ${point1X} ${point1Y} L ${point2X} ${point2Y} Z`;
|
|
964
|
+
|
|
965
|
+
return (
|
|
966
|
+
<svg
|
|
967
|
+
key={`fov-${camera.id}`}
|
|
968
|
+
className={styles.fovTriangle}
|
|
969
|
+
style={{
|
|
970
|
+
position: 'absolute',
|
|
971
|
+
top: 0,
|
|
972
|
+
left: 0,
|
|
973
|
+
width: '100%',
|
|
974
|
+
height: '100%',
|
|
975
|
+
pointerEvents: 'none',
|
|
976
|
+
zIndex: 1,
|
|
977
|
+
}}
|
|
978
|
+
>
|
|
979
|
+
<path
|
|
980
|
+
d={pathData}
|
|
981
|
+
fill={fovColor}
|
|
982
|
+
fillOpacity="0.25"
|
|
983
|
+
stroke={fovColor}
|
|
984
|
+
strokeOpacity="0.5"
|
|
985
|
+
strokeWidth="2"
|
|
986
|
+
/>
|
|
987
|
+
</svg>
|
|
988
|
+
);
|
|
989
|
+
})}
|
|
990
|
+
|
|
991
|
+
{/* Render camera markers on image */}
|
|
992
|
+
{cameras.map((camera) => {
|
|
993
|
+
const verkadaCamera = camera.verkadaModel ? getCameraById(camera.verkadaModel) : null;
|
|
994
|
+
const environment = verkadaCamera ? verkadaCamera.environment : 'outdoor';
|
|
995
|
+
const cameraNumber = (camera.customName || camera.name).replace(/\D/g, '') || camera.id;
|
|
996
|
+
|
|
997
|
+
return (
|
|
998
|
+
<div
|
|
999
|
+
key={camera.id}
|
|
1000
|
+
className={`camera-marker-container ${styles.imageMarker}`}
|
|
1001
|
+
style={{
|
|
1002
|
+
position: 'absolute',
|
|
1003
|
+
left: camera.x - 20, // Adjusted for smaller circular marker
|
|
1004
|
+
top: camera.y - 20, // Adjusted for smaller circular marker
|
|
1005
|
+
transform: 'none',
|
|
1006
|
+
zIndex: 2,
|
|
1007
|
+
}}
|
|
1008
|
+
onMouseEnter={(e) => {
|
|
1009
|
+
// Only show tooltip if not hovering over rotation handle
|
|
1010
|
+
if (verkadaCamera && !e.target.closest(`.${styles.rotationHandle}`)) {
|
|
1011
|
+
// Add delay to prevent tooltip from showing during quick rotations
|
|
1012
|
+
const targetElement = e.currentTarget;
|
|
1013
|
+
targetElement._tooltipTimeout = setTimeout(() => {
|
|
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
|
+
}}
|
|
1058
|
+
onMouseDown={(e) => {
|
|
1059
|
+
e.stopPropagation();
|
|
1060
|
+
let isDragging = false;
|
|
1061
|
+
let startTime = Date.now();
|
|
1062
|
+
const startX = e.clientX;
|
|
1063
|
+
const startY = e.clientY;
|
|
1064
|
+
const containerRect = imageContainer.current.getBoundingClientRect();
|
|
1065
|
+
|
|
1066
|
+
const handleMouseMove = (moveEvent) => {
|
|
1067
|
+
const deltaX = Math.abs(moveEvent.clientX - startX);
|
|
1068
|
+
const deltaY = Math.abs(moveEvent.clientY - startY);
|
|
1069
|
+
|
|
1070
|
+
// Start dragging if moved more than 5px
|
|
1071
|
+
if (!isDragging && (deltaX > 5 || deltaY > 5)) {
|
|
1072
|
+
isDragging = true;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (isDragging) {
|
|
1076
|
+
const newX = moveEvent.clientX - containerRect.left;
|
|
1077
|
+
const newY = moveEvent.clientY - containerRect.top;
|
|
1078
|
+
|
|
1079
|
+
// Update camera position
|
|
1080
|
+
updateCamera(camera.id, { x: newX, y: newY });
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const handleMouseUp = () => {
|
|
1085
|
+
const clickDuration = Date.now() - startTime;
|
|
1086
|
+
|
|
1087
|
+
// If it was a quick click without dragging, show modal
|
|
1088
|
+
if (!isDragging && clickDuration < 200) {
|
|
1089
|
+
setSelectedCamera(camera);
|
|
1090
|
+
setShowModal(true);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
1094
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
1098
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
1099
|
+
}}
|
|
1100
|
+
>
|
|
1101
|
+
<div className={`${styles.circularMarker} ${environment} ${verkadaCamera?.ptz ? styles.ptzMarker : ''}`}>
|
|
1102
|
+
<div className={styles.markerNumber}>{cameraNumber}</div>
|
|
1103
|
+
<div
|
|
1104
|
+
className={styles.rotationHandle}
|
|
1105
|
+
style={{
|
|
1106
|
+
transform: `rotate(${camera.direction || 0}deg)`,
|
|
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
|
+
}}
|
|
1120
|
+
onMouseDown={(e) => {
|
|
1121
|
+
|
|
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
|
+
|
|
1129
|
+
let isRotating = false;
|
|
1130
|
+
let startAngle = 0;
|
|
1131
|
+
let currentRotation = camera.direction || 0;
|
|
1132
|
+
|
|
1133
|
+
// Get the center of the camera marker
|
|
1134
|
+
const markerContainer = rotationHandle.parentElement;
|
|
1135
|
+
const markerRect = markerContainer.getBoundingClientRect();
|
|
1136
|
+
const centerX = markerRect.left + markerRect.width / 2;
|
|
1137
|
+
const centerY = markerRect.top + markerRect.height / 2;
|
|
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
|
+
|
|
1146
|
+
const handleRotationMove = (moveEvent) => {
|
|
1147
|
+
const deltaX = Math.abs(moveEvent.clientX - startMouseX);
|
|
1148
|
+
const deltaY = Math.abs(moveEvent.clientY - startMouseY);
|
|
1149
|
+
|
|
1150
|
+
|
|
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
|
+
}
|
|
1156
|
+
|
|
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
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
const handleRotationEnd = () => {
|
|
1188
|
+
|
|
1189
|
+
if (isRotating) {
|
|
1190
|
+
markerContainer.classList.remove('rotating');
|
|
1191
|
+
}
|
|
1192
|
+
document.removeEventListener('mousemove', handleRotationMove);
|
|
1193
|
+
document.removeEventListener('mouseup', handleRotationEnd);
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
document.addEventListener('mousemove', handleRotationMove);
|
|
1197
|
+
document.addEventListener('mouseup', handleRotationEnd);
|
|
1198
|
+
}}
|
|
1199
|
+
/>
|
|
1200
|
+
</div>
|
|
1201
|
+
</div>
|
|
1202
|
+
);
|
|
1203
|
+
})}
|
|
1204
|
+
</div>
|
|
1205
|
+
) : (
|
|
1206
|
+
<div className={styles.uploadPlaceholder}>
|
|
1207
|
+
<div className={styles.uploadIcon}>📁</div>
|
|
1208
|
+
<h3>Upload an Image</h3>
|
|
1209
|
+
<p>Click "Upload Image" to add a floor plan, site layout, or any image where you want to place cameras.</p>
|
|
1210
|
+
<button
|
|
1211
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1212
|
+
className={styles.uploadBtn}
|
|
1213
|
+
>
|
|
1214
|
+
Choose Image File
|
|
1215
|
+
</button>
|
|
1216
|
+
</div>
|
|
1217
|
+
)}
|
|
1218
|
+
</div>
|
|
1219
|
+
</div>
|
|
1220
|
+
|
|
1221
|
+
{/* Hidden file input */}
|
|
1222
|
+
<input
|
|
1223
|
+
type="file"
|
|
1224
|
+
ref={fileInputRef}
|
|
1225
|
+
onChange={handleImageUpload}
|
|
1226
|
+
accept="image/*"
|
|
1227
|
+
style={{ display: 'none' }}
|
|
1228
|
+
/>
|
|
1229
|
+
|
|
1230
|
+
<div
|
|
1231
|
+
className={`${styles.sidebar} ${
|
|
1232
|
+
sidebarCollapsed ? styles.collapsed : ''
|
|
1233
|
+
}`}
|
|
1234
|
+
>
|
|
1235
|
+
<div className={styles.sidebarHeader}>
|
|
1236
|
+
<h3 className={styles.sidebarTitle}>Camera List</h3>
|
|
1237
|
+
<p className={styles.cameraCount}>
|
|
1238
|
+
{cameras.length} camera
|
|
1239
|
+
{cameras.length !== 1 ? 's' : ''} placed
|
|
1240
|
+
</p>
|
|
1241
|
+
</div>
|
|
1242
|
+
|
|
1243
|
+
<div className={styles.camerasList}>
|
|
1244
|
+
{cameras.length === 0 ? (
|
|
1245
|
+
<div
|
|
1246
|
+
style={{
|
|
1247
|
+
textAlign: 'center',
|
|
1248
|
+
color: '#6c757d',
|
|
1249
|
+
padding: '40px 20px',
|
|
1250
|
+
fontStyle: 'italic',
|
|
1251
|
+
}}
|
|
1252
|
+
>
|
|
1253
|
+
<div>
|
|
1254
|
+
Click "Add Camera" to place your first camera
|
|
1255
|
+
</div>
|
|
1256
|
+
<div
|
|
1257
|
+
style={{
|
|
1258
|
+
fontSize: '12px',
|
|
1259
|
+
marginTop: '8px',
|
|
1260
|
+
}}
|
|
1261
|
+
>
|
|
1262
|
+
Once placed, you can drag cameras to reposition them and use the rotation handle to adjust direction
|
|
1263
|
+
</div>
|
|
1264
|
+
</div>
|
|
1265
|
+
) : (
|
|
1266
|
+
cameras.map((camera) => {
|
|
1267
|
+
const verkadaCamera = camera.verkadaModel
|
|
1268
|
+
? getCameraById(camera.verkadaModel)
|
|
1269
|
+
: null;
|
|
1270
|
+
|
|
1271
|
+
return (
|
|
1272
|
+
<div
|
|
1273
|
+
key={camera.id}
|
|
1274
|
+
className={styles.cameraItem}
|
|
1275
|
+
>
|
|
1276
|
+
<div className={styles.cameraHeader}>
|
|
1277
|
+
<div className={styles.sidebarCameraInfo}>
|
|
1278
|
+
{verkadaCamera && (
|
|
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"
|
|
1325
|
+
>
|
|
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>
|
|
1336
|
+
</div>
|
|
1337
|
+
|
|
1338
|
+
{verkadaCamera ? (
|
|
1339
|
+
<div
|
|
1340
|
+
className={styles.cameraDetails}
|
|
1341
|
+
>
|
|
1342
|
+
<div
|
|
1343
|
+
className={
|
|
1344
|
+
modalStyles.cameraModelName
|
|
1345
|
+
}
|
|
1346
|
+
>
|
|
1347
|
+
<strong>
|
|
1348
|
+
{verkadaCamera.name}
|
|
1349
|
+
</strong>
|
|
1350
|
+
</div>
|
|
1351
|
+
|
|
1352
|
+
<div
|
|
1353
|
+
className={
|
|
1354
|
+
modalStyles.cameraSpecGrid
|
|
1355
|
+
}
|
|
1356
|
+
>
|
|
1357
|
+
<div
|
|
1358
|
+
className={
|
|
1359
|
+
modalStyles.specItem
|
|
1360
|
+
}
|
|
1361
|
+
>
|
|
1362
|
+
<span
|
|
1363
|
+
className={
|
|
1364
|
+
modalStyles.specLabel
|
|
1365
|
+
}
|
|
1366
|
+
>
|
|
1367
|
+
Resolution:
|
|
1368
|
+
</span>
|
|
1369
|
+
<span
|
|
1370
|
+
className={
|
|
1371
|
+
modalStyles.specValue
|
|
1372
|
+
}
|
|
1373
|
+
>
|
|
1374
|
+
{
|
|
1375
|
+
verkadaCamera.resolution
|
|
1376
|
+
}
|
|
1377
|
+
</span>
|
|
1378
|
+
</div>
|
|
1379
|
+
<div
|
|
1380
|
+
className={
|
|
1381
|
+
modalStyles.specItem
|
|
1382
|
+
}
|
|
1383
|
+
>
|
|
1384
|
+
<span
|
|
1385
|
+
className={
|
|
1386
|
+
modalStyles.specLabel
|
|
1387
|
+
}
|
|
1388
|
+
>
|
|
1389
|
+
FOV:
|
|
1390
|
+
</span>
|
|
1391
|
+
<span
|
|
1392
|
+
className={
|
|
1393
|
+
modalStyles.specValue
|
|
1394
|
+
}
|
|
1395
|
+
>
|
|
1396
|
+
{verkadaCamera.fov}°
|
|
1397
|
+
</span>
|
|
1398
|
+
</div>
|
|
1399
|
+
<div
|
|
1400
|
+
className={
|
|
1401
|
+
modalStyles.specItem
|
|
1402
|
+
}
|
|
1403
|
+
>
|
|
1404
|
+
<span
|
|
1405
|
+
className={
|
|
1406
|
+
modalStyles.specLabel
|
|
1407
|
+
}
|
|
1408
|
+
>
|
|
1409
|
+
Environment:
|
|
1410
|
+
</span>
|
|
1411
|
+
<span
|
|
1412
|
+
className={`${
|
|
1413
|
+
modalStyles.specValue
|
|
1414
|
+
} ${
|
|
1415
|
+
modalStyles[
|
|
1416
|
+
verkadaCamera
|
|
1417
|
+
.environment
|
|
1418
|
+
]
|
|
1419
|
+
}`}
|
|
1420
|
+
>
|
|
1421
|
+
{
|
|
1422
|
+
verkadaCamera.environment
|
|
1423
|
+
}
|
|
1424
|
+
</span>
|
|
1425
|
+
</div>
|
|
1426
|
+
<div
|
|
1427
|
+
className={
|
|
1428
|
+
modalStyles.specItem
|
|
1429
|
+
}
|
|
1430
|
+
>
|
|
1431
|
+
<span
|
|
1432
|
+
className={
|
|
1433
|
+
modalStyles.specLabel
|
|
1434
|
+
}
|
|
1435
|
+
>
|
|
1436
|
+
IR Range:
|
|
1437
|
+
</span>
|
|
1438
|
+
<span
|
|
1439
|
+
className={
|
|
1440
|
+
modalStyles.specValue
|
|
1441
|
+
}
|
|
1442
|
+
>
|
|
1443
|
+
{
|
|
1444
|
+
verkadaCamera.irRange
|
|
1445
|
+
}
|
|
1446
|
+
m
|
|
1447
|
+
</span>
|
|
1448
|
+
</div>
|
|
1449
|
+
<div
|
|
1450
|
+
className={
|
|
1451
|
+
modalStyles.specItem
|
|
1452
|
+
}
|
|
1453
|
+
>
|
|
1454
|
+
<span
|
|
1455
|
+
className={
|
|
1456
|
+
modalStyles.specLabel
|
|
1457
|
+
}
|
|
1458
|
+
>
|
|
1459
|
+
Zoom:
|
|
1460
|
+
</span>
|
|
1461
|
+
<span
|
|
1462
|
+
className={
|
|
1463
|
+
modalStyles.specValue
|
|
1464
|
+
}
|
|
1465
|
+
>
|
|
1466
|
+
{verkadaCamera.zoom}
|
|
1467
|
+
</span>
|
|
1468
|
+
</div>
|
|
1469
|
+
<div
|
|
1470
|
+
className={
|
|
1471
|
+
modalStyles.specItem
|
|
1472
|
+
}
|
|
1473
|
+
>
|
|
1474
|
+
<span
|
|
1475
|
+
className={
|
|
1476
|
+
modalStyles.specLabel
|
|
1477
|
+
}
|
|
1478
|
+
>
|
|
1479
|
+
Rating:
|
|
1480
|
+
</span>
|
|
1481
|
+
<span
|
|
1482
|
+
className={
|
|
1483
|
+
modalStyles.specValue
|
|
1484
|
+
}
|
|
1485
|
+
>
|
|
1486
|
+
{
|
|
1487
|
+
verkadaCamera.rating
|
|
1488
|
+
}
|
|
1489
|
+
</span>
|
|
1490
|
+
</div>
|
|
1491
|
+
{verkadaCamera.ptz && (
|
|
1492
|
+
<>
|
|
1493
|
+
<div
|
|
1494
|
+
className={
|
|
1495
|
+
modalStyles.specItem
|
|
1496
|
+
}
|
|
1497
|
+
>
|
|
1498
|
+
<span
|
|
1499
|
+
className={
|
|
1500
|
+
modalStyles.specLabel
|
|
1501
|
+
}
|
|
1502
|
+
>
|
|
1503
|
+
Pan:
|
|
1504
|
+
</span>
|
|
1505
|
+
<span
|
|
1506
|
+
className={
|
|
1507
|
+
modalStyles.specValue
|
|
1508
|
+
}
|
|
1509
|
+
>
|
|
1510
|
+
{
|
|
1511
|
+
verkadaCamera.pan
|
|
1512
|
+
}
|
|
1513
|
+
</span>
|
|
1514
|
+
</div>
|
|
1515
|
+
<div
|
|
1516
|
+
className={
|
|
1517
|
+
modalStyles.specItem
|
|
1518
|
+
}
|
|
1519
|
+
>
|
|
1520
|
+
<span
|
|
1521
|
+
className={
|
|
1522
|
+
modalStyles.specLabel
|
|
1523
|
+
}
|
|
1524
|
+
>
|
|
1525
|
+
Tilt:
|
|
1526
|
+
</span>
|
|
1527
|
+
<span
|
|
1528
|
+
className={
|
|
1529
|
+
modalStyles.specValue
|
|
1530
|
+
}
|
|
1531
|
+
>
|
|
1532
|
+
{
|
|
1533
|
+
verkadaCamera.tilt
|
|
1534
|
+
}
|
|
1535
|
+
</span>
|
|
1536
|
+
</div>
|
|
1537
|
+
</>
|
|
1538
|
+
)}
|
|
1539
|
+
</div>
|
|
1540
|
+
|
|
1541
|
+
<div
|
|
1542
|
+
className={
|
|
1543
|
+
modalStyles.cameraFeatures
|
|
1544
|
+
}
|
|
1545
|
+
>
|
|
1546
|
+
<span
|
|
1547
|
+
className={
|
|
1548
|
+
modalStyles.specLabel
|
|
1549
|
+
}
|
|
1550
|
+
>
|
|
1551
|
+
Features:
|
|
1552
|
+
</span>
|
|
1553
|
+
<div
|
|
1554
|
+
className={
|
|
1555
|
+
modalStyles.featuresList
|
|
1556
|
+
}
|
|
1557
|
+
>
|
|
1558
|
+
{verkadaCamera.features.map(
|
|
1559
|
+
(
|
|
1560
|
+
feature,
|
|
1561
|
+
index
|
|
1562
|
+
) => (
|
|
1563
|
+
<span
|
|
1564
|
+
key={index}
|
|
1565
|
+
className={
|
|
1566
|
+
modalStyles.featureTag
|
|
1567
|
+
}
|
|
1568
|
+
>
|
|
1569
|
+
{feature}
|
|
1570
|
+
</span>
|
|
1571
|
+
)
|
|
1572
|
+
)}
|
|
1573
|
+
</div>
|
|
1574
|
+
</div>
|
|
1575
|
+
|
|
1576
|
+
<div
|
|
1577
|
+
className={
|
|
1578
|
+
modalStyles.directionControl
|
|
1579
|
+
}
|
|
1580
|
+
>
|
|
1581
|
+
<label>
|
|
1582
|
+
Direction:{' '}
|
|
1583
|
+
{camera.direction}°
|
|
1584
|
+
</label>
|
|
1585
|
+
<input
|
|
1586
|
+
type="range"
|
|
1587
|
+
min="0"
|
|
1588
|
+
max="360"
|
|
1589
|
+
value={camera.direction}
|
|
1590
|
+
onChange={(e) =>
|
|
1591
|
+
updateCamera(
|
|
1592
|
+
camera.id,
|
|
1593
|
+
{
|
|
1594
|
+
direction:
|
|
1595
|
+
parseInt(
|
|
1596
|
+
e
|
|
1597
|
+
.target
|
|
1598
|
+
.value
|
|
1599
|
+
),
|
|
1600
|
+
}
|
|
1601
|
+
)
|
|
1602
|
+
}
|
|
1603
|
+
className={
|
|
1604
|
+
modalStyles.directionSlider
|
|
1605
|
+
}
|
|
1606
|
+
/>
|
|
1607
|
+
<div
|
|
1608
|
+
className={
|
|
1609
|
+
modalStyles.directionHelper
|
|
1610
|
+
}
|
|
1611
|
+
>
|
|
1612
|
+
0° = North, 90° = East,
|
|
1613
|
+
180° = South, 270° =
|
|
1614
|
+
West
|
|
1615
|
+
</div>
|
|
1616
|
+
</div>
|
|
1617
|
+
</div>
|
|
1618
|
+
) : (
|
|
1619
|
+
<div
|
|
1620
|
+
className={styles.cameraDetails}
|
|
1621
|
+
>
|
|
1622
|
+
<div
|
|
1623
|
+
className={
|
|
1624
|
+
modalStyles.noModel
|
|
1625
|
+
}
|
|
1626
|
+
>
|
|
1627
|
+
No Verkada model selected -
|
|
1628
|
+
Click "Edit" to choose a
|
|
1629
|
+
camera
|
|
1630
|
+
</div>
|
|
1631
|
+
<div
|
|
1632
|
+
className={
|
|
1633
|
+
modalStyles.directionControl
|
|
1634
|
+
}
|
|
1635
|
+
>
|
|
1636
|
+
<label>
|
|
1637
|
+
Direction:{' '}
|
|
1638
|
+
{camera.direction}°
|
|
1639
|
+
</label>
|
|
1640
|
+
<input
|
|
1641
|
+
type="range"
|
|
1642
|
+
min="0"
|
|
1643
|
+
max="360"
|
|
1644
|
+
value={camera.direction}
|
|
1645
|
+
onChange={(e) =>
|
|
1646
|
+
updateCamera(
|
|
1647
|
+
camera.id,
|
|
1648
|
+
{
|
|
1649
|
+
direction:
|
|
1650
|
+
parseInt(
|
|
1651
|
+
e
|
|
1652
|
+
.target
|
|
1653
|
+
.value
|
|
1654
|
+
),
|
|
1655
|
+
}
|
|
1656
|
+
)
|
|
1657
|
+
}
|
|
1658
|
+
className={
|
|
1659
|
+
modalStyles.directionSlider
|
|
1660
|
+
}
|
|
1661
|
+
/>
|
|
1662
|
+
<div
|
|
1663
|
+
className={
|
|
1664
|
+
modalStyles.directionHelper
|
|
1665
|
+
}
|
|
1666
|
+
>
|
|
1667
|
+
0° = North, 90° = East,
|
|
1668
|
+
180° = South, 270° =
|
|
1669
|
+
West
|
|
1670
|
+
</div>
|
|
1671
|
+
</div>
|
|
1672
|
+
</div>
|
|
1673
|
+
)}
|
|
1674
|
+
|
|
1675
|
+
<div className={styles.cameraCoords}>
|
|
1676
|
+
<span className={styles.coordLabel}>
|
|
1677
|
+
Location:
|
|
1678
|
+
</span>
|
|
1679
|
+
{mode === 'map' ? (
|
|
1680
|
+
<>
|
|
1681
|
+
{camera.lat?.toFixed(6)},{' '}
|
|
1682
|
+
{camera.lng?.toFixed(6)}
|
|
1683
|
+
</>
|
|
1684
|
+
) : (
|
|
1685
|
+
<>
|
|
1686
|
+
X: {Math.round(camera.x)}, Y: {Math.round(camera.y)}
|
|
1687
|
+
</>
|
|
1688
|
+
)}
|
|
1689
|
+
</div>
|
|
1690
|
+
|
|
1691
|
+
</div>
|
|
1692
|
+
);
|
|
1693
|
+
})
|
|
1694
|
+
)}
|
|
1695
|
+
</div>
|
|
1696
|
+
</div>
|
|
1697
|
+
|
|
1698
|
+
{showModal && selectedCamera && (
|
|
1699
|
+
<CameraSelectionModal
|
|
1700
|
+
camera={selectedCamera}
|
|
1701
|
+
onSave={(updates) => {
|
|
1702
|
+
updateCamera(selectedCamera.id, updates);
|
|
1703
|
+
setShowModal(false);
|
|
1704
|
+
}}
|
|
1705
|
+
onClose={() => {
|
|
1706
|
+
setShowModal(false);
|
|
1707
|
+
}}
|
|
1708
|
+
/>
|
|
1709
|
+
)}
|
|
1710
|
+
</div>
|
|
1711
|
+
);
|
|
1712
|
+
};
|
|
1713
|
+
|
|
1714
|
+
const CameraSelectionModal = ({ camera, onSave, onClose }) => {
|
|
1715
|
+
// Determine if this is editing an existing camera or adding a new one
|
|
1716
|
+
const isUpdating = Boolean(camera.verkadaModel);
|
|
1717
|
+
|
|
1718
|
+
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
1719
|
+
const [selectedCamera, setSelectedCamera] = useState(camera.verkadaModel ? getCameraById(camera.verkadaModel) : null);
|
|
1720
|
+
|
|
1721
|
+
// Filter cameras based on selected category
|
|
1722
|
+
const filteredCameras = Object.values(VERKADA_CAMERAS).filter((cam) => {
|
|
1723
|
+
if (selectedCategory === 'all') return true;
|
|
1724
|
+
return cam.type === selectedCategory;
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
const handleCameraSelect = (cameraModel) => {
|
|
1729
|
+
setSelectedCamera(cameraModel);
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
const handleSave = () => {
|
|
1733
|
+
if (!selectedCamera) {
|
|
1734
|
+
alert('Please select a camera model before saving.');
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
onSave({
|
|
1739
|
+
verkadaModel: selectedCamera.id,
|
|
1740
|
+
});
|
|
1741
|
+
};
|
|
1742
|
+
|
|
1743
|
+
return (
|
|
1744
|
+
<div className={styles.modalOverlay}>
|
|
1745
|
+
<div className={styles.cameraSelectionModal}>
|
|
1746
|
+
<div className={styles.modalHeader}>
|
|
1747
|
+
<h3>Select Verkada Camera</h3>
|
|
1748
|
+
<button className={styles.closeBtn} onClick={onClose}>×</button>
|
|
1749
|
+
</div>
|
|
1750
|
+
|
|
1751
|
+
<div className={styles.modalContent}>
|
|
1752
|
+
<div className={styles.topSection}>
|
|
1753
|
+
<div className={styles.categoryFilter}>
|
|
1754
|
+
<label>Filter by Category:</label>
|
|
1755
|
+
<div className={styles.categoryButtons}>
|
|
1756
|
+
<button
|
|
1757
|
+
className={selectedCategory === 'all' ? styles.active : ''}
|
|
1758
|
+
onClick={() => setSelectedCategory('all')}
|
|
1759
|
+
>
|
|
1760
|
+
All Cameras
|
|
1761
|
+
</button>
|
|
1762
|
+
{Object.entries(CAMERA_CATEGORIES).map(([key, category]) => (
|
|
1763
|
+
<button
|
|
1764
|
+
key={key}
|
|
1765
|
+
className={selectedCategory === key ? styles.active : ''}
|
|
1766
|
+
onClick={() => setSelectedCategory(key)}
|
|
1767
|
+
>
|
|
1768
|
+
{category.name}
|
|
1769
|
+
</button>
|
|
1770
|
+
))}
|
|
1771
|
+
</div>
|
|
1772
|
+
</div>
|
|
1773
|
+
</div>
|
|
1774
|
+
|
|
1775
|
+
<div className={styles.cameraGrid}>
|
|
1776
|
+
{filteredCameras.map((cam) => (
|
|
1777
|
+
<div
|
|
1778
|
+
key={cam.id}
|
|
1779
|
+
className={`${styles.cameraCard} ${selectedCamera?.id === cam.id ? styles.selectedCard : ''}`}
|
|
1780
|
+
onClick={() => handleCameraSelect(cam)}
|
|
1781
|
+
>
|
|
1782
|
+
<div className={styles.cardHeader}>
|
|
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>
|
|
1801
|
+
</div>
|
|
1802
|
+
<div className={styles.cameraTitle}>
|
|
1803
|
+
<h4>{cam.name}</h4>
|
|
1804
|
+
<span className={`${styles.environmentBadge} ${styles[cam.environment]}`}>
|
|
1805
|
+
{cam.environment}
|
|
1806
|
+
</span>
|
|
1807
|
+
</div>
|
|
1808
|
+
</div>
|
|
1809
|
+
|
|
1810
|
+
<div className={styles.cardBody}>
|
|
1811
|
+
<div className={styles.specRow}>
|
|
1812
|
+
<span className={styles.specLabel}>Resolution:</span>
|
|
1813
|
+
<span className={styles.specValue}>{cam.resolution}</span>
|
|
1814
|
+
</div>
|
|
1815
|
+
<div className={styles.specRow}>
|
|
1816
|
+
<span className={styles.specLabel}>FOV:</span>
|
|
1817
|
+
<span className={styles.specValue}>{cam.fov}°</span>
|
|
1818
|
+
</div>
|
|
1819
|
+
<div className={styles.specRow}>
|
|
1820
|
+
<span className={styles.specLabel}>Range:</span>
|
|
1821
|
+
<span className={styles.specValue}>{cam.irRange}m IR</span>
|
|
1822
|
+
</div>
|
|
1823
|
+
{cam.ptz && (
|
|
1824
|
+
<div className={styles.ptzBadge}>PTZ</div>
|
|
1825
|
+
)}
|
|
1826
|
+
</div>
|
|
1827
|
+
|
|
1828
|
+
<div className={styles.cardFooter}>
|
|
1829
|
+
<div className={styles.features}>
|
|
1830
|
+
{cam.features?.slice(0, 2).map((feature, index) => (
|
|
1831
|
+
<span key={index} className={styles.featureBadge}>
|
|
1832
|
+
{feature}
|
|
1833
|
+
</span>
|
|
1834
|
+
))}
|
|
1835
|
+
{cam.features?.length > 2 && (
|
|
1836
|
+
<span className={styles.moreFeatures}>
|
|
1837
|
+
+{cam.features.length - 2} more
|
|
1838
|
+
</span>
|
|
1839
|
+
)}
|
|
1840
|
+
</div>
|
|
1841
|
+
</div>
|
|
1842
|
+
</div>
|
|
1843
|
+
))}
|
|
1844
|
+
</div>
|
|
1845
|
+
</div>
|
|
1846
|
+
|
|
1847
|
+
<div className={styles.modalActions}>
|
|
1848
|
+
<div className={styles.selectedInfo}>
|
|
1849
|
+
{selectedCamera && (
|
|
1850
|
+
<span>Selected: {selectedCamera.name}</span>
|
|
1851
|
+
)}
|
|
1852
|
+
</div>
|
|
1853
|
+
<div className={styles.actionButtons}>
|
|
1854
|
+
<button onClick={onClose} className={styles.cancelBtn}>
|
|
1855
|
+
Cancel
|
|
1856
|
+
</button>
|
|
1857
|
+
<button
|
|
1858
|
+
onClick={handleSave}
|
|
1859
|
+
disabled={!selectedCamera}
|
|
1860
|
+
className={styles.saveBtn}
|
|
1861
|
+
>
|
|
1862
|
+
{isUpdating ? 'Update Camera' : 'Add Camera'}
|
|
1863
|
+
</button>
|
|
1864
|
+
</div>
|
|
1865
|
+
</div>
|
|
1866
|
+
</div>
|
|
1867
|
+
</div>
|
|
1868
|
+
);
|
|
1869
|
+
};
|
|
1870
|
+
|
|
1871
|
+
export default CameraPlacement;
|