@blueharford/scrypted-spatial-awareness 0.1.16 → 0.2.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/README.md +152 -35
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +1443 -57
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/spatial-reasoning.ts +700 -0
- package/src/core/tracking-engine.ts +137 -53
- package/src/main.ts +266 -3
- package/src/models/alert.ts +21 -1
- package/src/models/topology.ts +382 -9
- package/src/ui/editor-html.ts +328 -6
package/src/ui/editor-html.ts
CHANGED
|
@@ -89,6 +89,22 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
89
89
|
<div class="connection-item" style="color: #666; text-align: center; cursor: default;">No connections configured</div>
|
|
90
90
|
</div>
|
|
91
91
|
</div>
|
|
92
|
+
<div class="section">
|
|
93
|
+
<div class="section-title">
|
|
94
|
+
<span>Landmarks</span>
|
|
95
|
+
<button class="btn btn-small" onclick="openAddLandmarkModal()">+ Add</button>
|
|
96
|
+
</div>
|
|
97
|
+
<div id="landmark-list">
|
|
98
|
+
<div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="section" id="suggestions-section" style="display: none;">
|
|
102
|
+
<div class="section-title">
|
|
103
|
+
<span>AI Suggestions</span>
|
|
104
|
+
<button class="btn btn-small" onclick="loadSuggestions()">Refresh</button>
|
|
105
|
+
</div>
|
|
106
|
+
<div id="suggestions-list"></div>
|
|
107
|
+
</div>
|
|
92
108
|
</div>
|
|
93
109
|
</div>
|
|
94
110
|
<div class="editor">
|
|
@@ -102,6 +118,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
102
118
|
<button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
|
|
103
119
|
<button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
|
|
104
120
|
<button class="btn" id="tool-camera" onclick="setTool('camera')">Place Camera</button>
|
|
121
|
+
<button class="btn" id="tool-landmark" onclick="setTool('landmark')">Place Landmark</button>
|
|
105
122
|
<button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
|
|
106
123
|
</div>
|
|
107
124
|
<div class="toolbar-group">
|
|
@@ -129,7 +146,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
129
146
|
<span id="status-text">Ready</span>
|
|
130
147
|
</div>
|
|
131
148
|
<div>
|
|
132
|
-
<span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections
|
|
149
|
+
<span id="camera-count">0</span> cameras | <span id="connection-count">0</span> connections | <span id="landmark-count">0</span> landmarks
|
|
133
150
|
</div>
|
|
134
151
|
</div>
|
|
135
152
|
</div>
|
|
@@ -223,12 +240,61 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
223
240
|
</div>
|
|
224
241
|
</div>
|
|
225
242
|
|
|
243
|
+
<div class="modal-overlay" id="add-landmark-modal">
|
|
244
|
+
<div class="modal">
|
|
245
|
+
<h2>Add Landmark</h2>
|
|
246
|
+
<div class="form-group">
|
|
247
|
+
<label>Landmark Type</label>
|
|
248
|
+
<select id="landmark-type-select" onchange="updateLandmarkSuggestions()">
|
|
249
|
+
<option value="structure">Structure (House, Garage, Shed)</option>
|
|
250
|
+
<option value="feature">Feature (Mailbox, Tree, Pool)</option>
|
|
251
|
+
<option value="boundary">Boundary (Fence, Wall, Hedge)</option>
|
|
252
|
+
<option value="access">Access (Driveway, Walkway, Gate)</option>
|
|
253
|
+
<option value="vehicle">Vehicle (Parking, Boat, RV)</option>
|
|
254
|
+
<option value="neighbor">Neighbor (House, Driveway)</option>
|
|
255
|
+
<option value="zone">Zone (Front Yard, Back Yard)</option>
|
|
256
|
+
<option value="street">Street (Street, Sidewalk, Alley)</option>
|
|
257
|
+
</select>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="form-group">
|
|
260
|
+
<label>Quick Templates</label>
|
|
261
|
+
<div id="landmark-templates" style="display: flex; flex-wrap: wrap; gap: 5px;"></div>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="form-group">
|
|
264
|
+
<label>Name</label>
|
|
265
|
+
<input type="text" id="landmark-name-input" placeholder="e.g., Front Porch, Red Shed">
|
|
266
|
+
</div>
|
|
267
|
+
<div class="form-group">
|
|
268
|
+
<label>Description (optional)</label>
|
|
269
|
+
<input type="text" id="landmark-desc-input" placeholder="Brief description for AI context">
|
|
270
|
+
</div>
|
|
271
|
+
<div class="form-group">
|
|
272
|
+
<label class="checkbox-group">
|
|
273
|
+
<input type="checkbox" id="landmark-entry-checkbox">
|
|
274
|
+
Entry Point (people can enter property here)
|
|
275
|
+
</label>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="form-group">
|
|
278
|
+
<label class="checkbox-group">
|
|
279
|
+
<input type="checkbox" id="landmark-exit-checkbox">
|
|
280
|
+
Exit Point (people can exit property here)
|
|
281
|
+
</label>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="modal-actions">
|
|
284
|
+
<button class="btn" onclick="closeModal('add-landmark-modal')">Cancel</button>
|
|
285
|
+
<button class="btn btn-primary" onclick="addLandmark()">Add Landmark</button>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
226
290
|
<script>
|
|
227
|
-
let topology = { version: '
|
|
291
|
+
let topology = { version: '2.0', cameras: [], connections: [], globalZones: [], landmarks: [], relationships: [], floorPlan: null, drawings: [] };
|
|
228
292
|
let selectedItem = null;
|
|
229
293
|
let currentTool = 'select';
|
|
230
294
|
let floorPlanImage = null;
|
|
231
295
|
let availableCameras = [];
|
|
296
|
+
let landmarkTemplates = [];
|
|
297
|
+
let pendingSuggestions = [];
|
|
232
298
|
let isDrawing = false;
|
|
233
299
|
let drawStart = null;
|
|
234
300
|
let currentDrawing = null;
|
|
@@ -239,6 +305,8 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
239
305
|
async function init() {
|
|
240
306
|
await loadTopology();
|
|
241
307
|
await loadAvailableCameras();
|
|
308
|
+
await loadLandmarkTemplates();
|
|
309
|
+
await loadSuggestions();
|
|
242
310
|
resizeCanvas();
|
|
243
311
|
render();
|
|
244
312
|
updateUI();
|
|
@@ -274,6 +342,192 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
274
342
|
updateCameraSelects();
|
|
275
343
|
}
|
|
276
344
|
|
|
345
|
+
async function loadLandmarkTemplates() {
|
|
346
|
+
try {
|
|
347
|
+
const response = await fetch('../api/landmark-templates');
|
|
348
|
+
if (response.ok) {
|
|
349
|
+
const data = await response.json();
|
|
350
|
+
landmarkTemplates = data.templates || [];
|
|
351
|
+
}
|
|
352
|
+
} catch (e) { console.error('Failed to load landmark templates:', e); }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function loadSuggestions() {
|
|
356
|
+
try {
|
|
357
|
+
const response = await fetch('../api/landmark-suggestions');
|
|
358
|
+
if (response.ok) {
|
|
359
|
+
const data = await response.json();
|
|
360
|
+
pendingSuggestions = data.suggestions || [];
|
|
361
|
+
updateSuggestionsUI();
|
|
362
|
+
}
|
|
363
|
+
} catch (e) { console.error('Failed to load suggestions:', e); }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function updateSuggestionsUI() {
|
|
367
|
+
const section = document.getElementById('suggestions-section');
|
|
368
|
+
const list = document.getElementById('suggestions-list');
|
|
369
|
+
if (pendingSuggestions.length === 0) {
|
|
370
|
+
section.style.display = 'none';
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
section.style.display = 'block';
|
|
374
|
+
list.innerHTML = pendingSuggestions.map(s =>
|
|
375
|
+
'<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
|
|
376
|
+
'<div><div class="camera-name">' + s.landmark.name + '</div>' +
|
|
377
|
+
'<div class="camera-info">' + s.landmark.type + ' - ' + Math.round((s.landmark.aiConfidence || 0) * 100) + '% confidence</div></div>' +
|
|
378
|
+
'<div style="display: flex; gap: 5px;">' +
|
|
379
|
+
'<button class="btn btn-small btn-primary" onclick="acceptSuggestion(\\'' + s.id + '\\')">Accept</button>' +
|
|
380
|
+
'<button class="btn btn-small" onclick="rejectSuggestion(\\'' + s.id + '\\')">Reject</button>' +
|
|
381
|
+
'</div></div>'
|
|
382
|
+
).join('');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function acceptSuggestion(id) {
|
|
386
|
+
try {
|
|
387
|
+
const response = await fetch('../api/landmark-suggestions/' + id + '/accept', { method: 'POST' });
|
|
388
|
+
if (response.ok) {
|
|
389
|
+
const data = await response.json();
|
|
390
|
+
if (data.landmark) {
|
|
391
|
+
topology.landmarks.push(data.landmark);
|
|
392
|
+
updateUI();
|
|
393
|
+
render();
|
|
394
|
+
}
|
|
395
|
+
await loadSuggestions();
|
|
396
|
+
setStatus('Landmark accepted', 'success');
|
|
397
|
+
}
|
|
398
|
+
} catch (e) { console.error('Failed to accept suggestion:', e); }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function rejectSuggestion(id) {
|
|
402
|
+
try {
|
|
403
|
+
await fetch('../api/landmark-suggestions/' + id + '/reject', { method: 'POST' });
|
|
404
|
+
await loadSuggestions();
|
|
405
|
+
setStatus('Suggestion rejected', 'success');
|
|
406
|
+
} catch (e) { console.error('Failed to reject suggestion:', e); }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function openAddLandmarkModal() {
|
|
410
|
+
updateLandmarkSuggestions();
|
|
411
|
+
document.getElementById('add-landmark-modal').classList.add('active');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function updateLandmarkSuggestions() {
|
|
415
|
+
const type = document.getElementById('landmark-type-select').value;
|
|
416
|
+
const template = landmarkTemplates.find(t => t.type === type);
|
|
417
|
+
const container = document.getElementById('landmark-templates');
|
|
418
|
+
if (template) {
|
|
419
|
+
container.innerHTML = template.suggestions.map(s =>
|
|
420
|
+
'<button class="btn btn-small" onclick="setLandmarkName(\\'' + s + '\\')" style="margin: 2px;">' + s + '</button>'
|
|
421
|
+
).join('');
|
|
422
|
+
} else {
|
|
423
|
+
container.innerHTML = '<span style="color: #666; font-size: 12px;">No templates for this type</span>';
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function setLandmarkName(name) {
|
|
428
|
+
document.getElementById('landmark-name-input').value = name;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function addLandmark() {
|
|
432
|
+
const name = document.getElementById('landmark-name-input').value;
|
|
433
|
+
if (!name) { alert('Please enter a landmark name'); return; }
|
|
434
|
+
const type = document.getElementById('landmark-type-select').value;
|
|
435
|
+
const description = document.getElementById('landmark-desc-input').value;
|
|
436
|
+
const isEntry = document.getElementById('landmark-entry-checkbox').checked;
|
|
437
|
+
const isExit = document.getElementById('landmark-exit-checkbox').checked;
|
|
438
|
+
const pos = topology._pendingLandmarkPos || { x: canvas.width / 2 + Math.random() * 100 - 50, y: canvas.height / 2 + Math.random() * 100 - 50 };
|
|
439
|
+
delete topology._pendingLandmarkPos;
|
|
440
|
+
const landmark = {
|
|
441
|
+
id: 'landmark_' + Date.now(),
|
|
442
|
+
name,
|
|
443
|
+
type,
|
|
444
|
+
position: pos,
|
|
445
|
+
description: description || undefined,
|
|
446
|
+
isEntryPoint: isEntry,
|
|
447
|
+
isExitPoint: isExit,
|
|
448
|
+
visibleFromCameras: [],
|
|
449
|
+
};
|
|
450
|
+
if (!topology.landmarks) topology.landmarks = [];
|
|
451
|
+
topology.landmarks.push(landmark);
|
|
452
|
+
closeModal('add-landmark-modal');
|
|
453
|
+
document.getElementById('landmark-name-input').value = '';
|
|
454
|
+
document.getElementById('landmark-desc-input').value = '';
|
|
455
|
+
document.getElementById('landmark-entry-checkbox').checked = false;
|
|
456
|
+
document.getElementById('landmark-exit-checkbox').checked = false;
|
|
457
|
+
updateUI();
|
|
458
|
+
render();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function selectLandmark(id) {
|
|
462
|
+
selectedItem = { type: 'landmark', id };
|
|
463
|
+
const landmark = topology.landmarks.find(l => l.id === id);
|
|
464
|
+
showLandmarkProperties(landmark);
|
|
465
|
+
updateUI();
|
|
466
|
+
render();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function showLandmarkProperties(landmark) {
|
|
470
|
+
const panel = document.getElementById('properties-panel');
|
|
471
|
+
const cameraOptions = topology.cameras.map(c =>
|
|
472
|
+
'<label class="checkbox-group" style="margin-bottom: 5px;"><input type="checkbox" ' +
|
|
473
|
+
((landmark.visibleFromCameras || []).includes(c.deviceId) ? 'checked' : '') +
|
|
474
|
+
' onchange="toggleLandmarkCamera(\\'' + landmark.id + '\\', \\'' + c.deviceId + '\\', this.checked)">' +
|
|
475
|
+
c.name + '</label>'
|
|
476
|
+
).join('');
|
|
477
|
+
panel.innerHTML = '<h3>Landmark Properties</h3>' +
|
|
478
|
+
'<div class="form-group"><label>Name</label><input type="text" value="' + landmark.name + '" onchange="updateLandmarkName(\\'' + landmark.id + '\\', this.value)"></div>' +
|
|
479
|
+
'<div class="form-group"><label>Type</label><select onchange="updateLandmarkType(\\'' + landmark.id + '\\', this.value)">' +
|
|
480
|
+
'<option value="structure"' + (landmark.type === 'structure' ? ' selected' : '') + '>Structure</option>' +
|
|
481
|
+
'<option value="feature"' + (landmark.type === 'feature' ? ' selected' : '') + '>Feature</option>' +
|
|
482
|
+
'<option value="boundary"' + (landmark.type === 'boundary' ? ' selected' : '') + '>Boundary</option>' +
|
|
483
|
+
'<option value="access"' + (landmark.type === 'access' ? ' selected' : '') + '>Access</option>' +
|
|
484
|
+
'<option value="vehicle"' + (landmark.type === 'vehicle' ? ' selected' : '') + '>Vehicle</option>' +
|
|
485
|
+
'<option value="neighbor"' + (landmark.type === 'neighbor' ? ' selected' : '') + '>Neighbor</option>' +
|
|
486
|
+
'<option value="zone"' + (landmark.type === 'zone' ? ' selected' : '') + '>Zone</option>' +
|
|
487
|
+
'<option value="street"' + (landmark.type === 'street' ? ' selected' : '') + '>Street</option>' +
|
|
488
|
+
'</select></div>' +
|
|
489
|
+
'<div class="form-group"><label>Description</label><input type="text" value="' + (landmark.description || '') + '" onchange="updateLandmarkDesc(\\'' + landmark.id + '\\', this.value)"></div>' +
|
|
490
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (landmark.isEntryPoint ? 'checked' : '') + ' onchange="updateLandmarkEntry(\\'' + landmark.id + '\\', this.checked)">Entry Point</label></div>' +
|
|
491
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (landmark.isExitPoint ? 'checked' : '') + ' onchange="updateLandmarkExit(\\'' + landmark.id + '\\', this.checked)">Exit Point</label></div>' +
|
|
492
|
+
'<div class="form-group"><label>Visible from Cameras</label>' + (cameraOptions || '<span style="color:#666;font-size:12px;">Add cameras first</span>') + '</div>' +
|
|
493
|
+
'<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteLandmark(\\'' + landmark.id + '\\')">Delete Landmark</button></div>';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function updateLandmarkName(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.name = value; updateUI(); }
|
|
497
|
+
function updateLandmarkType(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.type = value; render(); }
|
|
498
|
+
function updateLandmarkDesc(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.description = value || undefined; }
|
|
499
|
+
function updateLandmarkEntry(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.isEntryPoint = value; }
|
|
500
|
+
function updateLandmarkExit(id, value) { const l = topology.landmarks.find(x => x.id === id); if (l) l.isExitPoint = value; }
|
|
501
|
+
function toggleLandmarkCamera(landmarkId, cameraId, visible) {
|
|
502
|
+
const l = topology.landmarks.find(x => x.id === landmarkId);
|
|
503
|
+
if (!l) return;
|
|
504
|
+
if (!l.visibleFromCameras) l.visibleFromCameras = [];
|
|
505
|
+
if (visible && !l.visibleFromCameras.includes(cameraId)) {
|
|
506
|
+
l.visibleFromCameras.push(cameraId);
|
|
507
|
+
} else if (!visible) {
|
|
508
|
+
l.visibleFromCameras = l.visibleFromCameras.filter(id => id !== cameraId);
|
|
509
|
+
}
|
|
510
|
+
// Also update camera's visibleLandmarks
|
|
511
|
+
const camera = topology.cameras.find(c => c.deviceId === cameraId);
|
|
512
|
+
if (camera) {
|
|
513
|
+
if (!camera.context) camera.context = {};
|
|
514
|
+
if (!camera.context.visibleLandmarks) camera.context.visibleLandmarks = [];
|
|
515
|
+
if (visible && !camera.context.visibleLandmarks.includes(landmarkId)) {
|
|
516
|
+
camera.context.visibleLandmarks.push(landmarkId);
|
|
517
|
+
} else if (!visible) {
|
|
518
|
+
camera.context.visibleLandmarks = camera.context.visibleLandmarks.filter(id => id !== landmarkId);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function deleteLandmark(id) {
|
|
523
|
+
if (!confirm('Delete this landmark?')) return;
|
|
524
|
+
topology.landmarks = topology.landmarks.filter(l => l.id !== id);
|
|
525
|
+
selectedItem = null;
|
|
526
|
+
document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
|
|
527
|
+
updateUI();
|
|
528
|
+
render();
|
|
529
|
+
}
|
|
530
|
+
|
|
277
531
|
async function saveTopology() {
|
|
278
532
|
try {
|
|
279
533
|
setStatus('Saving...', 'warning');
|
|
@@ -361,6 +615,10 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
361
615
|
ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
|
|
362
616
|
}
|
|
363
617
|
}
|
|
618
|
+
// Draw landmarks first (below cameras and connections)
|
|
619
|
+
for (const landmark of (topology.landmarks || [])) {
|
|
620
|
+
if (landmark.position) { drawLandmark(landmark); }
|
|
621
|
+
}
|
|
364
622
|
for (const conn of topology.connections) {
|
|
365
623
|
const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
|
|
366
624
|
const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
|
|
@@ -373,6 +631,46 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
373
631
|
}
|
|
374
632
|
}
|
|
375
633
|
|
|
634
|
+
function drawLandmark(landmark) {
|
|
635
|
+
const pos = landmark.position;
|
|
636
|
+
const isSelected = selectedItem?.type === 'landmark' && selectedItem?.id === landmark.id;
|
|
637
|
+
// Color by type
|
|
638
|
+
const colors = {
|
|
639
|
+
structure: '#8b5cf6', // purple
|
|
640
|
+
feature: '#10b981', // green
|
|
641
|
+
boundary: '#f59e0b', // amber
|
|
642
|
+
access: '#3b82f6', // blue
|
|
643
|
+
vehicle: '#6366f1', // indigo
|
|
644
|
+
neighbor: '#ec4899', // pink
|
|
645
|
+
zone: '#14b8a6', // teal
|
|
646
|
+
street: '#6b7280', // gray
|
|
647
|
+
};
|
|
648
|
+
const color = colors[landmark.type] || '#888';
|
|
649
|
+
// Draw landmark marker
|
|
650
|
+
ctx.beginPath();
|
|
651
|
+
ctx.moveTo(pos.x, pos.y - 15);
|
|
652
|
+
ctx.lineTo(pos.x + 12, pos.y + 8);
|
|
653
|
+
ctx.lineTo(pos.x - 12, pos.y + 8);
|
|
654
|
+
ctx.closePath();
|
|
655
|
+
ctx.fillStyle = isSelected ? '#e94560' : color;
|
|
656
|
+
ctx.fill();
|
|
657
|
+
ctx.strokeStyle = '#fff';
|
|
658
|
+
ctx.lineWidth = 2;
|
|
659
|
+
ctx.stroke();
|
|
660
|
+
// Entry/exit indicators
|
|
661
|
+
if (landmark.isEntryPoint || landmark.isExitPoint) {
|
|
662
|
+
ctx.beginPath();
|
|
663
|
+
ctx.arc(pos.x, pos.y - 20, 5, 0, Math.PI * 2);
|
|
664
|
+
ctx.fillStyle = landmark.isEntryPoint ? '#4caf50' : '#ff9800';
|
|
665
|
+
ctx.fill();
|
|
666
|
+
}
|
|
667
|
+
// Label
|
|
668
|
+
ctx.fillStyle = '#fff';
|
|
669
|
+
ctx.font = '11px sans-serif';
|
|
670
|
+
ctx.textAlign = 'center';
|
|
671
|
+
ctx.fillText(landmark.name, pos.x, pos.y + 25);
|
|
672
|
+
}
|
|
673
|
+
|
|
376
674
|
function drawCamera(camera) {
|
|
377
675
|
const pos = camera.floorPlanPosition;
|
|
378
676
|
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
@@ -539,8 +837,17 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
539
837
|
} else {
|
|
540
838
|
connectionList.innerHTML = topology.connections.map(c => '<div class="connection-item ' + (selectedItem?.type === 'connection' && selectedItem?.id === c.id ? 'selected' : '') + '" onclick="selectConnection(\\'' + c.id + '\\')"><div class="camera-name">' + c.name + '</div><div class="camera-info">' + (c.transitTime.typical / 1000) + 's typical ' + (c.bidirectional ? '<->' : '->') + '</div></div>').join('');
|
|
541
839
|
}
|
|
840
|
+
// Landmark list
|
|
841
|
+
const landmarkList = document.getElementById('landmark-list');
|
|
842
|
+
const landmarks = topology.landmarks || [];
|
|
843
|
+
if (landmarks.length === 0) {
|
|
844
|
+
landmarkList.innerHTML = '<div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>';
|
|
845
|
+
} else {
|
|
846
|
+
landmarkList.innerHTML = landmarks.map(l => '<div class="camera-item ' + (selectedItem?.type === 'landmark' && selectedItem?.id === l.id ? 'selected' : '') + '" onclick="selectLandmark(\\'' + l.id + '\\')"><div class="camera-name">' + l.name + '</div><div class="camera-info">' + l.type + (l.isEntryPoint ? ' | Entry' : '') + (l.isExitPoint ? ' | Exit' : '') + '</div></div>').join('');
|
|
847
|
+
}
|
|
542
848
|
document.getElementById('camera-count').textContent = topology.cameras.length;
|
|
543
849
|
document.getElementById('connection-count').textContent = topology.connections.length;
|
|
850
|
+
document.getElementById('landmark-count').textContent = landmarks.length;
|
|
544
851
|
}
|
|
545
852
|
|
|
546
853
|
function selectCamera(deviceId) {
|
|
@@ -611,10 +918,18 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
611
918
|
const y = e.clientY - rect.top;
|
|
612
919
|
|
|
613
920
|
if (currentTool === 'select') {
|
|
921
|
+
// Check cameras first
|
|
614
922
|
for (const camera of topology.cameras) {
|
|
615
923
|
if (camera.floorPlanPosition) {
|
|
616
924
|
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
617
|
-
if (dist < 25) { selectCamera(camera.deviceId); dragging = camera; return; }
|
|
925
|
+
if (dist < 25) { selectCamera(camera.deviceId); dragging = { type: 'camera', item: camera }; return; }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Check landmarks
|
|
929
|
+
for (const landmark of (topology.landmarks || [])) {
|
|
930
|
+
if (landmark.position) {
|
|
931
|
+
const dist = Math.hypot(x - landmark.position.x, y - landmark.position.y);
|
|
932
|
+
if (dist < 20) { selectLandmark(landmark.id); dragging = { type: 'landmark', item: landmark }; return; }
|
|
618
933
|
}
|
|
619
934
|
}
|
|
620
935
|
} else if (currentTool === 'wall') {
|
|
@@ -627,8 +942,10 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
627
942
|
currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
|
|
628
943
|
} else if (currentTool === 'camera') {
|
|
629
944
|
openAddCameraModal();
|
|
630
|
-
// Will position camera at click location after adding
|
|
631
945
|
topology._pendingCameraPos = { x, y };
|
|
946
|
+
} else if (currentTool === 'landmark') {
|
|
947
|
+
openAddLandmarkModal();
|
|
948
|
+
topology._pendingLandmarkPos = { x, y };
|
|
632
949
|
}
|
|
633
950
|
});
|
|
634
951
|
|
|
@@ -638,8 +955,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
638
955
|
const y = e.clientY - rect.top;
|
|
639
956
|
|
|
640
957
|
if (dragging) {
|
|
641
|
-
dragging.
|
|
642
|
-
|
|
958
|
+
if (dragging.type === 'camera') {
|
|
959
|
+
dragging.item.floorPlanPosition.x = x;
|
|
960
|
+
dragging.item.floorPlanPosition.y = y;
|
|
961
|
+
} else if (dragging.type === 'landmark') {
|
|
962
|
+
dragging.item.position.x = x;
|
|
963
|
+
dragging.item.position.y = y;
|
|
964
|
+
}
|
|
643
965
|
render();
|
|
644
966
|
} else if (isDrawing && currentDrawing) {
|
|
645
967
|
if (currentDrawing.type === 'wall') {
|