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.
@@ -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
+ })();