@blueharford/scrypted-spatial-awareness 0.1.0
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/.vscode/settings.json +3 -0
- package/CLAUDE.md +168 -0
- package/README.md +152 -0
- package/dist/main.nodejs.js +3 -0
- package/dist/main.nodejs.js.LICENSE.txt +1 -0
- package/dist/main.nodejs.js.map +1 -0
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +37376 -0
- package/out/main.nodejs.js.map +1 -0
- package/out/plugin.zip +0 -0
- package/package.json +59 -0
- package/src/alerts/alert-manager.ts +347 -0
- package/src/core/object-correlator.ts +376 -0
- package/src/core/tracking-engine.ts +367 -0
- package/src/devices/global-tracker-sensor.ts +191 -0
- package/src/devices/tracking-zone.ts +245 -0
- package/src/integrations/mqtt-publisher.ts +320 -0
- package/src/main.ts +690 -0
- package/src/models/alert.ts +229 -0
- package/src/models/topology.ts +168 -0
- package/src/models/tracked-object.ts +226 -0
- package/src/state/tracking-state.ts +285 -0
- package/src/ui/editor.html +1051 -0
- package/src/utils/id-generator.ts +36 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Spatial Awareness - Topology Editor</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
|
16
|
+
background: #1a1a2e;
|
|
17
|
+
color: #eee;
|
|
18
|
+
min-height: 100vh;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.container {
|
|
22
|
+
display: flex;
|
|
23
|
+
height: 100vh;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Sidebar */
|
|
27
|
+
.sidebar {
|
|
28
|
+
width: 300px;
|
|
29
|
+
background: #16213e;
|
|
30
|
+
border-right: 1px solid #0f3460;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.sidebar-header {
|
|
37
|
+
padding: 20px;
|
|
38
|
+
border-bottom: 1px solid #0f3460;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.sidebar-header h1 {
|
|
42
|
+
font-size: 18px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
margin-bottom: 5px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.sidebar-header p {
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
color: #888;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.sidebar-content {
|
|
53
|
+
flex: 1;
|
|
54
|
+
overflow-y: auto;
|
|
55
|
+
padding: 15px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.section {
|
|
59
|
+
margin-bottom: 20px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.section-title {
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
text-transform: uppercase;
|
|
66
|
+
color: #888;
|
|
67
|
+
margin-bottom: 10px;
|
|
68
|
+
display: flex;
|
|
69
|
+
justify-content: space-between;
|
|
70
|
+
align-items: center;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.btn {
|
|
74
|
+
background: #0f3460;
|
|
75
|
+
color: #fff;
|
|
76
|
+
border: none;
|
|
77
|
+
padding: 8px 16px;
|
|
78
|
+
border-radius: 4px;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
transition: background 0.2s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.btn:hover {
|
|
85
|
+
background: #1a4a7a;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.btn-primary {
|
|
89
|
+
background: #e94560;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.btn-primary:hover {
|
|
93
|
+
background: #ff6b6b;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.btn-small {
|
|
97
|
+
padding: 4px 8px;
|
|
98
|
+
font-size: 11px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.camera-item, .connection-item {
|
|
102
|
+
background: #0f3460;
|
|
103
|
+
border-radius: 6px;
|
|
104
|
+
padding: 12px;
|
|
105
|
+
margin-bottom: 8px;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
transition: background 0.2s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.camera-item:hover, .connection-item:hover {
|
|
111
|
+
background: #1a4a7a;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.camera-item.selected, .connection-item.selected {
|
|
115
|
+
outline: 2px solid #e94560;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.camera-name {
|
|
119
|
+
font-weight: 500;
|
|
120
|
+
margin-bottom: 4px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.camera-info {
|
|
124
|
+
font-size: 11px;
|
|
125
|
+
color: #888;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Main editor area */
|
|
129
|
+
.editor {
|
|
130
|
+
flex: 1;
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-direction: column;
|
|
133
|
+
overflow: hidden;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.toolbar {
|
|
137
|
+
background: #16213e;
|
|
138
|
+
border-bottom: 1px solid #0f3460;
|
|
139
|
+
padding: 10px 20px;
|
|
140
|
+
display: flex;
|
|
141
|
+
gap: 10px;
|
|
142
|
+
align-items: center;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.toolbar-group {
|
|
146
|
+
display: flex;
|
|
147
|
+
gap: 5px;
|
|
148
|
+
padding-right: 15px;
|
|
149
|
+
border-right: 1px solid #0f3460;
|
|
150
|
+
margin-right: 5px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.toolbar-group:last-child {
|
|
154
|
+
border-right: none;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.canvas-container {
|
|
158
|
+
flex: 1;
|
|
159
|
+
position: relative;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
background: #0f0f1a;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#floor-plan-canvas {
|
|
165
|
+
position: absolute;
|
|
166
|
+
top: 0;
|
|
167
|
+
left: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.canvas-placeholder {
|
|
171
|
+
position: absolute;
|
|
172
|
+
top: 50%;
|
|
173
|
+
left: 50%;
|
|
174
|
+
transform: translate(-50%, -50%);
|
|
175
|
+
text-align: center;
|
|
176
|
+
color: #666;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.canvas-placeholder h2 {
|
|
180
|
+
margin-bottom: 15px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Properties panel */
|
|
184
|
+
.properties-panel {
|
|
185
|
+
width: 280px;
|
|
186
|
+
background: #16213e;
|
|
187
|
+
border-left: 1px solid #0f3460;
|
|
188
|
+
overflow-y: auto;
|
|
189
|
+
padding: 15px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.properties-panel h3 {
|
|
193
|
+
font-size: 14px;
|
|
194
|
+
margin-bottom: 15px;
|
|
195
|
+
padding-bottom: 10px;
|
|
196
|
+
border-bottom: 1px solid #0f3460;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.form-group {
|
|
200
|
+
margin-bottom: 15px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.form-group label {
|
|
204
|
+
display: block;
|
|
205
|
+
font-size: 12px;
|
|
206
|
+
color: #888;
|
|
207
|
+
margin-bottom: 5px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.form-group input,
|
|
211
|
+
.form-group select {
|
|
212
|
+
width: 100%;
|
|
213
|
+
padding: 8px 10px;
|
|
214
|
+
background: #0f3460;
|
|
215
|
+
border: 1px solid #1a4a7a;
|
|
216
|
+
border-radius: 4px;
|
|
217
|
+
color: #fff;
|
|
218
|
+
font-size: 13px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.form-group input:focus,
|
|
222
|
+
.form-group select:focus {
|
|
223
|
+
outline: none;
|
|
224
|
+
border-color: #e94560;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.checkbox-group {
|
|
228
|
+
display: flex;
|
|
229
|
+
align-items: center;
|
|
230
|
+
gap: 8px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.checkbox-group input[type="checkbox"] {
|
|
234
|
+
width: auto;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.transit-time-inputs {
|
|
238
|
+
display: grid;
|
|
239
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
240
|
+
gap: 8px;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.transit-time-inputs input {
|
|
244
|
+
text-align: center;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.transit-time-labels {
|
|
248
|
+
display: grid;
|
|
249
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
250
|
+
gap: 8px;
|
|
251
|
+
font-size: 10px;
|
|
252
|
+
color: #666;
|
|
253
|
+
text-align: center;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Modal */
|
|
257
|
+
.modal-overlay {
|
|
258
|
+
display: none;
|
|
259
|
+
position: fixed;
|
|
260
|
+
top: 0;
|
|
261
|
+
left: 0;
|
|
262
|
+
right: 0;
|
|
263
|
+
bottom: 0;
|
|
264
|
+
background: rgba(0, 0, 0, 0.7);
|
|
265
|
+
z-index: 1000;
|
|
266
|
+
align-items: center;
|
|
267
|
+
justify-content: center;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.modal-overlay.active {
|
|
271
|
+
display: flex;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.modal {
|
|
275
|
+
background: #16213e;
|
|
276
|
+
border-radius: 8px;
|
|
277
|
+
padding: 25px;
|
|
278
|
+
max-width: 500px;
|
|
279
|
+
width: 90%;
|
|
280
|
+
max-height: 80vh;
|
|
281
|
+
overflow-y: auto;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.modal h2 {
|
|
285
|
+
margin-bottom: 20px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.modal-actions {
|
|
289
|
+
display: flex;
|
|
290
|
+
gap: 10px;
|
|
291
|
+
justify-content: flex-end;
|
|
292
|
+
margin-top: 20px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* File upload */
|
|
296
|
+
.upload-zone {
|
|
297
|
+
border: 2px dashed #0f3460;
|
|
298
|
+
border-radius: 8px;
|
|
299
|
+
padding: 40px;
|
|
300
|
+
text-align: center;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
transition: border-color 0.2s, background 0.2s;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.upload-zone:hover {
|
|
306
|
+
border-color: #e94560;
|
|
307
|
+
background: rgba(233, 69, 96, 0.1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.upload-zone input {
|
|
311
|
+
display: none;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* Status bar */
|
|
315
|
+
.status-bar {
|
|
316
|
+
background: #0f3460;
|
|
317
|
+
padding: 8px 20px;
|
|
318
|
+
font-size: 12px;
|
|
319
|
+
color: #888;
|
|
320
|
+
display: flex;
|
|
321
|
+
justify-content: space-between;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.status-indicator {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
gap: 6px;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.status-dot {
|
|
331
|
+
width: 8px;
|
|
332
|
+
height: 8px;
|
|
333
|
+
border-radius: 50%;
|
|
334
|
+
background: #4caf50;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.status-dot.warning {
|
|
338
|
+
background: #ff9800;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.status-dot.error {
|
|
342
|
+
background: #f44336;
|
|
343
|
+
}
|
|
344
|
+
</style>
|
|
345
|
+
</head>
|
|
346
|
+
<body>
|
|
347
|
+
<div class="container">
|
|
348
|
+
<!-- Sidebar -->
|
|
349
|
+
<div class="sidebar">
|
|
350
|
+
<div class="sidebar-header">
|
|
351
|
+
<h1>Spatial Awareness</h1>
|
|
352
|
+
<p>Topology Editor</p>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="sidebar-content">
|
|
355
|
+
<!-- Cameras section -->
|
|
356
|
+
<div class="section">
|
|
357
|
+
<div class="section-title">
|
|
358
|
+
<span>Cameras</span>
|
|
359
|
+
<button class="btn btn-small" onclick="openAddCameraModal()">+ Add</button>
|
|
360
|
+
</div>
|
|
361
|
+
<div id="camera-list">
|
|
362
|
+
<div class="camera-item" style="color: #666; text-align: center; cursor: default;">
|
|
363
|
+
No cameras configured
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<!-- Connections section -->
|
|
369
|
+
<div class="section">
|
|
370
|
+
<div class="section-title">
|
|
371
|
+
<span>Connections</span>
|
|
372
|
+
<button class="btn btn-small" onclick="openAddConnectionModal()">+ Add</button>
|
|
373
|
+
</div>
|
|
374
|
+
<div id="connection-list">
|
|
375
|
+
<div class="connection-item" style="color: #666; text-align: center; cursor: default;">
|
|
376
|
+
No connections configured
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<!-- Main editor -->
|
|
384
|
+
<div class="editor">
|
|
385
|
+
<div class="toolbar">
|
|
386
|
+
<div class="toolbar-group">
|
|
387
|
+
<button class="btn" onclick="uploadFloorPlan()">
|
|
388
|
+
📁 Upload Floor Plan
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
<div class="toolbar-group">
|
|
392
|
+
<button class="btn" onclick="setTool('select')">🔍 Select</button>
|
|
393
|
+
<button class="btn" onclick="setTool('camera')">📷 Place Camera</button>
|
|
394
|
+
<button class="btn" onclick="setTool('connect')">🔗 Connect</button>
|
|
395
|
+
</div>
|
|
396
|
+
<div class="toolbar-group">
|
|
397
|
+
<button class="btn btn-primary" onclick="saveTopology()">💾 Save</button>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
<div class="canvas-container">
|
|
402
|
+
<canvas id="floor-plan-canvas"></canvas>
|
|
403
|
+
<div class="canvas-placeholder" id="canvas-placeholder">
|
|
404
|
+
<h2>📐 Floor Plan Editor</h2>
|
|
405
|
+
<p>Upload a floor plan image to get started</p>
|
|
406
|
+
<br>
|
|
407
|
+
<button class="btn btn-primary" onclick="uploadFloorPlan()">Upload Floor Plan</button>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div class="status-bar">
|
|
412
|
+
<div class="status-indicator">
|
|
413
|
+
<div class="status-dot" id="status-dot"></div>
|
|
414
|
+
<span id="status-text">Ready</span>
|
|
415
|
+
</div>
|
|
416
|
+
<div>
|
|
417
|
+
<span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
<!-- Properties panel -->
|
|
423
|
+
<div class="properties-panel" id="properties-panel">
|
|
424
|
+
<h3>Properties</h3>
|
|
425
|
+
<p style="color: #666; font-size: 13px;">Select a camera or connection to edit its properties.</p>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<!-- Add Camera Modal -->
|
|
430
|
+
<div class="modal-overlay" id="add-camera-modal">
|
|
431
|
+
<div class="modal">
|
|
432
|
+
<h2>Add Camera</h2>
|
|
433
|
+
<div class="form-group">
|
|
434
|
+
<label>Camera Device</label>
|
|
435
|
+
<select id="camera-device-select">
|
|
436
|
+
<option value="">Loading cameras...</option>
|
|
437
|
+
</select>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="form-group">
|
|
440
|
+
<label>Display Name</label>
|
|
441
|
+
<input type="text" id="camera-name-input" placeholder="e.g., Front Door Camera">
|
|
442
|
+
</div>
|
|
443
|
+
<div class="form-group">
|
|
444
|
+
<label class="checkbox-group">
|
|
445
|
+
<input type="checkbox" id="camera-entry-checkbox">
|
|
446
|
+
Entry Point (objects can enter property here)
|
|
447
|
+
</label>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="form-group">
|
|
450
|
+
<label class="checkbox-group">
|
|
451
|
+
<input type="checkbox" id="camera-exit-checkbox">
|
|
452
|
+
Exit Point (objects can exit property here)
|
|
453
|
+
</label>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="modal-actions">
|
|
456
|
+
<button class="btn" onclick="closeModal('add-camera-modal')">Cancel</button>
|
|
457
|
+
<button class="btn btn-primary" onclick="addCamera()">Add Camera</button>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<!-- Add Connection Modal -->
|
|
463
|
+
<div class="modal-overlay" id="add-connection-modal">
|
|
464
|
+
<div class="modal">
|
|
465
|
+
<h2>Add Connection</h2>
|
|
466
|
+
<div class="form-group">
|
|
467
|
+
<label>Connection Name</label>
|
|
468
|
+
<input type="text" id="connection-name-input" placeholder="e.g., Driveway to Front Door">
|
|
469
|
+
</div>
|
|
470
|
+
<div class="form-group">
|
|
471
|
+
<label>From Camera</label>
|
|
472
|
+
<select id="connection-from-select"></select>
|
|
473
|
+
</div>
|
|
474
|
+
<div class="form-group">
|
|
475
|
+
<label>To Camera</label>
|
|
476
|
+
<select id="connection-to-select"></select>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="form-group">
|
|
479
|
+
<label>Transit Time (seconds)</label>
|
|
480
|
+
<div class="transit-time-inputs">
|
|
481
|
+
<input type="number" id="transit-min" placeholder="Min" value="3">
|
|
482
|
+
<input type="number" id="transit-typical" placeholder="Typical" value="10">
|
|
483
|
+
<input type="number" id="transit-max" placeholder="Max" value="30">
|
|
484
|
+
</div>
|
|
485
|
+
<div class="transit-time-labels">
|
|
486
|
+
<span>Minimum</span>
|
|
487
|
+
<span>Typical</span>
|
|
488
|
+
<span>Maximum</span>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
<div class="form-group">
|
|
492
|
+
<label class="checkbox-group">
|
|
493
|
+
<input type="checkbox" id="connection-bidirectional" checked>
|
|
494
|
+
Bidirectional (works both ways)
|
|
495
|
+
</label>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="modal-actions">
|
|
498
|
+
<button class="btn" onclick="closeModal('add-connection-modal')">Cancel</button>
|
|
499
|
+
<button class="btn btn-primary" onclick="addConnection()">Add Connection</button>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<!-- Floor Plan Upload Modal -->
|
|
505
|
+
<div class="modal-overlay" id="upload-modal">
|
|
506
|
+
<div class="modal">
|
|
507
|
+
<h2>Upload Floor Plan</h2>
|
|
508
|
+
<div class="upload-zone" onclick="document.getElementById('floor-plan-input').click()">
|
|
509
|
+
<p>Click to select an image<br><small>PNG, JPG, or SVG</small></p>
|
|
510
|
+
<input type="file" id="floor-plan-input" accept="image/*" onchange="handleFloorPlanUpload(event)">
|
|
511
|
+
</div>
|
|
512
|
+
<div class="modal-actions">
|
|
513
|
+
<button class="btn" onclick="closeModal('upload-modal')">Cancel</button>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<script>
|
|
519
|
+
// State
|
|
520
|
+
let topology = {
|
|
521
|
+
version: '1.0',
|
|
522
|
+
cameras: [],
|
|
523
|
+
connections: [],
|
|
524
|
+
globalZones: [],
|
|
525
|
+
floorPlan: null
|
|
526
|
+
};
|
|
527
|
+
let selectedItem = null;
|
|
528
|
+
let currentTool = 'select';
|
|
529
|
+
let floorPlanImage = null;
|
|
530
|
+
let availableCameras = [];
|
|
531
|
+
|
|
532
|
+
// Canvas
|
|
533
|
+
const canvas = document.getElementById('floor-plan-canvas');
|
|
534
|
+
const ctx = canvas.getContext('2d');
|
|
535
|
+
|
|
536
|
+
// Initialize
|
|
537
|
+
async function init() {
|
|
538
|
+
await loadTopology();
|
|
539
|
+
await loadAvailableCameras();
|
|
540
|
+
resizeCanvas();
|
|
541
|
+
render();
|
|
542
|
+
updateUI();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Load topology from API
|
|
546
|
+
async function loadTopology() {
|
|
547
|
+
try {
|
|
548
|
+
const response = await fetch('api/topology');
|
|
549
|
+
if (response.ok) {
|
|
550
|
+
topology = await response.json();
|
|
551
|
+
if (topology.floorPlan?.imageData) {
|
|
552
|
+
await loadFloorPlanImage(topology.floorPlan.imageData);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch (e) {
|
|
556
|
+
console.error('Failed to load topology:', e);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Load available cameras from Scrypted
|
|
561
|
+
async function loadAvailableCameras() {
|
|
562
|
+
// For now, use topology cameras
|
|
563
|
+
// In production, this would query Scrypted for ObjectDetector devices
|
|
564
|
+
availableCameras = topology.cameras.map(c => ({
|
|
565
|
+
id: c.deviceId,
|
|
566
|
+
name: c.name
|
|
567
|
+
}));
|
|
568
|
+
updateCameraSelects();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Save topology to API
|
|
572
|
+
async function saveTopology() {
|
|
573
|
+
try {
|
|
574
|
+
setStatus('Saving...', 'warning');
|
|
575
|
+
const response = await fetch('api/topology', {
|
|
576
|
+
method: 'PUT',
|
|
577
|
+
headers: { 'Content-Type': 'application/json' },
|
|
578
|
+
body: JSON.stringify(topology)
|
|
579
|
+
});
|
|
580
|
+
if (response.ok) {
|
|
581
|
+
setStatus('Saved successfully', 'success');
|
|
582
|
+
} else {
|
|
583
|
+
setStatus('Failed to save', 'error');
|
|
584
|
+
}
|
|
585
|
+
} catch (e) {
|
|
586
|
+
console.error('Failed to save topology:', e);
|
|
587
|
+
setStatus('Failed to save', 'error');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Canvas rendering
|
|
592
|
+
function resizeCanvas() {
|
|
593
|
+
const container = canvas.parentElement;
|
|
594
|
+
canvas.width = container.clientWidth;
|
|
595
|
+
canvas.height = container.clientHeight;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function render() {
|
|
599
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
600
|
+
|
|
601
|
+
// Draw floor plan
|
|
602
|
+
if (floorPlanImage) {
|
|
603
|
+
document.getElementById('canvas-placeholder').style.display = 'none';
|
|
604
|
+
const scale = Math.min(
|
|
605
|
+
canvas.width / floorPlanImage.width,
|
|
606
|
+
canvas.height / floorPlanImage.height
|
|
607
|
+
) * 0.9;
|
|
608
|
+
const x = (canvas.width - floorPlanImage.width * scale) / 2;
|
|
609
|
+
const y = (canvas.height - floorPlanImage.height * scale) / 2;
|
|
610
|
+
ctx.drawImage(floorPlanImage, x, y, floorPlanImage.width * scale, floorPlanImage.height * scale);
|
|
611
|
+
} else {
|
|
612
|
+
document.getElementById('canvas-placeholder').style.display = 'block';
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Draw connections
|
|
616
|
+
for (const conn of topology.connections) {
|
|
617
|
+
const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
|
|
618
|
+
const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
|
|
619
|
+
if (fromCam?.floorPlanPosition && toCam?.floorPlanPosition) {
|
|
620
|
+
drawConnection(fromCam.floorPlanPosition, toCam.floorPlanPosition, conn);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Draw cameras
|
|
625
|
+
for (const camera of topology.cameras) {
|
|
626
|
+
if (camera.floorPlanPosition) {
|
|
627
|
+
drawCamera(camera);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function drawCamera(camera) {
|
|
633
|
+
const pos = camera.floorPlanPosition;
|
|
634
|
+
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
635
|
+
|
|
636
|
+
ctx.beginPath();
|
|
637
|
+
ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
|
|
638
|
+
ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
|
|
639
|
+
ctx.fill();
|
|
640
|
+
ctx.strokeStyle = '#fff';
|
|
641
|
+
ctx.lineWidth = 2;
|
|
642
|
+
ctx.stroke();
|
|
643
|
+
|
|
644
|
+
// Camera icon
|
|
645
|
+
ctx.fillStyle = '#fff';
|
|
646
|
+
ctx.font = '16px sans-serif';
|
|
647
|
+
ctx.textAlign = 'center';
|
|
648
|
+
ctx.textBaseline = 'middle';
|
|
649
|
+
ctx.fillText('📷', pos.x, pos.y);
|
|
650
|
+
|
|
651
|
+
// Label
|
|
652
|
+
ctx.font = '12px sans-serif';
|
|
653
|
+
ctx.fillStyle = '#fff';
|
|
654
|
+
ctx.fillText(camera.name, pos.x, pos.y + 35);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function drawConnection(from, to, conn) {
|
|
658
|
+
const isSelected = selectedItem?.type === 'connection' && selectedItem?.id === conn.id;
|
|
659
|
+
|
|
660
|
+
ctx.beginPath();
|
|
661
|
+
ctx.moveTo(from.x, from.y);
|
|
662
|
+
ctx.lineTo(to.x, to.y);
|
|
663
|
+
ctx.strokeStyle = isSelected ? '#e94560' : '#4caf50';
|
|
664
|
+
ctx.lineWidth = isSelected ? 4 : 2;
|
|
665
|
+
ctx.stroke();
|
|
666
|
+
|
|
667
|
+
// Arrow
|
|
668
|
+
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
669
|
+
const midX = (from.x + to.x) / 2;
|
|
670
|
+
const midY = (from.y + to.y) / 2;
|
|
671
|
+
|
|
672
|
+
if (!conn.bidirectional) {
|
|
673
|
+
ctx.beginPath();
|
|
674
|
+
ctx.moveTo(midX, midY);
|
|
675
|
+
ctx.lineTo(midX - 10 * Math.cos(angle - 0.5), midY - 10 * Math.sin(angle - 0.5));
|
|
676
|
+
ctx.lineTo(midX - 10 * Math.cos(angle + 0.5), midY - 10 * Math.sin(angle + 0.5));
|
|
677
|
+
ctx.closePath();
|
|
678
|
+
ctx.fillStyle = isSelected ? '#e94560' : '#4caf50';
|
|
679
|
+
ctx.fill();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Floor plan handling
|
|
684
|
+
function uploadFloorPlan() {
|
|
685
|
+
document.getElementById('upload-modal').classList.add('active');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function handleFloorPlanUpload(event) {
|
|
689
|
+
const file = event.target.files[0];
|
|
690
|
+
if (!file) return;
|
|
691
|
+
|
|
692
|
+
const reader = new FileReader();
|
|
693
|
+
reader.onload = async (e) => {
|
|
694
|
+
const imageData = e.target.result;
|
|
695
|
+
await loadFloorPlanImage(imageData);
|
|
696
|
+
topology.floorPlan = {
|
|
697
|
+
imageData,
|
|
698
|
+
width: floorPlanImage.width,
|
|
699
|
+
height: floorPlanImage.height
|
|
700
|
+
};
|
|
701
|
+
closeModal('upload-modal');
|
|
702
|
+
render();
|
|
703
|
+
};
|
|
704
|
+
reader.readAsDataURL(file);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function loadFloorPlanImage(imageData) {
|
|
708
|
+
return new Promise((resolve) => {
|
|
709
|
+
floorPlanImage = new Image();
|
|
710
|
+
floorPlanImage.onload = resolve;
|
|
711
|
+
floorPlanImage.src = imageData;
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Camera handling
|
|
716
|
+
function openAddCameraModal() {
|
|
717
|
+
document.getElementById('add-camera-modal').classList.add('active');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function addCamera() {
|
|
721
|
+
const deviceId = document.getElementById('camera-device-select').value;
|
|
722
|
+
const name = document.getElementById('camera-name-input').value || 'New Camera';
|
|
723
|
+
const isEntry = document.getElementById('camera-entry-checkbox').checked;
|
|
724
|
+
const isExit = document.getElementById('camera-exit-checkbox').checked;
|
|
725
|
+
|
|
726
|
+
if (!deviceId) {
|
|
727
|
+
alert('Please select a camera device');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const camera = {
|
|
732
|
+
deviceId: deviceId || `camera-${Date.now()}`,
|
|
733
|
+
nativeId: `cam-${Date.now()}`,
|
|
734
|
+
name,
|
|
735
|
+
isEntryPoint: isEntry,
|
|
736
|
+
isExitPoint: isExit,
|
|
737
|
+
trackClasses: ['person', 'car', 'animal'],
|
|
738
|
+
floorPlanPosition: {
|
|
739
|
+
x: canvas.width / 2 + Math.random() * 100 - 50,
|
|
740
|
+
y: canvas.height / 2 + Math.random() * 100 - 50
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
topology.cameras.push(camera);
|
|
745
|
+
closeModal('add-camera-modal');
|
|
746
|
+
updateUI();
|
|
747
|
+
render();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Connection handling
|
|
751
|
+
function openAddConnectionModal() {
|
|
752
|
+
if (topology.cameras.length < 2) {
|
|
753
|
+
alert('Add at least 2 cameras before creating connections');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
updateCameraSelects();
|
|
757
|
+
document.getElementById('add-connection-modal').classList.add('active');
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function updateCameraSelects() {
|
|
761
|
+
const options = topology.cameras.map(c =>
|
|
762
|
+
`<option value="${c.deviceId}">${c.name}</option>`
|
|
763
|
+
).join('');
|
|
764
|
+
|
|
765
|
+
document.getElementById('connection-from-select').innerHTML = options;
|
|
766
|
+
document.getElementById('connection-to-select').innerHTML = options;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function addConnection() {
|
|
770
|
+
const name = document.getElementById('connection-name-input').value;
|
|
771
|
+
const fromId = document.getElementById('connection-from-select').value;
|
|
772
|
+
const toId = document.getElementById('connection-to-select').value;
|
|
773
|
+
const minTransit = parseInt(document.getElementById('transit-min').value) * 1000;
|
|
774
|
+
const typicalTransit = parseInt(document.getElementById('transit-typical').value) * 1000;
|
|
775
|
+
const maxTransit = parseInt(document.getElementById('transit-max').value) * 1000;
|
|
776
|
+
const bidirectional = document.getElementById('connection-bidirectional').checked;
|
|
777
|
+
|
|
778
|
+
if (fromId === toId) {
|
|
779
|
+
alert('Please select different cameras');
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const connection = {
|
|
784
|
+
id: `conn-${Date.now()}`,
|
|
785
|
+
fromCameraId: fromId,
|
|
786
|
+
toCameraId: toId,
|
|
787
|
+
name: name || `${fromId} → ${toId}`,
|
|
788
|
+
exitZone: [],
|
|
789
|
+
entryZone: [],
|
|
790
|
+
transitTime: {
|
|
791
|
+
min: minTransit,
|
|
792
|
+
typical: typicalTransit,
|
|
793
|
+
max: maxTransit
|
|
794
|
+
},
|
|
795
|
+
bidirectional
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
topology.connections.push(connection);
|
|
799
|
+
closeModal('add-connection-modal');
|
|
800
|
+
updateUI();
|
|
801
|
+
render();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// UI updates
|
|
805
|
+
function updateUI() {
|
|
806
|
+
// Update camera list
|
|
807
|
+
const cameraList = document.getElementById('camera-list');
|
|
808
|
+
if (topology.cameras.length === 0) {
|
|
809
|
+
cameraList.innerHTML = '<div class="camera-item" style="color: #666; text-align: center; cursor: default;">No cameras configured</div>';
|
|
810
|
+
} else {
|
|
811
|
+
cameraList.innerHTML = topology.cameras.map(c => `
|
|
812
|
+
<div class="camera-item ${selectedItem?.type === 'camera' && selectedItem?.id === c.deviceId ? 'selected' : ''}"
|
|
813
|
+
onclick="selectCamera('${c.deviceId}')">
|
|
814
|
+
<div class="camera-name">📷 ${c.name}</div>
|
|
815
|
+
<div class="camera-info">
|
|
816
|
+
${c.isEntryPoint ? '🚪 Entry' : ''} ${c.isExitPoint ? '🚶 Exit' : ''}
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
`).join('');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Update connection list
|
|
823
|
+
const connectionList = document.getElementById('connection-list');
|
|
824
|
+
if (topology.connections.length === 0) {
|
|
825
|
+
connectionList.innerHTML = '<div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>';
|
|
826
|
+
} else {
|
|
827
|
+
connectionList.innerHTML = topology.connections.map(c => `
|
|
828
|
+
<div class="connection-item ${selectedItem?.type === 'connection' && selectedItem?.id === c.id ? 'selected' : ''}"
|
|
829
|
+
onclick="selectConnection('${c.id}')">
|
|
830
|
+
<div class="camera-name">🔗 ${c.name}</div>
|
|
831
|
+
<div class="camera-info">
|
|
832
|
+
${c.transitTime.typical / 1000}s typical ${c.bidirectional ? '↔️' : '→'}
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
`).join('');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Update counts
|
|
839
|
+
document.getElementById('camera-count').textContent = topology.cameras.length;
|
|
840
|
+
document.getElementById('connection-count').textContent = topology.connections.length;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function selectCamera(deviceId) {
|
|
844
|
+
selectedItem = { type: 'camera', id: deviceId };
|
|
845
|
+
const camera = topology.cameras.find(c => c.deviceId === deviceId);
|
|
846
|
+
showCameraProperties(camera);
|
|
847
|
+
updateUI();
|
|
848
|
+
render();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function selectConnection(connId) {
|
|
852
|
+
selectedItem = { type: 'connection', id: connId };
|
|
853
|
+
const connection = topology.connections.find(c => c.id === connId);
|
|
854
|
+
showConnectionProperties(connection);
|
|
855
|
+
updateUI();
|
|
856
|
+
render();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function showCameraProperties(camera) {
|
|
860
|
+
const panel = document.getElementById('properties-panel');
|
|
861
|
+
panel.innerHTML = `
|
|
862
|
+
<h3>Camera Properties</h3>
|
|
863
|
+
<div class="form-group">
|
|
864
|
+
<label>Name</label>
|
|
865
|
+
<input type="text" value="${camera.name}" onchange="updateCameraName('${camera.deviceId}', this.value)">
|
|
866
|
+
</div>
|
|
867
|
+
<div class="form-group">
|
|
868
|
+
<label class="checkbox-group">
|
|
869
|
+
<input type="checkbox" ${camera.isEntryPoint ? 'checked' : ''} onchange="updateCameraEntry('${camera.deviceId}', this.checked)">
|
|
870
|
+
Entry Point
|
|
871
|
+
</label>
|
|
872
|
+
</div>
|
|
873
|
+
<div class="form-group">
|
|
874
|
+
<label class="checkbox-group">
|
|
875
|
+
<input type="checkbox" ${camera.isExitPoint ? 'checked' : ''} onchange="updateCameraExit('${camera.deviceId}', this.checked)">
|
|
876
|
+
Exit Point
|
|
877
|
+
</label>
|
|
878
|
+
</div>
|
|
879
|
+
<div class="form-group">
|
|
880
|
+
<button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera('${camera.deviceId}')">Delete Camera</button>
|
|
881
|
+
</div>
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function showConnectionProperties(connection) {
|
|
886
|
+
const panel = document.getElementById('properties-panel');
|
|
887
|
+
panel.innerHTML = `
|
|
888
|
+
<h3>Connection Properties</h3>
|
|
889
|
+
<div class="form-group">
|
|
890
|
+
<label>Name</label>
|
|
891
|
+
<input type="text" value="${connection.name}" onchange="updateConnectionName('${connection.id}', this.value)">
|
|
892
|
+
</div>
|
|
893
|
+
<div class="form-group">
|
|
894
|
+
<label>Transit Time (seconds)</label>
|
|
895
|
+
<div class="transit-time-inputs">
|
|
896
|
+
<input type="number" value="${connection.transitTime.min / 1000}" onchange="updateTransitTime('${connection.id}', 'min', this.value)">
|
|
897
|
+
<input type="number" value="${connection.transitTime.typical / 1000}" onchange="updateTransitTime('${connection.id}', 'typical', this.value)">
|
|
898
|
+
<input type="number" value="${connection.transitTime.max / 1000}" onchange="updateTransitTime('${connection.id}', 'max', this.value)">
|
|
899
|
+
</div>
|
|
900
|
+
<div class="transit-time-labels">
|
|
901
|
+
<span>Min</span>
|
|
902
|
+
<span>Typical</span>
|
|
903
|
+
<span>Max</span>
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
<div class="form-group">
|
|
907
|
+
<label class="checkbox-group">
|
|
908
|
+
<input type="checkbox" ${connection.bidirectional ? 'checked' : ''} onchange="updateConnectionBidi('${connection.id}', this.checked)">
|
|
909
|
+
Bidirectional
|
|
910
|
+
</label>
|
|
911
|
+
</div>
|
|
912
|
+
<div class="form-group">
|
|
913
|
+
<button class="btn" style="width: 100%; background: #f44336;" onclick="deleteConnection('${connection.id}')">Delete Connection</button>
|
|
914
|
+
</div>
|
|
915
|
+
`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Update functions
|
|
919
|
+
function updateCameraName(id, value) {
|
|
920
|
+
const camera = topology.cameras.find(c => c.deviceId === id);
|
|
921
|
+
if (camera) camera.name = value;
|
|
922
|
+
updateUI();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function updateCameraEntry(id, value) {
|
|
926
|
+
const camera = topology.cameras.find(c => c.deviceId === id);
|
|
927
|
+
if (camera) camera.isEntryPoint = value;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function updateCameraExit(id, value) {
|
|
931
|
+
const camera = topology.cameras.find(c => c.deviceId === id);
|
|
932
|
+
if (camera) camera.isExitPoint = value;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function updateConnectionName(id, value) {
|
|
936
|
+
const conn = topology.connections.find(c => c.id === id);
|
|
937
|
+
if (conn) conn.name = value;
|
|
938
|
+
updateUI();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function updateTransitTime(id, field, value) {
|
|
942
|
+
const conn = topology.connections.find(c => c.id === id);
|
|
943
|
+
if (conn) conn.transitTime[field] = parseInt(value) * 1000;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function updateConnectionBidi(id, value) {
|
|
947
|
+
const conn = topology.connections.find(c => c.id === id);
|
|
948
|
+
if (conn) conn.bidirectional = value;
|
|
949
|
+
render();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function deleteCamera(id) {
|
|
953
|
+
if (!confirm('Delete this camera?')) return;
|
|
954
|
+
topology.cameras = topology.cameras.filter(c => c.deviceId !== id);
|
|
955
|
+
topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id);
|
|
956
|
+
selectedItem = null;
|
|
957
|
+
document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>';
|
|
958
|
+
updateUI();
|
|
959
|
+
render();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function deleteConnection(id) {
|
|
963
|
+
if (!confirm('Delete this connection?')) return;
|
|
964
|
+
topology.connections = topology.connections.filter(c => c.id !== id);
|
|
965
|
+
selectedItem = null;
|
|
966
|
+
document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>';
|
|
967
|
+
updateUI();
|
|
968
|
+
render();
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Tools
|
|
972
|
+
function setTool(tool) {
|
|
973
|
+
currentTool = tool;
|
|
974
|
+
setStatus(`Tool: ${tool}`, 'success');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Utility
|
|
978
|
+
function closeModal(id) {
|
|
979
|
+
document.getElementById(id).classList.remove('active');
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function setStatus(text, type = 'success') {
|
|
983
|
+
document.getElementById('status-text').textContent = text;
|
|
984
|
+
const dot = document.getElementById('status-dot');
|
|
985
|
+
dot.className = 'status-dot';
|
|
986
|
+
if (type === 'warning') dot.classList.add('warning');
|
|
987
|
+
if (type === 'error') dot.classList.add('error');
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Canvas interactions
|
|
991
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
992
|
+
const rect = canvas.getBoundingClientRect();
|
|
993
|
+
const x = e.clientX - rect.left;
|
|
994
|
+
const y = e.clientY - rect.top;
|
|
995
|
+
|
|
996
|
+
if (currentTool === 'select') {
|
|
997
|
+
// Check if clicked on a camera
|
|
998
|
+
for (const camera of topology.cameras) {
|
|
999
|
+
if (camera.floorPlanPosition) {
|
|
1000
|
+
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
1001
|
+
if (dist < 25) {
|
|
1002
|
+
selectCamera(camera.deviceId);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// Drag handling
|
|
1011
|
+
let dragging = null;
|
|
1012
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
1013
|
+
if (currentTool !== 'select') return;
|
|
1014
|
+
const rect = canvas.getBoundingClientRect();
|
|
1015
|
+
const x = e.clientX - rect.left;
|
|
1016
|
+
const y = e.clientY - rect.top;
|
|
1017
|
+
|
|
1018
|
+
for (const camera of topology.cameras) {
|
|
1019
|
+
if (camera.floorPlanPosition) {
|
|
1020
|
+
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
1021
|
+
if (dist < 25) {
|
|
1022
|
+
dragging = camera;
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
1030
|
+
if (!dragging) return;
|
|
1031
|
+
const rect = canvas.getBoundingClientRect();
|
|
1032
|
+
dragging.floorPlanPosition.x = e.clientX - rect.left;
|
|
1033
|
+
dragging.floorPlanPosition.y = e.clientY - rect.top;
|
|
1034
|
+
render();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
canvas.addEventListener('mouseup', () => {
|
|
1038
|
+
dragging = null;
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// Window resize
|
|
1042
|
+
window.addEventListener('resize', () => {
|
|
1043
|
+
resizeCanvas();
|
|
1044
|
+
render();
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// Initialize
|
|
1048
|
+
init();
|
|
1049
|
+
</script>
|
|
1050
|
+
</body>
|
|
1051
|
+
</html>
|