1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.material3.carousel
18 
19 import androidx.collection.FloatList
20 import androidx.collection.mutableFloatListOf
21 import androidx.compose.ui.util.fastFilter
22 import androidx.compose.ui.util.fastForEach
23 import androidx.compose.ui.util.fastMapIndexed
24 import androidx.compose.ui.util.lerp
25 import kotlin.math.abs
26 import kotlin.math.max
27 import kotlin.math.roundToInt
28 
29 /**
30  * An immutable class responsible for supplying carousel with a [KeylineList] that is corrected for
31  * scroll offset, layout direction, and snapping behaviors.
32  *
33  * @param defaultKeylines the keylines that define how items should be arranged in their default
34  *   state
35  * @param startKeylineSteps a list of [KeylineList]s that move the focal range from its position in
36  *   [defaultKeylines] to the start of the carousel container, one keyline at a time per step
37  * @param endKeylineSteps a list of [KeylineList]s that move the focal range from its position in
38  *   [defaultKeylines] to the end of the carousel container, one keyline at a time per step.
39  *   [endKeylineSteps] and whose value is the percentage of [endShiftDistance] that should be
40  *   scrolled when the end step is used.
41  * @param availableSpace the available space in the main axis
42  * @param itemSpacing the spacing between each item
43  * @param beforeContentPadding the padding preceding the first item in the list
44  * @param afterContentPadding the padding proceeding the last item in the list
45  */
46 internal class Strategy
47 private constructor(
48     val defaultKeylines: KeylineList,
49     val startKeylineSteps: List<KeylineList>,
50     val endKeylineSteps: List<KeylineList>,
51     val availableSpace: Float,
52     val itemSpacing: Float,
53     val beforeContentPadding: Float,
54     val afterContentPadding: Float,
55 ) {
56 
57     /**
58      * Creates a new [Strategy] for a keyline list and set of carousel container parameters.
59      *
60      * The [defaultKeylines] are a list of keylines that defines how items should be arranged, from
61      * left-to-right (or top-to-bottom), to achieve the carousel's desired appearance. For example,
62      * a start-aligned large item, followed by a medium and a small item for a multi-browse
63      * carousel. Or a small item, a center-aligned large item, and a small item for a centered hero
64      * carousel. This method will use the [defaultKeylines] to then derive new scroll and layout
65      * direction-aware [KeylineList]s to be used by carousel. For example, when a device is running
66      * in a right-to-left layout direction, Strategy will handle reversing the default
67      * [KeylineList]. Or if the default keylines use a center-aligned large item, Strategy will
68      * generate additional KeylineLists that handle shifting the large item to the start or end of
69      * the screen when the carousel is scrolled to the start or end of the list, letting all items
70      * become large without having them detach from the edges of the scroll container.
71      *
72      * @param defaultKeylines a default [KeylineList] that represents the arrangement of items in a
73      *   left-to-right (or top-to-bottom) layout.
74      * @param availableSpace the size of the carousel container in scrolling axis
75      * @param beforeContentPadding the padding to add before the list content
76      * @param afterContentPadding the padding to add after the list content
77      */
78     constructor(
79         defaultKeylines: KeylineList,
80         availableSpace: Float,
81         itemSpacing: Float,
82         beforeContentPadding: Float,
83         afterContentPadding: Float
84     ) : this(
85         defaultKeylines = defaultKeylines,
86         startKeylineSteps =
87             getStartKeylineSteps(
88                 defaultKeylines,
89                 availableSpace,
90                 itemSpacing,
91                 beforeContentPadding
92             ),
93         endKeylineSteps =
94             getEndKeylineSteps(defaultKeylines, availableSpace, itemSpacing, afterContentPadding),
95         availableSpace = availableSpace,
96         itemSpacing = itemSpacing,
97         beforeContentPadding = beforeContentPadding,
98         afterContentPadding = afterContentPadding,
99     )
100 
101     /** The scroll distance needed to move through all steps in [startKeylineSteps]. */
102     private val startShiftDistance = getStartShiftDistance(startKeylineSteps, beforeContentPadding)
103     /** The scroll distance needed to move through all steps in [endKeylineSteps]. */
104     private val endShiftDistance = getEndShiftDistance(endKeylineSteps, afterContentPadding)
105     /**
106      * A list of floats whose index aligns with a [KeylineList] from [startKeylineSteps] and whose
107      * value is the percentage of [startShiftDistance] that should be scrolled when the start step
108      * is used.
109      */
110     private val startShiftPoints =
111         getStepInterpolationPoints(startShiftDistance, startKeylineSteps, true)
112     /**
113      * A list of floats whose index aligns with a [KeylineList] from [endKeylineSteps] and whose
114      * value is the percentage of [endShiftDistance] that should be scrolled when the end step is
115      * used.
116      */
117     private val endShiftPoints =
118         getStepInterpolationPoints(endShiftDistance, endKeylineSteps, false)
119 
120     /** The size of items when in focus and fully unmasked. */
121     val itemMainAxisSize: Float
122         get() = defaultKeylines.firstFocal.size
123 
124     /** True if this strategy contains a valid arrangement of keylines for a valid container */
125     val isValid: Boolean =
126         defaultKeylines.isNotEmpty() && availableSpace != 0f && itemMainAxisSize != 0f
127 
128     /**
129      * Returns the [KeylineList] that should be used for the current [scrollOffset].
130      *
131      * @param scrollOffset the current scroll offset of the scrollable component
132      * @param maxScrollOffset the maximum scroll offset
133      * @param roundToNearestStep true if the KeylineList returned should be a complete shift step
134      */
135     internal fun getKeylineListForScrollOffset(
136         scrollOffset: Float,
137         maxScrollOffset: Float,
138         roundToNearestStep: Boolean = false
139     ): KeylineList {
140         // The scroll offset could sometimes be slightly negative due to rounding; it should always
141         // be positive
142         val positiveScrollOffset = max(0f, scrollOffset)
143         val startShiftOffset = startShiftDistance
144         val endShiftOffset = max(0f, maxScrollOffset - endShiftDistance)
145 
146         // If we're not within either shift range, return the default keylines
147         if (positiveScrollOffset in startShiftOffset..endShiftOffset) {
148             return defaultKeylines
149         }
150 
151         var interpolation =
152             lerp(
153                 outputMin = 1f,
154                 outputMax = 0f,
155                 inputMin = 0f,
156                 inputMax = startShiftOffset,
157                 value = positiveScrollOffset
158             )
159         var shiftPoints = startShiftPoints
160         var steps = startKeylineSteps
161 
162         if (positiveScrollOffset > endShiftOffset) {
163             interpolation =
164                 lerp(
165                     outputMin = 0f,
166                     outputMax = 1f,
167                     inputMin = endShiftOffset,
168                     inputMax = maxScrollOffset,
169                     value = positiveScrollOffset
170                 )
171             shiftPoints = endShiftPoints
172             steps = endKeylineSteps
173         }
174 
175         val shiftPointRange = getShiftPointRange(steps.size, shiftPoints, interpolation)
176 
177         if (roundToNearestStep) {
178             val roundedStepIndex =
179                 if (shiftPointRange.steppedInterpolation.roundToInt() == 0) {
180                     shiftPointRange.fromStepIndex
181                 } else {
182                     shiftPointRange.toStepIndex
183                 }
184             return steps[roundedStepIndex]
185         }
186 
187         return lerp(
188             steps[shiftPointRange.fromStepIndex],
189             steps[shiftPointRange.toStepIndex],
190             shiftPointRange.steppedInterpolation
191         )
192     }
193 
194     override fun equals(other: Any?): Boolean {
195         if (this === other) return true
196         if (other !is Strategy) return false
197 
198         // If neither strategy is valid, they should be considered equal
199         if (!isValid && !other.isValid) return true
200 
201         if (isValid != other.isValid) return false
202         if (availableSpace != other.availableSpace) return false
203         if (itemSpacing != other.itemSpacing) return false
204         if (beforeContentPadding != other.beforeContentPadding) return false
205         if (afterContentPadding != other.afterContentPadding) return false
206         if (itemMainAxisSize != other.itemMainAxisSize) return false
207         if (startShiftDistance != other.startShiftDistance) return false
208         if (endShiftDistance != other.endShiftDistance) return false
209         if (startShiftPoints != other.startShiftPoints) return false
210         if (endShiftPoints != other.endShiftPoints) return false
211         // Only check default keyline equality since all other keylines are
212         // derived from the defaults
213         if (defaultKeylines != other.defaultKeylines) return false
214 
215         return true
216     }
217 
218     override fun hashCode(): Int {
219         if (!isValid) return isValid.hashCode()
220 
221         var result = isValid.hashCode()
222         result = 31 * result + availableSpace.hashCode()
223         result = 31 * result + itemSpacing.hashCode()
224         result = 31 * result + beforeContentPadding.hashCode()
225         result = 31 * result + afterContentPadding.hashCode()
226         result = 31 * result + itemMainAxisSize.hashCode()
227         result = 31 * result + startShiftDistance.hashCode()
228         result = 31 * result + endShiftDistance.hashCode()
229         result = 31 * result + startShiftPoints.hashCode()
230         result = 31 * result + endShiftPoints.hashCode()
231         result = 31 * result + defaultKeylines.hashCode()
232         return result
233     }
234 
235     companion object {
236         val Empty =
237             Strategy(
238                 defaultKeylines = emptyKeylineList(),
239                 startKeylineSteps = emptyList(),
240                 endKeylineSteps = emptyList(),
241                 availableSpace = 0f,
242                 itemSpacing = 0f,
243                 beforeContentPadding = 0f,
244                 afterContentPadding = 0f,
245             )
246     }
247 }
248 
249 /**
250  * Returns the total scroll offset needed to move through the entire list of [startKeylineSteps].
251  */
getStartShiftDistancenull252 private fun getStartShiftDistance(
253     startKeylineSteps: List<KeylineList>,
254     beforeContentPadding: Float
255 ): Float {
256     if (startKeylineSteps.isEmpty()) return 0f
257     return max(
258         startKeylineSteps.last().first().unadjustedOffset -
259             startKeylineSteps.first().first().unadjustedOffset,
260         beforeContentPadding
261     )
262 }
263 
264 /** Returns the total scroll offset needed to move through the entire list of [endKeylineSteps]. */
getEndShiftDistancenull265 private fun getEndShiftDistance(
266     endKeylineSteps: List<KeylineList>,
267     afterContentPadding: Float
268 ): Float {
269     if (endKeylineSteps.isEmpty()) return 0f
270     return max(
271         endKeylineSteps.first().last().unadjustedOffset -
272             endKeylineSteps.last().last().unadjustedOffset,
273         afterContentPadding
274     )
275 }
276 
277 /**
278  * Generates discreet steps which move the focal range from its original position until it reaches
279  * the start of the carousel container.
280  *
281  * Each step can only move the focal range by one keyline at a time to ensure every item in the list
282  * passes through the focal range. Each step removes the keyline at the start of the container and
283  * re-inserts it after the focal range in an order that retains visual balance. This is repeated
284  * until the first focal keyline is at the start of the container. Re-inserting keylines after the
285  * focal range in a balanced way is done by looking at the size of they keyline next to the keyline
286  * that is being re-positioned and finding a match on the other side of the focal range.
287  *
288  * The first state in the returned list is always the default [KeylineList] while the last state
289  * will be the start state or the state that has the focal range at the beginning of the carousel.
290  */
getStartKeylineStepsnull291 private fun getStartKeylineSteps(
292     defaultKeylines: KeylineList,
293     carouselMainAxisSize: Float,
294     itemSpacing: Float,
295     beforeContentPadding: Float
296 ): List<KeylineList> {
297     if (defaultKeylines.isEmpty()) return emptyList()
298 
299     val steps: MutableList<KeylineList> = mutableListOf()
300     steps.add(defaultKeylines)
301 
302     if (defaultKeylines.isFirstFocalItemAtStartOfContainer()) {
303         if (beforeContentPadding != 0f) {
304             steps.add(
305                 createShiftedKeylineListForContentPadding(
306                     defaultKeylines,
307                     carouselMainAxisSize,
308                     itemSpacing,
309                     beforeContentPadding,
310                     defaultKeylines.firstFocal,
311                     defaultKeylines.firstFocalIndex
312                 )
313             )
314         }
315         return steps
316     }
317 
318     val startIndex = defaultKeylines.firstNonAnchorIndex
319     val endIndex = defaultKeylines.firstFocalIndex
320     val numberOfSteps = endIndex - startIndex
321 
322     // If there are no steps but we need to account for a cutoff, create a
323     // list of keylines shifted for the cutoff.
324     if (numberOfSteps <= 0 && defaultKeylines.firstFocal.cutoff > 0) {
325         steps.add(
326             moveKeylineAndCreateShiftedKeylineList(
327                 from = defaultKeylines,
328                 srcIndex = 0,
329                 dstIndex = 0,
330                 carouselMainAxisSize = carouselMainAxisSize,
331                 itemSpacing = itemSpacing
332             )
333         )
334         return steps
335     }
336 
337     var i = 0
338     while (i < numberOfSteps) {
339         val prevStep = steps.last()
340         val originalItemIndex = startIndex + i
341         var dstIndex = defaultKeylines.lastIndex
342         if (originalItemIndex > 0) {
343             val originalNeighborBeforeSize = defaultKeylines[originalItemIndex - 1].size
344             dstIndex = prevStep.firstIndexAfterFocalRangeWithSize(originalNeighborBeforeSize) - 1
345         }
346 
347         steps.add(
348             moveKeylineAndCreateShiftedKeylineList(
349                 from = prevStep,
350                 srcIndex = defaultKeylines.firstNonAnchorIndex,
351                 dstIndex = dstIndex,
352                 carouselMainAxisSize = carouselMainAxisSize,
353                 itemSpacing = itemSpacing
354             )
355         )
356         i++
357     }
358 
359     if (beforeContentPadding != 0f) {
360         steps[steps.lastIndex] =
361             createShiftedKeylineListForContentPadding(
362                 steps.last(),
363                 carouselMainAxisSize,
364                 itemSpacing,
365                 beforeContentPadding,
366                 steps.last().firstFocal,
367                 steps.last().firstFocalIndex
368             )
369     }
370 
371     return steps
372 }
373 
374 /**
375  * Generates discreet steps which move the focal range from its original position until it reaches
376  * the end of the carousel container.
377  *
378  * Each step can only move the focal range by one keyline at a time to ensure every item in the list
379  * passes through the focal range. Each step removes the keyline at the end of the container and
380  * re-inserts it before the focal range in an order that retains visual balance. This is repeated
381  * until the last focal keyline is at the start of the container. Re-inserting keylines before the
382  * focal range in a balanced way is done by looking at the size of they keyline next to the keyline
383  * that is being re-positioned and finding a match on the other side of the focal range.
384  *
385  * The first state in the returned list is always the default [KeylineList] while the last state
386  * will be the end state or the state that has the focal range at the end of the carousel.
387  */
getEndKeylineStepsnull388 private fun getEndKeylineSteps(
389     defaultKeylines: KeylineList,
390     carouselMainAxisSize: Float,
391     itemSpacing: Float,
392     afterContentPadding: Float
393 ): List<KeylineList> {
394     if (defaultKeylines.isEmpty()) return emptyList()
395     val steps: MutableList<KeylineList> = mutableListOf()
396     steps.add(defaultKeylines)
397 
398     if (defaultKeylines.isLastFocalItemAtEndOfContainer(carouselMainAxisSize)) {
399         if (afterContentPadding != 0f) {
400             steps.add(
401                 createShiftedKeylineListForContentPadding(
402                     defaultKeylines,
403                     carouselMainAxisSize,
404                     itemSpacing,
405                     -afterContentPadding,
406                     defaultKeylines.lastFocal,
407                     defaultKeylines.lastFocalIndex
408                 )
409             )
410         }
411         return steps
412     }
413 
414     val startIndex = defaultKeylines.lastFocalIndex
415     val endIndex = defaultKeylines.lastNonAnchorIndex
416     val numberOfSteps = endIndex - startIndex
417 
418     // If there are no steps but we need to account for a cutoff, create a
419     // list of keylines shifted for the cutoff.
420     if (numberOfSteps <= 0 && defaultKeylines.lastFocal.cutoff > 0) {
421         steps.add(
422             moveKeylineAndCreateShiftedKeylineList(
423                 from = defaultKeylines,
424                 srcIndex = 0,
425                 dstIndex = 0,
426                 carouselMainAxisSize = carouselMainAxisSize,
427                 itemSpacing = itemSpacing
428             )
429         )
430         return steps
431     }
432 
433     var i = 0
434     while (i < numberOfSteps) {
435         val prevStep = steps.last()
436         val originalItemIndex = endIndex - i
437         var dstIndex = 0
438 
439         if (originalItemIndex < defaultKeylines.lastIndex) {
440             val originalNeighborAfterSize = defaultKeylines[originalItemIndex + 1].size
441             dstIndex = prevStep.lastIndexBeforeFocalRangeWithSize(originalNeighborAfterSize) + 1
442         }
443 
444         val keylines =
445             moveKeylineAndCreateShiftedKeylineList(
446                 from = prevStep,
447                 srcIndex = defaultKeylines.lastNonAnchorIndex,
448                 dstIndex = dstIndex,
449                 carouselMainAxisSize = carouselMainAxisSize,
450                 itemSpacing = itemSpacing
451             )
452         steps.add(keylines)
453         i++
454     }
455 
456     if (afterContentPadding != 0f) {
457         steps[steps.lastIndex] =
458             createShiftedKeylineListForContentPadding(
459                 steps.last(),
460                 carouselMainAxisSize,
461                 itemSpacing,
462                 -afterContentPadding,
463                 steps.last().lastFocal,
464                 steps.last().lastFocalIndex
465             )
466     }
467 
468     return steps
469 }
470 
471 /**
472  * Returns a new [KeylineList] identical to [from] but with each keyline's offset shifted by
473  * [contentPadding].
474  */
createShiftedKeylineListForContentPaddingnull475 private fun createShiftedKeylineListForContentPadding(
476     from: KeylineList,
477     carouselMainAxisSize: Float,
478     itemSpacing: Float,
479     contentPadding: Float,
480     pivot: Keyline,
481     pivotIndex: Int
482 ): KeylineList {
483     val numberOfNonAnchorKeylines = from.fastFilter { !it.isAnchor }.count()
484     val sizeReduction = contentPadding / numberOfNonAnchorKeylines
485     // Let keylineListOf create a new keyline list with offsets adjusted for each item's
486     // reduction in size
487     val newKeylines =
488         keylineListOf(
489             carouselMainAxisSize = carouselMainAxisSize,
490             itemSpacing = itemSpacing,
491             pivotIndex = pivotIndex,
492             pivotOffset = pivot.offset - (sizeReduction / 2f) + contentPadding
493         ) {
494             from.fastForEach { k -> add(k.size - abs(sizeReduction), k.isAnchor) }
495         }
496 
497     // Then reset each item's unadjusted offset back to their original value from the
498     // incoming keyline list. This is necessary because Pager will still be laying out items
499     // end-to-end with the original page size and not the new reduced size.
500     return KeylineList(
501         newKeylines.fastMapIndexed { i, k -> k.copy(unadjustedOffset = from[i].unadjustedOffset) }
502     )
503 }
504 
505 /**
506  * Returns a new [KeylineList] where the keyline at [srcIndex] is moved to [dstIndex] and with
507  * updated pivot and offsets that reflect any change in focal shift.
508  */
moveKeylineAndCreateShiftedKeylineListnull509 private fun moveKeylineAndCreateShiftedKeylineList(
510     from: KeylineList,
511     srcIndex: Int,
512     dstIndex: Int,
513     carouselMainAxisSize: Float,
514     itemSpacing: Float
515 ): KeylineList {
516     // -1 if the pivot is shifting left/top, 1 if shifting right/bottom
517     val pivotDir = if (srcIndex > dstIndex) 1 else -1
518     val pivotDelta = (from[srcIndex].size - from[srcIndex].cutoff + itemSpacing) * pivotDir
519     val newPivotIndex = from.pivotIndex + pivotDir
520     val newPivotOffset = from.pivot.offset + pivotDelta
521     return keylineListOf(carouselMainAxisSize, itemSpacing, newPivotIndex, newPivotOffset) {
522         from.toMutableList().move(srcIndex, dstIndex).fastForEach { k -> add(k.size, k.isAnchor) }
523     }
524 }
525 
526 /**
527  * Creates and returns a list of float values containing points between 0 and 1 that represent
528  * interpolation values for when the [KeylineList] at the corresponding index in [steps] should be
529  * visible.
530  *
531  * For example, if [steps] has a size of 4, this method will return an array of 4 float values that
532  * could look like [0, .33, .66, 1]. When interpolating through a list of [KeylineList]s, an
533  * interpolation value will be between 0-1. This interpolation will be used to find the range it
534  * falls within from this method's returned value. If interpolation is .25, that would fall between
535  * the 0 and .33, the 0th and 1st indices of the float array. Meaning the 0th and 1st items from
536  * [steps] should be the current [KeylineList]s being interpolated. This is an example with equally
537  * distributed values but these values will typically be unequally distributed since their size
538  * depends on the distance keylines shift between each step.
539  *
540  * @param totalShiftDistance the total distance keylines will shift between the first and last
541  *   [KeylineList] of [steps]
542  * @param steps the steps to find interpolation points for
543  * @param isShiftingLeft true if this method should find interpolation points for shifting keylines
544  *   to the left/top of a carousel, false if this method should find interpolation points for
545  *   shifting keylines to the right/bottom of a carousel
546  * @return a list of floats, equal in size to [steps] that contains points between 0-1 that align
547  *   with when a [KeylineList] from [steps should be shown for a 0-1 interpolation value
548  * @see [lerp] for more details on how interpolation points are used
549  * @see [Strategy.getKeylineListForScrollOffset] for more details on how interpolation points are
550  *   used
551  */
getStepInterpolationPointsnull552 private fun getStepInterpolationPoints(
553     totalShiftDistance: Float,
554     steps: List<KeylineList>,
555     isShiftingLeft: Boolean
556 ): FloatList {
557     val points = mutableFloatListOf(0f)
558     if (totalShiftDistance == 0f || steps.isEmpty()) {
559         return points
560     }
561 
562     (1 until steps.size).map { i ->
563         val prevKeylines = steps[i - 1]
564         val currKeylines = steps[i]
565         val distanceShifted =
566             if (isShiftingLeft) {
567                 currKeylines.first().unadjustedOffset - prevKeylines.first().unadjustedOffset
568             } else {
569                 prevKeylines.last().unadjustedOffset - currKeylines.last().unadjustedOffset
570             }
571         val stepPercentage = distanceShifted / totalShiftDistance
572         val point = if (i == steps.lastIndex) 1f else points[i - 1] + stepPercentage
573         points.add(point)
574     }
575     return points
576 }
577 
578 private data class ShiftPointRange(
579     val fromStepIndex: Int,
580     val toStepIndex: Int,
581     val steppedInterpolation: Float
582 )
583 
getShiftPointRangenull584 private fun getShiftPointRange(
585     stepsCount: Int,
586     shiftPoint: FloatList,
587     interpolation: Float
588 ): ShiftPointRange {
589     var lowerBounds = shiftPoint[0]
590     (1 until stepsCount).forEach { i ->
591         val upperBounds = shiftPoint[i]
592         if (interpolation <= upperBounds) {
593             return ShiftPointRange(
594                 fromStepIndex = i - 1,
595                 toStepIndex = i,
596                 steppedInterpolation = lerp(0f, 1f, lowerBounds, upperBounds, interpolation)
597             )
598         }
599         lowerBounds = upperBounds
600     }
601     return ShiftPointRange(fromStepIndex = 0, toStepIndex = 0, steppedInterpolation = 0f)
602 }
603 
movenull604 private fun MutableList<Keyline>.move(srcIndex: Int, dstIndex: Int): MutableList<Keyline> {
605     val keyline = get(srcIndex)
606     removeAt(srcIndex)
607     add(dstIndex, keyline)
608     return this
609 }
610 
lerpnull611 private fun lerp(
612     outputMin: Float,
613     outputMax: Float,
614     inputMin: Float,
615     inputMax: Float,
616     value: Float
617 ): Float {
618     if (value <= inputMin) {
619         return outputMin
620     } else if (value >= inputMax) {
621         return outputMax
622     }
623 
624     return lerp(outputMin, outputMax, (value - inputMin) / (inputMax - inputMin))
625 }
626