@wandelbots/wandelbots-js-react-components 2.29.0 → 2.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wandelbots/wandelbots-js-react-components",
3
- "version": "2.29.0",
3
+ "version": "2.30.0",
4
4
  "description": "React UI toolkit for building applications on top of the Wandelbots platform",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -138,6 +138,7 @@
138
138
  },
139
139
  "dependencies": {
140
140
  "@monaco-editor/react": "^4.7.0",
141
+ "@mui/x-charts": "^8.9.0",
141
142
  "@shikijs/monaco": "^3.1.0",
142
143
  "i18next-browser-languagedetector": "^8.0.4",
143
144
  "lodash-es": "^4.17.21",
@@ -0,0 +1,490 @@
1
+ import { Box, Typography, useTheme } from "@mui/material"
2
+ import { Gauge } from "@mui/x-charts/Gauge"
3
+ import { observer } from "mobx-react-lite"
4
+ import { useCallback, useEffect, useRef, useState } from "react"
5
+ import { useTranslation } from "react-i18next"
6
+ import { externalizeComponent } from "../externalizeComponent"
7
+ import { useInterpolation } from "./utils/interpolation"
8
+
9
+ export interface CycleTimerProps {
10
+ /**
11
+ * Callback that receives the timer control functions:
12
+ * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
13
+ * - `pause()` - Pause the countdown while preserving remaining time
14
+ * - `resume()` - Resume countdown from where it was paused
15
+ * - `isPaused()` - Check current pause state
16
+ */
17
+ onCycleComplete: (controls: {
18
+ startNewCycle: (maxTimeSeconds: number, elapsedSeconds?: number) => void
19
+ pause: () => void
20
+ resume: () => void
21
+ isPaused: () => boolean
22
+ }) => void
23
+ /** Callback fired when a cycle actually completes (reaches zero) */
24
+ onCycleEnd?: () => void
25
+ /** Whether the timer should start automatically when maxTime is set */
26
+ autoStart?: boolean
27
+ /** Visual variant of the timer */
28
+ variant?: "default" | "small"
29
+ /** For small variant: whether to show remaining time details (compact hides them) */
30
+ compact?: boolean
31
+ /** Additional CSS classes */
32
+ className?: string
33
+ }
34
+
35
+ /**
36
+ * A circular gauge timer component that shows the remaining time of a cycle
37
+ *
38
+ * Features:
39
+ * - Circular gauge with 264px diameter and 40px thickness
40
+ * - Shows remaining time prominently in the center (60px font)
41
+ * - Displays "remaining time" label at top and total time at bottom
42
+ * - Automatically counts down and triggers callback when reaching zero
43
+ * - Full timer control: start, pause, resume functionality
44
+ * - Support for starting with elapsed time (resume mid-cycle)
45
+ * - Smooth spring-based progress animations for all state transitions
46
+ * - Fully localized with i18next
47
+ * - Material-UI theming integration
48
+ * - Small variant with animated progress icon (gauge border only) next to text
49
+ *
50
+ * @param onCycleComplete - Callback that receives timer control functions
51
+ * @param onCycleEnd - Optional callback fired when a cycle actually completes (reaches zero)
52
+ * @param autoStart - Whether to start timer automatically (default: true)
53
+ * @param variant - Visual variant: "default" (large gauge) or "small" (animated icon with text)
54
+ * @param compact - For small variant: whether to hide remaining time details
55
+ * @param className - Additional CSS classes
56
+ *
57
+ * Usage:
58
+ * ```tsx
59
+ * <CycleTimer
60
+ * onCycleComplete={(controls) => {
61
+ * // Start a 5-minute cycle
62
+ * controls.startNewCycle(300)
63
+ *
64
+ * // Or start a 5-minute cycle with 2 minutes already elapsed
65
+ * controls.startNewCycle(300, 120)
66
+ *
67
+ * // Pause the timer
68
+ * controls.pause()
69
+ *
70
+ * // Resume the timer
71
+ * controls.resume()
72
+ *
73
+ * // Check if paused
74
+ * const paused = controls.isPaused()
75
+ * }}
76
+ * onCycleEnd={() => console.log('Cycle completed!')}
77
+ * />
78
+ * ```
79
+ *
80
+ * Control Functions:
81
+ * - `startNewCycle(maxTimeSeconds, elapsedSeconds?)` - Start a new timer cycle
82
+ * - `pause()` - Pause the countdown while preserving remaining time
83
+ * - `resume()` - Resume countdown from where it was paused
84
+ * - `isPaused()` - Check current pause state
85
+ */
86
+ export const CycleTimer = externalizeComponent(
87
+ observer(
88
+ ({
89
+ onCycleComplete,
90
+ onCycleEnd,
91
+ autoStart = true,
92
+ variant = "default",
93
+ compact = false,
94
+ className,
95
+ }: CycleTimerProps) => {
96
+ const theme = useTheme()
97
+ const { t } = useTranslation()
98
+ const [remainingTime, setRemainingTime] = useState(0)
99
+ const [maxTime, setMaxTime] = useState(0)
100
+ const [isRunning, setIsRunning] = useState(false)
101
+ const [isPausedState, setIsPausedState] = useState(false)
102
+ const [currentProgress, setCurrentProgress] = useState(0)
103
+ const animationRef = useRef<number | null>(null)
104
+ const startTimeRef = useRef<number | null>(null)
105
+ const pausedTimeRef = useRef<number>(0)
106
+
107
+ // Spring-based interpolator for smooth gauge progress animations
108
+ // Uses physics simulation to create natural, smooth transitions between progress values
109
+ const [progressInterpolator] = useInterpolation([0], {
110
+ tension: 80, // Higher values = faster, more responsive animations
111
+ friction: 18, // Higher values = more damping, less bouncy animations
112
+ onChange: ([progress]) => {
113
+ setCurrentProgress(progress)
114
+ },
115
+ })
116
+
117
+ const startNewCycle = useCallback(
118
+ (maxTimeSeconds: number, elapsedSeconds: number = 0) => {
119
+ setMaxTime(maxTimeSeconds)
120
+ const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
121
+ setRemainingTime(remainingSeconds)
122
+ setIsPausedState(false)
123
+ pausedTimeRef.current = 0
124
+
125
+ // Animate progress smoothly to starting position
126
+ // For new cycles (no elapsed time), animate from current position to 0%
127
+ // For resumed cycles, animate to the appropriate progress percentage
128
+ const initialProgress =
129
+ elapsedSeconds > 0 ? (elapsedSeconds / maxTimeSeconds) * 100 : 0
130
+ if (elapsedSeconds === 0) {
131
+ progressInterpolator.setTarget([0])
132
+ } else {
133
+ progressInterpolator.setTarget([initialProgress])
134
+ }
135
+
136
+ if (remainingSeconds === 0) {
137
+ setIsRunning(false)
138
+ startTimeRef.current = null
139
+ // Trigger completion callback immediately if time is already up
140
+ if (onCycleEnd) {
141
+ setTimeout(() => onCycleEnd(), 0)
142
+ }
143
+ } else if (autoStart) {
144
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
145
+ setIsRunning(true)
146
+ } else {
147
+ startTimeRef.current = null
148
+ }
149
+ },
150
+ [autoStart, onCycleEnd, progressInterpolator],
151
+ )
152
+
153
+ const pause = useCallback(() => {
154
+ if (startTimeRef.current && isRunning) {
155
+ const now = Date.now()
156
+ const additionalElapsed = now - startTimeRef.current
157
+ pausedTimeRef.current += additionalElapsed
158
+
159
+ // Calculate exact progress position and smoothly animate to it when pausing
160
+ // This ensures the visual progress matches the actual elapsed time
161
+ const totalElapsed = pausedTimeRef.current / 1000
162
+ const exactProgress = Math.min(100, (totalElapsed / maxTime) * 100)
163
+ progressInterpolator.setTarget([exactProgress])
164
+ }
165
+ setIsRunning(false)
166
+ setIsPausedState(true)
167
+ }, [isRunning, maxTime, progressInterpolator])
168
+
169
+ const resume = useCallback(() => {
170
+ if (isPausedState && remainingTime > 0) {
171
+ startTimeRef.current = Date.now()
172
+ setIsRunning(true)
173
+ setIsPausedState(false)
174
+ }
175
+ }, [isPausedState, remainingTime])
176
+
177
+ const isPaused = useCallback(() => {
178
+ return isPausedState
179
+ }, [isPausedState])
180
+
181
+ // Call onCycleComplete immediately to provide the timer control functions
182
+ useEffect(() => {
183
+ let isMounted = true
184
+ const timeoutId = setTimeout(() => {
185
+ if (isMounted) {
186
+ onCycleComplete({
187
+ startNewCycle,
188
+ pause,
189
+ resume,
190
+ isPaused,
191
+ })
192
+ }
193
+ }, 0)
194
+
195
+ return () => {
196
+ isMounted = false
197
+ clearTimeout(timeoutId)
198
+ }
199
+ }, [onCycleComplete, startNewCycle, pause, resume, isPaused])
200
+
201
+ useEffect(() => {
202
+ if (isRunning) {
203
+ // Single animation frame loop that handles both time updates and progress
204
+ const updateTimer = () => {
205
+ if (startTimeRef.current && maxTime > 0) {
206
+ const now = Date.now()
207
+ const elapsed =
208
+ (now - startTimeRef.current + pausedTimeRef.current) / 1000
209
+ const remaining = Math.max(0, maxTime - elapsed)
210
+
211
+ // Update remaining time based on timestamp calculation
212
+ setRemainingTime(Math.ceil(remaining))
213
+
214
+ // Smoothly animate progress based on elapsed time for fluid visual feedback
215
+ const progress = Math.min(100, (elapsed / maxTime) * 100)
216
+ progressInterpolator.setTarget([progress])
217
+
218
+ if (remaining <= 0) {
219
+ setIsRunning(false)
220
+ startTimeRef.current = null
221
+ setRemainingTime(0)
222
+ // Animate to 100% completion with smooth spring transition
223
+ progressInterpolator.setTarget([100])
224
+ // Call onCycleEnd when timer reaches zero to notify about completion
225
+ if (onCycleEnd) {
226
+ setTimeout(() => onCycleEnd(), 0)
227
+ }
228
+ return
229
+ }
230
+
231
+ // Continue animation loop while running
232
+ if (isRunning) {
233
+ animationRef.current = requestAnimationFrame(updateTimer)
234
+ }
235
+ }
236
+ }
237
+
238
+ animationRef.current = requestAnimationFrame(updateTimer)
239
+ } else {
240
+ if (animationRef.current) {
241
+ cancelAnimationFrame(animationRef.current)
242
+ animationRef.current = null
243
+ }
244
+ }
245
+
246
+ return () => {
247
+ if (animationRef.current) {
248
+ cancelAnimationFrame(animationRef.current)
249
+ }
250
+ }
251
+ }, [isRunning, onCycleEnd, maxTime, progressInterpolator])
252
+
253
+ // Dedicated animation loop for spring physics interpolation
254
+ // Runs at 60fps to ensure smooth progress animations independent of timer updates
255
+ useEffect(() => {
256
+ let interpolationAnimationId: number | null = null
257
+
258
+ const animateInterpolation = () => {
259
+ progressInterpolator.update(1 / 60) // 60fps interpolation
260
+ interpolationAnimationId = requestAnimationFrame(animateInterpolation)
261
+ }
262
+
263
+ interpolationAnimationId = requestAnimationFrame(animateInterpolation)
264
+
265
+ return () => {
266
+ if (interpolationAnimationId) {
267
+ cancelAnimationFrame(interpolationAnimationId)
268
+ }
269
+ }
270
+ }, [progressInterpolator])
271
+
272
+ // Keep interpolator synchronized with static progress when timer is stopped
273
+ // Ensures correct visual state when component initializes or timer stops
274
+ useEffect(() => {
275
+ if (!isRunning && !isPausedState && maxTime > 0) {
276
+ const staticProgress = ((maxTime - remainingTime) / maxTime) * 100
277
+ progressInterpolator.setTarget([staticProgress])
278
+ }
279
+ }, [
280
+ isRunning,
281
+ isPausedState,
282
+ maxTime,
283
+ remainingTime,
284
+ progressInterpolator,
285
+ ])
286
+
287
+ const formatTime = (seconds: number): string => {
288
+ const minutes = Math.floor(seconds / 60)
289
+ const remainingSeconds = seconds % 60
290
+ return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
291
+ }
292
+
293
+ // Use interpolated progress value for smooth gauge animations
294
+ const progressValue = currentProgress
295
+
296
+ // Small variant: horizontal layout with gauge icon and text
297
+ if (variant === "small") {
298
+ return (
299
+ <Box
300
+ className={className}
301
+ sx={{
302
+ display: "flex",
303
+ alignItems: "center",
304
+ gap: 0.125, // Minimal gap - 1px
305
+ }}
306
+ >
307
+ {/* Animated progress gauge icon */}
308
+ <Box
309
+ sx={{
310
+ position: "relative",
311
+ width: 40,
312
+ height: 40,
313
+ display: "flex",
314
+ alignItems: "center",
315
+ justifyContent: "center",
316
+ borderRadius: "50%",
317
+ overflow: "visible",
318
+ }}
319
+ >
320
+ <Gauge
321
+ width={40}
322
+ height={40}
323
+ value={progressValue}
324
+ valueMin={0}
325
+ valueMax={100}
326
+ innerRadius="70%"
327
+ outerRadius="95%"
328
+ skipAnimation={true}
329
+ sx={{
330
+ opacity: isPausedState ? 0.6 : 1,
331
+ transition: "opacity 0.2s ease",
332
+ [`& .MuiGauge-valueArc`]: {
333
+ fill: theme.palette.success.main,
334
+ },
335
+ [`& .MuiGauge-referenceArc`]: {
336
+ fill: theme.palette.success.main,
337
+ opacity: 0.3,
338
+ },
339
+ [`& .MuiGauge-valueText`]: {
340
+ display: "none",
341
+ },
342
+ [`& .MuiGauge-text`]: {
343
+ display: "none",
344
+ },
345
+ [`& text`]: {
346
+ display: "none",
347
+ },
348
+ // Hide any inner circle elements that might flash
349
+ [`& .MuiGauge-referenceArcBackground`]: {
350
+ display: "none",
351
+ },
352
+ [`& .MuiGauge-valueArcBackground`]: {
353
+ display: "none",
354
+ },
355
+ [`& circle`]: {
356
+ display: "none",
357
+ },
358
+ }}
359
+ />
360
+
361
+ {/* Inner circle overlay to prevent flashing */}
362
+ <Box
363
+ sx={{
364
+ position: "absolute",
365
+ top: "50%",
366
+ left: "50%",
367
+ transform: "translate(-50%, -50%)",
368
+ width: 13,
369
+ height: 13,
370
+ borderRadius: "50%",
371
+ backgroundColor: theme.palette.background?.paper || "white",
372
+ pointerEvents: "none",
373
+ }}
374
+ />
375
+ </Box>
376
+
377
+ {/* Timer text display */}
378
+ <Typography
379
+ variant="body2"
380
+ sx={{
381
+ color: theme.palette.text.primary,
382
+ fontSize: "14px",
383
+ }}
384
+ >
385
+ {compact
386
+ ? // Compact mode: show only remaining time
387
+ formatTime(remainingTime)
388
+ : // Full mode: show "remaining / of total min." format
389
+ `${formatTime(remainingTime)} / ${t("CycleTimer.Time.lb", { time: formatTime(maxTime) })}`}
390
+ </Typography>
391
+ </Box>
392
+ )
393
+ }
394
+
395
+ // Default variant: large circular gauge with centered content
396
+ return (
397
+ <Box
398
+ className={className}
399
+ sx={{
400
+ position: "relative",
401
+ width: 264,
402
+ height: 264,
403
+ display: "flex",
404
+ alignItems: "center",
405
+ justifyContent: "center",
406
+ }}
407
+ >
408
+ <Gauge
409
+ width={264}
410
+ height={264}
411
+ value={progressValue}
412
+ valueMin={0}
413
+ valueMax={100}
414
+ innerRadius="71%"
415
+ outerRadius="90%"
416
+ skipAnimation={true}
417
+ sx={{
418
+ opacity: isPausedState ? 0.6 : 1,
419
+ transition: "opacity 0.2s ease",
420
+ [`& .MuiGauge-valueArc`]: {
421
+ fill: theme.palette.success.main,
422
+ },
423
+ [`& .MuiGauge-referenceArc`]: {
424
+ fill: "white",
425
+ stroke: "transparent",
426
+ },
427
+ }}
428
+ />
429
+
430
+ {/* Center content overlay with timer information */}
431
+ <Box
432
+ sx={{
433
+ position: "absolute",
434
+ top: "50%",
435
+ left: "50%",
436
+ transform: "translate(-50%, -50%)",
437
+ width: 187, // 71% of 264 = ~187px inner radius
438
+ height: 187,
439
+ borderRadius: "50%",
440
+ backgroundColor: theme.palette.backgroundPaperElevation?.[8],
441
+ display: "flex",
442
+ flexDirection: "column",
443
+ alignItems: "center",
444
+ justifyContent: "center",
445
+ textAlign: "center",
446
+ gap: 1,
447
+ }}
448
+ >
449
+ {/* "remaining time" label */}
450
+ <Typography
451
+ variant="body2"
452
+ sx={{
453
+ fontSize: "12px",
454
+ color: theme.palette.text.secondary,
455
+ marginBottom: 0.5,
456
+ }}
457
+ >
458
+ {t("CycleTimer.RemainingTime.lb")}
459
+ </Typography>
460
+
461
+ {/* Main timer display */}
462
+ <Typography
463
+ variant="h1"
464
+ sx={{
465
+ fontSize: "48px",
466
+ fontWeight: 500,
467
+ color: theme.palette.text.primary,
468
+ lineHeight: 1,
469
+ marginBottom: 0.5,
470
+ }}
471
+ >
472
+ {formatTime(remainingTime)}
473
+ </Typography>
474
+
475
+ {/* Total time display */}
476
+ <Typography
477
+ variant="body2"
478
+ sx={{
479
+ fontSize: "12px",
480
+ color: theme.palette.text.secondary,
481
+ }}
482
+ >
483
+ {t("CycleTimer.OfTime.lb", { time: formatTime(maxTime) })}
484
+ </Typography>
485
+ </Box>
486
+ </Box>
487
+ )
488
+ },
489
+ ),
490
+ )
@@ -44,6 +44,9 @@
44
44
  "Jogging.Cartesian.bt": "Kartesisch",
45
45
  "Jogging.Joints.bt": "Gelenke",
46
46
  "Jogging.Velocity.bt": "Geschwindigkeit",
47
+ "CycleTimer.RemainingTime.lb": "Verbleibende Zeit",
48
+ "CycleTimer.OfTime.lb": "von {{time}} min.",
49
+ "CycleTimer.Time.lb": "{{time}} min.",
47
50
  "ProgramControl.Start.bt": "Start",
48
51
  "ProgramControl.Resume.bt": "Weiter",
49
52
  "ProgramControl.Pause.bt": "Pause",
@@ -45,6 +45,9 @@
45
45
  "Jogging.Cartesian.bt": "Cartesian",
46
46
  "Jogging.Joints.bt": "Joints",
47
47
  "Jogging.Velocity.bt": "Velocity",
48
+ "CycleTimer.RemainingTime.lb": "Time remaining",
49
+ "CycleTimer.OfTime.lb": "of {{time}} min.",
50
+ "CycleTimer.Time.lb": "{{time}} min.",
48
51
  "ProgramControl.Start.bt": "Start",
49
52
  "ProgramControl.Resume.bt": "Resume",
50
53
  "ProgramControl.Pause.bt": "Pause",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./components/3d-viewport/PresetEnvironment"
2
2
  export * from "./components/3d-viewport/SafetyZonesRenderer"
3
3
  export * from "./components/3d-viewport/TrajectoryRenderer"
4
+ export * from "./components/CycleTimer"
4
5
  export * from "./components/jogging/JoggingCartesianAxisControl"
5
6
  export * from "./components/jogging/JoggingJointRotationControl"
6
7
  export * from "./components/jogging/JoggingPanel"