@blueharford/scrypted-spatial-awareness 0.1.6 → 0.1.8

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/dist/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
package/src/main.ts CHANGED
@@ -335,63 +335,13 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
335
335
  async getSettings(): Promise<Setting[]> {
336
336
  const settings = await this.storageSettings.getSettings();
337
337
 
338
- // Topology editor button that opens modal overlay
338
+ // Topology editor button that opens modal overlay (appended to body for proper z-index)
339
339
  settings.push({
340
340
  key: 'topologyEditor',
341
341
  title: 'Topology Editor',
342
342
  type: 'html' as any,
343
343
  value: `
344
344
  <style>
345
- .sa-modal-overlay {
346
- display: none;
347
- position: fixed;
348
- top: 0;
349
- left: 0;
350
- right: 0;
351
- bottom: 0;
352
- background: rgba(0, 0, 0, 0.85);
353
- z-index: 999999;
354
- align-items: center;
355
- justify-content: center;
356
- }
357
- .sa-modal-overlay.active {
358
- display: flex;
359
- }
360
- .sa-modal-container {
361
- width: 95vw;
362
- height: 90vh;
363
- max-width: 1600px;
364
- background: #1a1a2e;
365
- border-radius: 12px;
366
- overflow: hidden;
367
- position: relative;
368
- box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
369
- }
370
- .sa-modal-close {
371
- position: absolute;
372
- top: 10px;
373
- right: 10px;
374
- z-index: 1000000;
375
- background: #e94560;
376
- color: white;
377
- border: none;
378
- width: 36px;
379
- height: 36px;
380
- border-radius: 50%;
381
- font-size: 20px;
382
- cursor: pointer;
383
- display: flex;
384
- align-items: center;
385
- justify-content: center;
386
- }
387
- .sa-modal-close:hover {
388
- background: #ff6b6b;
389
- }
390
- .sa-modal-iframe {
391
- width: 100%;
392
- height: 100%;
393
- border: none;
394
- }
395
345
  .sa-open-btn {
396
346
  background: linear-gradient(135deg, #e94560 0%, #0f3460 100%);
397
347
  color: white;
@@ -424,16 +374,30 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
424
374
  </style>
425
375
  <div class="sa-btn-container">
426
376
  <p class="sa-btn-desc">Configure camera positions, connections, and transit times</p>
427
- <button class="sa-open-btn" onclick="document.getElementById('sa-topology-modal').classList.add('active')">
377
+ <button class="sa-open-btn" onclick="window.openSATopologyEditor()">
428
378
  <span>&#9881;</span> Open Topology Editor
429
379
  </button>
430
380
  </div>
431
- <div id="sa-topology-modal" class="sa-modal-overlay" onclick="if(event.target===this)this.classList.remove('active')">
432
- <div class="sa-modal-container">
433
- <button class="sa-modal-close" onclick="document.getElementById('sa-topology-modal').classList.remove('active')">&times;</button>
434
- <iframe class="sa-modal-iframe" src="/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor"></iframe>
435
- </div>
436
- </div>
381
+ <script>
382
+ window.openSATopologyEditor = function() {
383
+ // Remove existing modal if any
384
+ const existing = document.getElementById('sa-topology-modal');
385
+ if (existing) existing.remove();
386
+
387
+ // Create modal and append to body
388
+ const modal = document.createElement('div');
389
+ modal.id = 'sa-topology-modal';
390
+ modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.9);z-index:2147483647;display:flex;align-items:center;justify-content:center;';
391
+ modal.innerHTML = \`
392
+ <div style="width:95vw;height:92vh;max-width:1800px;background:#1a1a2e;border-radius:12px;overflow:hidden;position:relative;box-shadow:0 25px 50px rgba(0,0,0,0.5);">
393
+ <button onclick="document.getElementById('sa-topology-modal').remove()" style="position:absolute;top:15px;right:15px;z-index:2147483647;background:#e94560;color:white;border:none;width:40px;height:40px;border-radius:50%;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center;">&times;</button>
394
+ <iframe src="/endpoint/@blueharford/scrypted-spatial-awareness/ui/editor" style="width:100%;height:100%;border:none;"></iframe>
395
+ </div>
396
+ \`;
397
+ modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
398
+ document.body.appendChild(modal);
399
+ };
400
+ </script>
437
401
  `,
438
402
  group: 'Topology',
439
403
  });
@@ -94,12 +94,18 @@ export const EDITOR_HTML = `<!DOCTYPE html>
94
94
  <div class="editor">
95
95
  <div class="toolbar">
96
96
  <div class="toolbar-group">
97
- <button class="btn" onclick="uploadFloorPlan()">Upload Floor Plan</button>
97
+ <button class="btn" onclick="uploadFloorPlan()">Upload Image</button>
98
+ <button class="btn" onclick="useBlankCanvas()">Blank Canvas</button>
98
99
  </div>
99
100
  <div class="toolbar-group">
100
- <button class="btn" onclick="setTool('select')">Select</button>
101
- <button class="btn" onclick="setTool('camera')">Place Camera</button>
102
- <button class="btn" onclick="setTool('connect')">Connect</button>
101
+ <button class="btn" id="tool-select" onclick="setTool('select')">Select</button>
102
+ <button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
103
+ <button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
104
+ <button class="btn" id="tool-camera" onclick="setTool('camera')">Place Camera</button>
105
+ <button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
106
+ </div>
107
+ <div class="toolbar-group">
108
+ <button class="btn" onclick="clearDrawings()">Clear Drawings</button>
103
109
  </div>
104
110
  <div class="toolbar-group">
105
111
  <button class="btn btn-primary" onclick="saveTopology()">Save</button>
@@ -109,9 +115,12 @@ export const EDITOR_HTML = `<!DOCTYPE html>
109
115
  <canvas id="floor-plan-canvas"></canvas>
110
116
  <div class="canvas-placeholder" id="canvas-placeholder">
111
117
  <h2>Floor Plan Editor</h2>
112
- <p>Upload a floor plan image to get started</p>
118
+ <p>Upload an image or use a blank canvas to draw your floor plan</p>
113
119
  <br>
114
- <button class="btn btn-primary" onclick="uploadFloorPlan()">Upload Floor Plan</button>
120
+ <div style="display: flex; gap: 15px; justify-content: center;">
121
+ <button class="btn btn-primary" onclick="uploadFloorPlan()">Upload Image</button>
122
+ <button class="btn" onclick="useBlankCanvas()">Use Blank Canvas</button>
123
+ </div>
115
124
  </div>
116
125
  </div>
117
126
  <div class="status-bar">
@@ -215,11 +224,15 @@ export const EDITOR_HTML = `<!DOCTYPE html>
215
224
  </div>
216
225
 
217
226
  <script>
218
- let topology = { version: '1.0', cameras: [], connections: [], globalZones: [], floorPlan: null };
227
+ let topology = { version: '1.0', cameras: [], connections: [], globalZones: [], floorPlan: null, drawings: [] };
219
228
  let selectedItem = null;
220
229
  let currentTool = 'select';
221
230
  let floorPlanImage = null;
222
231
  let availableCameras = [];
232
+ let isDrawing = false;
233
+ let drawStart = null;
234
+ let currentDrawing = null;
235
+ let blankCanvasMode = false;
223
236
  const canvas = document.getElementById('floor-plan-canvas');
224
237
  const ctx = canvas.getContext('2d');
225
238
 
@@ -236,8 +249,11 @@ export const EDITOR_HTML = `<!DOCTYPE html>
236
249
  const response = await fetch('../api/topology');
237
250
  if (response.ok) {
238
251
  topology = await response.json();
252
+ if (!topology.drawings) topology.drawings = [];
239
253
  if (topology.floorPlan?.imageData) {
240
254
  await loadFloorPlanImage(topology.floorPlan.imageData);
255
+ } else if (topology.floorPlan?.type === 'blank') {
256
+ blankCanvasMode = true;
241
257
  }
242
258
  }
243
259
  } catch (e) { console.error('Failed to load topology:', e); }
@@ -269,7 +285,22 @@ export const EDITOR_HTML = `<!DOCTYPE html>
269
285
 
270
286
  function render() {
271
287
  ctx.clearRect(0, 0, canvas.width, canvas.height);
272
- if (floorPlanImage) {
288
+
289
+ // Draw grid for blank canvas
290
+ if (blankCanvasMode && !floorPlanImage) {
291
+ document.getElementById('canvas-placeholder').style.display = 'none';
292
+ ctx.fillStyle = '#1a1a2e';
293
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
294
+ ctx.strokeStyle = '#2a2a4e';
295
+ ctx.lineWidth = 1;
296
+ const gridSize = 40;
297
+ for (let x = 0; x < canvas.width; x += gridSize) {
298
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
299
+ }
300
+ for (let y = 0; y < canvas.height; y += gridSize) {
301
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
302
+ }
303
+ } else if (floorPlanImage) {
273
304
  document.getElementById('canvas-placeholder').style.display = 'none';
274
305
  const scale = Math.min(canvas.width / floorPlanImage.width, canvas.height / floorPlanImage.height) * 0.9;
275
306
  const x = (canvas.width - floorPlanImage.width * scale) / 2;
@@ -278,6 +309,48 @@ export const EDITOR_HTML = `<!DOCTYPE html>
278
309
  } else {
279
310
  document.getElementById('canvas-placeholder').style.display = 'block';
280
311
  }
312
+
313
+ // Draw saved drawings (walls and rooms)
314
+ if (topology.drawings) {
315
+ for (const drawing of topology.drawings) {
316
+ if (drawing.type === 'wall') {
317
+ ctx.beginPath();
318
+ ctx.moveTo(drawing.x1, drawing.y1);
319
+ ctx.lineTo(drawing.x2, drawing.y2);
320
+ ctx.strokeStyle = '#888';
321
+ ctx.lineWidth = 4;
322
+ ctx.stroke();
323
+ } else if (drawing.type === 'room') {
324
+ ctx.strokeStyle = '#666';
325
+ ctx.lineWidth = 2;
326
+ ctx.strokeRect(drawing.x, drawing.y, drawing.width, drawing.height);
327
+ ctx.fillStyle = 'rgba(100, 100, 150, 0.1)';
328
+ ctx.fillRect(drawing.x, drawing.y, drawing.width, drawing.height);
329
+ if (drawing.label) {
330
+ ctx.fillStyle = '#888';
331
+ ctx.font = '12px sans-serif';
332
+ ctx.textAlign = 'center';
333
+ ctx.fillText(drawing.label, drawing.x + drawing.width/2, drawing.y + drawing.height/2);
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ // Draw current drawing in progress
340
+ if (currentDrawing) {
341
+ if (currentDrawing.type === 'wall') {
342
+ ctx.beginPath();
343
+ ctx.moveTo(currentDrawing.x1, currentDrawing.y1);
344
+ ctx.lineTo(currentDrawing.x2, currentDrawing.y2);
345
+ ctx.strokeStyle = '#e94560';
346
+ ctx.lineWidth = 4;
347
+ ctx.stroke();
348
+ } else if (currentDrawing.type === 'room') {
349
+ ctx.strokeStyle = '#e94560';
350
+ ctx.lineWidth = 2;
351
+ ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
352
+ }
353
+ }
281
354
  for (const conn of topology.connections) {
282
355
  const fromCam = topology.cameras.find(c => c.deviceId === conn.fromCameraId);
283
356
  const toCam = topology.cameras.find(c => c.deviceId === conn.toCameraId);
@@ -361,6 +434,9 @@ export const EDITOR_HTML = `<!DOCTYPE html>
361
434
  const name = document.getElementById('camera-name-input').value || 'New Camera';
362
435
  const isEntry = document.getElementById('camera-entry-checkbox').checked;
363
436
  const isExit = document.getElementById('camera-exit-checkbox').checked;
437
+ // Use pending position from click, or default to center
438
+ const pos = topology._pendingCameraPos || { x: canvas.width / 2 + Math.random() * 100 - 50, y: canvas.height / 2 + Math.random() * 100 - 50 };
439
+ delete topology._pendingCameraPos;
364
440
  const camera = {
365
441
  deviceId: deviceId || 'camera-' + Date.now(),
366
442
  nativeId: 'cam-' + Date.now(),
@@ -368,7 +444,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
368
444
  isEntryPoint: isEntry,
369
445
  isExitPoint: isExit,
370
446
  trackClasses: ['person', 'car', 'animal'],
371
- floorPlanPosition: { x: canvas.width / 2 + Math.random() * 100 - 50, y: canvas.height / 2 + Math.random() * 100 - 50 }
447
+ floorPlanPosition: pos
372
448
  };
373
449
  topology.cameras.push(camera);
374
450
  closeModal('add-camera-modal');
@@ -464,47 +540,115 @@ export const EDITOR_HTML = `<!DOCTYPE html>
464
540
  function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
465
541
  function deleteCamera(id) { if (!confirm('Delete this camera?')) return; topology.cameras = topology.cameras.filter(c => c.deviceId !== id); topology.connections = topology.connections.filter(c => c.fromCameraId !== id && c.toCameraId !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
466
542
  function deleteConnection(id) { if (!confirm('Delete this connection?')) return; topology.connections = topology.connections.filter(c => c.id !== id); selectedItem = null; document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select a camera or connection.</p>'; updateUI(); render(); }
467
- function setTool(tool) { currentTool = tool; setStatus('Tool: ' + tool, 'success'); }
543
+ function setTool(tool) {
544
+ currentTool = tool;
545
+ setStatus('Tool: ' + tool, 'success');
546
+ document.querySelectorAll('.toolbar .btn').forEach(b => b.style.background = '');
547
+ const btn = document.getElementById('tool-' + tool);
548
+ if (btn) btn.style.background = '#e94560';
549
+ }
550
+
551
+ function useBlankCanvas() {
552
+ blankCanvasMode = true;
553
+ floorPlanImage = null;
554
+ topology.floorPlan = { type: 'blank', width: canvas.width, height: canvas.height };
555
+ render();
556
+ setStatus('Blank canvas ready - use Draw Wall or Draw Room tools', 'success');
557
+ }
558
+
559
+ function clearDrawings() {
560
+ if (!confirm('Clear all drawings (walls and rooms)?')) return;
561
+ topology.drawings = [];
562
+ render();
563
+ setStatus('Drawings cleared', 'success');
564
+ }
565
+
468
566
  function closeModal(id) { document.getElementById(id).classList.remove('active'); }
469
567
  function setStatus(text, type) { document.getElementById('status-text').textContent = text; const dot = document.getElementById('status-dot'); dot.className = 'status-dot'; if (type === 'warning') dot.classList.add('warning'); if (type === 'error') dot.classList.add('error'); }
470
568
 
569
+ let dragging = null;
570
+
471
571
  canvas.addEventListener('mousedown', (e) => {
472
572
  const rect = canvas.getBoundingClientRect();
473
573
  const x = e.clientX - rect.left;
474
574
  const y = e.clientY - rect.top;
575
+
475
576
  if (currentTool === 'select') {
476
577
  for (const camera of topology.cameras) {
477
578
  if (camera.floorPlanPosition) {
478
579
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
479
- if (dist < 25) { selectCamera(camera.deviceId); return; }
580
+ if (dist < 25) { selectCamera(camera.deviceId); dragging = camera; return; }
480
581
  }
481
582
  }
583
+ } else if (currentTool === 'wall') {
584
+ isDrawing = true;
585
+ drawStart = { x, y };
586
+ currentDrawing = { type: 'wall', x1: x, y1: y, x2: x, y2: y };
587
+ } else if (currentTool === 'room') {
588
+ isDrawing = true;
589
+ drawStart = { x, y };
590
+ currentDrawing = { type: 'room', x: x, y: y, width: 0, height: 0 };
591
+ } else if (currentTool === 'camera') {
592
+ openAddCameraModal();
593
+ // Will position camera at click location after adding
594
+ topology._pendingCameraPos = { x, y };
482
595
  }
483
596
  });
484
597
 
485
- let dragging = null;
486
- canvas.addEventListener('mousedown', (e) => {
487
- if (currentTool !== 'select') return;
598
+ canvas.addEventListener('mousemove', (e) => {
488
599
  const rect = canvas.getBoundingClientRect();
489
600
  const x = e.clientX - rect.left;
490
601
  const y = e.clientY - rect.top;
491
- for (const camera of topology.cameras) {
492
- if (camera.floorPlanPosition) {
493
- const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
494
- if (dist < 25) { dragging = camera; return; }
602
+
603
+ if (dragging) {
604
+ dragging.floorPlanPosition.x = x;
605
+ dragging.floorPlanPosition.y = y;
606
+ render();
607
+ } else if (isDrawing && currentDrawing) {
608
+ if (currentDrawing.type === 'wall') {
609
+ currentDrawing.x2 = x;
610
+ currentDrawing.y2 = y;
611
+ } else if (currentDrawing.type === 'room') {
612
+ currentDrawing.width = x - drawStart.x;
613
+ currentDrawing.height = y - drawStart.y;
495
614
  }
615
+ render();
496
616
  }
497
617
  });
498
618
 
499
- canvas.addEventListener('mousemove', (e) => {
500
- if (!dragging) return;
501
- const rect = canvas.getBoundingClientRect();
502
- dragging.floorPlanPosition.x = e.clientX - rect.left;
503
- dragging.floorPlanPosition.y = e.clientY - rect.top;
504
- render();
619
+ canvas.addEventListener('mouseup', (e) => {
620
+ if (isDrawing && currentDrawing) {
621
+ if (!topology.drawings) topology.drawings = [];
622
+ // Normalize room coordinates if drawn backwards
623
+ if (currentDrawing.type === 'room') {
624
+ if (currentDrawing.width < 0) {
625
+ currentDrawing.x += currentDrawing.width;
626
+ currentDrawing.width = Math.abs(currentDrawing.width);
627
+ }
628
+ if (currentDrawing.height < 0) {
629
+ currentDrawing.y += currentDrawing.height;
630
+ currentDrawing.height = Math.abs(currentDrawing.height);
631
+ }
632
+ // Only add if room is big enough
633
+ if (currentDrawing.width > 20 && currentDrawing.height > 20) {
634
+ const label = prompt('Room name (optional):');
635
+ if (label) currentDrawing.label = label;
636
+ topology.drawings.push(currentDrawing);
637
+ }
638
+ } else if (currentDrawing.type === 'wall') {
639
+ // Only add if wall is long enough
640
+ const len = Math.hypot(currentDrawing.x2 - currentDrawing.x1, currentDrawing.y2 - currentDrawing.y1);
641
+ if (len > 20) {
642
+ topology.drawings.push(currentDrawing);
643
+ }
644
+ }
645
+ isDrawing = false;
646
+ currentDrawing = null;
647
+ render();
648
+ }
649
+ dragging = null;
505
650
  });
506
651
 
507
- canvas.addEventListener('mouseup', () => { dragging = null; });
508
652
  window.addEventListener('resize', () => { resizeCanvas(); render(); });
509
653
  init();
510
654
  </script>