@blueharford/scrypted-spatial-awareness 0.4.8-beta.1 → 0.5.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.
@@ -98,6 +98,15 @@ export const EDITOR_HTML = `<!DOCTYPE html>
98
98
  <div class="landmark-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No landmarks configured</div>
99
99
  </div>
100
100
  </div>
101
+ <div class="section">
102
+ <div class="section-title">
103
+ <span>Zones</span>
104
+ <button class="btn btn-small" onclick="setTool('zone')" style="background: #2e7d32;">+ Draw</button>
105
+ </div>
106
+ <div id="zone-list">
107
+ <div class="zone-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No zones drawn</div>
108
+ </div>
109
+ </div>
101
110
  <div class="section" id="suggestions-section" style="display: none;">
102
111
  <div class="section-title">
103
112
  <span>AI Suggestions</span>
@@ -112,6 +121,16 @@ export const EDITOR_HTML = `<!DOCTYPE html>
112
121
  </div>
113
122
  <div id="connection-suggestions-list"></div>
114
123
  </div>
124
+ <div class="section" id="discovery-section">
125
+ <div class="section-title">
126
+ <span>Auto-Discovery</span>
127
+ <button class="btn btn-small btn-primary" id="scan-now-btn" onclick="runDiscoveryScan()">Scan Now</button>
128
+ </div>
129
+ <div id="discovery-status" style="font-size: 11px; color: #888; margin-bottom: 8px;">
130
+ <span id="discovery-status-text">Not scanned yet</span>
131
+ </div>
132
+ <div id="discovery-suggestions-list"></div>
133
+ </div>
115
134
  <div class="section" id="live-tracking-section">
116
135
  <div class="section-title">
117
136
  <span>Live Tracking</span>
@@ -134,6 +153,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
134
153
  <button class="btn" id="tool-select" onclick="setTool('select')">Select</button>
135
154
  <button class="btn" id="tool-wall" onclick="setTool('wall')">Draw Wall</button>
136
155
  <button class="btn" id="tool-room" onclick="setTool('room')">Draw Room</button>
156
+ <button class="btn" id="tool-zone" onclick="setTool('zone')" style="background: #2e7d32;">Draw Zone</button>
137
157
  <button class="btn" id="tool-camera" onclick="setTool('camera')">Place Camera</button>
138
158
  <button class="btn" id="tool-landmark" onclick="setTool('landmark')">Place Landmark</button>
139
159
  <button class="btn" id="tool-connect" onclick="setTool('connect')">Connect</button>
@@ -304,6 +324,41 @@ export const EDITOR_HTML = `<!DOCTYPE html>
304
324
  </div>
305
325
  </div>
306
326
 
327
+ <div class="modal-overlay" id="add-zone-modal">
328
+ <div class="modal">
329
+ <h2>Create Zone</h2>
330
+ <p style="color: #888; margin-bottom: 15px; font-size: 13px;">Click points on the canvas to draw a polygon. Double-click or press Enter to finish.</p>
331
+ <div class="form-group">
332
+ <label>Zone Name</label>
333
+ <input type="text" id="zone-name-input" placeholder="e.g., Front Yard">
334
+ </div>
335
+ <div class="form-group">
336
+ <label>Zone Type</label>
337
+ <select id="zone-type-select">
338
+ <option value="yard">Yard</option>
339
+ <option value="driveway">Driveway</option>
340
+ <option value="street">Street</option>
341
+ <option value="patio">Patio/Deck</option>
342
+ <option value="walkway">Walkway</option>
343
+ <option value="parking">Parking</option>
344
+ <option value="garden">Garden</option>
345
+ <option value="pool">Pool Area</option>
346
+ <option value="garage">Garage</option>
347
+ <option value="entrance">Entrance</option>
348
+ <option value="custom">Custom</option>
349
+ </select>
350
+ </div>
351
+ <div class="form-group">
352
+ <label>Description (optional)</label>
353
+ <input type="text" id="zone-desc-input" placeholder="e.g., Main front lawn area">
354
+ </div>
355
+ <div class="modal-actions">
356
+ <button class="btn" onclick="cancelZoneDrawing()">Cancel</button>
357
+ <button class="btn btn-primary" onclick="startZoneDrawing()">Start Drawing</button>
358
+ </div>
359
+ </div>
360
+ </div>
361
+
307
362
  <script>
308
363
  let topology = { version: '2.0', cameras: [], connections: [], globalZones: [], landmarks: [], relationships: [], floorPlan: null, drawings: [] };
309
364
  let selectedItem = null;
@@ -322,6 +377,40 @@ export const EDITOR_HTML = `<!DOCTYPE html>
322
377
  let drawStart = null;
323
378
  let currentDrawing = null;
324
379
  let blankCanvasMode = false;
380
+
381
+ // Zone drawing state
382
+ let zoneDrawingMode = false;
383
+ let currentZonePoints = [];
384
+ let pendingZoneConfig = null;
385
+
386
+ // Zone colors by type
387
+ const ZONE_COLORS = {
388
+ yard: 'rgba(76, 175, 80, 0.3)',
389
+ driveway: 'rgba(158, 158, 158, 0.3)',
390
+ street: 'rgba(96, 96, 96, 0.3)',
391
+ patio: 'rgba(255, 152, 0, 0.3)',
392
+ walkway: 'rgba(121, 85, 72, 0.3)',
393
+ parking: 'rgba(189, 189, 189, 0.3)',
394
+ garden: 'rgba(139, 195, 74, 0.3)',
395
+ pool: 'rgba(33, 150, 243, 0.3)',
396
+ garage: 'rgba(117, 117, 117, 0.3)',
397
+ entrance: 'rgba(233, 30, 99, 0.3)',
398
+ custom: 'rgba(156, 39, 176, 0.3)',
399
+ };
400
+ const ZONE_STROKE_COLORS = {
401
+ yard: '#4caf50',
402
+ driveway: '#9e9e9e',
403
+ street: '#606060',
404
+ patio: '#ff9800',
405
+ walkway: '#795548',
406
+ parking: '#bdbdbd',
407
+ garden: '#8bc34a',
408
+ pool: '#2196f3',
409
+ garage: '#757575',
410
+ entrance: '#e91e63',
411
+ custom: '#9c27b0',
412
+ };
413
+
325
414
  const canvas = document.getElementById('floor-plan-canvas');
326
415
  const ctx = canvas.getContext('2d');
327
416
 
@@ -331,6 +420,8 @@ export const EDITOR_HTML = `<!DOCTYPE html>
331
420
  await loadLandmarkTemplates();
332
421
  await loadSuggestions();
333
422
  await loadConnectionSuggestions();
423
+ await loadDiscoveryStatus();
424
+ await loadDiscoverySuggestions();
334
425
  resizeCanvas();
335
426
  render();
336
427
  updateUI();
@@ -503,6 +594,153 @@ export const EDITOR_HTML = `<!DOCTYPE html>
503
594
  } catch (e) { console.error('Failed to reject connection suggestion:', e); }
504
595
  }
505
596
 
597
+ // ==================== Auto-Discovery ====================
598
+ let discoverySuggestions = [];
599
+ let discoveryStatus = { isScanning: false, lastScanTime: null, pendingSuggestions: 0 };
600
+
601
+ async function loadDiscoveryStatus() {
602
+ try {
603
+ const response = await fetch('../api/discovery/status');
604
+ if (response.ok) {
605
+ discoveryStatus = await response.json();
606
+ updateDiscoveryStatusUI();
607
+ }
608
+ } catch (e) { console.error('Failed to load discovery status:', e); }
609
+ }
610
+
611
+ async function loadDiscoverySuggestions() {
612
+ try {
613
+ const response = await fetch('../api/discovery/suggestions');
614
+ if (response.ok) {
615
+ const data = await response.json();
616
+ discoverySuggestions = data.suggestions || [];
617
+ updateDiscoverySuggestionsUI();
618
+ }
619
+ } catch (e) { console.error('Failed to load discovery suggestions:', e); }
620
+ }
621
+
622
+ function updateDiscoveryStatusUI() {
623
+ const statusText = document.getElementById('discovery-status-text');
624
+ const scanBtn = document.getElementById('scan-now-btn');
625
+
626
+ if (discoveryStatus.isScanning) {
627
+ statusText.textContent = 'Scanning cameras...';
628
+ scanBtn.disabled = true;
629
+ scanBtn.textContent = 'Scanning...';
630
+ } else if (discoveryStatus.lastScanTime) {
631
+ const ago = Math.round((Date.now() - discoveryStatus.lastScanTime) / 1000 / 60);
632
+ const agoStr = ago < 1 ? 'just now' : ago + 'm ago';
633
+ statusText.textContent = 'Last scan: ' + agoStr + ' | ' + discoveryStatus.pendingSuggestions + ' suggestions';
634
+ scanBtn.disabled = false;
635
+ scanBtn.textContent = 'Scan Now';
636
+ } else {
637
+ statusText.textContent = 'Not scanned yet';
638
+ scanBtn.disabled = false;
639
+ scanBtn.textContent = 'Scan Now';
640
+ }
641
+ }
642
+
643
+ function updateDiscoverySuggestionsUI() {
644
+ const list = document.getElementById('discovery-suggestions-list');
645
+ if (discoverySuggestions.length === 0) {
646
+ list.innerHTML = '<div style="color: #666; font-size: 11px; text-align: center; padding: 8px;">No pending suggestions</div>';
647
+ return;
648
+ }
649
+ list.innerHTML = discoverySuggestions.map(s => {
650
+ const name = s.type === 'landmark' ? s.landmark?.name : (s.type === 'connection' ? s.connection?.via : s.zone?.name);
651
+ const typeLabel = s.type === 'landmark' ? s.landmark?.type : s.type;
652
+ return '<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center; padding: 8px;">' +
653
+ '<div><div class="camera-name" style="font-size: 12px;">' + (name || 'Unknown') + '</div>' +
654
+ '<div class="camera-info">' + typeLabel + ' - ' + Math.round(s.confidence * 100) + '% confidence</div></div>' +
655
+ '<div style="display: flex; gap: 4px;">' +
656
+ '<button class="btn btn-small btn-primary" onclick="acceptDiscoverySuggestion(\\'' + s.id + '\\')">✓</button>' +
657
+ '<button class="btn btn-small" onclick="rejectDiscoverySuggestion(\\'' + s.id + '\\')">✗</button>' +
658
+ '</div></div>';
659
+ }).join('');
660
+ }
661
+
662
+ let scanPollingInterval = null;
663
+
664
+ async function runDiscoveryScan() {
665
+ const scanBtn = document.getElementById('scan-now-btn');
666
+ const statusText = document.getElementById('discovery-status-text');
667
+ scanBtn.disabled = true;
668
+ scanBtn.textContent = 'Scanning...';
669
+ setStatus('Starting discovery scan...', 'warning');
670
+
671
+ // Start polling for live status updates
672
+ let camerasDone = 0;
673
+ scanPollingInterval = setInterval(async () => {
674
+ try {
675
+ const statusResp = await fetch('../api/discovery/status');
676
+ if (statusResp.ok) {
677
+ const status = await statusResp.json();
678
+ if (status.isScanning) {
679
+ statusText.textContent = 'Scanning: ' + status.camerasAnalyzed + ' cameras analyzed...';
680
+ // Check for new suggestions during scan
681
+ if (status.pendingSuggestions > camerasDone) {
682
+ camerasDone = status.pendingSuggestions;
683
+ await loadDiscoverySuggestions();
684
+ }
685
+ }
686
+ }
687
+ } catch (e) { /* ignore polling errors */ }
688
+ }, 1000);
689
+
690
+ try {
691
+ const response = await fetch('../api/discovery/scan', { method: 'POST' });
692
+ if (response.ok) {
693
+ const result = await response.json();
694
+ discoveryStatus = result.status || discoveryStatus;
695
+ discoverySuggestions = result.suggestions || [];
696
+ updateDiscoveryStatusUI();
697
+ updateDiscoverySuggestionsUI();
698
+ setStatus('Discovery scan complete: ' + discoverySuggestions.length + ' suggestions found', 'success');
699
+
700
+ // Also reload topology to get any auto-accepted items
701
+ await loadTopology();
702
+ updateUI();
703
+ render();
704
+ } else {
705
+ const error = await response.json();
706
+ setStatus('Scan failed: ' + (error.error || 'Unknown error'), 'error');
707
+ }
708
+ } catch (e) {
709
+ console.error('Discovery scan failed:', e);
710
+ setStatus('Discovery scan failed', 'error');
711
+ } finally {
712
+ // Stop polling
713
+ if (scanPollingInterval) {
714
+ clearInterval(scanPollingInterval);
715
+ scanPollingInterval = null;
716
+ }
717
+ scanBtn.disabled = false;
718
+ scanBtn.textContent = 'Scan Now';
719
+ }
720
+ }
721
+
722
+ async function acceptDiscoverySuggestion(id) {
723
+ try {
724
+ const response = await fetch('../api/discovery/suggestions/' + id + '/accept', { method: 'POST' });
725
+ if (response.ok) {
726
+ // Reload topology and suggestions
727
+ await loadTopology();
728
+ await loadDiscoverySuggestions();
729
+ updateUI();
730
+ render();
731
+ setStatus('Suggestion accepted', 'success');
732
+ }
733
+ } catch (e) { console.error('Failed to accept discovery suggestion:', e); }
734
+ }
735
+
736
+ async function rejectDiscoverySuggestion(id) {
737
+ try {
738
+ await fetch('../api/discovery/suggestions/' + id + '/reject', { method: 'POST' });
739
+ await loadDiscoverySuggestions();
740
+ setStatus('Suggestion rejected', 'success');
741
+ } catch (e) { console.error('Failed to reject discovery suggestion:', e); }
742
+ }
743
+
506
744
  // ==================== Live Tracking ====================
507
745
  function toggleLiveTracking(enabled) {
508
746
  liveTrackingEnabled = enabled;
@@ -782,6 +1020,43 @@ export const EDITOR_HTML = `<!DOCTYPE html>
782
1020
  ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
783
1021
  }
784
1022
  }
1023
+
1024
+ // Draw saved zones
1025
+ if (topology.drawnZones) {
1026
+ for (const zone of topology.drawnZones) {
1027
+ drawZone(zone);
1028
+ }
1029
+ }
1030
+
1031
+ // Draw zone currently being drawn
1032
+ if (zoneDrawingMode && currentZonePoints.length > 0) {
1033
+ const color = pendingZoneConfig ? (ZONE_COLORS[pendingZoneConfig.type] || ZONE_COLORS.custom) : 'rgba(233, 69, 96, 0.3)';
1034
+ const strokeColor = pendingZoneConfig ? (ZONE_STROKE_COLORS[pendingZoneConfig.type] || ZONE_STROKE_COLORS.custom) : '#e94560';
1035
+
1036
+ ctx.beginPath();
1037
+ ctx.moveTo(currentZonePoints[0].x, currentZonePoints[0].y);
1038
+ for (let i = 1; i < currentZonePoints.length; i++) {
1039
+ ctx.lineTo(currentZonePoints[i].x, currentZonePoints[i].y);
1040
+ }
1041
+ ctx.strokeStyle = strokeColor;
1042
+ ctx.lineWidth = 2;
1043
+ ctx.stroke();
1044
+
1045
+ // Draw points
1046
+ for (const pt of currentZonePoints) {
1047
+ ctx.beginPath();
1048
+ ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
1049
+ ctx.fillStyle = strokeColor;
1050
+ ctx.fill();
1051
+ }
1052
+
1053
+ // Draw instruction text
1054
+ ctx.fillStyle = '#fff';
1055
+ ctx.font = 'bold 12px sans-serif';
1056
+ ctx.textAlign = 'left';
1057
+ ctx.fillText('Click to add points. Double-click or press Enter to finish. Esc to cancel.', 10, canvas.height - 10);
1058
+ }
1059
+
785
1060
  // Draw landmarks first (below cameras and connections)
786
1061
  for (const landmark of (topology.landmarks || [])) {
787
1062
  if (landmark.position) { drawLandmark(landmark); }
@@ -904,6 +1179,47 @@ export const EDITOR_HTML = `<!DOCTYPE html>
904
1179
  }
905
1180
  }
906
1181
 
1182
+ function drawZone(zone) {
1183
+ if (!zone.polygon || zone.polygon.length < 3) return;
1184
+
1185
+ const isSelected = selectedItem?.type === 'zone' && selectedItem?.id === zone.id;
1186
+ const fillColor = zone.color || ZONE_COLORS[zone.type] || ZONE_COLORS.custom;
1187
+ const strokeColor = ZONE_STROKE_COLORS[zone.type] || ZONE_STROKE_COLORS.custom;
1188
+
1189
+ // Draw filled polygon
1190
+ ctx.beginPath();
1191
+ ctx.moveTo(zone.polygon[0].x, zone.polygon[0].y);
1192
+ for (let i = 1; i < zone.polygon.length; i++) {
1193
+ ctx.lineTo(zone.polygon[i].x, zone.polygon[i].y);
1194
+ }
1195
+ ctx.closePath();
1196
+ ctx.fillStyle = fillColor;
1197
+ ctx.fill();
1198
+ ctx.strokeStyle = isSelected ? '#e94560' : strokeColor;
1199
+ ctx.lineWidth = isSelected ? 3 : 2;
1200
+ ctx.stroke();
1201
+
1202
+ // Draw zone label at centroid
1203
+ const centroid = getPolygonCentroid(zone.polygon);
1204
+ ctx.fillStyle = isSelected ? '#e94560' : '#fff';
1205
+ ctx.font = 'bold 12px sans-serif';
1206
+ ctx.textAlign = 'center';
1207
+ ctx.textBaseline = 'middle';
1208
+ ctx.fillText(zone.name, centroid.x, centroid.y);
1209
+ ctx.font = '10px sans-serif';
1210
+ ctx.fillStyle = '#ccc';
1211
+ ctx.fillText(zone.type, centroid.x, centroid.y + 14);
1212
+ }
1213
+
1214
+ function getPolygonCentroid(polygon) {
1215
+ let x = 0, y = 0;
1216
+ for (const pt of polygon) {
1217
+ x += pt.x;
1218
+ y += pt.y;
1219
+ }
1220
+ return { x: x / polygon.length, y: y / polygon.length };
1221
+ }
1222
+
907
1223
  function drawLandmark(landmark) {
908
1224
  const pos = landmark.position;
909
1225
  const isSelected = selectedItem?.type === 'landmark' && selectedItem?.id === landmark.id;
@@ -1186,6 +1502,17 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1186
1502
  } else {
1187
1503
  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('');
1188
1504
  }
1505
+ // Zone list
1506
+ const zoneList = document.getElementById('zone-list');
1507
+ const zones = topology.drawnZones || [];
1508
+ if (zones.length === 0) {
1509
+ zoneList.innerHTML = '<div class="zone-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No zones drawn</div>';
1510
+ } else {
1511
+ zoneList.innerHTML = zones.map(z => {
1512
+ const color = ZONE_STROKE_COLORS[z.type] || ZONE_STROKE_COLORS.custom;
1513
+ return '<div class="camera-item ' + (selectedItem?.type === 'zone' && selectedItem?.id === z.id ? 'selected' : '') + '" onclick="selectZone(\\'' + z.id + '\\')" style="border-left: 3px solid ' + color + ';"><div class="camera-name">' + z.name + '</div><div class="camera-info">' + z.type + ' | ' + z.polygon.length + ' points</div></div>';
1514
+ }).join('');
1515
+ }
1189
1516
  document.getElementById('camera-count').textContent = topology.cameras.length;
1190
1517
  document.getElementById('connection-count').textContent = topology.connections.length;
1191
1518
  document.getElementById('landmark-count').textContent = landmarks.length;
@@ -1226,11 +1553,126 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1226
1553
  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>'; updateCameraSelects(); updateUI(); render(); }
1227
1554
  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(); }
1228
1555
  function setTool(tool) {
1556
+ // If switching away from zone tool while drawing, cancel
1557
+ if (currentTool === 'zone' && tool !== 'zone' && zoneDrawingMode) {
1558
+ cancelZoneDrawing();
1559
+ }
1229
1560
  currentTool = tool;
1230
1561
  setStatus('Tool: ' + tool, 'success');
1231
1562
  document.querySelectorAll('.toolbar .btn').forEach(b => b.style.background = '');
1232
1563
  const btn = document.getElementById('tool-' + tool);
1233
- if (btn) btn.style.background = '#e94560';
1564
+ if (btn) btn.style.background = tool === 'zone' ? '#2e7d32' : '#e94560';
1565
+
1566
+ // If zone tool selected, open the zone config modal
1567
+ if (tool === 'zone') {
1568
+ openZoneModal();
1569
+ }
1570
+ }
1571
+
1572
+ // ==================== Zone Drawing Functions ====================
1573
+
1574
+ function openZoneModal() {
1575
+ document.getElementById('zone-name-input').value = '';
1576
+ document.getElementById('zone-type-select').value = 'yard';
1577
+ document.getElementById('zone-desc-input').value = '';
1578
+ document.getElementById('add-zone-modal').classList.add('active');
1579
+ }
1580
+
1581
+ function startZoneDrawing() {
1582
+ const name = document.getElementById('zone-name-input').value.trim();
1583
+ const type = document.getElementById('zone-type-select').value;
1584
+ const description = document.getElementById('zone-desc-input').value.trim();
1585
+
1586
+ if (!name) {
1587
+ alert('Please enter a zone name');
1588
+ return;
1589
+ }
1590
+
1591
+ pendingZoneConfig = { name, type, description };
1592
+ zoneDrawingMode = true;
1593
+ currentZonePoints = [];
1594
+ closeModal('add-zone-modal');
1595
+ setStatus('Zone drawing mode - click to add points, double-click to finish', 'warning');
1596
+ render();
1597
+ }
1598
+
1599
+ function cancelZoneDrawing() {
1600
+ zoneDrawingMode = false;
1601
+ currentZonePoints = [];
1602
+ pendingZoneConfig = null;
1603
+ closeModal('add-zone-modal');
1604
+ setTool('select');
1605
+ setStatus('Zone drawing cancelled', 'success');
1606
+ render();
1607
+ }
1608
+
1609
+ function finishZoneDrawing() {
1610
+ if (currentZonePoints.length < 3) {
1611
+ alert('A zone needs at least 3 points');
1612
+ return;
1613
+ }
1614
+
1615
+ if (!pendingZoneConfig) {
1616
+ cancelZoneDrawing();
1617
+ return;
1618
+ }
1619
+
1620
+ // Create the zone
1621
+ const zone = {
1622
+ id: 'zone_' + Date.now(),
1623
+ name: pendingZoneConfig.name,
1624
+ type: pendingZoneConfig.type,
1625
+ description: pendingZoneConfig.description || undefined,
1626
+ polygon: currentZonePoints.map(pt => ({ x: pt.x, y: pt.y })),
1627
+ };
1628
+
1629
+ if (!topology.drawnZones) topology.drawnZones = [];
1630
+ topology.drawnZones.push(zone);
1631
+
1632
+ // Reset state
1633
+ zoneDrawingMode = false;
1634
+ currentZonePoints = [];
1635
+ pendingZoneConfig = null;
1636
+
1637
+ setTool('select');
1638
+ updateUI();
1639
+ render();
1640
+ setStatus('Zone "' + zone.name + '" created with ' + zone.polygon.length + ' points', 'success');
1641
+ }
1642
+
1643
+ function selectZone(id) {
1644
+ selectedItem = { type: 'zone', id };
1645
+ showZoneProperties(id);
1646
+ render();
1647
+ }
1648
+
1649
+ function showZoneProperties(id) {
1650
+ const zone = (topology.drawnZones || []).find(z => z.id === id);
1651
+ if (!zone) return;
1652
+
1653
+ const panel = document.getElementById('properties-panel');
1654
+ panel.innerHTML = '<h3>Zone Properties</h3>' +
1655
+ '<div class="form-group"><label>Name</label><input type="text" value="' + zone.name + '" onchange="updateZoneName(\\'' + id + '\\', this.value)"></div>' +
1656
+ '<div class="form-group"><label>Type</label><select onchange="updateZoneType(\\'' + id + '\\', this.value)">' +
1657
+ ['yard','driveway','street','patio','walkway','parking','garden','pool','garage','entrance','custom'].map(t =>
1658
+ '<option value="' + t + '"' + (zone.type === t ? ' selected' : '') + '>' + t.charAt(0).toUpperCase() + t.slice(1) + '</option>'
1659
+ ).join('') + '</select></div>' +
1660
+ '<div class="form-group"><label>Description</label><input type="text" value="' + (zone.description || '') + '" onchange="updateZoneDesc(\\'' + id + '\\', this.value)"></div>' +
1661
+ '<div class="form-group"><label>Points: ' + zone.polygon.length + '</label></div>' +
1662
+ '<button class="btn btn-primary" onclick="deleteZone(\\'' + id + '\\')" style="background: #dc2626; width: 100%;">Delete Zone</button>';
1663
+ }
1664
+
1665
+ function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); } }
1666
+ function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); } }
1667
+ function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; } }
1668
+ function deleteZone(id) {
1669
+ if (!confirm('Delete this zone?')) return;
1670
+ topology.drawnZones = (topology.drawnZones || []).filter(z => z.id !== id);
1671
+ selectedItem = null;
1672
+ document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
1673
+ updateUI();
1674
+ render();
1675
+ setStatus('Zone deleted', 'success');
1234
1676
  }
1235
1677
 
1236
1678
  function useBlankCanvas() {
@@ -1258,6 +1700,14 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1258
1700
  const x = e.clientX - rect.left;
1259
1701
  const y = e.clientY - rect.top;
1260
1702
 
1703
+ // Handle zone drawing mode separately
1704
+ if (zoneDrawingMode) {
1705
+ currentZonePoints.push({ x, y });
1706
+ render();
1707
+ setStatus('Point ' + currentZonePoints.length + ' added. ' + (currentZonePoints.length < 3 ? 'Need at least 3 points.' : 'Double-click or Enter to finish.'), 'warning');
1708
+ return;
1709
+ }
1710
+
1261
1711
  if (currentTool === 'select') {
1262
1712
  // Check cameras first
1263
1713
  for (const camera of topology.cameras) {
@@ -1273,6 +1723,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1273
1723
  if (dist < 20) { selectLandmark(landmark.id); dragging = { type: 'landmark', item: landmark }; return; }
1274
1724
  }
1275
1725
  }
1726
+ // Check zones (click inside polygon)
1727
+ for (const zone of (topology.drawnZones || [])) {
1728
+ if (zone.polygon && isPointInPolygon({ x, y }, zone.polygon)) {
1729
+ selectZone(zone.id);
1730
+ return;
1731
+ }
1732
+ }
1276
1733
  } else if (currentTool === 'wall') {
1277
1734
  isDrawing = true;
1278
1735
  drawStart = { x, y };
@@ -1290,6 +1747,27 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1290
1747
  }
1291
1748
  });
1292
1749
 
1750
+ // Double-click to finish zone drawing
1751
+ canvas.addEventListener('dblclick', (e) => {
1752
+ if (zoneDrawingMode && currentZonePoints.length >= 3) {
1753
+ finishZoneDrawing();
1754
+ }
1755
+ });
1756
+
1757
+ // Point-in-polygon test (ray casting algorithm)
1758
+ function isPointInPolygon(point, polygon) {
1759
+ let inside = false;
1760
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
1761
+ const xi = polygon[i].x, yi = polygon[i].y;
1762
+ const xj = polygon[j].x, yj = polygon[j].y;
1763
+ if (((yi > point.y) !== (yj > point.y)) &&
1764
+ (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) {
1765
+ inside = !inside;
1766
+ }
1767
+ }
1768
+ return inside;
1769
+ }
1770
+
1293
1771
  canvas.addEventListener('mousemove', (e) => {
1294
1772
  const rect = canvas.getBoundingClientRect();
1295
1773
  const x = e.clientX - rect.left;
@@ -1350,6 +1828,21 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1350
1828
  });
1351
1829
 
1352
1830
  window.addEventListener('resize', () => { resizeCanvas(); render(); });
1831
+
1832
+ // Keyboard handler for zone drawing
1833
+ document.addEventListener('keydown', (e) => {
1834
+ if (zoneDrawingMode) {
1835
+ if (e.key === 'Enter' && currentZonePoints.length >= 3) {
1836
+ finishZoneDrawing();
1837
+ } else if (e.key === 'Escape') {
1838
+ cancelZoneDrawing();
1839
+ } else if (e.key === 'Backspace' && currentZonePoints.length > 0) {
1840
+ currentZonePoints.pop();
1841
+ render();
1842
+ setStatus('Last point removed. ' + currentZonePoints.length + ' points remaining.', 'warning');
1843
+ }
1844
+ }
1845
+ });
1353
1846
  init();
1354
1847
  </script>
1355
1848
  </body>