@startsimpli/ui-native 0.0.0-stage → 0.1.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.
Files changed (54) hide show
  1. package/README.md +172 -2
  2. package/package.json +50 -4
  3. package/src/__tests__/charts-math.test.ts +73 -0
  4. package/src/__tests__/styles-dashboard.test.ts +92 -0
  5. package/src/__tests__/styles-primitives.test.ts +114 -0
  6. package/src/__tests__/styles.test.ts +101 -0
  7. package/src/__tests__/theme.test.ts +23 -0
  8. package/src/__tests__/tokens.test.ts +33 -0
  9. package/src/charts-math.ts +106 -0
  10. package/src/components/AccentContext.tsx +22 -0
  11. package/src/components/Alert.tsx +27 -0
  12. package/src/components/Avatar.tsx +42 -0
  13. package/src/components/Badge.tsx +18 -0
  14. package/src/components/Button.tsx +40 -0
  15. package/src/components/Card.tsx +30 -0
  16. package/src/components/Checkbox.tsx +31 -0
  17. package/src/components/Chip.tsx +26 -0
  18. package/src/components/CompareChart.tsx +56 -0
  19. package/src/components/DataTable.tsx +89 -0
  20. package/src/components/DemographicBars.tsx +50 -0
  21. package/src/components/DonutChart.tsx +57 -0
  22. package/src/components/DonutLegend.tsx +49 -0
  23. package/src/components/DualRangeControl.tsx +31 -0
  24. package/src/components/EmptyState.tsx +63 -0
  25. package/src/components/ErrorBoundary.tsx +52 -0
  26. package/src/components/Flag.tsx +26 -0
  27. package/src/components/GradientBox.tsx +48 -0
  28. package/src/components/HeroBanner.tsx +160 -0
  29. package/src/components/Input.tsx +33 -0
  30. package/src/components/Label.tsx +9 -0
  31. package/src/components/ListRow.tsx +44 -0
  32. package/src/components/MetricRow.tsx +48 -0
  33. package/src/components/MultiLineLegend.tsx +30 -0
  34. package/src/components/Progress.tsx +18 -0
  35. package/src/components/Screen.tsx +38 -0
  36. package/src/components/SegmentedControl.tsx +31 -0
  37. package/src/components/Separator.tsx +13 -0
  38. package/src/components/ShareBar.tsx +32 -0
  39. package/src/components/Skeleton.tsx +29 -0
  40. package/src/components/SkeletonList.tsx +23 -0
  41. package/src/components/Sparkline.tsx +38 -0
  42. package/src/components/Spinner.tsx +8 -0
  43. package/src/components/StatTile.tsx +69 -0
  44. package/src/components/Switch.tsx +17 -0
  45. package/src/components/TabBar.tsx +50 -0
  46. package/src/components/Text.tsx +13 -0
  47. package/src/components/ThemeProvider.tsx +25 -0
  48. package/src/components/Timeline.tsx +47 -0
  49. package/src/components/TrendCard.tsx +39 -0
  50. package/src/components/TrendChart.tsx +68 -0
  51. package/src/index.ts +64 -0
  52. package/src/styles.ts +479 -0
  53. package/src/theme.ts +46 -0
  54. package/src/tokens.ts +145 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Pure chart geometry — no react-native-svg import, so it is unit-testable in
3
+ * plain Node and shared by the SVG components in ./components.
4
+ */
5
+
6
+ export interface Box {
7
+ width: number;
8
+ height: number;
9
+ /** Inner padding so strokes/points aren't clipped at the edges. */
10
+ pad?: number;
11
+ }
12
+
13
+ /**
14
+ * Map a series of y-values to [x,y] pixel points inside a padded box. Pass an
15
+ * explicit `domain` ([min,max]) to scale several series on a SHARED axis so
16
+ * they're visually comparable (see {@link multiLinePaths}); omit it and each
17
+ * series is scaled to its own min/max.
18
+ */
19
+ export function points(
20
+ values: number[],
21
+ box: Box,
22
+ domain?: [number, number]
23
+ ): Array<[number, number]> {
24
+ const { width, height } = box;
25
+ const pad = box.pad ?? 0;
26
+ const n = values.length;
27
+ if (n === 0) return [];
28
+ const min = domain ? domain[0] : Math.min(...values);
29
+ const max = domain ? domain[1] : Math.max(...values);
30
+ const span = max - min || 1;
31
+ const innerW = width - pad * 2;
32
+ const innerH = height - pad * 2;
33
+ return values.map((v, i) => {
34
+ const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
35
+ const y = pad + innerH - ((v - min) / span) * innerH;
36
+ return [x, y];
37
+ });
38
+ }
39
+
40
+ /** SVG path for a polyline through the values (optionally on a shared domain). */
41
+ export function linePath(values: number[], box: Box, domain?: [number, number]): string {
42
+ const pts = points(values, box, domain);
43
+ if (pts.length === 0) return '';
44
+ return pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`).join(' ');
45
+ }
46
+
47
+ /** Min/max across every value of every series. Finite even when empty. */
48
+ export function sharedExtent(series: number[][]): [number, number] {
49
+ const flat = series.flat();
50
+ if (flat.length === 0) return [0, 1];
51
+ return [Math.min(...flat), Math.max(...flat)];
52
+ }
53
+
54
+ /** One line path per series, all scaled to a single shared y-domain so the
55
+ * lines are directly comparable (compare-artists, period-over-period). */
56
+ export function multiLinePaths(series: number[][], box: Box): string[] {
57
+ const domain = sharedExtent(series);
58
+ return series.map((s) => linePath(s, box, domain));
59
+ }
60
+
61
+ /** SVG path for the filled area under the line (closed to the baseline). */
62
+ export function areaPath(values: number[], box: Box): string {
63
+ const pts = points(values, box);
64
+ if (pts.length === 0) return '';
65
+ const pad = box.pad ?? 0;
66
+ const baseline = box.height - pad;
67
+ const first = pts[0];
68
+ const last = pts[pts.length - 1];
69
+ const line = pts.map(([x, y]) => `L${x.toFixed(2)} ${y.toFixed(2)}`).join(' ');
70
+ return `M${first[0].toFixed(2)} ${baseline.toFixed(2)} ${line} L${last[0].toFixed(2)} ${baseline.toFixed(2)} Z`;
71
+ }
72
+
73
+ export interface DonutSlice {
74
+ value: number;
75
+ color: string;
76
+ }
77
+
78
+ export interface DonutDash {
79
+ color: string;
80
+ /** Length of the colored portion of the ring. */
81
+ dash: number;
82
+ /** Remaining (gap) length. */
83
+ gap: number;
84
+ /** Negative dashoffset that rotates this slice to start where the last ended. */
85
+ offset: number;
86
+ /** Fraction of the whole (0–1). */
87
+ fraction: number;
88
+ }
89
+
90
+ /**
91
+ * Turn slice values into stroke-dasharray/offset specs for a donut drawn as
92
+ * overlaid circles of the given circumference. Slices are laid out clockwise
93
+ * from the top (the caller rotates the group -90°).
94
+ */
95
+ export function donutDashes(slices: DonutSlice[], circumference: number, gapPx = 0): DonutDash[] {
96
+ const total = slices.reduce((s, x) => s + Math.max(0, x.value), 0) || 1;
97
+ let acc = 0;
98
+ return slices.map(({ value, color }) => {
99
+ const fraction = Math.max(0, value) / total;
100
+ const len = fraction * circumference;
101
+ const dash = Math.max(0, len - gapPx);
102
+ const offset = -acc;
103
+ acc += len;
104
+ return { color, dash, gap: circumference - dash, offset, fraction };
105
+ });
106
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Per-screen accent colour. Lets an app set a contextual brand colour (e.g.
3
+ * "the artist currently being viewed") that hero/badges/charts can pick up
4
+ * without prop-drilling. Defaults to theme.primary when no provider is mounted.
5
+ *
6
+ * Independent of ThemeProvider so apps can override the accent under any
7
+ * theme without rebuilding the whole theme object.
8
+ */
9
+ import { createContext, useContext, type ReactNode } from 'react';
10
+ import { useTheme } from './ThemeProvider';
11
+
12
+ const AccentContext = createContext<string | null>(null);
13
+
14
+ export function AccentProvider({ accent, children }: { accent: string; children: ReactNode }) {
15
+ return <AccentContext.Provider value={accent}>{children}</AccentContext.Provider>;
16
+ }
17
+
18
+ /** The current accent colour (or theme.primary if no provider is mounted). */
19
+ export function useAccent(): string {
20
+ const theme = useTheme();
21
+ return useContext(AccentContext) ?? theme.colors.primary;
22
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react';
2
+ import { View, Text as RNText, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+ import { alertStyle, alertTitleStyle, alertTextStyle, type AlertVariant } from '../styles';
5
+
6
+ export interface AlertProps {
7
+ title?: string;
8
+ children?: ReactNode;
9
+ variant?: AlertVariant;
10
+ /** Layout overrides (e.g. margins), merged after the themed alert style. */
11
+ style?: StyleProp<ViewStyle>;
12
+ }
13
+
14
+ /** Callout box. Mirrors @startsimpli/ui alert (default / destructive). */
15
+ export function Alert({ title, children, variant = 'default', style }: AlertProps) {
16
+ const theme = useTheme();
17
+ return (
18
+ <View style={[alertStyle(theme, variant), style]}>
19
+ {title ? <RNText style={alertTitleStyle(theme, variant)}>{title}</RNText> : null}
20
+ {typeof children === 'string' ? (
21
+ <RNText style={[alertTextStyle(theme, variant), { marginTop: title ? 4 : 0 }]}>{children}</RNText>
22
+ ) : (
23
+ children
24
+ )}
25
+ </View>
26
+ );
27
+ }
@@ -0,0 +1,42 @@
1
+ import { View, Image, Text as RNText, type ViewProps, type ImageSourcePropType } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { avatarStyle, avatarTextStyle, type AvatarSize } from '../styles';
4
+
5
+ export interface AvatarProps extends ViewProps {
6
+ /** Bundled/required image (e.g. a logo). Takes precedence over `uri`/`name`. */
7
+ source?: ImageSourcePropType;
8
+ /** Remote image URL. Used when no `source` is given. */
9
+ uri?: string;
10
+ /** Used to derive initials when no image is given. */
11
+ name?: string;
12
+ size?: AvatarSize;
13
+ /** Background color. Defaults to white behind an image, theme.secondary otherwise. */
14
+ color?: string;
15
+ /** Text color for initials (defaults to theme.secondaryForeground). */
16
+ textColor?: string;
17
+ }
18
+
19
+ /** Up to two uppercase initials from a name ("Amr Diab" -> "AD"). */
20
+ function initials(name?: string): string {
21
+ if (!name) return '';
22
+ const parts = name.trim().split(/\s+/).slice(0, 2);
23
+ return parts.map((p) => p.charAt(0).toUpperCase()).join('');
24
+ }
25
+
26
+ /** Circular avatar: an image (`source` or `uri`) cover-filled into the circle,
27
+ * otherwise the name's initials. */
28
+ export function Avatar({ source, uri, name, size = 'md', color, textColor, style, ...rest }: AvatarProps) {
29
+ const theme = useTheme();
30
+ const hasImage = source != null || uri != null;
31
+ const bg = color ?? (hasImage ? '#FFFFFF' : undefined);
32
+ const imageSource = source ?? (uri != null ? { uri } : undefined);
33
+ return (
34
+ <View style={[avatarStyle(theme, size, bg), style]} {...rest}>
35
+ {imageSource ? (
36
+ <Image source={imageSource} style={{ width: '100%', height: '100%' }} resizeMode="cover" />
37
+ ) : (
38
+ <RNText style={avatarTextStyle(theme, size, textColor)}>{initials(name)}</RNText>
39
+ )}
40
+ </View>
41
+ );
42
+ }
@@ -0,0 +1,18 @@
1
+ import { View, Text as RNText, type ViewProps } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { badgeStyle, badgeTextStyle, type BadgeVariant } from '../styles';
4
+
5
+ export interface BadgeProps extends ViewProps {
6
+ label: string;
7
+ variant?: BadgeVariant;
8
+ }
9
+
10
+ /** Small status pill. Mirrors @startsimpli/ui badge variants. */
11
+ export function Badge({ label, variant = 'default', style, ...rest }: BadgeProps) {
12
+ const theme = useTheme();
13
+ return (
14
+ <View style={[badgeStyle(theme, variant), style]} {...rest}>
15
+ <RNText style={badgeTextStyle(theme, variant)}>{label}</RNText>
16
+ </View>
17
+ );
18
+ }
@@ -0,0 +1,40 @@
1
+ import {
2
+ Pressable,
3
+ Text as RNText,
4
+ type PressableProps,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ } from 'react-native';
8
+ import { useTheme } from './ThemeProvider';
9
+ import { buttonContainerStyle, buttonTextStyle, type ButtonVariant, type ButtonSize } from '../styles';
10
+
11
+ export interface ButtonProps extends Omit<PressableProps, 'children' | 'style'> {
12
+ title: string;
13
+ variant?: ButtonVariant;
14
+ size?: ButtonSize;
15
+ style?: StyleProp<ViewStyle>;
16
+ }
17
+
18
+ /** Themed pressable button. Dims slightly while pressed. */
19
+ export function Button({
20
+ title,
21
+ variant = 'primary',
22
+ size = 'md',
23
+ style,
24
+ ...rest
25
+ }: ButtonProps) {
26
+ const theme = useTheme();
27
+ return (
28
+ <Pressable
29
+ accessibilityRole="button"
30
+ style={({ pressed }) => [
31
+ buttonContainerStyle(theme, variant, size),
32
+ pressed ? { opacity: 0.85 } : null,
33
+ style,
34
+ ]}
35
+ {...rest}
36
+ >
37
+ <RNText style={buttonTextStyle(theme, variant, size)}>{title}</RNText>
38
+ </Pressable>
39
+ );
40
+ }
@@ -0,0 +1,30 @@
1
+ import { View, Text as RNText, type ViewProps, type TextProps } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { cardStyle, cardTitleStyle, cardDescriptionStyle } from '../styles';
4
+
5
+ /** Elevated surface: card background, lg radius, hairline border, padding. */
6
+ export function Card({ style, ...rest }: ViewProps) {
7
+ const theme = useTheme();
8
+ return <View style={[cardStyle(theme), style]} {...rest} />;
9
+ }
10
+
11
+ /** Title + description grouping at the top of a Card. */
12
+ export function CardHeader({ style, ...rest }: ViewProps) {
13
+ const theme = useTheme();
14
+ return <View style={[{ gap: theme.spacing.xs, marginBottom: theme.spacing.sm }, style]} {...rest} />;
15
+ }
16
+
17
+ export function CardTitle({ style, ...rest }: TextProps) {
18
+ const theme = useTheme();
19
+ return <RNText style={[cardTitleStyle(theme), style]} {...rest} />;
20
+ }
21
+
22
+ export function CardDescription({ style, ...rest }: TextProps) {
23
+ const theme = useTheme();
24
+ return <RNText style={[cardDescriptionStyle(theme), style]} {...rest} />;
25
+ }
26
+
27
+ export function CardContent({ style, ...rest }: ViewProps) {
28
+ const theme = useTheme();
29
+ return <View style={[{ gap: theme.spacing.xs }, style]} {...rest} />;
30
+ }
@@ -0,0 +1,31 @@
1
+ import { Pressable, Text as RNText, View } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { checkboxStyle, labelStyle } from '../styles';
4
+
5
+ export interface CheckboxProps {
6
+ checked: boolean;
7
+ onChange: (checked: boolean) => void;
8
+ label?: string;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ /** Box checkbox with an optional inline label. Mirrors @startsimpli/ui checkbox. */
13
+ export function Checkbox({ checked, onChange, label, disabled = false }: CheckboxProps) {
14
+ const theme = useTheme();
15
+ return (
16
+ <Pressable
17
+ accessibilityRole="checkbox"
18
+ accessibilityState={{ checked, disabled }}
19
+ disabled={disabled}
20
+ onPress={() => onChange(!checked)}
21
+ style={{ flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm, opacity: disabled ? 0.5 : 1 }}
22
+ >
23
+ <View style={checkboxStyle(theme, checked)}>
24
+ {checked ? (
25
+ <RNText style={{ color: theme.colors.primaryForeground, fontSize: 14, fontWeight: '700' }}>✓</RNText>
26
+ ) : null}
27
+ </View>
28
+ {label ? <RNText style={labelStyle(theme)}>{label}</RNText> : null}
29
+ </Pressable>
30
+ );
31
+ }
@@ -0,0 +1,26 @@
1
+ import { Pressable, Text as RNText, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { chipStyle, chipTextStyle } from '../styles';
4
+
5
+ export interface ChipProps {
6
+ label: string;
7
+ selected?: boolean;
8
+ onPress?: () => void;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /** Selectable filter pill. Filled with the primary color when selected,
13
+ * outlined otherwise. Compose a row of these for a filter bar. */
14
+ export function Chip({ label, selected = false, onPress, style }: ChipProps) {
15
+ const theme = useTheme();
16
+ return (
17
+ <Pressable
18
+ accessibilityRole="button"
19
+ accessibilityState={{ selected }}
20
+ onPress={onPress}
21
+ style={({ pressed }) => [chipStyle(theme, selected), pressed ? { opacity: 0.7 } : null, style]}
22
+ >
23
+ <RNText style={chipTextStyle(theme, selected)}>{label}</RNText>
24
+ </Pressable>
25
+ );
26
+ }
@@ -0,0 +1,56 @@
1
+ import { useState } from 'react';
2
+ import { View, type LayoutChangeEvent } from 'react-native';
3
+ import Svg, { Path, Line } from 'react-native-svg';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { multiLinePaths } from '../charts-math';
6
+
7
+ export interface CompareSeries {
8
+ /** Daily/period values, same length across series for an honest comparison. */
9
+ values: number[];
10
+ color: string;
11
+ /** Optional label (the caller renders the legend, e.g. <MultiLineLegend />). */
12
+ label?: string;
13
+ }
14
+
15
+ export interface CompareChartProps {
16
+ series: CompareSeries[];
17
+ height?: number;
18
+ /** Number of horizontal gridlines. */
19
+ gridLines?: number;
20
+ strokeWidth?: number;
21
+ }
22
+
23
+ /** Several line series overlaid on a SHARED y-scale so they're directly
24
+ * comparable — compare-artists, period-over-period. Lines only (no fill) to
25
+ * keep multiple series legible. Measures its own width via onLayout. */
26
+ export function CompareChart({ series, height = 150, gridLines = 3, strokeWidth = 2 }: CompareChartProps) {
27
+ const theme = useTheme();
28
+ const [width, setWidth] = useState(0);
29
+ const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width);
30
+ const box = { width, height, pad: strokeWidth + 1 };
31
+ const paths = width > 0 ? multiLinePaths(series.map((s) => s.values), box) : [];
32
+
33
+ return (
34
+ <View onLayout={onLayout}>
35
+ {width > 0 && (
36
+ <Svg width={width} height={height}>
37
+ {Array.from({ length: gridLines }).map((_, i) => {
38
+ const y = (height / (gridLines + 1)) * (i + 1);
39
+ return <Line key={i} x1={0} y1={y} x2={width} y2={y} stroke={theme.colors.border} strokeWidth={1} />;
40
+ })}
41
+ {paths.map((d, i) => (
42
+ <Path
43
+ key={series[i].label ?? i}
44
+ d={d}
45
+ stroke={series[i].color}
46
+ strokeWidth={strokeWidth}
47
+ fill="none"
48
+ strokeLinecap="round"
49
+ strokeLinejoin="round"
50
+ />
51
+ ))}
52
+ </Svg>
53
+ )}
54
+ </View>
55
+ );
56
+ }
@@ -0,0 +1,89 @@
1
+ import { type ReactNode } from 'react';
2
+ import { Pressable, View, Text as RNText } from 'react-native';
3
+ import { useTheme } from './ThemeProvider';
4
+
5
+ export interface DataColumn {
6
+ key: string;
7
+ title: string;
8
+ /** Flex weight for the column (default 1). */
9
+ flex?: number;
10
+ align?: 'left' | 'right';
11
+ }
12
+
13
+ export interface DataRow {
14
+ key: string;
15
+ cells: Record<string, ReactNode>;
16
+ /** When set, the row becomes pressable (opens a detail screen, etc.). */
17
+ onPress?: () => void;
18
+ }
19
+
20
+ export interface DataTableProps {
21
+ columns: DataColumn[];
22
+ rows: DataRow[];
23
+ }
24
+
25
+ /** Compact multi-column table: muted header, hairline row separators. String
26
+ * cells are auto-styled; pass a node for anything richer (flags, badges). */
27
+ export function DataTable({ columns, rows }: DataTableProps) {
28
+ const theme = useTheme();
29
+ const cellAlign = (c: DataColumn) => (c.align === 'right' ? 'flex-end' : 'flex-start');
30
+
31
+ return (
32
+ <View>
33
+ <View style={{ flexDirection: 'row', paddingVertical: theme.spacing.xs, gap: theme.spacing.sm }}>
34
+ {columns.map((c) => (
35
+ <RNText
36
+ key={c.key}
37
+ style={{
38
+ flex: c.flex ?? 1,
39
+ fontSize: theme.fontSizes.xs,
40
+ fontWeight: theme.fontWeights.medium as never,
41
+ color: theme.colors.mutedForeground,
42
+ textAlign: c.align === 'right' ? 'right' : 'left',
43
+ }}
44
+ >
45
+ {c.title}
46
+ </RNText>
47
+ ))}
48
+ </View>
49
+ {rows.map((row) => {
50
+ const Wrapper: typeof View | typeof Pressable = row.onPress ? Pressable : View;
51
+ return (
52
+ <Wrapper
53
+ key={row.key}
54
+ {...(row.onPress ? { onPress: row.onPress, accessibilityRole: 'button' as const } : {})}
55
+ style={{
56
+ flexDirection: 'row',
57
+ alignItems: 'center',
58
+ gap: theme.spacing.sm,
59
+ paddingVertical: theme.spacing.sm,
60
+ borderTopWidth: 1,
61
+ borderTopColor: theme.colors.border,
62
+ }}
63
+ >
64
+ {columns.map((c) => {
65
+ const cell = row.cells[c.key];
66
+ return (
67
+ <View key={c.key} style={{ flex: c.flex ?? 1, alignItems: cellAlign(c) }}>
68
+ {typeof cell === 'string' || typeof cell === 'number' ? (
69
+ <RNText
70
+ style={{
71
+ fontSize: theme.fontSizes.sm,
72
+ color: theme.colors.foreground,
73
+ textAlign: c.align === 'right' ? 'right' : 'left',
74
+ }}
75
+ >
76
+ {cell}
77
+ </RNText>
78
+ ) : (
79
+ cell
80
+ )}
81
+ </View>
82
+ );
83
+ })}
84
+ </Wrapper>
85
+ );
86
+ })}
87
+ </View>
88
+ );
89
+ }
@@ -0,0 +1,50 @@
1
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { Text } from './Text';
4
+ import { shareBarTrackStyle } from '../styles';
5
+
6
+ export interface DemographicBar {
7
+ label: string;
8
+ /** Drives the bar width, scaled relative to the largest value in the set. */
9
+ value: number;
10
+ /** Pre-formatted trailing text (e.g. "38%") — kept locale-agnostic; the
11
+ * caller formats it with its own number/i18n helpers. */
12
+ valueLabel?: string;
13
+ /** Bar color; defaults to the theme primary. */
14
+ color?: string;
15
+ }
16
+
17
+ export interface DemographicBarsProps {
18
+ data: DemographicBar[];
19
+ /** Bar thickness in px. */
20
+ height?: number;
21
+ style?: StyleProp<ViewStyle>;
22
+ }
23
+
24
+ /** Labeled horizontal bars for audience breakdowns (age buckets, listening
25
+ * behavior…). Bars are scaled to the row set's max so the biggest fills the
26
+ * track and the rest read proportionally. */
27
+ export function DemographicBars({ data, height = 8, style }: DemographicBarsProps) {
28
+ const theme = useTheme();
29
+ const max = data.reduce((m, d) => Math.max(m, d.value), 0) || 1;
30
+ return (
31
+ <View style={[{ gap: 12 }, style]}>
32
+ {data.map((d, i) => (
33
+ <View key={`${d.label}-${i}`} style={{ gap: 4 }}>
34
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
35
+ <Text variant="muted">{d.label}</Text>
36
+ {d.valueLabel ? <Text variant="muted">{d.valueLabel}</Text> : null}
37
+ </View>
38
+ <View style={shareBarTrackStyle(theme, height)} accessibilityRole="image">
39
+ <View
40
+ style={{
41
+ flex: Math.max(0, d.value) / max,
42
+ backgroundColor: d.color ?? theme.colors.primary,
43
+ }}
44
+ />
45
+ </View>
46
+ </View>
47
+ ))}
48
+ </View>
49
+ );
50
+ }
@@ -0,0 +1,57 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+ import Svg, { Circle, G } from 'react-native-svg';
4
+ import { useTheme } from './ThemeProvider';
5
+ import { donutDashes, type DonutSlice } from '../charts-math';
6
+
7
+ export interface DonutChartProps {
8
+ segments: DonutSlice[];
9
+ size?: number;
10
+ /** Ring thickness in px. */
11
+ thickness?: number;
12
+ /** Gap between slices in px. */
13
+ gap?: number;
14
+ /** Track (unfilled) color. Defaults to theme.muted. */
15
+ trackColor?: string;
16
+ /** Content rendered centered in the hole (e.g. a total). */
17
+ children?: ReactNode;
18
+ }
19
+
20
+ /** Ring chart drawn as overlaid dash-array circles. Slices start at the top
21
+ * and go clockwise. Center content fills the hole. */
22
+ export function DonutChart({ segments, size = 150, thickness = 18, gap = 2, trackColor, children }: DonutChartProps) {
23
+ const theme = useTheme();
24
+ const r = (size - thickness) / 2;
25
+ const c = 2 * Math.PI * r;
26
+ const dashes = donutDashes(segments, c, gap);
27
+ const center = size / 2;
28
+
29
+ return (
30
+ <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
31
+ <Svg width={size} height={size}>
32
+ {/* Rotate so slices start at 12 o'clock. A transform string avoids the
33
+ rotation/origin props that map to a `transform-origin` DOM warning on web. */}
34
+ <G transform={`rotate(-90, ${center}, ${center})`}>
35
+ <Circle cx={center} cy={center} r={r} stroke={trackColor ?? theme.colors.muted} strokeWidth={thickness} fill="none" />
36
+ {dashes.map((d, i) => (
37
+ <Circle
38
+ key={i}
39
+ cx={center}
40
+ cy={center}
41
+ r={r}
42
+ stroke={d.color}
43
+ strokeWidth={thickness}
44
+ strokeLinecap="butt"
45
+ fill="none"
46
+ strokeDasharray={`${d.dash} ${d.gap}`}
47
+ strokeDashoffset={d.offset}
48
+ />
49
+ ))}
50
+ </G>
51
+ </Svg>
52
+ {children != null && (
53
+ <View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center' }}>{children}</View>
54
+ )}
55
+ </View>
56
+ );
57
+ }
@@ -0,0 +1,49 @@
1
+ import { type ReactNode } from 'react';
2
+ import { View } from 'react-native';
3
+ import { DonutChart } from './DonutChart';
4
+ import { Text } from './Text';
5
+
6
+ export interface DonutLegendItem {
7
+ label: string;
8
+ /** Segment magnitude. */
9
+ value: number;
10
+ color: string;
11
+ /** Optional secondary line under the label. */
12
+ caption?: string;
13
+ /** Right-aligned content (formatted value, badge, sparkline…). */
14
+ trailing?: ReactNode;
15
+ }
16
+
17
+ export interface DonutLegendProps {
18
+ items: DonutLegendItem[];
19
+ size?: number;
20
+ thickness?: number;
21
+ /** Content rendered in the donut hole. */
22
+ center?: ReactNode;
23
+ /** Gap between the donut and the legend. */
24
+ gap?: number;
25
+ }
26
+
27
+ /** Donut chart beside a colored-dot legend. The common "breakdown" block
28
+ * (revenue by source, fans by platform, …). */
29
+ export function DonutLegend({ items, size = 130, thickness = 16, center, gap = 16 }: DonutLegendProps) {
30
+ return (
31
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap }}>
32
+ <DonutChart size={size} thickness={thickness} segments={items.map((i) => ({ value: i.value, color: i.color }))}>
33
+ {center}
34
+ </DonutChart>
35
+ <View style={{ flex: 1, gap: 10 }}>
36
+ {items.map((it, idx) => (
37
+ <View key={`${it.label}-${idx}`} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
38
+ <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: it.color }} />
39
+ <View style={{ flex: 1 }}>
40
+ <Text variant="muted">{it.label}</Text>
41
+ {it.caption ? <Text variant="caption">{it.caption}</Text> : null}
42
+ </View>
43
+ {it.trailing ? <View>{it.trailing}</View> : null}
44
+ </View>
45
+ ))}
46
+ </View>
47
+ </View>
48
+ );
49
+ }