cyclecad 3.7.0 → 3.9.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/app/HELP-QUICK-START.md +207 -0
- package/app/HELP-SYSTEM-README.md +287 -0
- package/app/help-viewer.html +805 -0
- package/app/index.html +96 -0
- package/app/js/killer-features-help.json +310 -391
- package/app/js/modules/auto-assembly.js +1146 -0
- package/app/js/modules/digital-twin.js +1225 -0
- package/app/js/modules/engineering-notebook.js +1505 -0
- package/app/js/modules/generative-design.js +159 -6
- package/app/js/modules/machine-control.js +1270 -0
- package/app/js/modules/manufacturability.js +170 -3
- package/app/js/modules/multi-physics.js +167 -7
- package/app/js/modules/parametric-from-example.js +900 -0
- package/app/js/modules/photo-to-cad.js +200 -10
- package/app/js/modules/smart-assembly.js +1667 -0
- package/app/js/modules/smart-parts.js +179 -9
- package/app/js/modules/text-to-cad.js +242 -33
- package/app/tests/KILLER_FEATURES_TEST_GUIDE.md +324 -0
- package/app/tests/index.html +24 -7
- package/app/tests/killer-features-visual-test.html +1362 -0
- package/docs/KILLER-FEATURES-GUIDE.md +2728 -0
- package/docs/KILLER-FEATURES-TUTORIAL.md +1663 -5
- package/package.json +1 -1
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Digital Twin Live Data Module for cycleCAD
|
|
3
|
+
* Real-time sensor data integration, 3D visualization, predictive analytics
|
|
4
|
+
* Supports MQTT, REST API, WebSocket, and simulated sensor data
|
|
5
|
+
* @version 1.0.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
window.CycleCAD = window.CycleCAD || {};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Digital Twin module - connects IoT sensors to 3D CAD model
|
|
12
|
+
* @namespace CycleCAD.DigitalTwin
|
|
13
|
+
*/
|
|
14
|
+
window.CycleCAD.DigitalTwin = (() => {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// SENSOR DATA SYSTEM
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const SENSOR_TYPES = {
|
|
22
|
+
TEMPERATURE: 'temperature',
|
|
23
|
+
VIBRATION: 'vibration',
|
|
24
|
+
PRESSURE: 'pressure',
|
|
25
|
+
FORCE: 'force',
|
|
26
|
+
DISPLACEMENT: 'displacement',
|
|
27
|
+
RPM: 'rpm',
|
|
28
|
+
FLOW_RATE: 'flow_rate',
|
|
29
|
+
HUMIDITY: 'humidity',
|
|
30
|
+
CURRENT: 'current',
|
|
31
|
+
VOLTAGE: 'voltage'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SENSOR_UNITS = {
|
|
35
|
+
temperature: '°C',
|
|
36
|
+
vibration: 'mm/s',
|
|
37
|
+
pressure: 'bar',
|
|
38
|
+
force: 'N',
|
|
39
|
+
displacement: 'mm',
|
|
40
|
+
rpm: 'RPM',
|
|
41
|
+
flow_rate: 'L/min',
|
|
42
|
+
humidity: '%RH',
|
|
43
|
+
current: 'A',
|
|
44
|
+
voltage: 'V'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const SENSOR_RANGES = {
|
|
48
|
+
temperature: { min: -40, max: 120, safe: 80 },
|
|
49
|
+
vibration: { min: 0, max: 50, safe: 20 },
|
|
50
|
+
pressure: { min: 0, max: 10, safe: 8 },
|
|
51
|
+
force: { min: 0, max: 1000, safe: 800 },
|
|
52
|
+
displacement: { min: 0, max: 100, safe: 80 },
|
|
53
|
+
rpm: { min: 0, max: 5000, safe: 4000 },
|
|
54
|
+
flow_rate: { min: 0, max: 100, safe: 80 },
|
|
55
|
+
humidity: { min: 0, max: 100, safe: 70 },
|
|
56
|
+
current: { min: 0, max: 50, safe: 40 },
|
|
57
|
+
voltage: { min: 0, max: 500, safe: 400 }
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Internal state
|
|
61
|
+
const state = {
|
|
62
|
+
sensors: new Map(), // id -> sensor object
|
|
63
|
+
monitoringActive: false,
|
|
64
|
+
dataBuffer: new Map(), // sensor id -> array of { timestamp, value }
|
|
65
|
+
alertLog: [],
|
|
66
|
+
dataSource: 'demo', // 'mqtt', 'rest', 'websocket', 'demo'
|
|
67
|
+
refreshRate: 500, // ms
|
|
68
|
+
mqttClient: null,
|
|
69
|
+
wsConnection: null,
|
|
70
|
+
pollIntervals: new Map(),
|
|
71
|
+
activeAlerts: new Map(), // sensor id -> { severity, firstSeen, acknowledged }
|
|
72
|
+
heatmapOverlay: null,
|
|
73
|
+
scene: null,
|
|
74
|
+
camera: null,
|
|
75
|
+
colorLegend: null
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a new sensor
|
|
80
|
+
* @param {string} id - Unique sensor ID
|
|
81
|
+
* @param {string} name - Display name
|
|
82
|
+
* @param {string} type - Sensor type (from SENSOR_TYPES)
|
|
83
|
+
* @param {THREE.Vector3} position - 3D position on model
|
|
84
|
+
* @param {string} partId - Feature/part ID this sensor is attached to
|
|
85
|
+
* @param {number} min - Minimum threshold for warnings
|
|
86
|
+
* @param {number} max - Maximum threshold for alerts
|
|
87
|
+
* @returns {Object} Sensor object
|
|
88
|
+
*/
|
|
89
|
+
function createSensor(id, name, type, position, partId, min, max) {
|
|
90
|
+
const sensor = {
|
|
91
|
+
id,
|
|
92
|
+
name,
|
|
93
|
+
type,
|
|
94
|
+
unit: SENSOR_UNITS[type] || '',
|
|
95
|
+
position: position.clone ? position.clone() : new THREE.Vector3(...position),
|
|
96
|
+
partId,
|
|
97
|
+
value: 0,
|
|
98
|
+
min: min !== undefined ? min : SENSOR_RANGES[type].safe * 0.8,
|
|
99
|
+
max: max !== undefined ? max : SENSOR_RANGES[type].safe * 1.2,
|
|
100
|
+
history: [],
|
|
101
|
+
alertThresholds: {
|
|
102
|
+
warning: (SENSOR_RANGES[type].min + SENSOR_RANGES[type].max) * 0.7,
|
|
103
|
+
critical: (SENSOR_RANGES[type].min + SENSOR_RANGES[type].max) * 0.9
|
|
104
|
+
},
|
|
105
|
+
lastUpdate: null,
|
|
106
|
+
trend: 0, // -1, 0, 1 for down, stable, up
|
|
107
|
+
anomalyScore: 0,
|
|
108
|
+
displayLabel: null, // THREE.Sprite or canvas element
|
|
109
|
+
heatmapMesh: null
|
|
110
|
+
};
|
|
111
|
+
state.sensors.set(id, sensor);
|
|
112
|
+
state.dataBuffer.set(id, []);
|
|
113
|
+
return sensor;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Update sensor reading and trigger alerts
|
|
118
|
+
* @param {string} sensorId
|
|
119
|
+
* @param {number} value
|
|
120
|
+
*/
|
|
121
|
+
function updateSensorValue(sensorId, value) {
|
|
122
|
+
const sensor = state.sensors.get(sensorId);
|
|
123
|
+
if (!sensor) return;
|
|
124
|
+
|
|
125
|
+
const timestamp = Date.now();
|
|
126
|
+
sensor.value = value;
|
|
127
|
+
sensor.lastUpdate = timestamp;
|
|
128
|
+
|
|
129
|
+
// Add to history (keep last 1000 readings)
|
|
130
|
+
const buffer = state.dataBuffer.get(sensorId) || [];
|
|
131
|
+
buffer.push({ timestamp, value });
|
|
132
|
+
if (buffer.length > 1000) buffer.shift();
|
|
133
|
+
state.dataBuffer.set(sensorId, buffer);
|
|
134
|
+
|
|
135
|
+
// Calculate trend
|
|
136
|
+
if (buffer.length >= 2) {
|
|
137
|
+
const recent = buffer.slice(-5);
|
|
138
|
+
const avg = recent.reduce((a, b) => a + b.value, 0) / recent.length;
|
|
139
|
+
const prevAvg = buffer.length >= 10
|
|
140
|
+
? buffer.slice(-10, -5).reduce((a, b) => a + b.value, 0) / 5
|
|
141
|
+
: buffer[0].value;
|
|
142
|
+
sensor.trend = avg > prevAvg ? 1 : (avg < prevAvg ? -1 : 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Anomaly detection (>3 sigma)
|
|
146
|
+
if (buffer.length >= 10) {
|
|
147
|
+
const recent = buffer.slice(-10).map(r => r.value);
|
|
148
|
+
const mean = recent.reduce((a, b) => a + b, 0) / recent.length;
|
|
149
|
+
const variance = recent.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / recent.length;
|
|
150
|
+
const stddev = Math.sqrt(variance);
|
|
151
|
+
sensor.anomalyScore = Math.abs((value - mean) / (stddev || 1));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check thresholds
|
|
155
|
+
checkAlerts(sensorId, value);
|
|
156
|
+
updateVisualization(sensorId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check alert thresholds with hysteresis
|
|
161
|
+
*/
|
|
162
|
+
function checkAlerts(sensorId, value) {
|
|
163
|
+
const sensor = state.sensors.get(sensorId);
|
|
164
|
+
const currentAlert = state.activeAlerts.get(sensorId);
|
|
165
|
+
|
|
166
|
+
let severity = null;
|
|
167
|
+
if (value >= sensor.alertThresholds.critical) {
|
|
168
|
+
severity = 'critical';
|
|
169
|
+
} else if (value >= sensor.alertThresholds.warning) {
|
|
170
|
+
severity = 'warning';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (severity && !currentAlert) {
|
|
174
|
+
// New alert
|
|
175
|
+
const alertEntry = {
|
|
176
|
+
sensorId,
|
|
177
|
+
sensorName: sensor.name,
|
|
178
|
+
severity,
|
|
179
|
+
value,
|
|
180
|
+
firstSeen: Date.now(),
|
|
181
|
+
acknowledged: false,
|
|
182
|
+
recovered: null
|
|
183
|
+
};
|
|
184
|
+
state.activeAlerts.set(sensorId, alertEntry);
|
|
185
|
+
state.alertLog.push(alertEntry);
|
|
186
|
+
triggerAlertActions(sensorId, severity);
|
|
187
|
+
} else if (!severity && currentAlert) {
|
|
188
|
+
// Alert recovered
|
|
189
|
+
currentAlert.recovered = Date.now();
|
|
190
|
+
state.activeAlerts.delete(sensorId);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Trigger visual/audio alerts
|
|
196
|
+
*/
|
|
197
|
+
function triggerAlertActions(sensorId, severity) {
|
|
198
|
+
const sensor = state.sensors.get(sensorId);
|
|
199
|
+
if (!sensor) return;
|
|
200
|
+
|
|
201
|
+
// Visual: flash the heatmap overlay
|
|
202
|
+
if (state.heatmapOverlay) {
|
|
203
|
+
state.heatmapOverlay.style.animation = severity === 'critical'
|
|
204
|
+
? 'pulse-critical 0.5s infinite'
|
|
205
|
+
: 'pulse-warning 0.5s infinite';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Audio: beep
|
|
209
|
+
playAlertSound(severity);
|
|
210
|
+
|
|
211
|
+
// Toast notification
|
|
212
|
+
showToastNotification(
|
|
213
|
+
`${sensor.name}: ${sensor.value.toFixed(2)} ${sensor.unit}`,
|
|
214
|
+
severity
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Simple alert sound
|
|
220
|
+
*/
|
|
221
|
+
function playAlertSound(severity) {
|
|
222
|
+
try {
|
|
223
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
224
|
+
const osc = ctx.createOscillator();
|
|
225
|
+
const gain = ctx.createGain();
|
|
226
|
+
osc.connect(gain);
|
|
227
|
+
gain.connect(ctx.destination);
|
|
228
|
+
|
|
229
|
+
const freq = severity === 'critical' ? 800 : 600;
|
|
230
|
+
const duration = severity === 'critical' ? 0.2 : 0.15;
|
|
231
|
+
osc.frequency.value = freq;
|
|
232
|
+
osc.start();
|
|
233
|
+
osc.stop(ctx.currentTime + duration);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// Audio context not available
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Show toast notification
|
|
241
|
+
*/
|
|
242
|
+
function showToastNotification(message, severity = 'info') {
|
|
243
|
+
const toast = document.createElement('div');
|
|
244
|
+
toast.style.cssText = `
|
|
245
|
+
position: fixed; bottom: 20px; right: 20px;
|
|
246
|
+
background: ${severity === 'critical' ? '#dc2626' : severity === 'warning' ? '#ea580c' : '#3b82f6'};
|
|
247
|
+
color: white; padding: 12px 16px; border-radius: 4px;
|
|
248
|
+
font-size: 14px; z-index: 10000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
|
249
|
+
`;
|
|
250
|
+
toast.textContent = message;
|
|
251
|
+
document.body.appendChild(toast);
|
|
252
|
+
setTimeout(() => toast.remove(), 4000);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// DATA SOURCES
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Simulated sensor data with realistic noise
|
|
261
|
+
*/
|
|
262
|
+
function generateSimulatedData(sensor) {
|
|
263
|
+
const range = SENSOR_RANGES[sensor.type];
|
|
264
|
+
const baseValue = range.min + (range.max - range.min) * 0.5;
|
|
265
|
+
|
|
266
|
+
// Sine wave with noise
|
|
267
|
+
const time = Date.now() / 1000;
|
|
268
|
+
const frequency = 0.05;
|
|
269
|
+
const amplitude = (range.max - range.min) * 0.15;
|
|
270
|
+
const noise = (Math.random() - 0.5) * (range.max - range.min) * 0.05;
|
|
271
|
+
|
|
272
|
+
return baseValue + amplitude * Math.sin(frequency * time) + noise;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Connect to MQTT broker via WebSocket bridge
|
|
277
|
+
*/
|
|
278
|
+
function connectMQTT(brokerUrl) {
|
|
279
|
+
try {
|
|
280
|
+
state.wsConnection = new WebSocket(brokerUrl);
|
|
281
|
+
state.wsConnection.onmessage = (event) => {
|
|
282
|
+
const data = JSON.parse(event.data);
|
|
283
|
+
if (data.sensorId && state.sensors.has(data.sensorId)) {
|
|
284
|
+
updateSensorValue(data.sensorId, data.value);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
state.dataSource = 'mqtt';
|
|
288
|
+
} catch (e) {
|
|
289
|
+
console.error('MQTT connection failed:', e);
|
|
290
|
+
state.dataSource = 'demo';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Connect via REST API polling
|
|
296
|
+
*/
|
|
297
|
+
function connectREST(apiUrl) {
|
|
298
|
+
state.dataSource = 'rest';
|
|
299
|
+
|
|
300
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
301
|
+
const pollInterval = setInterval(async () => {
|
|
302
|
+
try {
|
|
303
|
+
const response = await fetch(`${apiUrl}/sensors/${sensorId}/value`);
|
|
304
|
+
const data = await response.json();
|
|
305
|
+
updateSensorValue(sensorId, data.value);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.error(`REST poll failed for ${sensorId}:`, e);
|
|
308
|
+
}
|
|
309
|
+
}, state.refreshRate);
|
|
310
|
+
state.pollIntervals.set(sensorId, pollInterval);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Connect via WebSocket for real-time push
|
|
316
|
+
*/
|
|
317
|
+
function connectWebSocket(wsUrl) {
|
|
318
|
+
try {
|
|
319
|
+
state.wsConnection = new WebSocket(wsUrl);
|
|
320
|
+
state.wsConnection.onmessage = (event) => {
|
|
321
|
+
const data = JSON.parse(event.data);
|
|
322
|
+
if (data.sensorId) {
|
|
323
|
+
updateSensorValue(data.sensorId, data.value);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
state.dataSource = 'websocket';
|
|
327
|
+
} catch (e) {
|
|
328
|
+
console.error('WebSocket connection failed:', e);
|
|
329
|
+
state.dataSource = 'demo';
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ============================================================================
|
|
334
|
+
// 3D VISUALIZATION
|
|
335
|
+
// ============================================================================
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Apply heatmap overlay to mesh based on sensor values
|
|
339
|
+
*/
|
|
340
|
+
function updateHeatmapOverlay() {
|
|
341
|
+
if (!state.scene) return;
|
|
342
|
+
|
|
343
|
+
// Find meshes and apply color gradient based on sensor proximity
|
|
344
|
+
const meshes = [];
|
|
345
|
+
state.scene.traverse(obj => {
|
|
346
|
+
if (obj.isMesh && !obj.isHelper) meshes.push(obj);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (meshes.length === 0) return;
|
|
350
|
+
|
|
351
|
+
meshes.forEach(mesh => {
|
|
352
|
+
if (!mesh.geometry.attributes.color) {
|
|
353
|
+
mesh.geometry.setAttribute('color', new THREE.BufferAttribute(
|
|
354
|
+
new Float32Array(mesh.geometry.attributes.position.count * 3),
|
|
355
|
+
3
|
|
356
|
+
));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const colors = mesh.geometry.attributes.color;
|
|
360
|
+
const positions = mesh.geometry.attributes.position;
|
|
361
|
+
const colorArray = colors.array;
|
|
362
|
+
|
|
363
|
+
for (let i = 0; i < positions.count; i++) {
|
|
364
|
+
const px = positions.getX(i);
|
|
365
|
+
const py = positions.getY(i);
|
|
366
|
+
const pz = positions.getZ(i);
|
|
367
|
+
const vertPos = new THREE.Vector3(px, py, pz);
|
|
368
|
+
|
|
369
|
+
let closestValue = 0;
|
|
370
|
+
let closestDist = Infinity;
|
|
371
|
+
|
|
372
|
+
// Find nearest sensor
|
|
373
|
+
for (const sensor of state.sensors.values()) {
|
|
374
|
+
const dist = vertPos.distanceTo(sensor.position);
|
|
375
|
+
if (dist < closestDist) {
|
|
376
|
+
closestDist = dist;
|
|
377
|
+
closestValue = sensor.value;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Map value to color (blue=cold, red=hot)
|
|
382
|
+
const range = SENSOR_RANGES.temperature || { min: 0, max: 100 };
|
|
383
|
+
const normalized = Math.max(0, Math.min(1, (closestValue - range.min) / (range.max - range.min)));
|
|
384
|
+
|
|
385
|
+
// Interpolate blue -> cyan -> green -> yellow -> red
|
|
386
|
+
let r, g, b;
|
|
387
|
+
if (normalized < 0.25) {
|
|
388
|
+
// Blue to cyan
|
|
389
|
+
r = 0; g = normalized * 4; b = 1;
|
|
390
|
+
} else if (normalized < 0.5) {
|
|
391
|
+
// Cyan to green
|
|
392
|
+
r = 0; g = 1; b = 1 - (normalized - 0.25) * 4;
|
|
393
|
+
} else if (normalized < 0.75) {
|
|
394
|
+
// Green to yellow
|
|
395
|
+
r = (normalized - 0.5) * 4; g = 1; b = 0;
|
|
396
|
+
} else {
|
|
397
|
+
// Yellow to red
|
|
398
|
+
r = 1; g = 1 - (normalized - 0.75) * 4; b = 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
colorArray[i * 3] = r;
|
|
402
|
+
colorArray[i * 3 + 1] = g;
|
|
403
|
+
colorArray[i * 3 + 2] = b;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
colors.needsUpdate = true;
|
|
407
|
+
mesh.material = new THREE.MeshStandardMaterial({
|
|
408
|
+
vertexColors: true,
|
|
409
|
+
roughness: 0.5,
|
|
410
|
+
metalness: 0.3
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Update sensor visualization for individual sensor
|
|
417
|
+
*/
|
|
418
|
+
function updateVisualization(sensorId) {
|
|
419
|
+
const sensor = state.sensors.get(sensorId);
|
|
420
|
+
if (!sensor || !state.scene) return;
|
|
421
|
+
|
|
422
|
+
// Update floating label
|
|
423
|
+
if (!sensor.displayLabel) {
|
|
424
|
+
sensor.displayLabel = createFloatingLabel(sensor);
|
|
425
|
+
state.scene.add(sensor.displayLabel);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Update heatmap
|
|
429
|
+
updateHeatmapOverlay();
|
|
430
|
+
|
|
431
|
+
// Alert indicator (pulsing red sphere)
|
|
432
|
+
const alert = state.activeAlerts.get(sensorId);
|
|
433
|
+
if (alert && !sensor.alertIndicator) {
|
|
434
|
+
const geom = new THREE.SphereGeometry(0.5, 8, 8);
|
|
435
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
436
|
+
color: alert.severity === 'critical' ? 0xff0000 : 0xffaa00,
|
|
437
|
+
emissive: alert.severity === 'critical' ? 0xff0000 : 0xffaa00,
|
|
438
|
+
emissiveIntensity: 0.8
|
|
439
|
+
});
|
|
440
|
+
sensor.alertIndicator = new THREE.Mesh(geom, mat);
|
|
441
|
+
sensor.alertIndicator.position.copy(sensor.position);
|
|
442
|
+
sensor.alertIndicator.scale.set(0.3, 0.3, 0.3);
|
|
443
|
+
state.scene.add(sensor.alertIndicator);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Create floating 3D label for sensor
|
|
449
|
+
*/
|
|
450
|
+
function createFloatingLabel(sensor) {
|
|
451
|
+
const canvas = document.createElement('canvas');
|
|
452
|
+
canvas.width = 256;
|
|
453
|
+
canvas.height = 128;
|
|
454
|
+
const ctx = canvas.getContext('2d');
|
|
455
|
+
|
|
456
|
+
ctx.fillStyle = '#000000';
|
|
457
|
+
ctx.globalAlpha = 0.8;
|
|
458
|
+
ctx.fillRect(0, 0, 256, 128);
|
|
459
|
+
|
|
460
|
+
ctx.fillStyle = '#ffffff';
|
|
461
|
+
ctx.font = 'bold 16px Arial';
|
|
462
|
+
ctx.textAlign = 'center';
|
|
463
|
+
ctx.fillText(sensor.name, 128, 30);
|
|
464
|
+
|
|
465
|
+
ctx.font = '20px Arial';
|
|
466
|
+
ctx.fillStyle = '#00ff00';
|
|
467
|
+
const valueText = `${sensor.value.toFixed(2)} ${sensor.unit}`;
|
|
468
|
+
ctx.fillText(valueText, 128, 70);
|
|
469
|
+
|
|
470
|
+
ctx.font = '12px Arial';
|
|
471
|
+
ctx.fillStyle = '#cccccc';
|
|
472
|
+
const trend = sensor.trend > 0 ? '↑' : (sensor.trend < 0 ? '↓' : '→');
|
|
473
|
+
ctx.fillText(trend, 128, 110);
|
|
474
|
+
|
|
475
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
476
|
+
const material = new THREE.SpriteMaterial({ map: texture });
|
|
477
|
+
const label = new THREE.Sprite(material);
|
|
478
|
+
label.scale.set(2, 1, 1);
|
|
479
|
+
label.position.copy(sensor.position).add(new THREE.Vector3(0, 1, 0));
|
|
480
|
+
|
|
481
|
+
return label;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Add flow visualization (animated particles)
|
|
486
|
+
*/
|
|
487
|
+
function addFlowVisualization(sensorId, pathPoints) {
|
|
488
|
+
const sensor = state.sensors.get(sensorId);
|
|
489
|
+
if (!sensor || !state.scene || sensor.type !== SENSOR_TYPES.FLOW_RATE) return;
|
|
490
|
+
|
|
491
|
+
// Create particle system along path
|
|
492
|
+
const particleCount = Math.ceil(sensor.value / 5);
|
|
493
|
+
const geom = new THREE.BufferGeometry();
|
|
494
|
+
const positions = new Float32Array(particleCount * 3);
|
|
495
|
+
|
|
496
|
+
for (let i = 0; i < particleCount; i++) {
|
|
497
|
+
const t = (i / particleCount);
|
|
498
|
+
const point = getPointAlongPath(pathPoints, t);
|
|
499
|
+
positions[i * 3] = point.x;
|
|
500
|
+
positions[i * 3 + 1] = point.y;
|
|
501
|
+
positions[i * 3 + 2] = point.z;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
505
|
+
const mat = new THREE.PointsMaterial({ color: 0x0088ff, size: 0.1 });
|
|
506
|
+
|
|
507
|
+
if (sensor.flowParticles) state.scene.remove(sensor.flowParticles);
|
|
508
|
+
sensor.flowParticles = new THREE.Points(geom, mat);
|
|
509
|
+
state.scene.add(sensor.flowParticles);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function getPointAlongPath(pathPoints, t) {
|
|
513
|
+
const segmentIndex = Math.floor(t * (pathPoints.length - 1));
|
|
514
|
+
const nextIndex = Math.min(segmentIndex + 1, pathPoints.length - 1);
|
|
515
|
+
const localT = t * (pathPoints.length - 1) - segmentIndex;
|
|
516
|
+
|
|
517
|
+
const p1 = pathPoints[segmentIndex];
|
|
518
|
+
const p2 = pathPoints[nextIndex];
|
|
519
|
+
return p1.clone().lerp(p2, localT);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Vibration animation - shake mesh proportionally
|
|
524
|
+
*/
|
|
525
|
+
function updateVibrationAnimation() {
|
|
526
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
527
|
+
if (sensor.type !== SENSOR_TYPES.VIBRATION) continue;
|
|
528
|
+
|
|
529
|
+
// Find mesh associated with sensor's partId
|
|
530
|
+
if (!sensor.heatmapMesh && state.scene) {
|
|
531
|
+
state.scene.traverse(obj => {
|
|
532
|
+
if (obj.isMesh && obj.userData.partId === sensor.partId) {
|
|
533
|
+
sensor.heatmapMesh = obj;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (sensor.heatmapMesh) {
|
|
539
|
+
const magnitude = sensor.value / SENSOR_RANGES.vibration.max;
|
|
540
|
+
const shake = 0.01 * magnitude;
|
|
541
|
+
|
|
542
|
+
sensor.heatmapMesh.position.x += (Math.random() - 0.5) * shake;
|
|
543
|
+
sensor.heatmapMesh.position.y += (Math.random() - 0.5) * shake;
|
|
544
|
+
sensor.heatmapMesh.position.z += (Math.random() - 0.5) * shake;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ============================================================================
|
|
550
|
+
// PREDICTIVE ANALYTICS
|
|
551
|
+
// ============================================================================
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Linear extrapolation - when will sensor hit critical?
|
|
555
|
+
*/
|
|
556
|
+
function estimateTimeToThreshold(sensorId) {
|
|
557
|
+
const sensor = state.sensors.get(sensorId);
|
|
558
|
+
const buffer = state.dataBuffer.get(sensorId);
|
|
559
|
+
|
|
560
|
+
if (!sensor || buffer.length < 10) return null;
|
|
561
|
+
|
|
562
|
+
const recent = buffer.slice(-10);
|
|
563
|
+
const times = recent.map(r => r.timestamp);
|
|
564
|
+
const values = recent.map(r => r.value);
|
|
565
|
+
|
|
566
|
+
// Linear regression
|
|
567
|
+
const n = times.length;
|
|
568
|
+
const sumT = times.reduce((a, b) => a + b, 0);
|
|
569
|
+
const sumV = values.reduce((a, b) => a + b, 0);
|
|
570
|
+
const sumTT = times.reduce((a, t) => a + t * t, 0);
|
|
571
|
+
const sumTV = times.reduce((a, t, i) => a + t * values[i], 0);
|
|
572
|
+
|
|
573
|
+
const slope = (n * sumTV - sumT * sumV) / (n * sumTT - sumT * sumT);
|
|
574
|
+
const intercept = (sumV - slope * sumT) / n;
|
|
575
|
+
|
|
576
|
+
if (slope <= 0) return null; // Not increasing
|
|
577
|
+
|
|
578
|
+
const threshold = sensor.alertThresholds.critical;
|
|
579
|
+
const timeToThreshold = (threshold - intercept) / slope;
|
|
580
|
+
const secondsToThreshold = (timeToThreshold - Date.now()) / 1000;
|
|
581
|
+
|
|
582
|
+
return secondsToThreshold > 0 ? secondsToThreshold : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Estimate remaining useful life (RUL) based on degradation
|
|
587
|
+
*/
|
|
588
|
+
function estimateRUL(sensorId, operatingHours) {
|
|
589
|
+
const sensor = state.sensors.get(sensorId);
|
|
590
|
+
const buffer = state.dataBuffer.get(sensorId);
|
|
591
|
+
|
|
592
|
+
if (!sensor || buffer.length < 50) return null;
|
|
593
|
+
|
|
594
|
+
const oldestTime = buffer[0].timestamp;
|
|
595
|
+
const newestTime = buffer[buffer.length - 1].timestamp;
|
|
596
|
+
const timeDelta = (newestTime - oldestTime) / (1000 * 3600); // hours
|
|
597
|
+
|
|
598
|
+
const oldestValue = buffer[0].value;
|
|
599
|
+
const newestValue = buffer[buffer.length - 1].value;
|
|
600
|
+
const degradationRate = (newestValue - oldestValue) / timeDelta;
|
|
601
|
+
|
|
602
|
+
if (degradationRate <= 0) return null;
|
|
603
|
+
|
|
604
|
+
const threshold = sensor.alertThresholds.critical;
|
|
605
|
+
const hoursToThreshold = (threshold - newestValue) / degradationRate;
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
estimatedHours: hoursToThreshold,
|
|
609
|
+
recommendedMaintenance: Math.ceil(hoursToThreshold * 0.8),
|
|
610
|
+
confidence: Math.min(buffer.length / 100, 1) // 0-1
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Anomaly detection
|
|
616
|
+
*/
|
|
617
|
+
function detectAnomalies(sensorId) {
|
|
618
|
+
const sensor = state.sensors.get(sensorId);
|
|
619
|
+
const buffer = state.dataBuffer.get(sensorId);
|
|
620
|
+
|
|
621
|
+
if (!sensor || buffer.length < 20) return [];
|
|
622
|
+
|
|
623
|
+
const recent = buffer.slice(-20).map(r => r.value);
|
|
624
|
+
const mean = recent.reduce((a, b) => a + b, 0) / recent.length;
|
|
625
|
+
const variance = recent.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / recent.length;
|
|
626
|
+
const stddev = Math.sqrt(variance);
|
|
627
|
+
|
|
628
|
+
const anomalies = [];
|
|
629
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
630
|
+
const zScore = Math.abs((buffer[i].value - mean) / (stddev || 1));
|
|
631
|
+
if (zScore > 3) {
|
|
632
|
+
anomalies.push({
|
|
633
|
+
timestamp: buffer[i].timestamp,
|
|
634
|
+
value: buffer[i].value,
|
|
635
|
+
zScore
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return anomalies;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Generate maintenance report
|
|
645
|
+
*/
|
|
646
|
+
function generateMaintenanceReport() {
|
|
647
|
+
const report = {
|
|
648
|
+
timestamp: new Date().toISOString(),
|
|
649
|
+
sensors: [],
|
|
650
|
+
summary: {
|
|
651
|
+
healthy: 0,
|
|
652
|
+
warning: 0,
|
|
653
|
+
critical: 0,
|
|
654
|
+
anomalies: 0
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
659
|
+
const rul = estimateRUL(sensorId, 0);
|
|
660
|
+
const timeToThreshold = estimateTimeToThreshold(sensorId);
|
|
661
|
+
const anomalies = detectAnomalies(sensorId);
|
|
662
|
+
|
|
663
|
+
const sensorReport = {
|
|
664
|
+
id: sensorId,
|
|
665
|
+
name: sensor.name,
|
|
666
|
+
type: sensor.type,
|
|
667
|
+
currentValue: sensor.value,
|
|
668
|
+
trend: sensor.trend,
|
|
669
|
+
rul,
|
|
670
|
+
timeToThreshold,
|
|
671
|
+
anomalyCount: anomalies.length,
|
|
672
|
+
status: state.activeAlerts.has(sensorId)
|
|
673
|
+
? state.activeAlerts.get(sensorId).severity
|
|
674
|
+
: 'healthy'
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
report.sensors.push(sensorReport);
|
|
678
|
+
|
|
679
|
+
if (sensorReport.status === 'healthy') report.summary.healthy++;
|
|
680
|
+
else if (sensorReport.status === 'warning') report.summary.warning++;
|
|
681
|
+
else if (sensorReport.status === 'critical') report.summary.critical++;
|
|
682
|
+
report.summary.anomalies += anomalies.length;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return report;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ============================================================================
|
|
689
|
+
// DASHBOARD UI
|
|
690
|
+
// ============================================================================
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Get UI panel HTML
|
|
694
|
+
*/
|
|
695
|
+
function getUI() {
|
|
696
|
+
const alertCount = state.activeAlerts.size;
|
|
697
|
+
const report = generateMaintenanceReport();
|
|
698
|
+
|
|
699
|
+
return `
|
|
700
|
+
<div class="digital-twin-panel" style="display: flex; flex-direction: column; height: 100%; background: var(--bg-secondary); color: var(--text-primary);">
|
|
701
|
+
<style>
|
|
702
|
+
.digital-twin-panel {
|
|
703
|
+
font-family: 'Segoe UI', sans-serif;
|
|
704
|
+
font-size: 13px;
|
|
705
|
+
}
|
|
706
|
+
.dt-tabs {
|
|
707
|
+
display: flex; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary);
|
|
708
|
+
}
|
|
709
|
+
.dt-tab {
|
|
710
|
+
flex: 1; padding: 10px; cursor: pointer; text-align: center; border: none;
|
|
711
|
+
background: var(--bg-tertiary); color: var(--text-secondary); font-size: 12px;
|
|
712
|
+
}
|
|
713
|
+
.dt-tab.active {
|
|
714
|
+
border-bottom: 2px solid var(--accent-color); color: var(--accent-color); background: var(--bg-secondary);
|
|
715
|
+
}
|
|
716
|
+
.dt-content {
|
|
717
|
+
flex: 1; overflow-y: auto; padding: 10px; display: none;
|
|
718
|
+
}
|
|
719
|
+
.dt-content.active {
|
|
720
|
+
display: block;
|
|
721
|
+
}
|
|
722
|
+
.dt-status-bar {
|
|
723
|
+
padding: 8px 10px; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color);
|
|
724
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
725
|
+
}
|
|
726
|
+
.dt-status-badge {
|
|
727
|
+
display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: bold;
|
|
728
|
+
}
|
|
729
|
+
.dt-status-badge.online { background: #10b981; color: white; }
|
|
730
|
+
.dt-status-badge.offline { background: #ef4444; color: white; }
|
|
731
|
+
.dt-sensor-card {
|
|
732
|
+
background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px;
|
|
733
|
+
padding: 10px; margin-bottom: 8px; cursor: pointer;
|
|
734
|
+
}
|
|
735
|
+
.dt-sensor-card:hover {
|
|
736
|
+
background: var(--bg-quaternary);
|
|
737
|
+
}
|
|
738
|
+
.dt-sensor-header {
|
|
739
|
+
display: flex; justify-content: space-between; margin-bottom: 6px;
|
|
740
|
+
}
|
|
741
|
+
.dt-sensor-name {
|
|
742
|
+
font-weight: bold; color: var(--text-primary);
|
|
743
|
+
}
|
|
744
|
+
.dt-sensor-value {
|
|
745
|
+
font-size: 14px; font-weight: bold; font-family: 'Monaco', monospace;
|
|
746
|
+
}
|
|
747
|
+
.dt-sensor-trend {
|
|
748
|
+
font-size: 12px; margin-left: 4px;
|
|
749
|
+
}
|
|
750
|
+
.dt-gauge {
|
|
751
|
+
width: 120px; height: 120px; border-radius: 50%; background: conic-gradient(#10b981 0%, #f59e0b 70%, #ef4444 100%);
|
|
752
|
+
display: flex; align-items: center; justify-content: center; margin: 10px auto;
|
|
753
|
+
font-weight: bold; color: white; text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
|
754
|
+
}
|
|
755
|
+
.dt-sparkline {
|
|
756
|
+
height: 30px; margin-top: 6px;
|
|
757
|
+
}
|
|
758
|
+
.dt-alert-item {
|
|
759
|
+
background: var(--bg-tertiary); border-left: 3px solid #ef4444; padding: 8px;
|
|
760
|
+
margin-bottom: 6px; border-radius: 2px;
|
|
761
|
+
}
|
|
762
|
+
.dt-alert-item.warning {
|
|
763
|
+
border-left-color: #f59e0b;
|
|
764
|
+
}
|
|
765
|
+
.dt-control-group {
|
|
766
|
+
display: flex; gap: 8px; margin-bottom: 10px;
|
|
767
|
+
}
|
|
768
|
+
.dt-button {
|
|
769
|
+
padding: 6px 12px; background: var(--accent-color); color: white; border: none;
|
|
770
|
+
border-radius: 3px; cursor: pointer; font-size: 12px; flex: 1;
|
|
771
|
+
}
|
|
772
|
+
.dt-button:hover {
|
|
773
|
+
opacity: 0.9;
|
|
774
|
+
}
|
|
775
|
+
.dt-select {
|
|
776
|
+
padding: 4px 8px; background: var(--bg-tertiary); color: var(--text-primary);
|
|
777
|
+
border: 1px solid var(--border-color); border-radius: 2px; font-size: 12px;
|
|
778
|
+
}
|
|
779
|
+
@keyframes pulse-critical {
|
|
780
|
+
0%, 100% { opacity: 1; }
|
|
781
|
+
50% { opacity: 0.5; }
|
|
782
|
+
}
|
|
783
|
+
@keyframes pulse-warning {
|
|
784
|
+
0%, 100% { opacity: 0.8; }
|
|
785
|
+
50% { opacity: 0.6; }
|
|
786
|
+
}
|
|
787
|
+
</style>
|
|
788
|
+
|
|
789
|
+
<div class="dt-status-bar">
|
|
790
|
+
<span>Digital Twin</span>
|
|
791
|
+
<span class="dt-status-badge ${state.monitoringActive ? 'online' : 'offline'}">
|
|
792
|
+
${state.monitoringActive ? 'MONITORING' : 'OFFLINE'}
|
|
793
|
+
</span>
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
<div class="dt-tabs">
|
|
797
|
+
<button class="dt-tab active" onclick="CycleCAD.DigitalTwin.switchTab('live')">Live</button>
|
|
798
|
+
<button class="dt-tab" onclick="CycleCAD.DigitalTwin.switchTab('dashboard')">Dashboard</button>
|
|
799
|
+
<button class="dt-tab" onclick="CycleCAD.DigitalTwin.switchTab('alerts')">Alerts <span style="color: #ef4444;">(${alertCount})</span></button>
|
|
800
|
+
<button class="dt-tab" onclick="CycleCAD.DigitalTwin.switchTab('analytics')">Analytics</button>
|
|
801
|
+
<button class="dt-tab" onclick="CycleCAD.DigitalTwin.switchTab('config')">Config</button>
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
<!-- LIVE TAB -->
|
|
805
|
+
<div class="dt-content active" id="dt-live-tab" style="overflow-y: auto;">
|
|
806
|
+
<div class="dt-control-group">
|
|
807
|
+
<button class="dt-button" onclick="CycleCAD.DigitalTwin.startMonitoring()">Start</button>
|
|
808
|
+
<button class="dt-button" onclick="CycleCAD.DigitalTwin.stopMonitoring()">Stop</button>
|
|
809
|
+
</div>
|
|
810
|
+
<div style="margin-bottom: 8px;">
|
|
811
|
+
<label>Data Source:</label>
|
|
812
|
+
<select class="dt-select" onchange="CycleCAD.DigitalTwin.setDataSource(this.value)">
|
|
813
|
+
<option value="demo">Demo (Simulated)</option>
|
|
814
|
+
<option value="rest">REST API</option>
|
|
815
|
+
<option value="websocket">WebSocket</option>
|
|
816
|
+
<option value="mqtt">MQTT</option>
|
|
817
|
+
</select>
|
|
818
|
+
</div>
|
|
819
|
+
<div style="margin-bottom: 8px;">
|
|
820
|
+
<label>Refresh Rate:</label>
|
|
821
|
+
<select class="dt-select" onchange="CycleCAD.DigitalTwin.setRefreshRate(parseInt(this.value))">
|
|
822
|
+
<option value="100">100ms</option>
|
|
823
|
+
<option value="500" selected>500ms</option>
|
|
824
|
+
<option value="1000">1s</option>
|
|
825
|
+
<option value="5000">5s</option>
|
|
826
|
+
</select>
|
|
827
|
+
</div>
|
|
828
|
+
<div id="dt-sensor-list"></div>
|
|
829
|
+
</div>
|
|
830
|
+
|
|
831
|
+
<!-- DASHBOARD TAB -->
|
|
832
|
+
<div class="dt-content" id="dt-dashboard-tab">
|
|
833
|
+
<div id="dt-gauge-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;"></div>
|
|
834
|
+
</div>
|
|
835
|
+
|
|
836
|
+
<!-- ALERTS TAB -->
|
|
837
|
+
<div class="dt-content" id="dt-alerts-tab">
|
|
838
|
+
<div style="margin-bottom: 10px;">
|
|
839
|
+
<strong>Active Alerts (${alertCount})</strong>
|
|
840
|
+
</div>
|
|
841
|
+
<div id="dt-active-alerts"></div>
|
|
842
|
+
<div style="margin: 10px 0; border-top: 1px solid var(--border-color); padding-top: 10px;">
|
|
843
|
+
<strong>Alert History</strong>
|
|
844
|
+
</div>
|
|
845
|
+
<div id="dt-alert-history" style="max-height: 300px; overflow-y: auto;"></div>
|
|
846
|
+
</div>
|
|
847
|
+
|
|
848
|
+
<!-- ANALYTICS TAB -->
|
|
849
|
+
<div class="dt-content" id="dt-analytics-tab">
|
|
850
|
+
<div id="dt-analytics-summary" style="margin-bottom: 15px;">
|
|
851
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px;">
|
|
852
|
+
<div style="background: var(--bg-tertiary); padding: 8px; border-radius: 3px; text-align: center;">
|
|
853
|
+
<div style="font-size: 20px; font-weight: bold; color: #10b981;">${report.summary.healthy}</div>
|
|
854
|
+
<div style="font-size: 11px; color: var(--text-secondary);">Healthy</div>
|
|
855
|
+
</div>
|
|
856
|
+
<div style="background: var(--bg-tertiary); padding: 8px; border-radius: 3px; text-align: center;">
|
|
857
|
+
<div style="font-size: 20px; font-weight: bold; color: #ef4444;">${report.summary.critical}</div>
|
|
858
|
+
<div style="font-size: 11px; color: var(--text-secondary);">Critical</div>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
</div>
|
|
862
|
+
<div id="dt-rul-list"></div>
|
|
863
|
+
<button class="dt-button" onclick="CycleCAD.DigitalTwin.exportAnalytics()" style="margin-top: 10px;">Export Report (JSON)</button>
|
|
864
|
+
</div>
|
|
865
|
+
|
|
866
|
+
<!-- CONFIG TAB -->
|
|
867
|
+
<div class="dt-content" id="dt-config-tab">
|
|
868
|
+
<div style="margin-bottom: 10px;">
|
|
869
|
+
<h4>Add New Sensor</h4>
|
|
870
|
+
<div style="background: var(--bg-tertiary); padding: 10px; border-radius: 3px;">
|
|
871
|
+
<input type="text" placeholder="Sensor ID" id="dt-sensor-id" class="dt-select" style="width: 100%; margin-bottom: 6px;">
|
|
872
|
+
<input type="text" placeholder="Name" id="dt-sensor-name" class="dt-select" style="width: 100%; margin-bottom: 6px;">
|
|
873
|
+
<select id="dt-sensor-type" class="dt-select" style="width: 100%; margin-bottom: 6px;">
|
|
874
|
+
<option value="temperature">Temperature</option>
|
|
875
|
+
<option value="vibration">Vibration</option>
|
|
876
|
+
<option value="pressure">Pressure</option>
|
|
877
|
+
<option value="force">Force</option>
|
|
878
|
+
</select>
|
|
879
|
+
<button class="dt-button" onclick="CycleCAD.DigitalTwin.addSensorUI()" style="margin-top: 8px;">Add Sensor</button>
|
|
880
|
+
</div>
|
|
881
|
+
</div>
|
|
882
|
+
<div>
|
|
883
|
+
<h4>Active Sensors (${state.sensors.size})</h4>
|
|
884
|
+
<div id="dt-sensor-list-config"></div>
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Update live sensor list
|
|
893
|
+
*/
|
|
894
|
+
function updateSensorList() {
|
|
895
|
+
const container = document.getElementById('dt-sensor-list');
|
|
896
|
+
if (!container) return;
|
|
897
|
+
|
|
898
|
+
let html = '';
|
|
899
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
900
|
+
const trend = sensor.trend > 0 ? '↑' : (sensor.trend < 0 ? '↓' : '→');
|
|
901
|
+
const alert = state.activeAlerts.get(sensorId);
|
|
902
|
+
const statusColor = !alert ? '#10b981' : (alert.severity === 'critical' ? '#ef4444' : '#f59e0b');
|
|
903
|
+
|
|
904
|
+
html += `
|
|
905
|
+
<div class="dt-sensor-card" style="border-left: 3px solid ${statusColor};">
|
|
906
|
+
<div class="dt-sensor-header">
|
|
907
|
+
<span class="dt-sensor-name">${sensor.name}</span>
|
|
908
|
+
<span class="dt-sensor-value">${sensor.value.toFixed(2)} ${sensor.unit}</span>
|
|
909
|
+
</div>
|
|
910
|
+
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-secondary);">
|
|
911
|
+
<span>${sensor.type}</span>
|
|
912
|
+
<span class="dt-sensor-trend">${trend}</span>
|
|
913
|
+
</div>
|
|
914
|
+
<svg class="dt-sparkline" id="sparkline-${sensorId}"></svg>
|
|
915
|
+
</div>
|
|
916
|
+
`;
|
|
917
|
+
}
|
|
918
|
+
container.innerHTML = html || '<div style="color: var(--text-secondary);">No sensors configured</div>';
|
|
919
|
+
|
|
920
|
+
// Draw sparklines
|
|
921
|
+
drawSparklines();
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Draw mini sparkline charts
|
|
926
|
+
*/
|
|
927
|
+
function drawSparklines() {
|
|
928
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
929
|
+
const svg = document.getElementById(`sparkline-${sensorId}`);
|
|
930
|
+
if (!svg) continue;
|
|
931
|
+
|
|
932
|
+
const buffer = state.dataBuffer.get(sensorId) || [];
|
|
933
|
+
if (buffer.length < 2) continue;
|
|
934
|
+
|
|
935
|
+
const width = 100;
|
|
936
|
+
const height = 30;
|
|
937
|
+
const recent = buffer.slice(-50);
|
|
938
|
+
|
|
939
|
+
const minVal = Math.min(...recent.map(r => r.value));
|
|
940
|
+
const maxVal = Math.max(...recent.map(r => r.value));
|
|
941
|
+
const range = maxVal - minVal || 1;
|
|
942
|
+
|
|
943
|
+
let pathData = '';
|
|
944
|
+
recent.forEach((point, i) => {
|
|
945
|
+
const x = (i / (recent.length - 1)) * width;
|
|
946
|
+
const y = height - ((point.value - minVal) / range) * height;
|
|
947
|
+
pathData += `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
svg.innerHTML = `
|
|
951
|
+
<polyline points="${pathData.replace(/^M /, '').split('L').map((p, i) => {
|
|
952
|
+
const [x, y] = p.trim().split(' ');
|
|
953
|
+
return `${x},${y}`;
|
|
954
|
+
}).join(' ')}"
|
|
955
|
+
style="fill: none; stroke: var(--accent-color); stroke-width: 1.5;" vector-effect="non-scaling-stroke" />
|
|
956
|
+
`;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Switch tab visibility
|
|
962
|
+
*/
|
|
963
|
+
function switchTab(tabName) {
|
|
964
|
+
// Hide all
|
|
965
|
+
document.querySelectorAll('.dt-content').forEach(el => el.classList.remove('active'));
|
|
966
|
+
document.querySelectorAll('.dt-tab').forEach(el => el.classList.remove('active'));
|
|
967
|
+
|
|
968
|
+
// Show selected
|
|
969
|
+
const contentEl = document.getElementById(`dt-${tabName}-tab`);
|
|
970
|
+
if (contentEl) {
|
|
971
|
+
contentEl.classList.add('active');
|
|
972
|
+
event.target.classList.add('active');
|
|
973
|
+
|
|
974
|
+
// Lazy render
|
|
975
|
+
if (tabName === 'dashboard') renderDashboard();
|
|
976
|
+
if (tabName === 'alerts') renderAlerts();
|
|
977
|
+
if (tabName === 'analytics') renderAnalytics();
|
|
978
|
+
if (tabName === 'config') renderConfig();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function renderDashboard() {
|
|
983
|
+
const grid = document.getElementById('dt-gauge-grid');
|
|
984
|
+
if (!grid) return;
|
|
985
|
+
|
|
986
|
+
let html = '';
|
|
987
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
988
|
+
const normalized = (sensor.value - SENSOR_RANGES[sensor.type].min) /
|
|
989
|
+
(SENSOR_RANGES[sensor.type].max - SENSOR_RANGES[sensor.type].min);
|
|
990
|
+
const angle = normalized * 180;
|
|
991
|
+
|
|
992
|
+
html += `
|
|
993
|
+
<div style="text-align: center;">
|
|
994
|
+
<div class="dt-gauge" style="background: conic-gradient(#10b981 0deg, #f59e0b 126deg, #ef4444 180deg);">
|
|
995
|
+
${sensor.value.toFixed(1)}<br><span style="font-size: 10px;">${sensor.unit}</span>
|
|
996
|
+
</div>
|
|
997
|
+
<div style="font-size: 12px; font-weight: bold;">${sensor.name}</div>
|
|
998
|
+
<div style="font-size: 11px; color: var(--text-secondary);">${sensor.type}</div>
|
|
999
|
+
</div>
|
|
1000
|
+
`;
|
|
1001
|
+
}
|
|
1002
|
+
grid.innerHTML = html;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function renderAlerts() {
|
|
1006
|
+
const activeDiv = document.getElementById('dt-active-alerts');
|
|
1007
|
+
const historyDiv = document.getElementById('dt-alert-history');
|
|
1008
|
+
|
|
1009
|
+
let activeHtml = '';
|
|
1010
|
+
for (const [sensorId, alert] of state.activeAlerts) {
|
|
1011
|
+
const duration = ((Date.now() - alert.firstSeen) / 1000 / 60).toFixed(1);
|
|
1012
|
+
activeHtml += `
|
|
1013
|
+
<div class="dt-alert-item ${alert.severity}">
|
|
1014
|
+
<div style="font-weight: bold;">${alert.sensorName}</div>
|
|
1015
|
+
<div style="font-size: 11px; color: var(--text-secondary);">
|
|
1016
|
+
${alert.value.toFixed(2)} - ${duration} minutes ago
|
|
1017
|
+
</div>
|
|
1018
|
+
</div>
|
|
1019
|
+
`;
|
|
1020
|
+
}
|
|
1021
|
+
activeDiv.innerHTML = activeHtml || '<div style="color: var(--text-secondary);">No active alerts</div>';
|
|
1022
|
+
|
|
1023
|
+
let historyHtml = '';
|
|
1024
|
+
state.alertLog.slice(-10).reverse().forEach(alert => {
|
|
1025
|
+
const duration = alert.recovered
|
|
1026
|
+
? ((alert.recovered - alert.firstSeen) / 1000 / 60).toFixed(1)
|
|
1027
|
+
: 'ongoing';
|
|
1028
|
+
historyHtml += `
|
|
1029
|
+
<div class="dt-alert-item ${alert.severity}" style="font-size: 11px;">
|
|
1030
|
+
<div>${alert.sensorName}</div>
|
|
1031
|
+
<div style="color: var(--text-secondary);">${duration} min (${new Date(alert.firstSeen).toLocaleTimeString()})</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
`;
|
|
1034
|
+
});
|
|
1035
|
+
historyDiv.innerHTML = historyHtml;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function renderAnalytics() {
|
|
1039
|
+
const rulDiv = document.getElementById('dt-rul-list');
|
|
1040
|
+
|
|
1041
|
+
let html = '';
|
|
1042
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
1043
|
+
const rul = estimateRUL(sensorId, 0);
|
|
1044
|
+
const timeToThreshold = estimateTimeToThreshold(sensorId);
|
|
1045
|
+
|
|
1046
|
+
html += `
|
|
1047
|
+
<div style="background: var(--bg-tertiary); padding: 8px; border-radius: 3px; margin-bottom: 8px;">
|
|
1048
|
+
<div style="font-weight: bold; margin-bottom: 4px;">${sensor.name}</div>
|
|
1049
|
+
<div style="font-size: 11px; color: var(--text-secondary);">
|
|
1050
|
+
${rul ? `RUL: ${rul.estimatedHours.toFixed(1)}h (${(rul.confidence * 100).toFixed(0)}% confidence)` : 'Insufficient data'}
|
|
1051
|
+
</div>
|
|
1052
|
+
${timeToThreshold ? `<div style="font-size: 11px; color: #ef4444;">Critical threshold in ${(timeToThreshold / 3600).toFixed(1)}h</div>` : ''}
|
|
1053
|
+
</div>
|
|
1054
|
+
`;
|
|
1055
|
+
}
|
|
1056
|
+
rulDiv.innerHTML = html;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function renderConfig() {
|
|
1060
|
+
const configDiv = document.getElementById('dt-sensor-list-config');
|
|
1061
|
+
let html = '';
|
|
1062
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
1063
|
+
html += `
|
|
1064
|
+
<div style="background: var(--bg-tertiary); padding: 6px; border-radius: 3px; margin-bottom: 6px; font-size: 12px;">
|
|
1065
|
+
<div><strong>${sensor.name}</strong></div>
|
|
1066
|
+
<div style="color: var(--text-secondary);">${sensor.id} • ${sensor.type}</div>
|
|
1067
|
+
<button class="dt-button" style="margin-top: 4px; padding: 3px 8px;" onclick="CycleCAD.DigitalTwin.removeSensor('${sensorId}')">Remove</button>
|
|
1068
|
+
</div>
|
|
1069
|
+
`;
|
|
1070
|
+
}
|
|
1071
|
+
configDiv.innerHTML = html;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function addSensorUI() {
|
|
1075
|
+
const id = document.getElementById('dt-sensor-id').value;
|
|
1076
|
+
const name = document.getElementById('dt-sensor-name').value;
|
|
1077
|
+
const type = document.getElementById('dt-sensor-type').value;
|
|
1078
|
+
|
|
1079
|
+
if (!id || !name) {
|
|
1080
|
+
alert('Please fill in all fields');
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
createSensor(id, name, type, new THREE.Vector3(0, 0, 0), 'root');
|
|
1085
|
+
updateSensorList();
|
|
1086
|
+
document.getElementById('dt-sensor-id').value = '';
|
|
1087
|
+
document.getElementById('dt-sensor-name').value = '';
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function removeSensor(sensorId) {
|
|
1091
|
+
state.sensors.delete(sensorId);
|
|
1092
|
+
state.dataBuffer.delete(sensorId);
|
|
1093
|
+
state.activeAlerts.delete(sensorId);
|
|
1094
|
+
updateSensorList();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function exportAnalytics() {
|
|
1098
|
+
const report = generateMaintenanceReport();
|
|
1099
|
+
const json = JSON.stringify(report, null, 2);
|
|
1100
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
1101
|
+
const url = URL.createObjectURL(blob);
|
|
1102
|
+
const a = document.createElement('a');
|
|
1103
|
+
a.href = url;
|
|
1104
|
+
a.download = `digital-twin-report-${Date.now()}.json`;
|
|
1105
|
+
a.click();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ============================================================================
|
|
1109
|
+
// MONITORING LOOP
|
|
1110
|
+
// ============================================================================
|
|
1111
|
+
|
|
1112
|
+
function startMonitoring() {
|
|
1113
|
+
if (state.monitoringActive) return;
|
|
1114
|
+
state.monitoringActive = true;
|
|
1115
|
+
|
|
1116
|
+
const monitorLoop = setInterval(() => {
|
|
1117
|
+
if (!state.monitoringActive) {
|
|
1118
|
+
clearInterval(monitorLoop);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Update all sensors
|
|
1123
|
+
for (const [sensorId, sensor] of state.sensors) {
|
|
1124
|
+
let value;
|
|
1125
|
+
if (state.dataSource === 'demo') {
|
|
1126
|
+
value = generateSimulatedData(sensor);
|
|
1127
|
+
}
|
|
1128
|
+
// For MQTT/REST/WebSocket, data updates come via callbacks
|
|
1129
|
+
if (value !== undefined) {
|
|
1130
|
+
updateSensorValue(sensorId, value);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Update animations
|
|
1135
|
+
updateVibrationAnimation();
|
|
1136
|
+
updateHeatmapOverlay();
|
|
1137
|
+
|
|
1138
|
+
// Refresh UI
|
|
1139
|
+
updateSensorList();
|
|
1140
|
+
}, state.refreshRate);
|
|
1141
|
+
|
|
1142
|
+
state.monitoringActive = true;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function stopMonitoring() {
|
|
1146
|
+
state.monitoringActive = false;
|
|
1147
|
+
|
|
1148
|
+
// Stop REST polling
|
|
1149
|
+
for (const interval of state.pollIntervals.values()) {
|
|
1150
|
+
clearInterval(interval);
|
|
1151
|
+
}
|
|
1152
|
+
state.pollIntervals.clear();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function setDataSource(source) {
|
|
1156
|
+
state.dataSource = source;
|
|
1157
|
+
if (source === 'rest') connectREST('http://localhost:3000/api');
|
|
1158
|
+
else if (source === 'websocket') connectWebSocket('ws://localhost:3001');
|
|
1159
|
+
else if (source === 'mqtt') connectMQTT('ws://localhost:9001');
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function setRefreshRate(ms) {
|
|
1163
|
+
state.refreshRate = ms;
|
|
1164
|
+
if (state.monitoringActive) {
|
|
1165
|
+
stopMonitoring();
|
|
1166
|
+
startMonitoring();
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ============================================================================
|
|
1171
|
+
// PUBLIC API
|
|
1172
|
+
// ============================================================================
|
|
1173
|
+
|
|
1174
|
+
return {
|
|
1175
|
+
init(scene, camera) {
|
|
1176
|
+
state.scene = scene;
|
|
1177
|
+
state.camera = camera;
|
|
1178
|
+
|
|
1179
|
+
// Create example cycleWASH sensors
|
|
1180
|
+
createSensor('temp-motor', 'Motor Temperature', 'temperature', new THREE.Vector3(2, 1, 0), 'motor', 70, 90);
|
|
1181
|
+
createSensor('vibr-brush', 'Brush Vibration', 'vibration', new THREE.Vector3(-2, 0, 1), 'brush', 10, 25);
|
|
1182
|
+
createSensor('press-water', 'Water Pressure', 'pressure', new THREE.Vector3(0, 2, -1), 'pump', 6, 9);
|
|
1183
|
+
createSensor('current-motor', 'Motor Current', 'current', new THREE.Vector3(1, 1, 1), 'motor', 30, 45);
|
|
1184
|
+
},
|
|
1185
|
+
|
|
1186
|
+
getUI,
|
|
1187
|
+
execute(command, params) {
|
|
1188
|
+
if (command === 'update') {
|
|
1189
|
+
updateSensorValue(params.sensorId, params.value);
|
|
1190
|
+
} else if (command === 'addSensor') {
|
|
1191
|
+
return createSensor(params.id, params.name, params.type, params.position, params.partId, params.min, params.max);
|
|
1192
|
+
} else if (command === 'getReport') {
|
|
1193
|
+
return generateMaintenanceReport();
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
|
|
1197
|
+
connectSensor(sensorId, position, partId) {
|
|
1198
|
+
const sensor = state.sensors.get(sensorId);
|
|
1199
|
+
if (sensor) {
|
|
1200
|
+
sensor.position.copy(position);
|
|
1201
|
+
sensor.partId = partId;
|
|
1202
|
+
updateVisualization(sensorId);
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
|
|
1206
|
+
addOverlay(type) {
|
|
1207
|
+
if (type === 'heatmap') {
|
|
1208
|
+
updateHeatmapOverlay();
|
|
1209
|
+
}
|
|
1210
|
+
},
|
|
1211
|
+
|
|
1212
|
+
startMonitoring,
|
|
1213
|
+
stopMonitoring,
|
|
1214
|
+
setDataSource,
|
|
1215
|
+
setRefreshRate,
|
|
1216
|
+
switchTab,
|
|
1217
|
+
updateSensorList,
|
|
1218
|
+
addSensorUI,
|
|
1219
|
+
removeSensor,
|
|
1220
|
+
exportAnalytics,
|
|
1221
|
+
|
|
1222
|
+
// Internal state access (debug)
|
|
1223
|
+
_state: () => state
|
|
1224
|
+
};
|
|
1225
|
+
})();
|