panelui-native 0.103.2 → 0.104.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,535 @@
1
+ /**
2
+ * Leaderboard — entries ranked by a value, with the top three on a podium.
3
+ *
4
+ * ```tsx
5
+ * <Leaderboard
6
+ * data={[
7
+ * { id: 'maya', name: 'Maya Chen', value: 12480, change: 2 },
8
+ * { id: 'omar', name: 'Omar Haddad', value: 11930 },
9
+ * { id: 'lena', name: 'Lena Brandt', value: 10215, change: -1 },
10
+ * { id: 'sam', name: 'Sam Okafor', value: 9870 },
11
+ * ]}
12
+ * unit="steps"
13
+ * highlightId="sam"
14
+ * />
15
+ * ```
16
+ *
17
+ * Data in, ranks out. The caller passes values and the component sorts and
18
+ * places them, so ties are numbered the same way everywhere and a row can
19
+ * never show a place its value does not earn. See `leaderboard-rank.ts`.
20
+ *
21
+ * The podium is laid out 2-1-3, first in the middle and highest, because that
22
+ * is the arrangement people already read a top three in. Its bars are drawn
23
+ * to scale against the leader by default: a close race looks close and a
24
+ * runaway looks like one, which a fixed staircase of heights cannot show.
25
+ *
26
+ * Every podium column and every row is one accessible element. A screen
27
+ * reader hears "Rank 2, Omar Haddad, 11,930 steps" once, rather than the
28
+ * rank, the name and the value as three stops in a grid it cannot see.
29
+ */
30
+ import { forwardRef, useEffect, useMemo } from 'react';
31
+ import {
32
+ Pressable,
33
+ View,
34
+ type ImageSourcePropType,
35
+ type ViewProps,
36
+ } from 'react-native';
37
+ import Animated, {
38
+ useAnimatedStyle,
39
+ useReducedMotion,
40
+ useSharedValue,
41
+ withDelay,
42
+ withTiming,
43
+ Easing,
44
+ } from 'react-native-reanimated';
45
+ import { tv } from 'tailwind-variants';
46
+ import { useCSSVariable } from 'uniwind';
47
+ import { ArrowDownIcon, ArrowUpIcon } from '../../icons';
48
+ import { Text } from '../../primitives/text';
49
+ import { cn } from '../../utils/cn';
50
+ import { Avatar } from '../avatar';
51
+ import { podiumHeights, rankEntries, type Ranked } from './leaderboard-rank';
52
+
53
+ export { rankEntries } from './leaderboard-rank';
54
+ export type { Ranked as LeaderboardRanked } from './leaderboard-rank';
55
+
56
+ /** Milliseconds for a podium bar to grow. */
57
+ const GROW_DURATION = 620;
58
+ /** Delay between bars. Third rises first and first last, so the leader lands on the beat. */
59
+ const GROW_STAGGER = 110;
60
+
61
+ const leaderboardVariants = tv({
62
+ slots: {
63
+ root: 'w-full gap-4',
64
+ podium: 'flex-row items-end gap-2 px-1',
65
+ column: 'flex-1 items-center',
66
+ person: 'items-center gap-1 pb-2',
67
+ rankBadge:
68
+ 'absolute -bottom-1.5 h-5 min-w-5 items-center justify-center self-center rounded-full border-2 border-background px-1',
69
+ bar: 'w-full items-center overflow-hidden rounded-t-2xl pt-2.5',
70
+ card: 'flex-1 items-center gap-1.5 rounded-2xl border border-border bg-card px-2 pb-3 pt-4',
71
+ list: 'overflow-hidden rounded-2xl border border-border bg-card',
72
+ row: 'min-h-14 flex-row items-center gap-3 px-3 py-2.5',
73
+ rowDivider: 'ml-14 h-px bg-border',
74
+ rank: 'w-7 text-center text-sm font-semibold tabular-nums text-muted-foreground',
75
+ rowText: 'flex-1 gap-0.5',
76
+ value: 'text-sm font-semibold tabular-nums text-foreground',
77
+ change: 'min-w-10 flex-row items-center justify-end gap-0.5',
78
+ gap: 'flex-row items-center justify-center gap-1 py-1.5',
79
+ gapDot: 'h-1 w-1 rounded-full bg-muted-foreground/40',
80
+ empty: 'items-center rounded-2xl border border-dashed border-border px-4 py-8',
81
+ },
82
+ });
83
+
84
+ /**
85
+ * Colour by podium place. A lookup rather than a `tv()` variant: place is
86
+ * derived from the data, not something a caller passes, and a variant here
87
+ * would be documented as a prop on `Leaderboard`.
88
+ *
89
+ * One hue stepped down in strength, so the podium reads as a single series
90
+ * and follows the theme's primary rather than borrowing medal colours that
91
+ * would clash with half the themes.
92
+ */
93
+ const PLACE: Record<1 | 2 | 3, { bar: string; badge: string }> = {
94
+ 1: { bar: 'bg-primary', badge: 'bg-primary' },
95
+ 2: { bar: 'bg-primary/40', badge: 'bg-foreground' },
96
+ 3: { bar: 'bg-primary/16', badge: 'bg-foreground' },
97
+ };
98
+
99
+ export interface LeaderboardEntry {
100
+ /** Stable identity, used for keys and `highlightId`. */
101
+ id: string;
102
+ /** Shown on the row and read out first after the rank. */
103
+ name: string;
104
+ /** What the board is ranked by. */
105
+ value: number;
106
+ /** Image for the avatar. A string is taken as a URI. Initials stand in without one. */
107
+ avatar?: ImageSourcePropType | string;
108
+ /** A second line under the name — a team, a level, a city. */
109
+ subtitle?: string;
110
+ /**
111
+ * Places moved since the last period. Positive is up the board, negative
112
+ * is down, 0 is unchanged. Leave it out to show no movement column.
113
+ */
114
+ change?: number;
115
+ }
116
+
117
+ export type LeaderboardPodium = 'bars' | 'cards' | 'none';
118
+
119
+ export interface LeaderboardProps extends Omit<ViewProps, 'children'> {
120
+ className?: string;
121
+ /** The entries, in any order. Places are worked out from `value`. */
122
+ data: readonly LeaderboardEntry[];
123
+ /**
124
+ * How the top three are drawn. `bars` stands them on a podium whose bars
125
+ * are scaled to their values; `cards` gives each a card, with first raised
126
+ * between the other two; `none` puts everyone in the list.
127
+ */
128
+ podium?: LeaderboardPodium;
129
+ /**
130
+ * `desc` ranks the highest value first. `asc` ranks the lowest first, for a
131
+ * time or a golf score.
132
+ */
133
+ order?: 'desc' | 'asc';
134
+ /**
135
+ * What the podium bar heights follow. `value` draws each against the
136
+ * leader's value; `rank` steps them by place. Always `rank` when `order` is
137
+ * `asc`, where the winning value is the smallest.
138
+ */
139
+ podiumScale?: 'value' | 'rank';
140
+ /** Height of the tallest podium bar, in points. */
141
+ podiumHeight?: number;
142
+ /** Show at most this many entries, podium included. */
143
+ limit?: number;
144
+ /**
145
+ * The entry to mark — usually the person looking at the board. Their row is
146
+ * tinted, and if `limit` cuts them off it is pinned at the bottom with their
147
+ * real place.
148
+ */
149
+ highlightId?: string;
150
+ /** Turns a value into its label. Defaults to grouped digits: 12,480. */
151
+ formatValue?: (value: number) => string;
152
+ /** Word read after the value by a screen reader, e.g. "points". */
153
+ unit?: string;
154
+ /** Makes every row and podium place pressable. */
155
+ onPressEntry?: (entry: LeaderboardEntry, rank: number) => void;
156
+ /** Shown when `data` has nothing to rank. */
157
+ emptyText?: string;
158
+ }
159
+
160
+ function initials(name: string): string {
161
+ const parts = name.trim().split(/\s+/).filter(Boolean);
162
+ const first = parts[0]?.[0] ?? '';
163
+ const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';
164
+ return (first + last).toUpperCase() || '?';
165
+ }
166
+
167
+ function toSource(avatar: LeaderboardEntry['avatar']): ImageSourcePropType | undefined {
168
+ return typeof avatar === 'string' ? { uri: avatar } : avatar;
169
+ }
170
+
171
+ const defaultFormat = (value: number) => value.toLocaleString('en-US');
172
+
173
+ function describe(
174
+ { entry, rank, tied }: Ranked<LeaderboardEntry>,
175
+ label: string,
176
+ unit: string | undefined
177
+ ): string {
178
+ const place = tied ? `Rank ${rank}, tied` : `Rank ${rank}`;
179
+ const parts = [place, entry.name, unit ? `${label} ${unit}` : label];
180
+ if (entry.subtitle) parts.splice(2, 0, entry.subtitle);
181
+ if (entry.change) {
182
+ const n = Math.abs(entry.change);
183
+ parts.push(`${entry.change > 0 ? 'up' : 'down'} ${n} ${n === 1 ? 'place' : 'places'}`);
184
+ } else if (entry.change === 0) {
185
+ parts.push('no change');
186
+ }
187
+ return parts.join(', ');
188
+ }
189
+
190
+ /** A pressable when the board is, a plain view when it is not. */
191
+ function Slot({
192
+ onPress,
193
+ label,
194
+ className,
195
+ children,
196
+ }: {
197
+ onPress?: () => void;
198
+ label: string;
199
+ className?: string;
200
+ children: React.ReactNode;
201
+ }) {
202
+ if (onPress) {
203
+ return (
204
+ <Pressable
205
+ accessible
206
+ accessibilityRole="button"
207
+ accessibilityLabel={label}
208
+ onPress={onPress}
209
+ className={cn(className, 'active:opacity-70')}
210
+ >
211
+ {children}
212
+ </Pressable>
213
+ );
214
+ }
215
+ return (
216
+ <View accessible accessibilityLabel={label} className={className}>
217
+ {children}
218
+ </View>
219
+ );
220
+ }
221
+
222
+ function PodiumBar({
223
+ place,
224
+ height,
225
+ delay,
226
+ rank,
227
+ }: {
228
+ place: 1 | 2 | 3;
229
+ height: number;
230
+ delay: number;
231
+ rank: number;
232
+ }) {
233
+ const slots = leaderboardVariants();
234
+ const reduced = useReducedMotion();
235
+ const grown = useSharedValue(reduced ? height : 0);
236
+
237
+ useEffect(() => {
238
+ grown.value = reduced
239
+ ? height
240
+ : withDelay(
241
+ delay,
242
+ withTiming(height, { duration: GROW_DURATION, easing: Easing.out(Easing.cubic) })
243
+ );
244
+ }, [grown, height, delay, reduced]);
245
+
246
+ const style = useAnimatedStyle(() => ({ height: grown.value }));
247
+ // The numeral fades in once the bar is tall enough to hold it, so it never
248
+ // overflows a bar that is still a few points high.
249
+ const numeral = useAnimatedStyle(() => ({
250
+ opacity: Math.min(1, Math.max(0, (grown.value - 28) / 24)),
251
+ }));
252
+
253
+ return (
254
+ <Animated.View className={cn(slots.bar(), PLACE[place].bar)} style={style}>
255
+ <Animated.View style={numeral}>
256
+ <Text
257
+ size={place === 1 ? '3xl' : '2xl'}
258
+ weight="bold"
259
+ className={cn(
260
+ 'tabular-nums',
261
+ place === 1 ? 'text-primary-foreground' : 'text-foreground/70'
262
+ )}
263
+ >
264
+ {rank}
265
+ </Text>
266
+ </Animated.View>
267
+ </Animated.View>
268
+ );
269
+ }
270
+
271
+ function RankBadge({ place, rank }: { place: 1 | 2 | 3; rank: number }) {
272
+ const slots = leaderboardVariants();
273
+ return (
274
+ <View className={cn(slots.rankBadge(), PLACE[place].badge)}>
275
+ <Text
276
+ className={cn(
277
+ 'text-[10px] font-bold tabular-nums',
278
+ place === 1 ? 'text-primary-foreground' : 'text-background'
279
+ )}
280
+ >
281
+ {rank}
282
+ </Text>
283
+ </View>
284
+ );
285
+ }
286
+
287
+ function ChangeLabel({ change }: { change: number | undefined }) {
288
+ const slots = leaderboardVariants();
289
+ const success = useCSSVariable('--color-success');
290
+ const destructive = useCSSVariable('--color-destructive');
291
+ if (change === undefined) return null;
292
+
293
+ if (change === 0) {
294
+ return (
295
+ <View className={slots.change()}>
296
+ <Text size="xs" muted className="tabular-nums">
297
+ –
298
+ </Text>
299
+ </View>
300
+ );
301
+ }
302
+
303
+ const up = change > 0;
304
+ const tint = up ? success : destructive;
305
+ const color = typeof tint === 'string' ? tint : undefined;
306
+ const Arrow = up ? ArrowUpIcon : ArrowDownIcon;
307
+ return (
308
+ <View className={slots.change()}>
309
+ <Arrow size={11} strokeWidth={2.5} color={color} />
310
+ <Text
311
+ size="xs"
312
+ weight="medium"
313
+ className={cn('tabular-nums', up ? 'text-success' : 'text-destructive')}
314
+ >
315
+ {Math.abs(change)}
316
+ </Text>
317
+ </View>
318
+ );
319
+ }
320
+
321
+ /**
322
+ * Ranked entries with a podium for the top three and a list for the rest.
323
+ *
324
+ * Renders every row it is given rather than virtualising them: a board is
325
+ * read from the top, and past a few dozen rows nobody is reading it. Use
326
+ * `limit` for a long board, and `highlightId` to keep the viewer's own row in
327
+ * sight when the limit cuts them off.
328
+ */
329
+ const LeaderboardRoot = forwardRef<View, LeaderboardProps>(function Leaderboard(
330
+ {
331
+ className,
332
+ data,
333
+ podium = 'bars',
334
+ order = 'desc',
335
+ podiumScale = 'value',
336
+ podiumHeight = 132,
337
+ limit,
338
+ highlightId,
339
+ formatValue = defaultFormat,
340
+ unit,
341
+ onPressEntry,
342
+ emptyText = 'No entries yet',
343
+ ...props
344
+ },
345
+ ref
346
+ ) {
347
+ const slots = leaderboardVariants();
348
+ const ranked = useMemo(() => rankEntries(data, order), [data, order]);
349
+ const shown = limit != null ? ranked.slice(0, Math.max(0, limit)) : ranked;
350
+
351
+ // The viewer's own row, when the limit has cut it off.
352
+ const pinned =
353
+ highlightId != null && !shown.some((r) => r.entry.id === highlightId)
354
+ ? ranked.find((r) => r.entry.id === highlightId)
355
+ : undefined;
356
+
357
+ const top = podium === 'none' ? [] : shown.slice(0, 3);
358
+ const rest = podium === 'none' ? shown : shown.slice(3);
359
+
360
+ const press = (item: Ranked<LeaderboardEntry>) =>
361
+ onPressEntry ? () => onPressEntry(item.entry, item.rank) : undefined;
362
+
363
+ if (ranked.length === 0) {
364
+ return (
365
+ <View ref={ref} className={slots.root({ className })} {...props}>
366
+ <View className={slots.empty()}>
367
+ <Text size="sm" muted>
368
+ {emptyText}
369
+ </Text>
370
+ </View>
371
+ </View>
372
+ );
373
+ }
374
+
375
+ // Second, first, third. With fewer than three the missing seats are left
376
+ // out rather than drawn empty.
377
+ const seats = [top[1], top[0], top[2]]
378
+ .map((item, slot) => (item ? { item, place: ([2, 1, 3] as const)[slot]! } : null))
379
+ .filter((seat): seat is { item: Ranked<LeaderboardEntry>; place: 1 | 2 | 3 } => !!seat);
380
+
381
+ const heights = podiumHeights(
382
+ seats.map((s) => s.item.entry.value),
383
+ seats.map((s) => s.item.rank),
384
+ order === 'asc' ? 'rank' : podiumScale
385
+ );
386
+
387
+ const renderRow = (item: Ranked<LeaderboardEntry>, index: number) => {
388
+ const label = formatValue(item.entry.value);
389
+ const highlighted = item.entry.id === highlightId;
390
+ return (
391
+ <View key={item.entry.id}>
392
+ {index > 0 ? <View className={slots.rowDivider()} /> : null}
393
+ <Slot
394
+ onPress={press(item)}
395
+ label={describe(item, label, unit)}
396
+ className={cn(slots.row(), highlighted && 'bg-primary/10')}
397
+ >
398
+ <Text className={cn(slots.rank(), highlighted && 'text-primary')}>
399
+ {item.rank}
400
+ </Text>
401
+ <Avatar
402
+ size="sm"
403
+ source={toSource(item.entry.avatar)}
404
+ fallback={initials(item.entry.name)}
405
+ />
406
+ <View className={slots.rowText()}>
407
+ <Text size="sm" weight="medium" numberOfLines={1}>
408
+ {item.entry.name}
409
+ </Text>
410
+ {item.entry.subtitle ? (
411
+ <Text size="xs" muted numberOfLines={1}>
412
+ {item.entry.subtitle}
413
+ </Text>
414
+ ) : null}
415
+ </View>
416
+ <Text className={slots.value()}>{label}</Text>
417
+ <ChangeLabel change={item.entry.change} />
418
+ </Slot>
419
+ </View>
420
+ );
421
+ };
422
+
423
+ return (
424
+ <View ref={ref} className={slots.root({ className })} {...props}>
425
+ {podium === 'bars' && seats.length > 0 ? (
426
+ <View className={slots.podium()}>
427
+ {seats.map(({ item, place }, i) => {
428
+ const label = formatValue(item.entry.value);
429
+ return (
430
+ <Slot
431
+ key={item.entry.id}
432
+ onPress={press(item)}
433
+ label={describe(item, label, unit)}
434
+ className={slots.column()}
435
+ >
436
+ <View className={slots.person()}>
437
+ <View className="mb-1.5">
438
+ <Avatar
439
+ size={place === 1 ? 'lg' : 'md'}
440
+ source={toSource(item.entry.avatar)}
441
+ fallback={initials(item.entry.name)}
442
+ className={cn(
443
+ place === 1 && 'border-2 border-primary',
444
+ item.entry.id === highlightId && place !== 1 && 'border-2 border-primary/60'
445
+ )}
446
+ />
447
+ <RankBadge place={place} rank={item.rank} />
448
+ </View>
449
+ <Text size="sm" weight="semibold" numberOfLines={1} className="text-center">
450
+ {item.entry.name}
451
+ </Text>
452
+ <Text size="xs" muted numberOfLines={1} className="tabular-nums">
453
+ {label}
454
+ </Text>
455
+ </View>
456
+ <PodiumBar
457
+ place={place}
458
+ rank={item.rank}
459
+ height={Math.round(podiumHeight * heights[i]!)}
460
+ delay={(3 - place) * GROW_STAGGER}
461
+ />
462
+ </Slot>
463
+ );
464
+ })}
465
+ </View>
466
+ ) : null}
467
+
468
+ {podium === 'cards' && seats.length > 0 ? (
469
+ <View className="flex-row items-end gap-2">
470
+ {seats.map(({ item, place }) => {
471
+ const label = formatValue(item.entry.value);
472
+ return (
473
+ <Slot
474
+ key={item.entry.id}
475
+ onPress={press(item)}
476
+ label={describe(item, label, unit)}
477
+ className={cn(
478
+ slots.card(),
479
+ place === 1 && 'border-primary pb-5 pt-5 shadow-sm',
480
+ item.entry.id === highlightId && place !== 1 && 'border-primary/40'
481
+ )}
482
+ >
483
+ <View className="mb-1.5">
484
+ <Avatar
485
+ size={place === 1 ? 'lg' : 'md'}
486
+ source={toSource(item.entry.avatar)}
487
+ fallback={initials(item.entry.name)}
488
+ className={cn(place === 1 && 'border-2 border-primary')}
489
+ />
490
+ <RankBadge place={place} rank={item.rank} />
491
+ </View>
492
+ <Text size="sm" weight="semibold" numberOfLines={1} className="text-center">
493
+ {item.entry.name}
494
+ </Text>
495
+ <Text
496
+ size={place === 1 ? 'base' : 'sm'}
497
+ weight="bold"
498
+ numberOfLines={1}
499
+ className={cn('tabular-nums', place === 1 && 'text-primary')}
500
+ >
501
+ {label}
502
+ </Text>
503
+ {item.entry.subtitle ? (
504
+ <Text size="xs" muted numberOfLines={1}>
505
+ {item.entry.subtitle}
506
+ </Text>
507
+ ) : null}
508
+ </Slot>
509
+ );
510
+ })}
511
+ </View>
512
+ ) : null}
513
+
514
+ {rest.length > 0 || pinned ? (
515
+ <View className={slots.list()} accessibilityRole="list">
516
+ {rest.map(renderRow)}
517
+ {pinned ? (
518
+ <>
519
+ {rest.length > 0 ? (
520
+ <View className={slots.gap()} importantForAccessibility="no-hide-descendants">
521
+ <View className={slots.gapDot()} />
522
+ <View className={slots.gapDot()} />
523
+ <View className={slots.gapDot()} />
524
+ </View>
525
+ ) : null}
526
+ {renderRow(pinned, 0)}
527
+ </>
528
+ ) : null}
529
+ </View>
530
+ ) : null}
531
+ </View>
532
+ );
533
+ });
534
+
535
+ export const Leaderboard = LeaderboardRoot;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Ranking for `Leaderboard`, kept apart from the views so it can be tested
3
+ * without mounting anything.
4
+ *
5
+ * Ties share a place and the next place is skipped — 1, 2, 2, 4. That is how
6
+ * a scoreboard is read: two people on the same score are both second, and the
7
+ * one below them is fourth, because three people beat them. Numbering the
8
+ * next one third would claim a place nobody below a tie can hold.
9
+ */
10
+
11
+ export interface RankableEntry {
12
+ id: string;
13
+ value: number;
14
+ }
15
+
16
+ export interface Ranked<T extends RankableEntry> {
17
+ entry: T;
18
+ /** The place, 1-based. Shared by everyone on the same value. */
19
+ rank: number;
20
+ /** Whether another entry holds the same place. */
21
+ tied: boolean;
22
+ }
23
+
24
+ /**
25
+ * Sorts by value and assigns competition ranks.
26
+ *
27
+ * `desc` puts the highest value first — points, revenue, steps. `asc` puts
28
+ * the lowest first, for a race time or a golf score. Entries whose value is
29
+ * not a finite number are dropped rather than sorted to an arbitrary end,
30
+ * since a row with no score has no place to be ranked at. The sort is stable,
31
+ * so equal values keep the order they were passed in.
32
+ */
33
+ export function rankEntries<T extends RankableEntry>(
34
+ data: readonly T[],
35
+ order: 'desc' | 'asc' = 'desc'
36
+ ): Ranked<T>[] {
37
+ const sign = order === 'asc' ? 1 : -1;
38
+ const sorted = data
39
+ .filter((entry) => Number.isFinite(entry.value))
40
+ .map((entry, index) => ({ entry, index }))
41
+ .sort((a, b) => sign * (a.entry.value - b.entry.value) || a.index - b.index)
42
+ .map(({ entry }) => entry);
43
+
44
+ const ranked: Ranked<T>[] = [];
45
+ sorted.forEach((entry, index) => {
46
+ const previous = ranked[index - 1];
47
+ const rank =
48
+ previous && previous.entry.value === entry.value ? previous.rank : index + 1;
49
+ ranked.push({ entry, rank, tied: false });
50
+ });
51
+
52
+ for (let i = 0; i < ranked.length; i += 1) {
53
+ const current = ranked[i]!;
54
+ current.tied =
55
+ ranked[i - 1]?.rank === current.rank || ranked[i + 1]?.rank === current.rank;
56
+ }
57
+
58
+ return ranked;
59
+ }
60
+
61
+ /**
62
+ * Bar heights for the podium, as fractions of the tallest bar.
63
+ *
64
+ * `value` scales each bar by its value against the leader's, so a close race
65
+ * looks close. A floor keeps third place a bar rather than a sliver when the
66
+ * leader is far ahead. `rank` steps the heights by place and ignores the
67
+ * values — the only honest choice for `asc`, where the best value is the
68
+ * smallest and a bar proportional to it would be shortest.
69
+ */
70
+ export function podiumHeights(
71
+ values: readonly number[],
72
+ ranks: readonly number[],
73
+ scale: 'value' | 'rank',
74
+ floor = 0.4
75
+ ): number[] {
76
+ if (scale === 'rank') {
77
+ const steps = [1, 0.74, 0.56];
78
+ return ranks.map((rank) => steps[Math.min(rank, 3) - 1] ?? steps[2]!);
79
+ }
80
+
81
+ const peak = Math.max(...values.map((value) => Math.abs(value)), 0);
82
+ if (peak === 0) return values.map(() => 1);
83
+ return values.map((value) => floor + (1 - floor) * (Math.abs(value) / peak));
84
+ }