@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.
- package/README.md +94 -0
- package/package.json +1 -1
- package/src/core/spatial-reasoning.ts +61 -5
- package/src/core/topology-discovery.ts +641 -0
- package/src/main.ts +294 -3
- package/src/models/discovery.ts +210 -0
- package/src/models/topology.ts +53 -0
- package/src/ui/editor-html.ts +494 -1
- package/dist/main.nodejs.js +0 -3
- package/dist/main.nodejs.js.LICENSE.txt +0 -1
- package/dist/main.nodejs.js.map +0 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +0 -42753
- package/out/main.nodejs.js.map +0 -1
- package/out/plugin.zip +0 -0
package/src/ui/editor-html.ts
CHANGED
|
@@ -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>
|