@wandelbots/wandelbots-js-react-components 2.29.0-pr.feature-add-cycle-timer.368.3937cdc → 2.29.0-pr.feature-add-cycle-timer.368.eb856cf

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-pr.feature-add-cycle-timer.368.3937cdc",
3
+ "version": "2.29.0-pr.feature-add-cycle-timer.368.eb856cf",
4
4
  "description": "React UI toolkit for building applications on top of the Wandelbots platform",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -4,6 +4,7 @@ import { observer } from "mobx-react-lite"
4
4
  import { useCallback, useEffect, useRef, useState } from "react"
5
5
  import { useTranslation } from "react-i18next"
6
6
  import { externalizeComponent } from "../externalizeComponent"
7
+ import { useInterpolation } from "./utils/interpolation"
7
8
 
8
9
  export interface CycleTimerProps {
9
10
  /**
@@ -41,6 +42,7 @@ export interface CycleTimerProps {
41
42
  * - Automatically counts down and triggers callback when reaching zero
42
43
  * - Full timer control: start, pause, resume functionality
43
44
  * - Support for starting with elapsed time (resume mid-cycle)
45
+ * - Smooth spring-based progress animations for all state transitions
44
46
  * - Fully localized with i18next
45
47
  * - Material-UI theming integration
46
48
  * - Small variant with animated progress icon (gauge border only) next to text
@@ -97,44 +99,76 @@ export const CycleTimer = externalizeComponent(
97
99
  const [maxTime, setMaxTime] = useState(0)
98
100
  const [isRunning, setIsRunning] = useState(false)
99
101
  const [isPausedState, setIsPausedState] = useState(false)
100
- const intervalRef = useRef<NodeJS.Timeout | null>(null)
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
+ })
101
116
 
102
117
  const startNewCycle = useCallback(
103
118
  (maxTimeSeconds: number, elapsedSeconds: number = 0) => {
104
- console.log(
105
- `Starting new cycle with ${maxTimeSeconds} seconds (${elapsedSeconds} seconds already elapsed)`,
106
- )
107
119
  setMaxTime(maxTimeSeconds)
108
120
  const remainingSeconds = Math.max(0, maxTimeSeconds - elapsedSeconds)
109
121
  setRemainingTime(remainingSeconds)
110
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
+ }
111
135
 
112
136
  if (remainingSeconds === 0) {
113
- console.log("Cycle already completed (elapsed time >= max time)")
114
137
  setIsRunning(false)
138
+ startTimeRef.current = null
115
139
  // Trigger completion callback immediately if time is already up
116
140
  if (onCycleEnd) {
117
141
  setTimeout(() => onCycleEnd(), 0)
118
142
  }
119
143
  } else if (autoStart) {
120
- console.log("Auto-start enabled, starting timer")
144
+ startTimeRef.current = Date.now() - elapsedSeconds * 1000
121
145
  setIsRunning(true)
122
146
  } else {
123
- console.log("Auto-start disabled, timer set but not started")
147
+ startTimeRef.current = null
124
148
  }
125
149
  },
126
- [autoStart, onCycleEnd],
150
+ [autoStart, onCycleEnd, progressInterpolator],
127
151
  )
128
152
 
129
153
  const pause = useCallback(() => {
130
- console.log("Pausing timer")
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
+ }
131
165
  setIsRunning(false)
132
166
  setIsPausedState(true)
133
- }, [])
167
+ }, [isRunning, maxTime, progressInterpolator])
134
168
 
135
169
  const resume = useCallback(() => {
136
170
  if (isPausedState && remainingTime > 0) {
137
- console.log("Resuming timer")
171
+ startTimeRef.current = Date.now()
138
172
  setIsRunning(true)
139
173
  setIsPausedState(false)
140
174
  }
@@ -165,34 +199,90 @@ export const CycleTimer = externalizeComponent(
165
199
  }, [onCycleComplete, startNewCycle, pause, resume, isPaused])
166
200
 
167
201
  useEffect(() => {
168
- if (isRunning && remainingTime > 0) {
169
- intervalRef.current = setInterval(() => {
170
- setRemainingTime((prev) => {
171
- if (prev <= 1) {
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) {
172
219
  setIsRunning(false)
220
+ startTimeRef.current = null
221
+ setRemainingTime(0)
222
+ // Animate to 100% completion with smooth spring transition
223
+ progressInterpolator.setTarget([100])
173
224
  // Call onCycleEnd when timer reaches zero to notify about completion
174
225
  if (onCycleEnd) {
175
226
  setTimeout(() => onCycleEnd(), 0)
176
227
  }
177
- console.log("Cycle completed! Timer reached zero.")
178
- return 0
228
+ return
179
229
  }
180
- return prev - 1
181
- })
182
- }, 1000)
230
+
231
+ // Continue animation loop while running
232
+ if (isRunning) {
233
+ animationRef.current = requestAnimationFrame(updateTimer)
234
+ }
235
+ }
236
+ }
237
+
238
+ animationRef.current = requestAnimationFrame(updateTimer)
183
239
  } else {
184
- if (intervalRef.current) {
185
- clearInterval(intervalRef.current)
186
- intervalRef.current = null
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)
187
249
  }
188
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)
189
264
 
190
265
  return () => {
191
- if (intervalRef.current) {
192
- clearInterval(intervalRef.current)
266
+ if (interpolationAnimationId) {
267
+ cancelAnimationFrame(interpolationAnimationId)
193
268
  }
194
269
  }
195
- }, [isRunning, remainingTime, onCycleEnd])
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
+ ])
196
286
 
197
287
  const formatTime = (seconds: number): string => {
198
288
  const minutes = Math.floor(seconds / 60)
@@ -200,8 +290,8 @@ export const CycleTimer = externalizeComponent(
200
290
  return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
201
291
  }
202
292
 
203
- const progressValue =
204
- maxTime > 0 ? ((maxTime - remainingTime) / maxTime) * 100 : 0
293
+ // Use interpolated progress value for smooth gauge animations
294
+ const progressValue = currentProgress
205
295
 
206
296
  // Small variant: horizontal layout with gauge icon and text
207
297
  if (variant === "small") {
@@ -235,7 +325,10 @@ export const CycleTimer = externalizeComponent(
235
325
  valueMax={100}
236
326
  innerRadius="70%"
237
327
  outerRadius="95%"
328
+ skipAnimation={true}
238
329
  sx={{
330
+ opacity: isPausedState ? 0.6 : 1,
331
+ transition: "opacity 0.2s ease",
239
332
  [`& .MuiGauge-valueArc`]: {
240
333
  fill: theme.palette.success.main,
241
334
  },
@@ -252,6 +345,31 @@ export const CycleTimer = externalizeComponent(
252
345
  [`& text`]: {
253
346
  display: "none",
254
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",
255
373
  }}
256
374
  />
257
375
  </Box>
@@ -295,7 +413,10 @@ export const CycleTimer = externalizeComponent(
295
413
  valueMax={100}
296
414
  innerRadius="71%"
297
415
  outerRadius="90%"
416
+ skipAnimation={true}
298
417
  sx={{
418
+ opacity: isPausedState ? 0.6 : 1,
419
+ transition: "opacity 0.2s ease",
299
420
  [`& .MuiGauge-valueArc`]: {
300
421
  fill: theme.palette.success.main,
301
422
  },