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