@blueharford/scrypted-spatial-awareness 0.4.7 → 0.5.0-beta

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,126 @@ 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
+ async function runDiscoveryScan() {
663
+ const scanBtn = document.getElementById('scan-now-btn');
664
+ scanBtn.disabled = true;
665
+ scanBtn.textContent = 'Scanning...';
666
+ setStatus('Starting discovery scan...', 'warning');
667
+
668
+ try {
669
+ const response = await fetch('../api/discovery/scan', { method: 'POST' });
670
+ if (response.ok) {
671
+ const result = await response.json();
672
+ discoveryStatus = result.status || discoveryStatus;
673
+ discoverySuggestions = result.suggestions || [];
674
+ updateDiscoveryStatusUI();
675
+ updateDiscoverySuggestionsUI();
676
+ setStatus('Discovery scan complete: ' + discoverySuggestions.length + ' suggestions found', 'success');
677
+
678
+ // Also reload topology to get any auto-accepted items
679
+ await loadTopology();
680
+ updateUI();
681
+ render();
682
+ } else {
683
+ const error = await response.json();
684
+ setStatus('Scan failed: ' + (error.error || 'Unknown error'), 'error');
685
+ }
686
+ } catch (e) {
687
+ console.error('Discovery scan failed:', e);
688
+ setStatus('Discovery scan failed', 'error');
689
+ } finally {
690
+ scanBtn.disabled = false;
691
+ scanBtn.textContent = 'Scan Now';
692
+ }
693
+ }
694
+
695
+ async function acceptDiscoverySuggestion(id) {
696
+ try {
697
+ const response = await fetch('../api/discovery/suggestions/' + id + '/accept', { method: 'POST' });
698
+ if (response.ok) {
699
+ // Reload topology and suggestions
700
+ await loadTopology();
701
+ await loadDiscoverySuggestions();
702
+ updateUI();
703
+ render();
704
+ setStatus('Suggestion accepted', 'success');
705
+ }
706
+ } catch (e) { console.error('Failed to accept discovery suggestion:', e); }
707
+ }
708
+
709
+ async function rejectDiscoverySuggestion(id) {
710
+ try {
711
+ await fetch('../api/discovery/suggestions/' + id + '/reject', { method: 'POST' });
712
+ await loadDiscoverySuggestions();
713
+ setStatus('Suggestion rejected', 'success');
714
+ } catch (e) { console.error('Failed to reject discovery suggestion:', e); }
715
+ }
716
+
506
717
  // ==================== Live Tracking ====================
507
718
  function toggleLiveTracking(enabled) {
508
719
  liveTrackingEnabled = enabled;
@@ -782,6 +993,43 @@ export const EDITOR_HTML = `<!DOCTYPE html>
782
993
  ctx.strokeRect(currentDrawing.x, currentDrawing.y, currentDrawing.width, currentDrawing.height);
783
994
  }
784
995
  }
996
+
997
+ // Draw saved zones
998
+ if (topology.drawnZones) {
999
+ for (const zone of topology.drawnZones) {
1000
+ drawZone(zone);
1001
+ }
1002
+ }
1003
+
1004
+ // Draw zone currently being drawn
1005
+ if (zoneDrawingMode && currentZonePoints.length > 0) {
1006
+ const color = pendingZoneConfig ? (ZONE_COLORS[pendingZoneConfig.type] || ZONE_COLORS.custom) : 'rgba(233, 69, 96, 0.3)';
1007
+ const strokeColor = pendingZoneConfig ? (ZONE_STROKE_COLORS[pendingZoneConfig.type] || ZONE_STROKE_COLORS.custom) : '#e94560';
1008
+
1009
+ ctx.beginPath();
1010
+ ctx.moveTo(currentZonePoints[0].x, currentZonePoints[0].y);
1011
+ for (let i = 1; i < currentZonePoints.length; i++) {
1012
+ ctx.lineTo(currentZonePoints[i].x, currentZonePoints[i].y);
1013
+ }
1014
+ ctx.strokeStyle = strokeColor;
1015
+ ctx.lineWidth = 2;
1016
+ ctx.stroke();
1017
+
1018
+ // Draw points
1019
+ for (const pt of currentZonePoints) {
1020
+ ctx.beginPath();
1021
+ ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
1022
+ ctx.fillStyle = strokeColor;
1023
+ ctx.fill();
1024
+ }
1025
+
1026
+ // Draw instruction text
1027
+ ctx.fillStyle = '#fff';
1028
+ ctx.font = 'bold 12px sans-serif';
1029
+ ctx.textAlign = 'left';
1030
+ ctx.fillText('Click to add points. Double-click or press Enter to finish. Esc to cancel.', 10, canvas.height - 10);
1031
+ }
1032
+
785
1033
  // Draw landmarks first (below cameras and connections)
786
1034
  for (const landmark of (topology.landmarks || [])) {
787
1035
  if (landmark.position) { drawLandmark(landmark); }
@@ -904,6 +1152,47 @@ export const EDITOR_HTML = `<!DOCTYPE html>
904
1152
  }
905
1153
  }
906
1154
 
1155
+ function drawZone(zone) {
1156
+ if (!zone.polygon || zone.polygon.length < 3) return;
1157
+
1158
+ const isSelected = selectedItem?.type === 'zone' && selectedItem?.id === zone.id;
1159
+ const fillColor = zone.color || ZONE_COLORS[zone.type] || ZONE_COLORS.custom;
1160
+ const strokeColor = ZONE_STROKE_COLORS[zone.type] || ZONE_STROKE_COLORS.custom;
1161
+
1162
+ // Draw filled polygon
1163
+ ctx.beginPath();
1164
+ ctx.moveTo(zone.polygon[0].x, zone.polygon[0].y);
1165
+ for (let i = 1; i < zone.polygon.length; i++) {
1166
+ ctx.lineTo(zone.polygon[i].x, zone.polygon[i].y);
1167
+ }
1168
+ ctx.closePath();
1169
+ ctx.fillStyle = fillColor;
1170
+ ctx.fill();
1171
+ ctx.strokeStyle = isSelected ? '#e94560' : strokeColor;
1172
+ ctx.lineWidth = isSelected ? 3 : 2;
1173
+ ctx.stroke();
1174
+
1175
+ // Draw zone label at centroid
1176
+ const centroid = getPolygonCentroid(zone.polygon);
1177
+ ctx.fillStyle = isSelected ? '#e94560' : '#fff';
1178
+ ctx.font = 'bold 12px sans-serif';
1179
+ ctx.textAlign = 'center';
1180
+ ctx.textBaseline = 'middle';
1181
+ ctx.fillText(zone.name, centroid.x, centroid.y);
1182
+ ctx.font = '10px sans-serif';
1183
+ ctx.fillStyle = '#ccc';
1184
+ ctx.fillText(zone.type, centroid.x, centroid.y + 14);
1185
+ }
1186
+
1187
+ function getPolygonCentroid(polygon) {
1188
+ let x = 0, y = 0;
1189
+ for (const pt of polygon) {
1190
+ x += pt.x;
1191
+ y += pt.y;
1192
+ }
1193
+ return { x: x / polygon.length, y: y / polygon.length };
1194
+ }
1195
+
907
1196
  function drawLandmark(landmark) {
908
1197
  const pos = landmark.position;
909
1198
  const isSelected = selectedItem?.type === 'landmark' && selectedItem?.id === landmark.id;
@@ -1186,6 +1475,17 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1186
1475
  } else {
1187
1476
  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
1477
  }
1478
+ // Zone list
1479
+ const zoneList = document.getElementById('zone-list');
1480
+ const zones = topology.drawnZones || [];
1481
+ if (zones.length === 0) {
1482
+ zoneList.innerHTML = '<div class="zone-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No zones drawn</div>';
1483
+ } else {
1484
+ zoneList.innerHTML = zones.map(z => {
1485
+ const color = ZONE_STROKE_COLORS[z.type] || ZONE_STROKE_COLORS.custom;
1486
+ 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>';
1487
+ }).join('');
1488
+ }
1189
1489
  document.getElementById('camera-count').textContent = topology.cameras.length;
1190
1490
  document.getElementById('connection-count').textContent = topology.connections.length;
1191
1491
  document.getElementById('landmark-count').textContent = landmarks.length;
@@ -1226,11 +1526,126 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1226
1526
  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
1527
  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
1528
  function setTool(tool) {
1529
+ // If switching away from zone tool while drawing, cancel
1530
+ if (currentTool === 'zone' && tool !== 'zone' && zoneDrawingMode) {
1531
+ cancelZoneDrawing();
1532
+ }
1229
1533
  currentTool = tool;
1230
1534
  setStatus('Tool: ' + tool, 'success');
1231
1535
  document.querySelectorAll('.toolbar .btn').forEach(b => b.style.background = '');
1232
1536
  const btn = document.getElementById('tool-' + tool);
1233
- if (btn) btn.style.background = '#e94560';
1537
+ if (btn) btn.style.background = tool === 'zone' ? '#2e7d32' : '#e94560';
1538
+
1539
+ // If zone tool selected, open the zone config modal
1540
+ if (tool === 'zone') {
1541
+ openZoneModal();
1542
+ }
1543
+ }
1544
+
1545
+ // ==================== Zone Drawing Functions ====================
1546
+
1547
+ function openZoneModal() {
1548
+ document.getElementById('zone-name-input').value = '';
1549
+ document.getElementById('zone-type-select').value = 'yard';
1550
+ document.getElementById('zone-desc-input').value = '';
1551
+ document.getElementById('add-zone-modal').classList.add('active');
1552
+ }
1553
+
1554
+ function startZoneDrawing() {
1555
+ const name = document.getElementById('zone-name-input').value.trim();
1556
+ const type = document.getElementById('zone-type-select').value;
1557
+ const description = document.getElementById('zone-desc-input').value.trim();
1558
+
1559
+ if (!name) {
1560
+ alert('Please enter a zone name');
1561
+ return;
1562
+ }
1563
+
1564
+ pendingZoneConfig = { name, type, description };
1565
+ zoneDrawingMode = true;
1566
+ currentZonePoints = [];
1567
+ closeModal('add-zone-modal');
1568
+ setStatus('Zone drawing mode - click to add points, double-click to finish', 'warning');
1569
+ render();
1570
+ }
1571
+
1572
+ function cancelZoneDrawing() {
1573
+ zoneDrawingMode = false;
1574
+ currentZonePoints = [];
1575
+ pendingZoneConfig = null;
1576
+ closeModal('add-zone-modal');
1577
+ setTool('select');
1578
+ setStatus('Zone drawing cancelled', 'success');
1579
+ render();
1580
+ }
1581
+
1582
+ function finishZoneDrawing() {
1583
+ if (currentZonePoints.length < 3) {
1584
+ alert('A zone needs at least 3 points');
1585
+ return;
1586
+ }
1587
+
1588
+ if (!pendingZoneConfig) {
1589
+ cancelZoneDrawing();
1590
+ return;
1591
+ }
1592
+
1593
+ // Create the zone
1594
+ const zone = {
1595
+ id: 'zone_' + Date.now(),
1596
+ name: pendingZoneConfig.name,
1597
+ type: pendingZoneConfig.type,
1598
+ description: pendingZoneConfig.description || undefined,
1599
+ polygon: currentZonePoints.map(pt => ({ x: pt.x, y: pt.y })),
1600
+ };
1601
+
1602
+ if (!topology.drawnZones) topology.drawnZones = [];
1603
+ topology.drawnZones.push(zone);
1604
+
1605
+ // Reset state
1606
+ zoneDrawingMode = false;
1607
+ currentZonePoints = [];
1608
+ pendingZoneConfig = null;
1609
+
1610
+ setTool('select');
1611
+ updateUI();
1612
+ render();
1613
+ setStatus('Zone "' + zone.name + '" created with ' + zone.polygon.length + ' points', 'success');
1614
+ }
1615
+
1616
+ function selectZone(id) {
1617
+ selectedItem = { type: 'zone', id };
1618
+ showZoneProperties(id);
1619
+ render();
1620
+ }
1621
+
1622
+ function showZoneProperties(id) {
1623
+ const zone = (topology.drawnZones || []).find(z => z.id === id);
1624
+ if (!zone) return;
1625
+
1626
+ const panel = document.getElementById('properties-panel');
1627
+ panel.innerHTML = '<h3>Zone Properties</h3>' +
1628
+ '<div class="form-group"><label>Name</label><input type="text" value="' + zone.name + '" onchange="updateZoneName(\\'' + id + '\\', this.value)"></div>' +
1629
+ '<div class="form-group"><label>Type</label><select onchange="updateZoneType(\\'' + id + '\\', this.value)">' +
1630
+ ['yard','driveway','street','patio','walkway','parking','garden','pool','garage','entrance','custom'].map(t =>
1631
+ '<option value="' + t + '"' + (zone.type === t ? ' selected' : '') + '>' + t.charAt(0).toUpperCase() + t.slice(1) + '</option>'
1632
+ ).join('') + '</select></div>' +
1633
+ '<div class="form-group"><label>Description</label><input type="text" value="' + (zone.description || '') + '" onchange="updateZoneDesc(\\'' + id + '\\', this.value)"></div>' +
1634
+ '<div class="form-group"><label>Points: ' + zone.polygon.length + '</label></div>' +
1635
+ '<button class="btn btn-primary" onclick="deleteZone(\\'' + id + '\\')" style="background: #dc2626; width: 100%;">Delete Zone</button>';
1636
+ }
1637
+
1638
+ function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); } }
1639
+ function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); } }
1640
+ function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; } }
1641
+ function deleteZone(id) {
1642
+ if (!confirm('Delete this zone?')) return;
1643
+ topology.drawnZones = (topology.drawnZones || []).filter(z => z.id !== id);
1644
+ selectedItem = null;
1645
+ document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
1646
+ updateUI();
1647
+ render();
1648
+ setStatus('Zone deleted', 'success');
1234
1649
  }
1235
1650
 
1236
1651
  function useBlankCanvas() {
@@ -1258,6 +1673,14 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1258
1673
  const x = e.clientX - rect.left;
1259
1674
  const y = e.clientY - rect.top;
1260
1675
 
1676
+ // Handle zone drawing mode separately
1677
+ if (zoneDrawingMode) {
1678
+ currentZonePoints.push({ x, y });
1679
+ render();
1680
+ setStatus('Point ' + currentZonePoints.length + ' added. ' + (currentZonePoints.length < 3 ? 'Need at least 3 points.' : 'Double-click or Enter to finish.'), 'warning');
1681
+ return;
1682
+ }
1683
+
1261
1684
  if (currentTool === 'select') {
1262
1685
  // Check cameras first
1263
1686
  for (const camera of topology.cameras) {
@@ -1273,6 +1696,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1273
1696
  if (dist < 20) { selectLandmark(landmark.id); dragging = { type: 'landmark', item: landmark }; return; }
1274
1697
  }
1275
1698
  }
1699
+ // Check zones (click inside polygon)
1700
+ for (const zone of (topology.drawnZones || [])) {
1701
+ if (zone.polygon && isPointInPolygon({ x, y }, zone.polygon)) {
1702
+ selectZone(zone.id);
1703
+ return;
1704
+ }
1705
+ }
1276
1706
  } else if (currentTool === 'wall') {
1277
1707
  isDrawing = true;
1278
1708
  drawStart = { x, y };
@@ -1290,6 +1720,27 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1290
1720
  }
1291
1721
  });
1292
1722
 
1723
+ // Double-click to finish zone drawing
1724
+ canvas.addEventListener('dblclick', (e) => {
1725
+ if (zoneDrawingMode && currentZonePoints.length >= 3) {
1726
+ finishZoneDrawing();
1727
+ }
1728
+ });
1729
+
1730
+ // Point-in-polygon test (ray casting algorithm)
1731
+ function isPointInPolygon(point, polygon) {
1732
+ let inside = false;
1733
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
1734
+ const xi = polygon[i].x, yi = polygon[i].y;
1735
+ const xj = polygon[j].x, yj = polygon[j].y;
1736
+ if (((yi > point.y) !== (yj > point.y)) &&
1737
+ (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi)) {
1738
+ inside = !inside;
1739
+ }
1740
+ }
1741
+ return inside;
1742
+ }
1743
+
1293
1744
  canvas.addEventListener('mousemove', (e) => {
1294
1745
  const rect = canvas.getBoundingClientRect();
1295
1746
  const x = e.clientX - rect.left;
@@ -1350,6 +1801,21 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1350
1801
  });
1351
1802
 
1352
1803
  window.addEventListener('resize', () => { resizeCanvas(); render(); });
1804
+
1805
+ // Keyboard handler for zone drawing
1806
+ document.addEventListener('keydown', (e) => {
1807
+ if (zoneDrawingMode) {
1808
+ if (e.key === 'Enter' && currentZonePoints.length >= 3) {
1809
+ finishZoneDrawing();
1810
+ } else if (e.key === 'Escape') {
1811
+ cancelZoneDrawing();
1812
+ } else if (e.key === 'Backspace' && currentZonePoints.length > 0) {
1813
+ currentZonePoints.pop();
1814
+ render();
1815
+ setStatus('Last point removed. ' + currentZonePoints.length + ' points remaining.', 'warning');
1816
+ }
1817
+ }
1818
+ });
1353
1819
  init();
1354
1820
  </script>
1355
1821
  </body>