@blueharford/scrypted-spatial-awareness 0.1.16 → 0.2.1

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.
@@ -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: '1.0', cameras: [], connections: [], globalZones: [], floorPlan: null, drawings: [] };
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.floorPlanPosition.x = x;
642
- dragging.floorPlanPosition.y = y;
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') {