panelui-native 0.103.1 → 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.
- package/README.md +1 -0
- package/lib/module/components/checkbox/index.js +8 -2
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/flow/index.js +64 -55
- package/lib/module/components/flow/index.js.map +1 -1
- package/lib/module/components/leaderboard/index.js +442 -0
- package/lib/module/components/leaderboard/index.js.map +1 -0
- package/lib/module/components/leaderboard/leaderboard-rank.js +65 -0
- package/lib/module/components/leaderboard/leaderboard-rank.js.map +1 -0
- package/lib/module/components/map/index.js +114 -50
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +11 -3
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +9 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
- package/lib/typescript/src/components/leaderboard/index.d.ts +63 -0
- package/lib/typescript/src/components/leaderboard/index.d.ts.map +1 -0
- package/lib/typescript/src/components/leaderboard/leaderboard-rank.d.ts +41 -0
- package/lib/typescript/src/components/leaderboard/leaderboard-rank.d.ts.map +1 -0
- package/lib/typescript/src/components/map/index.d.ts +30 -5
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts +9 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/checkbox/index.tsx +17 -2
- package/src/components/flow/index.tsx +65 -51
- package/src/components/leaderboard/index.tsx +535 -0
- package/src/components/leaderboard/leaderboard-rank.ts +84 -0
- package/src/components/map/index.tsx +130 -44
- package/src/components/radio-group/index.tsx +27 -4
- package/src/index.ts +10 -0
|
@@ -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
|
+
}
|