@blueharford/scrypted-spatial-awareness 0.2.1 → 0.3.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 +83 -9
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +679 -4
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/core/tracking-engine.ts +376 -10
- package/src/main.ts +175 -1
- package/src/ui/editor-html.ts +256 -0
package/out/main.nodejs.js
CHANGED
|
@@ -35645,6 +35645,18 @@ class TrackingEngine {
|
|
|
35645
35645
|
objectLastAlertTime = new Map();
|
|
35646
35646
|
/** Callback for topology changes (e.g., landmark suggestions) */
|
|
35647
35647
|
onTopologyChange;
|
|
35648
|
+
// ==================== LLM Debouncing ====================
|
|
35649
|
+
/** Last time LLM was called */
|
|
35650
|
+
lastLlmCallTime = 0;
|
|
35651
|
+
/** Queue of pending LLM requests (we only keep latest) */
|
|
35652
|
+
llmDebounceTimer = null;
|
|
35653
|
+
// ==================== Transit Time Learning ====================
|
|
35654
|
+
/** Observed transit times for learning */
|
|
35655
|
+
observedTransits = new Map();
|
|
35656
|
+
/** Connection suggestions based on observed patterns */
|
|
35657
|
+
connectionSuggestions = new Map();
|
|
35658
|
+
/** Minimum observations before suggesting a connection */
|
|
35659
|
+
MIN_OBSERVATIONS_FOR_SUGGESTION = 3;
|
|
35648
35660
|
constructor(topology, state, alertManager, config, console) {
|
|
35649
35661
|
this.topology = topology;
|
|
35650
35662
|
this.state = state;
|
|
@@ -35776,9 +35788,28 @@ class TrackingEngine {
|
|
|
35776
35788
|
recordAlertTime(globalId) {
|
|
35777
35789
|
this.objectLastAlertTime.set(globalId, Date.now());
|
|
35778
35790
|
}
|
|
35779
|
-
/**
|
|
35791
|
+
/** Check if LLM call is allowed (rate limiting) */
|
|
35792
|
+
isLlmCallAllowed() {
|
|
35793
|
+
const debounceInterval = this.config.llmDebounceInterval || 0;
|
|
35794
|
+
if (debounceInterval <= 0)
|
|
35795
|
+
return true;
|
|
35796
|
+
const timeSinceLastCall = Date.now() - this.lastLlmCallTime;
|
|
35797
|
+
return timeSinceLastCall >= debounceInterval;
|
|
35798
|
+
}
|
|
35799
|
+
/** Record that an LLM call was made */
|
|
35800
|
+
recordLlmCall() {
|
|
35801
|
+
this.lastLlmCallTime = Date.now();
|
|
35802
|
+
}
|
|
35803
|
+
/** Get spatial reasoning result for movement (uses RAG + LLM) with debouncing and fallback */
|
|
35780
35804
|
async getSpatialDescription(tracked, fromCameraId, toCameraId, transitTime, currentCameraId) {
|
|
35805
|
+
const fallbackEnabled = this.config.llmFallbackEnabled ?? true;
|
|
35806
|
+
const fallbackTimeout = this.config.llmFallbackTimeout ?? 3000;
|
|
35781
35807
|
try {
|
|
35808
|
+
// Check rate limiting - if not allowed, return null to use basic description
|
|
35809
|
+
if (!this.isLlmCallAllowed()) {
|
|
35810
|
+
this.console.log('LLM rate-limited, using basic notification');
|
|
35811
|
+
return null;
|
|
35812
|
+
}
|
|
35782
35813
|
// Get snapshot from camera for LLM analysis (if LLM is enabled)
|
|
35783
35814
|
let mediaObject;
|
|
35784
35815
|
if (this.config.useLlmDescriptions) {
|
|
@@ -35787,9 +35818,28 @@ class TrackingEngine {
|
|
|
35787
35818
|
mediaObject = await camera.takePicture();
|
|
35788
35819
|
}
|
|
35789
35820
|
}
|
|
35821
|
+
// Record that we're making an LLM call
|
|
35822
|
+
this.recordLlmCall();
|
|
35790
35823
|
// Use spatial reasoning engine for rich context-aware description
|
|
35791
|
-
|
|
35792
|
-
|
|
35824
|
+
// Apply timeout if fallback is enabled
|
|
35825
|
+
let result;
|
|
35826
|
+
if (fallbackEnabled && mediaObject) {
|
|
35827
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
35828
|
+
setTimeout(() => reject(new Error('LLM timeout')), fallbackTimeout);
|
|
35829
|
+
});
|
|
35830
|
+
const descriptionPromise = this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
|
|
35831
|
+
try {
|
|
35832
|
+
result = await Promise.race([descriptionPromise, timeoutPromise]);
|
|
35833
|
+
}
|
|
35834
|
+
catch (timeoutError) {
|
|
35835
|
+
this.console.log('LLM timed out, using basic notification');
|
|
35836
|
+
return null;
|
|
35837
|
+
}
|
|
35838
|
+
}
|
|
35839
|
+
else {
|
|
35840
|
+
result = await this.spatialReasoning.generateMovementDescription(tracked, fromCameraId, toCameraId, transitTime, mediaObject);
|
|
35841
|
+
}
|
|
35842
|
+
// Optionally trigger landmark learning (background, non-blocking)
|
|
35793
35843
|
if (this.config.enableLandmarkLearning && mediaObject) {
|
|
35794
35844
|
this.tryLearnLandmark(currentCameraId, mediaObject, tracked.className);
|
|
35795
35845
|
}
|
|
@@ -35837,6 +35887,8 @@ class TrackingEngine {
|
|
|
35837
35887
|
transitDuration,
|
|
35838
35888
|
correlationConfidence: correlation.confidence,
|
|
35839
35889
|
});
|
|
35890
|
+
// Record for transit time learning
|
|
35891
|
+
this.recordObservedTransit(lastSighting.cameraId, sighting.cameraId, transitDuration);
|
|
35840
35892
|
this.console.log(`Object ${tracked.globalId.slice(0, 8)} transited: ` +
|
|
35841
35893
|
`${lastSighting.cameraName} → ${sighting.cameraName} ` +
|
|
35842
35894
|
`(confidence: ${(correlation.confidence * 100).toFixed(0)}%)`);
|
|
@@ -36025,6 +36077,211 @@ class TrackingEngine {
|
|
|
36025
36077
|
getTrackedObject(globalId) {
|
|
36026
36078
|
return this.state.getObject(globalId);
|
|
36027
36079
|
}
|
|
36080
|
+
// ==================== Transit Time Learning ====================
|
|
36081
|
+
/** Record an observed transit time for learning */
|
|
36082
|
+
recordObservedTransit(fromCameraId, toCameraId, transitTime) {
|
|
36083
|
+
if (!this.config.enableTransitTimeLearning)
|
|
36084
|
+
return;
|
|
36085
|
+
const key = `${fromCameraId}->${toCameraId}`;
|
|
36086
|
+
const observation = {
|
|
36087
|
+
fromCameraId,
|
|
36088
|
+
toCameraId,
|
|
36089
|
+
transitTime,
|
|
36090
|
+
timestamp: Date.now(),
|
|
36091
|
+
};
|
|
36092
|
+
// Add to observations
|
|
36093
|
+
if (!this.observedTransits.has(key)) {
|
|
36094
|
+
this.observedTransits.set(key, []);
|
|
36095
|
+
}
|
|
36096
|
+
const observations = this.observedTransits.get(key);
|
|
36097
|
+
observations.push(observation);
|
|
36098
|
+
// Keep only last 100 observations per connection
|
|
36099
|
+
if (observations.length > 100) {
|
|
36100
|
+
observations.shift();
|
|
36101
|
+
}
|
|
36102
|
+
// Check if we should update existing connection
|
|
36103
|
+
const existingConnection = (0, topology_1.findConnection)(this.topology, fromCameraId, toCameraId);
|
|
36104
|
+
if (existingConnection) {
|
|
36105
|
+
this.maybeUpdateConnectionTransitTime(existingConnection, observations);
|
|
36106
|
+
}
|
|
36107
|
+
else if (this.config.enableConnectionSuggestions) {
|
|
36108
|
+
// No existing connection - suggest one
|
|
36109
|
+
this.maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations);
|
|
36110
|
+
}
|
|
36111
|
+
}
|
|
36112
|
+
/** Update an existing connection's transit time based on observations */
|
|
36113
|
+
maybeUpdateConnectionTransitTime(connection, observations) {
|
|
36114
|
+
if (observations.length < 5)
|
|
36115
|
+
return; // Need minimum observations
|
|
36116
|
+
const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
|
|
36117
|
+
// Calculate percentiles
|
|
36118
|
+
const newMin = times[Math.floor(times.length * 0.1)];
|
|
36119
|
+
const newTypical = times[Math.floor(times.length * 0.5)];
|
|
36120
|
+
const newMax = times[Math.floor(times.length * 0.9)];
|
|
36121
|
+
// Only update if significantly different (>20% change)
|
|
36122
|
+
const currentTypical = connection.transitTime.typical;
|
|
36123
|
+
const percentChange = Math.abs(newTypical - currentTypical) / currentTypical;
|
|
36124
|
+
if (percentChange > 0.2 && observations.length >= 10) {
|
|
36125
|
+
this.console.log(`Updating transit time for ${connection.name}: ` +
|
|
36126
|
+
`${Math.round(currentTypical / 1000)}s → ${Math.round(newTypical / 1000)}s (based on ${observations.length} observations)`);
|
|
36127
|
+
connection.transitTime = {
|
|
36128
|
+
min: newMin,
|
|
36129
|
+
typical: newTypical,
|
|
36130
|
+
max: newMax,
|
|
36131
|
+
};
|
|
36132
|
+
// Notify about topology change
|
|
36133
|
+
if (this.onTopologyChange) {
|
|
36134
|
+
this.onTopologyChange(this.topology);
|
|
36135
|
+
}
|
|
36136
|
+
}
|
|
36137
|
+
}
|
|
36138
|
+
/** Create or update a connection suggestion based on observations */
|
|
36139
|
+
maybeCreateConnectionSuggestion(fromCameraId, toCameraId, observations) {
|
|
36140
|
+
if (observations.length < this.MIN_OBSERVATIONS_FOR_SUGGESTION)
|
|
36141
|
+
return;
|
|
36142
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, fromCameraId);
|
|
36143
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, toCameraId);
|
|
36144
|
+
if (!fromCamera || !toCamera)
|
|
36145
|
+
return;
|
|
36146
|
+
const key = `${fromCameraId}->${toCameraId}`;
|
|
36147
|
+
const times = observations.map(o => o.transitTime).sort((a, b) => a - b);
|
|
36148
|
+
// Calculate transit time suggestion
|
|
36149
|
+
const suggestedMin = times[Math.floor(times.length * 0.1)] || times[0];
|
|
36150
|
+
const suggestedTypical = times[Math.floor(times.length * 0.5)] || times[0];
|
|
36151
|
+
const suggestedMax = times[Math.floor(times.length * 0.9)] || times[times.length - 1];
|
|
36152
|
+
// Calculate confidence based on consistency and count
|
|
36153
|
+
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
|
|
36154
|
+
const variance = times.reduce((sum, t) => sum + Math.pow(t - avgTime, 2), 0) / times.length;
|
|
36155
|
+
const stdDev = Math.sqrt(variance);
|
|
36156
|
+
const coefficientOfVariation = stdDev / avgTime;
|
|
36157
|
+
// Higher confidence with more observations and lower variance
|
|
36158
|
+
const countFactor = Math.min(observations.length / 10, 1);
|
|
36159
|
+
const consistencyFactor = Math.max(0, 1 - coefficientOfVariation);
|
|
36160
|
+
const confidence = (countFactor * 0.6 + consistencyFactor * 0.4);
|
|
36161
|
+
const suggestion = {
|
|
36162
|
+
id: `suggest_${key}`,
|
|
36163
|
+
fromCameraId,
|
|
36164
|
+
fromCameraName: fromCamera.name,
|
|
36165
|
+
toCameraId,
|
|
36166
|
+
toCameraName: toCamera.name,
|
|
36167
|
+
observedTransits: observations.slice(-10), // Keep last 10
|
|
36168
|
+
suggestedTransitTime: {
|
|
36169
|
+
min: suggestedMin,
|
|
36170
|
+
typical: suggestedTypical,
|
|
36171
|
+
max: suggestedMax,
|
|
36172
|
+
},
|
|
36173
|
+
confidence,
|
|
36174
|
+
timestamp: Date.now(),
|
|
36175
|
+
};
|
|
36176
|
+
this.connectionSuggestions.set(key, suggestion);
|
|
36177
|
+
if (observations.length === this.MIN_OBSERVATIONS_FOR_SUGGESTION) {
|
|
36178
|
+
this.console.log(`New connection suggested: ${fromCamera.name} → ${toCamera.name} ` +
|
|
36179
|
+
`(typical: ${Math.round(suggestedTypical / 1000)}s, confidence: ${Math.round(confidence * 100)}%)`);
|
|
36180
|
+
}
|
|
36181
|
+
}
|
|
36182
|
+
/** Get pending connection suggestions */
|
|
36183
|
+
getConnectionSuggestions() {
|
|
36184
|
+
return Array.from(this.connectionSuggestions.values())
|
|
36185
|
+
.filter(s => s.confidence >= 0.5) // Only suggest with reasonable confidence
|
|
36186
|
+
.sort((a, b) => b.confidence - a.confidence);
|
|
36187
|
+
}
|
|
36188
|
+
/** Accept a connection suggestion, adding it to topology */
|
|
36189
|
+
acceptConnectionSuggestion(suggestionId) {
|
|
36190
|
+
const key = suggestionId.replace('suggest_', '');
|
|
36191
|
+
const suggestion = this.connectionSuggestions.get(key);
|
|
36192
|
+
if (!suggestion)
|
|
36193
|
+
return null;
|
|
36194
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, suggestion.fromCameraId);
|
|
36195
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, suggestion.toCameraId);
|
|
36196
|
+
if (!fromCamera || !toCamera)
|
|
36197
|
+
return null;
|
|
36198
|
+
const connection = {
|
|
36199
|
+
id: `conn-${Date.now()}`,
|
|
36200
|
+
fromCameraId: suggestion.fromCameraId,
|
|
36201
|
+
toCameraId: suggestion.toCameraId,
|
|
36202
|
+
name: `${fromCamera.name} to ${toCamera.name}`,
|
|
36203
|
+
exitZone: [],
|
|
36204
|
+
entryZone: [],
|
|
36205
|
+
transitTime: suggestion.suggestedTransitTime,
|
|
36206
|
+
bidirectional: true, // Default to bidirectional
|
|
36207
|
+
};
|
|
36208
|
+
this.topology.connections.push(connection);
|
|
36209
|
+
this.connectionSuggestions.delete(key);
|
|
36210
|
+
// Notify about topology change
|
|
36211
|
+
if (this.onTopologyChange) {
|
|
36212
|
+
this.onTopologyChange(this.topology);
|
|
36213
|
+
}
|
|
36214
|
+
this.console.log(`Connection accepted: ${connection.name}`);
|
|
36215
|
+
return connection;
|
|
36216
|
+
}
|
|
36217
|
+
/** Reject a connection suggestion */
|
|
36218
|
+
rejectConnectionSuggestion(suggestionId) {
|
|
36219
|
+
const key = suggestionId.replace('suggest_', '');
|
|
36220
|
+
if (!this.connectionSuggestions.has(key))
|
|
36221
|
+
return false;
|
|
36222
|
+
this.connectionSuggestions.delete(key);
|
|
36223
|
+
// Also clear observations so it doesn't get re-suggested immediately
|
|
36224
|
+
this.observedTransits.delete(key);
|
|
36225
|
+
return true;
|
|
36226
|
+
}
|
|
36227
|
+
// ==================== Live Tracking State ====================
|
|
36228
|
+
/** Get current state of all tracked objects for live overlay */
|
|
36229
|
+
getLiveTrackingState() {
|
|
36230
|
+
const activeObjects = this.state.getActiveObjects();
|
|
36231
|
+
const objects = activeObjects.map(tracked => {
|
|
36232
|
+
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
36233
|
+
const camera = lastSighting ? (0, topology_1.findCamera)(this.topology, lastSighting.cameraId) : null;
|
|
36234
|
+
return {
|
|
36235
|
+
globalId: tracked.globalId,
|
|
36236
|
+
className: tracked.className,
|
|
36237
|
+
label: tracked.label,
|
|
36238
|
+
lastCameraId: lastSighting?.cameraId || '',
|
|
36239
|
+
lastCameraName: lastSighting?.cameraName || '',
|
|
36240
|
+
lastSeen: tracked.lastSeen,
|
|
36241
|
+
state: tracked.state,
|
|
36242
|
+
cameraPosition: camera?.floorPlanPosition,
|
|
36243
|
+
};
|
|
36244
|
+
});
|
|
36245
|
+
return {
|
|
36246
|
+
objects,
|
|
36247
|
+
timestamp: Date.now(),
|
|
36248
|
+
};
|
|
36249
|
+
}
|
|
36250
|
+
/** Get journey path for visualization */
|
|
36251
|
+
getJourneyPath(globalId) {
|
|
36252
|
+
const tracked = this.state.getObject(globalId);
|
|
36253
|
+
if (!tracked)
|
|
36254
|
+
return null;
|
|
36255
|
+
const segments = tracked.journey.map(j => {
|
|
36256
|
+
const fromCamera = (0, topology_1.findCamera)(this.topology, j.fromCameraId);
|
|
36257
|
+
const toCamera = (0, topology_1.findCamera)(this.topology, j.toCameraId);
|
|
36258
|
+
return {
|
|
36259
|
+
fromCamera: {
|
|
36260
|
+
id: j.fromCameraId,
|
|
36261
|
+
name: j.fromCameraName,
|
|
36262
|
+
position: fromCamera?.floorPlanPosition,
|
|
36263
|
+
},
|
|
36264
|
+
toCamera: {
|
|
36265
|
+
id: j.toCameraId,
|
|
36266
|
+
name: j.toCameraName,
|
|
36267
|
+
position: toCamera?.floorPlanPosition,
|
|
36268
|
+
},
|
|
36269
|
+
transitTime: j.transitDuration,
|
|
36270
|
+
timestamp: j.entryTime,
|
|
36271
|
+
};
|
|
36272
|
+
});
|
|
36273
|
+
const lastSighting = (0, tracked_object_1.getLastSighting)(tracked);
|
|
36274
|
+
let currentLocation;
|
|
36275
|
+
if (lastSighting) {
|
|
36276
|
+
const camera = (0, topology_1.findCamera)(this.topology, lastSighting.cameraId);
|
|
36277
|
+
currentLocation = {
|
|
36278
|
+
cameraId: lastSighting.cameraId,
|
|
36279
|
+
cameraName: lastSighting.cameraName,
|
|
36280
|
+
position: camera?.floorPlanPosition,
|
|
36281
|
+
};
|
|
36282
|
+
}
|
|
36283
|
+
return { segments, currentLocation };
|
|
36284
|
+
}
|
|
36028
36285
|
}
|
|
36029
36286
|
exports.TrackingEngine = TrackingEngine;
|
|
36030
36287
|
|
|
@@ -36806,6 +37063,41 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36806
37063
|
description: 'Use LLM plugin (if installed) to generate descriptive alerts like "Man walking from garage towards front door"',
|
|
36807
37064
|
group: 'AI & Spatial Reasoning',
|
|
36808
37065
|
},
|
|
37066
|
+
llmDebounceInterval: {
|
|
37067
|
+
title: 'LLM Rate Limit (seconds)',
|
|
37068
|
+
type: 'number',
|
|
37069
|
+
defaultValue: 10,
|
|
37070
|
+
description: 'Minimum time between LLM calls to prevent API overload (0 = no limit)',
|
|
37071
|
+
group: 'AI & Spatial Reasoning',
|
|
37072
|
+
},
|
|
37073
|
+
llmFallbackEnabled: {
|
|
37074
|
+
title: 'Fallback to Basic Notifications',
|
|
37075
|
+
type: 'boolean',
|
|
37076
|
+
defaultValue: true,
|
|
37077
|
+
description: 'When LLM is rate-limited or slow, fall back to basic notifications immediately',
|
|
37078
|
+
group: 'AI & Spatial Reasoning',
|
|
37079
|
+
},
|
|
37080
|
+
llmFallbackTimeout: {
|
|
37081
|
+
title: 'LLM Timeout (seconds)',
|
|
37082
|
+
type: 'number',
|
|
37083
|
+
defaultValue: 3,
|
|
37084
|
+
description: 'Maximum time to wait for LLM response before falling back to basic notification',
|
|
37085
|
+
group: 'AI & Spatial Reasoning',
|
|
37086
|
+
},
|
|
37087
|
+
enableTransitTimeLearning: {
|
|
37088
|
+
title: 'Learn Transit Times',
|
|
37089
|
+
type: 'boolean',
|
|
37090
|
+
defaultValue: true,
|
|
37091
|
+
description: 'Automatically adjust connection transit times based on observed movement patterns',
|
|
37092
|
+
group: 'AI & Spatial Reasoning',
|
|
37093
|
+
},
|
|
37094
|
+
enableConnectionSuggestions: {
|
|
37095
|
+
title: 'Suggest Camera Connections',
|
|
37096
|
+
type: 'boolean',
|
|
37097
|
+
defaultValue: true,
|
|
37098
|
+
description: 'Automatically suggest new camera connections based on observed movement patterns',
|
|
37099
|
+
group: 'AI & Spatial Reasoning',
|
|
37100
|
+
},
|
|
36809
37101
|
enableLandmarkLearning: {
|
|
36810
37102
|
title: 'Learn Landmarks from AI',
|
|
36811
37103
|
type: 'boolean',
|
|
@@ -36965,6 +37257,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
36965
37257
|
loiteringThreshold: (this.storageSettings.values.loiteringThreshold || 3) * 1000,
|
|
36966
37258
|
objectAlertCooldown: (this.storageSettings.values.objectAlertCooldown || 30) * 1000,
|
|
36967
37259
|
useLlmDescriptions: this.storageSettings.values.useLlmDescriptions ?? true,
|
|
37260
|
+
llmDebounceInterval: (this.storageSettings.values.llmDebounceInterval || 10) * 1000,
|
|
37261
|
+
llmFallbackEnabled: this.storageSettings.values.llmFallbackEnabled ?? true,
|
|
37262
|
+
llmFallbackTimeout: (this.storageSettings.values.llmFallbackTimeout || 3) * 1000,
|
|
37263
|
+
enableTransitTimeLearning: this.storageSettings.values.enableTransitTimeLearning ?? true,
|
|
37264
|
+
enableConnectionSuggestions: this.storageSettings.values.enableConnectionSuggestions ?? true,
|
|
36968
37265
|
enableLandmarkLearning: this.storageSettings.values.enableLandmarkLearning ?? true,
|
|
36969
37266
|
landmarkConfidenceThreshold: this.storageSettings.values.landmarkConfidenceThreshold ?? 0.7,
|
|
36970
37267
|
};
|
|
@@ -37214,6 +37511,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37214
37511
|
key === 'loiteringThreshold' ||
|
|
37215
37512
|
key === 'objectAlertCooldown' ||
|
|
37216
37513
|
key === 'useLlmDescriptions' ||
|
|
37514
|
+
key === 'llmDebounceInterval' ||
|
|
37515
|
+
key === 'llmFallbackEnabled' ||
|
|
37516
|
+
key === 'llmFallbackTimeout' ||
|
|
37517
|
+
key === 'enableTransitTimeLearning' ||
|
|
37518
|
+
key === 'enableConnectionSuggestions' ||
|
|
37217
37519
|
key === 'enableLandmarkLearning' ||
|
|
37218
37520
|
key === 'landmarkConfidenceThreshold') {
|
|
37219
37521
|
const topologyJson = this.storage.getItem('topology');
|
|
@@ -37289,6 +37591,25 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37289
37591
|
if (path.endsWith('/api/infer-relationships')) {
|
|
37290
37592
|
return this.handleInferRelationshipsRequest(response);
|
|
37291
37593
|
}
|
|
37594
|
+
// Connection suggestions
|
|
37595
|
+
if (path.endsWith('/api/connection-suggestions')) {
|
|
37596
|
+
return this.handleConnectionSuggestionsRequest(request, response);
|
|
37597
|
+
}
|
|
37598
|
+
if (path.match(/\/api\/connection-suggestions\/[\w->]+\/(accept|reject)$/)) {
|
|
37599
|
+
const parts = path.split('/');
|
|
37600
|
+
const action = parts.pop();
|
|
37601
|
+
const suggestionId = parts.pop();
|
|
37602
|
+
return this.handleConnectionSuggestionActionRequest(suggestionId, action, response);
|
|
37603
|
+
}
|
|
37604
|
+
// Live tracking state
|
|
37605
|
+
if (path.endsWith('/api/live-tracking')) {
|
|
37606
|
+
return this.handleLiveTrackingRequest(response);
|
|
37607
|
+
}
|
|
37608
|
+
// Journey visualization
|
|
37609
|
+
if (path.match(/\/api\/journey-path\/[\w-]+$/)) {
|
|
37610
|
+
const globalId = path.split('/').pop();
|
|
37611
|
+
return this.handleJourneyPathRequest(globalId, response);
|
|
37612
|
+
}
|
|
37292
37613
|
// UI Routes
|
|
37293
37614
|
if (path.endsWith('/ui/editor') || path.endsWith('/ui/editor/')) {
|
|
37294
37615
|
return this.serveEditorUI(response);
|
|
@@ -37299,14 +37620,18 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37299
37620
|
// Default: return info page
|
|
37300
37621
|
response.send(JSON.stringify({
|
|
37301
37622
|
name: 'Spatial Awareness Plugin',
|
|
37302
|
-
version: '0.
|
|
37623
|
+
version: '0.3.0',
|
|
37303
37624
|
endpoints: {
|
|
37304
37625
|
api: {
|
|
37305
37626
|
trackedObjects: '/api/tracked-objects',
|
|
37306
37627
|
journey: '/api/journey/{globalId}',
|
|
37628
|
+
journeyPath: '/api/journey-path/{globalId}',
|
|
37307
37629
|
topology: '/api/topology',
|
|
37308
37630
|
alerts: '/api/alerts',
|
|
37309
37631
|
floorPlan: '/api/floor-plan',
|
|
37632
|
+
liveTracking: '/api/live-tracking',
|
|
37633
|
+
connectionSuggestions: '/api/connection-suggestions',
|
|
37634
|
+
landmarkSuggestions: '/api/landmark-suggestions',
|
|
37310
37635
|
},
|
|
37311
37636
|
ui: {
|
|
37312
37637
|
editor: '/ui/editor',
|
|
@@ -37666,6 +37991,100 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
37666
37991
|
headers: { 'Content-Type': 'application/json' },
|
|
37667
37992
|
});
|
|
37668
37993
|
}
|
|
37994
|
+
handleConnectionSuggestionsRequest(request, response) {
|
|
37995
|
+
if (!this.trackingEngine) {
|
|
37996
|
+
response.send(JSON.stringify({ suggestions: [] }), {
|
|
37997
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37998
|
+
});
|
|
37999
|
+
return;
|
|
38000
|
+
}
|
|
38001
|
+
const suggestions = this.trackingEngine.getConnectionSuggestions();
|
|
38002
|
+
response.send(JSON.stringify({
|
|
38003
|
+
suggestions,
|
|
38004
|
+
count: suggestions.length,
|
|
38005
|
+
}), {
|
|
38006
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38007
|
+
});
|
|
38008
|
+
}
|
|
38009
|
+
handleConnectionSuggestionActionRequest(suggestionId, action, response) {
|
|
38010
|
+
if (!this.trackingEngine) {
|
|
38011
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38012
|
+
code: 500,
|
|
38013
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38014
|
+
});
|
|
38015
|
+
return;
|
|
38016
|
+
}
|
|
38017
|
+
if (action === 'accept') {
|
|
38018
|
+
const connection = this.trackingEngine.acceptConnectionSuggestion(suggestionId);
|
|
38019
|
+
if (connection) {
|
|
38020
|
+
// Save updated topology
|
|
38021
|
+
const topology = this.trackingEngine.getTopology();
|
|
38022
|
+
this.storage.setItem('topology', JSON.stringify(topology));
|
|
38023
|
+
response.send(JSON.stringify({ success: true, connection }), {
|
|
38024
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38025
|
+
});
|
|
38026
|
+
}
|
|
38027
|
+
else {
|
|
38028
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
38029
|
+
code: 404,
|
|
38030
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38031
|
+
});
|
|
38032
|
+
}
|
|
38033
|
+
}
|
|
38034
|
+
else if (action === 'reject') {
|
|
38035
|
+
const success = this.trackingEngine.rejectConnectionSuggestion(suggestionId);
|
|
38036
|
+
if (success) {
|
|
38037
|
+
response.send(JSON.stringify({ success: true }), {
|
|
38038
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38039
|
+
});
|
|
38040
|
+
}
|
|
38041
|
+
else {
|
|
38042
|
+
response.send(JSON.stringify({ error: 'Suggestion not found' }), {
|
|
38043
|
+
code: 404,
|
|
38044
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38045
|
+
});
|
|
38046
|
+
}
|
|
38047
|
+
}
|
|
38048
|
+
else {
|
|
38049
|
+
response.send(JSON.stringify({ error: 'Invalid action' }), {
|
|
38050
|
+
code: 400,
|
|
38051
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38052
|
+
});
|
|
38053
|
+
}
|
|
38054
|
+
}
|
|
38055
|
+
handleLiveTrackingRequest(response) {
|
|
38056
|
+
if (!this.trackingEngine) {
|
|
38057
|
+
response.send(JSON.stringify({ objects: [], timestamp: Date.now() }), {
|
|
38058
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38059
|
+
});
|
|
38060
|
+
return;
|
|
38061
|
+
}
|
|
38062
|
+
const liveState = this.trackingEngine.getLiveTrackingState();
|
|
38063
|
+
response.send(JSON.stringify(liveState), {
|
|
38064
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38065
|
+
});
|
|
38066
|
+
}
|
|
38067
|
+
handleJourneyPathRequest(globalId, response) {
|
|
38068
|
+
if (!this.trackingEngine) {
|
|
38069
|
+
response.send(JSON.stringify({ error: 'Tracking engine not running' }), {
|
|
38070
|
+
code: 500,
|
|
38071
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38072
|
+
});
|
|
38073
|
+
return;
|
|
38074
|
+
}
|
|
38075
|
+
const journeyPath = this.trackingEngine.getJourneyPath(globalId);
|
|
38076
|
+
if (journeyPath) {
|
|
38077
|
+
response.send(JSON.stringify(journeyPath), {
|
|
38078
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38079
|
+
});
|
|
38080
|
+
}
|
|
38081
|
+
else {
|
|
38082
|
+
response.send(JSON.stringify({ error: 'Object not found' }), {
|
|
38083
|
+
code: 404,
|
|
38084
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38085
|
+
});
|
|
38086
|
+
}
|
|
38087
|
+
}
|
|
37669
38088
|
serveEditorUI(response) {
|
|
37670
38089
|
response.send(editor_html_1.EDITOR_HTML, {
|
|
37671
38090
|
headers: { 'Content-Type': 'text/html' },
|
|
@@ -38615,6 +39034,23 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38615
39034
|
</div>
|
|
38616
39035
|
<div id="suggestions-list"></div>
|
|
38617
39036
|
</div>
|
|
39037
|
+
<div class="section" id="connection-suggestions-section" style="display: none;">
|
|
39038
|
+
<div class="section-title">
|
|
39039
|
+
<span>Connection Suggestions</span>
|
|
39040
|
+
<button class="btn btn-small" onclick="loadConnectionSuggestions()">Refresh</button>
|
|
39041
|
+
</div>
|
|
39042
|
+
<div id="connection-suggestions-list"></div>
|
|
39043
|
+
</div>
|
|
39044
|
+
<div class="section" id="live-tracking-section">
|
|
39045
|
+
<div class="section-title">
|
|
39046
|
+
<span>Live Tracking</span>
|
|
39047
|
+
<label class="checkbox-group" style="font-size: 11px; font-weight: normal; text-transform: none;">
|
|
39048
|
+
<input type="checkbox" id="live-tracking-toggle" onchange="toggleLiveTracking(this.checked)">
|
|
39049
|
+
Enable
|
|
39050
|
+
</label>
|
|
39051
|
+
</div>
|
|
39052
|
+
<div id="live-tracking-list" style="max-height: 150px; overflow-y: auto;"></div>
|
|
39053
|
+
</div>
|
|
38618
39054
|
</div>
|
|
38619
39055
|
</div>
|
|
38620
39056
|
<div class="editor">
|
|
@@ -38805,6 +39241,12 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38805
39241
|
let availableCameras = [];
|
|
38806
39242
|
let landmarkTemplates = [];
|
|
38807
39243
|
let pendingSuggestions = [];
|
|
39244
|
+
let connectionSuggestions = [];
|
|
39245
|
+
let liveTrackingData = { objects: [], timestamp: 0 };
|
|
39246
|
+
let liveTrackingEnabled = false;
|
|
39247
|
+
let liveTrackingInterval = null;
|
|
39248
|
+
let selectedJourneyId = null;
|
|
39249
|
+
let journeyPath = null;
|
|
38808
39250
|
let isDrawing = false;
|
|
38809
39251
|
let drawStart = null;
|
|
38810
39252
|
let currentDrawing = null;
|
|
@@ -38817,6 +39259,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38817
39259
|
await loadAvailableCameras();
|
|
38818
39260
|
await loadLandmarkTemplates();
|
|
38819
39261
|
await loadSuggestions();
|
|
39262
|
+
await loadConnectionSuggestions();
|
|
38820
39263
|
resizeCanvas();
|
|
38821
39264
|
render();
|
|
38822
39265
|
updateUI();
|
|
@@ -38916,6 +39359,132 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
38916
39359
|
} catch (e) { console.error('Failed to reject suggestion:', e); }
|
|
38917
39360
|
}
|
|
38918
39361
|
|
|
39362
|
+
// ==================== Connection Suggestions ====================
|
|
39363
|
+
async function loadConnectionSuggestions() {
|
|
39364
|
+
try {
|
|
39365
|
+
const response = await fetch('../api/connection-suggestions');
|
|
39366
|
+
if (response.ok) {
|
|
39367
|
+
const data = await response.json();
|
|
39368
|
+
connectionSuggestions = data.suggestions || [];
|
|
39369
|
+
updateConnectionSuggestionsUI();
|
|
39370
|
+
}
|
|
39371
|
+
} catch (e) { console.error('Failed to load connection suggestions:', e); }
|
|
39372
|
+
}
|
|
39373
|
+
|
|
39374
|
+
function updateConnectionSuggestionsUI() {
|
|
39375
|
+
const section = document.getElementById('connection-suggestions-section');
|
|
39376
|
+
const list = document.getElementById('connection-suggestions-list');
|
|
39377
|
+
if (connectionSuggestions.length === 0) {
|
|
39378
|
+
section.style.display = 'none';
|
|
39379
|
+
return;
|
|
39380
|
+
}
|
|
39381
|
+
section.style.display = 'block';
|
|
39382
|
+
list.innerHTML = connectionSuggestions.map(s =>
|
|
39383
|
+
'<div class="camera-item" style="display: flex; justify-content: space-between; align-items: center;">' +
|
|
39384
|
+
'<div><div class="camera-name">' + s.fromCameraName + ' → ' + s.toCameraName + '</div>' +
|
|
39385
|
+
'<div class="camera-info">' + Math.round(s.suggestedTransitTime.typical / 1000) + 's typical, ' +
|
|
39386
|
+
Math.round(s.confidence * 100) + '% confidence</div></div>' +
|
|
39387
|
+
'<div style="display: flex; gap: 5px;">' +
|
|
39388
|
+
'<button class="btn btn-small btn-primary" onclick="acceptConnectionSuggestion(\\'' + s.id + '\\')">Accept</button>' +
|
|
39389
|
+
'<button class="btn btn-small" onclick="rejectConnectionSuggestion(\\'' + s.id + '\\')">Reject</button>' +
|
|
39390
|
+
'</div></div>'
|
|
39391
|
+
).join('');
|
|
39392
|
+
}
|
|
39393
|
+
|
|
39394
|
+
async function acceptConnectionSuggestion(id) {
|
|
39395
|
+
try {
|
|
39396
|
+
const response = await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/accept', { method: 'POST' });
|
|
39397
|
+
if (response.ok) {
|
|
39398
|
+
const data = await response.json();
|
|
39399
|
+
if (data.connection) {
|
|
39400
|
+
topology.connections.push(data.connection);
|
|
39401
|
+
updateUI();
|
|
39402
|
+
render();
|
|
39403
|
+
}
|
|
39404
|
+
await loadConnectionSuggestions();
|
|
39405
|
+
setStatus('Connection accepted', 'success');
|
|
39406
|
+
}
|
|
39407
|
+
} catch (e) { console.error('Failed to accept connection suggestion:', e); }
|
|
39408
|
+
}
|
|
39409
|
+
|
|
39410
|
+
async function rejectConnectionSuggestion(id) {
|
|
39411
|
+
try {
|
|
39412
|
+
await fetch('../api/connection-suggestions/' + encodeURIComponent(id) + '/reject', { method: 'POST' });
|
|
39413
|
+
await loadConnectionSuggestions();
|
|
39414
|
+
setStatus('Connection suggestion rejected', 'success');
|
|
39415
|
+
} catch (e) { console.error('Failed to reject connection suggestion:', e); }
|
|
39416
|
+
}
|
|
39417
|
+
|
|
39418
|
+
// ==================== Live Tracking ====================
|
|
39419
|
+
function toggleLiveTracking(enabled) {
|
|
39420
|
+
liveTrackingEnabled = enabled;
|
|
39421
|
+
if (enabled) {
|
|
39422
|
+
loadLiveTracking();
|
|
39423
|
+
liveTrackingInterval = setInterval(loadLiveTracking, 2000); // Poll every 2 seconds
|
|
39424
|
+
} else {
|
|
39425
|
+
if (liveTrackingInterval) {
|
|
39426
|
+
clearInterval(liveTrackingInterval);
|
|
39427
|
+
liveTrackingInterval = null;
|
|
39428
|
+
}
|
|
39429
|
+
liveTrackingData = { objects: [], timestamp: 0 };
|
|
39430
|
+
selectedJourneyId = null;
|
|
39431
|
+
journeyPath = null;
|
|
39432
|
+
updateLiveTrackingUI();
|
|
39433
|
+
render();
|
|
39434
|
+
}
|
|
39435
|
+
}
|
|
39436
|
+
|
|
39437
|
+
async function loadLiveTracking() {
|
|
39438
|
+
try {
|
|
39439
|
+
const response = await fetch('../api/live-tracking');
|
|
39440
|
+
if (response.ok) {
|
|
39441
|
+
liveTrackingData = await response.json();
|
|
39442
|
+
updateLiveTrackingUI();
|
|
39443
|
+
render();
|
|
39444
|
+
}
|
|
39445
|
+
} catch (e) { console.error('Failed to load live tracking:', e); }
|
|
39446
|
+
}
|
|
39447
|
+
|
|
39448
|
+
function updateLiveTrackingUI() {
|
|
39449
|
+
const list = document.getElementById('live-tracking-list');
|
|
39450
|
+
if (liveTrackingData.objects.length === 0) {
|
|
39451
|
+
list.innerHTML = '<div style="color: #666; font-size: 12px; text-align: center; padding: 10px;">No active objects</div>';
|
|
39452
|
+
return;
|
|
39453
|
+
}
|
|
39454
|
+
list.innerHTML = liveTrackingData.objects.map(obj => {
|
|
39455
|
+
const isSelected = selectedJourneyId === obj.globalId;
|
|
39456
|
+
const ageSeconds = Math.round((Date.now() - obj.lastSeen) / 1000);
|
|
39457
|
+
const ageStr = ageSeconds < 60 ? ageSeconds + 's ago' : Math.round(ageSeconds / 60) + 'm ago';
|
|
39458
|
+
return '<div class="camera-item' + (isSelected ? ' selected' : '') + '" ' +
|
|
39459
|
+
'onclick="selectTrackedObject(\\'' + obj.globalId + '\\')" ' +
|
|
39460
|
+
'style="padding: 8px; cursor: pointer;">' +
|
|
39461
|
+
'<div class="camera-name" style="font-size: 12px;">' +
|
|
39462
|
+
(obj.className.charAt(0).toUpperCase() + obj.className.slice(1)) +
|
|
39463
|
+
(obj.label ? ' (' + obj.label + ')' : '') + '</div>' +
|
|
39464
|
+
'<div class="camera-info">' + obj.lastCameraName + ' • ' + ageStr + '</div>' +
|
|
39465
|
+
'</div>';
|
|
39466
|
+
}).join('');
|
|
39467
|
+
}
|
|
39468
|
+
|
|
39469
|
+
async function selectTrackedObject(globalId) {
|
|
39470
|
+
if (selectedJourneyId === globalId) {
|
|
39471
|
+
// Deselect
|
|
39472
|
+
selectedJourneyId = null;
|
|
39473
|
+
journeyPath = null;
|
|
39474
|
+
} else {
|
|
39475
|
+
selectedJourneyId = globalId;
|
|
39476
|
+
// Load journey path
|
|
39477
|
+
try {
|
|
39478
|
+
const response = await fetch('../api/journey-path/' + globalId);
|
|
39479
|
+
if (response.ok) {
|
|
39480
|
+
journeyPath = await response.json();
|
|
39481
|
+
}
|
|
39482
|
+
} catch (e) { console.error('Failed to load journey path:', e); }
|
|
39483
|
+
}
|
|
39484
|
+
updateLiveTrackingUI();
|
|
39485
|
+
render();
|
|
39486
|
+
}
|
|
39487
|
+
|
|
38919
39488
|
function openAddLandmarkModal() {
|
|
38920
39489
|
updateLandmarkSuggestions();
|
|
38921
39490
|
document.getElementById('add-landmark-modal').classList.add('active');
|
|
@@ -39139,6 +39708,112 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
39139
39708
|
for (const camera of topology.cameras) {
|
|
39140
39709
|
if (camera.floorPlanPosition) { drawCamera(camera); }
|
|
39141
39710
|
}
|
|
39711
|
+
|
|
39712
|
+
// Draw journey path if selected
|
|
39713
|
+
if (journeyPath && journeyPath.segments.length > 0) {
|
|
39714
|
+
drawJourneyPath();
|
|
39715
|
+
}
|
|
39716
|
+
|
|
39717
|
+
// Draw live tracking objects
|
|
39718
|
+
if (liveTrackingEnabled && liveTrackingData.objects.length > 0) {
|
|
39719
|
+
drawLiveTrackingObjects();
|
|
39720
|
+
}
|
|
39721
|
+
}
|
|
39722
|
+
|
|
39723
|
+
function drawJourneyPath() {
|
|
39724
|
+
if (!journeyPath) return;
|
|
39725
|
+
|
|
39726
|
+
ctx.strokeStyle = '#ff6b6b';
|
|
39727
|
+
ctx.lineWidth = 3;
|
|
39728
|
+
ctx.setLineDash([8, 4]);
|
|
39729
|
+
|
|
39730
|
+
// Draw path segments
|
|
39731
|
+
for (const segment of journeyPath.segments) {
|
|
39732
|
+
if (segment.fromCamera.position && segment.toCamera.position) {
|
|
39733
|
+
ctx.beginPath();
|
|
39734
|
+
ctx.moveTo(segment.fromCamera.position.x, segment.fromCamera.position.y);
|
|
39735
|
+
ctx.lineTo(segment.toCamera.position.x, segment.toCamera.position.y);
|
|
39736
|
+
ctx.stroke();
|
|
39737
|
+
|
|
39738
|
+
// Draw timestamp indicator
|
|
39739
|
+
const midX = (segment.fromCamera.position.x + segment.toCamera.position.x) / 2;
|
|
39740
|
+
const midY = (segment.fromCamera.position.y + segment.toCamera.position.y) / 2;
|
|
39741
|
+
ctx.fillStyle = 'rgba(255, 107, 107, 0.9)';
|
|
39742
|
+
ctx.beginPath();
|
|
39743
|
+
ctx.arc(midX, midY, 4, 0, Math.PI * 2);
|
|
39744
|
+
ctx.fill();
|
|
39745
|
+
}
|
|
39746
|
+
}
|
|
39747
|
+
|
|
39748
|
+
ctx.setLineDash([]);
|
|
39749
|
+
|
|
39750
|
+
// Draw current location indicator
|
|
39751
|
+
if (journeyPath.currentLocation?.position) {
|
|
39752
|
+
const pos = journeyPath.currentLocation.position;
|
|
39753
|
+
// Pulsing dot effect
|
|
39754
|
+
const pulse = (Date.now() % 1000) / 1000;
|
|
39755
|
+
const radius = 10 + pulse * 5;
|
|
39756
|
+
const alpha = 1 - pulse * 0.5;
|
|
39757
|
+
|
|
39758
|
+
ctx.beginPath();
|
|
39759
|
+
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
|
|
39760
|
+
ctx.fillStyle = 'rgba(255, 107, 107, ' + alpha + ')';
|
|
39761
|
+
ctx.fill();
|
|
39762
|
+
|
|
39763
|
+
ctx.beginPath();
|
|
39764
|
+
ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2);
|
|
39765
|
+
ctx.fillStyle = '#ff6b6b';
|
|
39766
|
+
ctx.fill();
|
|
39767
|
+
ctx.strokeStyle = '#fff';
|
|
39768
|
+
ctx.lineWidth = 2;
|
|
39769
|
+
ctx.stroke();
|
|
39770
|
+
}
|
|
39771
|
+
}
|
|
39772
|
+
|
|
39773
|
+
function drawLiveTrackingObjects() {
|
|
39774
|
+
const objectColors = {
|
|
39775
|
+
person: '#4caf50',
|
|
39776
|
+
car: '#2196f3',
|
|
39777
|
+
animal: '#ff9800',
|
|
39778
|
+
default: '#9c27b0'
|
|
39779
|
+
};
|
|
39780
|
+
|
|
39781
|
+
for (const obj of liveTrackingData.objects) {
|
|
39782
|
+
if (!obj.cameraPosition) continue;
|
|
39783
|
+
|
|
39784
|
+
// Skip if this is the selected journey object (drawn separately with path)
|
|
39785
|
+
if (obj.globalId === selectedJourneyId) continue;
|
|
39786
|
+
|
|
39787
|
+
const pos = obj.cameraPosition;
|
|
39788
|
+
const color = objectColors[obj.className] || objectColors.default;
|
|
39789
|
+
const ageSeconds = (Date.now() - obj.lastSeen) / 1000;
|
|
39790
|
+
|
|
39791
|
+
// Fade old objects
|
|
39792
|
+
const alpha = Math.max(0.3, 1 - ageSeconds / 60);
|
|
39793
|
+
|
|
39794
|
+
// Draw object indicator
|
|
39795
|
+
ctx.beginPath();
|
|
39796
|
+
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
|
|
39797
|
+
ctx.fillStyle = color.replace(')', ', ' + alpha + ')').replace('rgb', 'rgba');
|
|
39798
|
+
ctx.fill();
|
|
39799
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, ' + alpha + ')';
|
|
39800
|
+
ctx.lineWidth = 2;
|
|
39801
|
+
ctx.stroke();
|
|
39802
|
+
|
|
39803
|
+
// Draw class icon
|
|
39804
|
+
ctx.fillStyle = 'rgba(255, 255, 255, ' + alpha + ')';
|
|
39805
|
+
ctx.font = 'bold 10px sans-serif';
|
|
39806
|
+
ctx.textAlign = 'center';
|
|
39807
|
+
ctx.textBaseline = 'middle';
|
|
39808
|
+
const icon = obj.className === 'person' ? 'P' : obj.className === 'car' ? 'C' : obj.className === 'animal' ? 'A' : '?';
|
|
39809
|
+
ctx.fillText(icon, pos.x, pos.y);
|
|
39810
|
+
|
|
39811
|
+
// Draw label below
|
|
39812
|
+
if (obj.label) {
|
|
39813
|
+
ctx.font = '9px sans-serif';
|
|
39814
|
+
ctx.fillText(obj.label.slice(0, 10), pos.x, pos.y + 20);
|
|
39815
|
+
}
|
|
39816
|
+
}
|
|
39142
39817
|
}
|
|
39143
39818
|
|
|
39144
39819
|
function drawLandmark(landmark) {
|