@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/dist/components/CycleTimer.d.ts +1 -0
- package/dist/components/CycleTimer.d.ts.map +1 -1
- package/dist/index.cjs +45 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2815 -2732
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/CycleTimer.tsx +149 -28
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.
|
|
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
|
|
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
|
-
|
|
144
|
+
startTimeRef.current = Date.now() - elapsedSeconds * 1000
|
|
121
145
|
setIsRunning(true)
|
|
122
146
|
} else {
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
178
|
-
return 0
|
|
228
|
+
return
|
|
179
229
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 (
|
|
185
|
-
|
|
186
|
-
|
|
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 (
|
|
192
|
-
|
|
266
|
+
if (interpolationAnimationId) {
|
|
267
|
+
cancelAnimationFrame(interpolationAnimationId)
|
|
193
268
|
}
|
|
194
269
|
}
|
|
195
|
-
}, [
|
|
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
|
-
|
|
204
|
-
|
|
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
|
},
|