@blueharford/scrypted-spatial-awareness 0.4.8-beta.1 → 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.
- 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 +289 -1
- package/src/models/discovery.ts +210 -0
- package/src/models/topology.ts +53 -0
- package/src/ui/editor-html.ts +467 -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,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>
|