@lotics/ui 45.8.1 → 45.10.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/AGENTS.md +6 -6
- package/MIGRATION.md +18 -0
- package/docs/catalog.md +50 -16
- package/docs/composition.md +114 -0
- package/docs/reviewing.md +8 -0
- package/docs/templates.md +74 -1
- package/package.json +3 -1
- package/src/bar_chart.tsx +20 -2
- package/src/brand_mark.tsx +17 -34
- package/src/chip_group.tsx +5 -3
- package/src/finding.tsx +18 -3
- package/src/line_chart.tsx +5 -14
- package/src/line_chart_labels.ts +32 -0
- package/src/page_header.tsx +13 -1
- package/src/stacked_bar_chart.tsx +197 -0
- package/src/switch_button.tsx +7 -8
- package/src/waterfall_chart.tsx +398 -0
package/src/line_chart.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { useMemo, useState, useCallback } from "react";
|
|
5
5
|
import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
|
|
6
6
|
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { lineChartLabelIndices } from "./line_chart_labels";
|
|
7
8
|
|
|
8
9
|
export interface LineChartPoint {
|
|
9
10
|
x: string | number;
|
|
@@ -96,20 +97,10 @@ export function LineChart(props: LineChartProps) {
|
|
|
96
97
|
const visibleLabels = useMemo(() => {
|
|
97
98
|
if (data.length === 0 || chartWidth === 0) return [];
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const labels: { index: number; label: string }[] = [];
|
|
104
|
-
for (let i = 0; i < data.length; i++) {
|
|
105
|
-
const isFirst = i === 0;
|
|
106
|
-
const isLast = i === data.length - 1;
|
|
107
|
-
const isStep = i % step === 0;
|
|
108
|
-
if (isFirst || isLast || isStep) {
|
|
109
|
-
labels.push({ index: i, label: formatXLabel(data[i].x) });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return labels;
|
|
100
|
+
return lineChartLabelIndices(data.length, chartWidth).map((index) => ({
|
|
101
|
+
index,
|
|
102
|
+
label: formatXLabel(data[index].x),
|
|
103
|
+
}));
|
|
113
104
|
}, [data, chartWidth, formatXLabel]);
|
|
114
105
|
|
|
115
106
|
if (data.length === 0) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a line chart's x positions get a printed label.
|
|
3
|
+
*
|
|
4
|
+
* A label is roughly `minLabelWidth` wide, so only so many fit across the
|
|
5
|
+
* track; the rest are thinned out. The rule that matters is WHERE the thinning
|
|
6
|
+
* is anchored: stepping forwards from the first point and then forcing the last
|
|
7
|
+
* one in collides whenever the series length minus one is not a multiple of the
|
|
8
|
+
* step — the newest label lands a few pixels from the one before it while every
|
|
9
|
+
* other pair is a full step apart. Anchoring on the LAST point makes the spacing
|
|
10
|
+
* uniform by construction, and the last point is the one a reader looks up
|
|
11
|
+
* first. The first point is then kept only when it clears the same distance.
|
|
12
|
+
*/
|
|
13
|
+
export function lineChartLabelIndices(
|
|
14
|
+
count: number,
|
|
15
|
+
chartWidth: number,
|
|
16
|
+
minLabelWidth = 50,
|
|
17
|
+
): number[] {
|
|
18
|
+
if (count <= 0 || chartWidth <= 0) return [];
|
|
19
|
+
if (count === 1) return [0];
|
|
20
|
+
|
|
21
|
+
const last = count - 1;
|
|
22
|
+
const maxLabels = Math.max(2, Math.floor(chartWidth / minLabelWidth));
|
|
23
|
+
const step = Math.max(1, Math.ceil(count / maxLabels));
|
|
24
|
+
|
|
25
|
+
const kept: number[] = [];
|
|
26
|
+
for (let i = last; i >= 0; i -= step) kept.unshift(i);
|
|
27
|
+
|
|
28
|
+
const firstKept = kept[0] ?? 0;
|
|
29
|
+
if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
|
|
30
|
+
|
|
31
|
+
return kept;
|
|
32
|
+
}
|
package/src/page_header.tsx
CHANGED
|
@@ -48,12 +48,20 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
48
48
|
// then floors the item at its MIN-CONTENT width — the longest word — so
|
|
49
49
|
// `minWidth: 0` is what lets it actually wrap. `DetailRow`'s spread value is
|
|
50
50
|
// the same pair for the same reason.
|
|
51
|
+
// `flexWrap` is the floor under the shrink rule below, not a competing one. A
|
|
52
|
+
// title shrinks only as far as its longest WORD; past that, min-content title
|
|
53
|
+
// plus a full-width CTA can still exceed the row, and a row that cannot wrap
|
|
54
|
+
// has nowhere to put the excess but off the edge — measured at 393px of
|
|
55
|
+
// content in a 360px frame, clipped, scrolling sideways, which `composition.md`
|
|
56
|
+
// bans outright. Wrapping drops the actions to their own line and keeps the
|
|
57
|
+
// stated bargain intact: the CTA stays reachable, the header is merely taller.
|
|
51
58
|
const titleRow = (
|
|
52
59
|
<View
|
|
53
60
|
style={{
|
|
54
61
|
flexDirection: "row",
|
|
55
62
|
alignItems: "center",
|
|
56
63
|
justifyContent: "space-between",
|
|
64
|
+
flexWrap: "wrap",
|
|
57
65
|
gap: 12,
|
|
58
66
|
}}
|
|
59
67
|
>
|
|
@@ -73,7 +81,11 @@ export function PageHeader(props: PageHeaderProps) {
|
|
|
73
81
|
below its own icon is not a smaller control, it is a broken one. */}
|
|
74
82
|
{trailing !== undefined && <View style={{ flexShrink: 0 }}>{trailing}</View>}
|
|
75
83
|
</View>
|
|
76
|
-
{
|
|
84
|
+
{/* Wrapped so which side gives way is STATED rather than left to whatever
|
|
85
|
+
the caller happened to pass. A bare `{actions}` inherited its own
|
|
86
|
+
shrink behaviour, so the row's bargain held or broke depending on the
|
|
87
|
+
CTA — and a bargain that depends on the other party is not one. */}
|
|
88
|
+
{actions !== undefined && <View style={{ flexShrink: 0 }}>{actions}</View>}
|
|
77
89
|
</View>
|
|
78
90
|
);
|
|
79
91
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { LegendItem } from "./legend_item";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
|
+
import { SPACE } from "./spacing";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
import type { TextColor } from "./text_utils";
|
|
9
|
+
|
|
10
|
+
export interface StackedBarSeries {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
color: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StackedBarRow {
|
|
17
|
+
key: string;
|
|
18
|
+
/** The entity this row is about — a campaign, a depot, a month. */
|
|
19
|
+
label: string;
|
|
20
|
+
/** A neutral qualifier beside the label ("Đang chạy", "12 đơn"). */
|
|
21
|
+
meta?: string;
|
|
22
|
+
/** Per-series magnitudes, keyed by `StackedBarSeries.key`. Negatives are dropped. */
|
|
23
|
+
values: Record<string, number>;
|
|
24
|
+
/** The row's headline figure, right of the label. Already formatted by the caller. */
|
|
25
|
+
value?: string;
|
|
26
|
+
valueTone?: TextColor;
|
|
27
|
+
/** One full sentence under the bar, saying what the segments amount to. */
|
|
28
|
+
caption?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StackedBarChartProps {
|
|
32
|
+
series: StackedBarSeries[];
|
|
33
|
+
rows: StackedBarRow[];
|
|
34
|
+
/**
|
|
35
|
+
* The shared scale every row is measured on. Defaults to the largest row
|
|
36
|
+
* total, which is what makes this a chart: leave it alone unless a second
|
|
37
|
+
* chart beside this one must be read against the same ruler.
|
|
38
|
+
*/
|
|
39
|
+
max?: number;
|
|
40
|
+
/** Bar thickness in px. Default 12. */
|
|
41
|
+
height?: number;
|
|
42
|
+
/** Render the series legend above the rows. Default true. */
|
|
43
|
+
legend?: boolean;
|
|
44
|
+
emptyLabel?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rows of stacked bars on ONE SHARED SCALE — spend against return per campaign,
|
|
49
|
+
* cost make-up per product line, hours per crew. Each row's bar is as long as
|
|
50
|
+
* that row's total is against the largest row, and the segments inside it split
|
|
51
|
+
* that length; so bar LENGTH answers "how big is this one" and the segment run
|
|
52
|
+
* answers "what is it made of", in one pass.
|
|
53
|
+
*
|
|
54
|
+
* That shared scale is the whole difference from `StackedProgressBar`, and the
|
|
55
|
+
* reason this is a separate component rather than a prop on it. That one draws
|
|
56
|
+
* ONE whole split across segments and fills its track whatever the total — put
|
|
57
|
+
* it on ten rows and ten unequal totals draw ten equal bars, which reads as a
|
|
58
|
+
* chart and states nothing. `Breakdown` is the other neighbour: one whole, one
|
|
59
|
+
* bar, ranked share rows beneath it. Reach here the moment there are SEVERAL
|
|
60
|
+
* wholes to compare.
|
|
61
|
+
*
|
|
62
|
+
* Segments carry meaning by colour, so the legend is on by default and the
|
|
63
|
+
* colours are the caller's — one `ColorName` family per dimension, per the
|
|
64
|
+
* kit's colour discipline. A row's `caption` is the second channel: colour
|
|
65
|
+
* alone never has to carry the point.
|
|
66
|
+
*/
|
|
67
|
+
export function StackedBarChart(props: StackedBarChartProps) {
|
|
68
|
+
const locale = useLoticsLocale();
|
|
69
|
+
const {
|
|
70
|
+
series,
|
|
71
|
+
rows,
|
|
72
|
+
max: maxProp,
|
|
73
|
+
height = 12,
|
|
74
|
+
legend = true,
|
|
75
|
+
emptyLabel = locale.chart.noData,
|
|
76
|
+
} = props;
|
|
77
|
+
|
|
78
|
+
const totals = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
rows.map((r) => series.reduce((t, s) => t + Math.max(0, r.values[s.key] ?? 0), 0)),
|
|
81
|
+
[rows, series],
|
|
82
|
+
);
|
|
83
|
+
const max = maxProp ?? Math.max(...totals, 0);
|
|
84
|
+
|
|
85
|
+
if (rows.length === 0) {
|
|
86
|
+
return (
|
|
87
|
+
<View style={styles.empty}>
|
|
88
|
+
<Text color="muted">{emptyLabel}</Text>
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<View style={styles.container}>
|
|
95
|
+
{legend ? (
|
|
96
|
+
<View style={styles.legend}>
|
|
97
|
+
{series.map((s) => (
|
|
98
|
+
<LegendItem key={s.key} color={s.color} label={s.label} />
|
|
99
|
+
))}
|
|
100
|
+
</View>
|
|
101
|
+
) : null}
|
|
102
|
+
<View style={styles.rows}>
|
|
103
|
+
{rows.map((row, i) => {
|
|
104
|
+
const total = totals[i];
|
|
105
|
+
const share = max > 0 ? total / max : 0;
|
|
106
|
+
return (
|
|
107
|
+
<View key={row.key} style={styles.row}>
|
|
108
|
+
{/* The name over its qualifier, the figure alongside — not three
|
|
109
|
+
things on one line. A row's name is its identity, and a line
|
|
110
|
+
that also has to fit a status and a figure spends a narrow
|
|
111
|
+
container's width truncating exactly that. */}
|
|
112
|
+
<View style={styles.head}>
|
|
113
|
+
<View style={styles.headText}>
|
|
114
|
+
<Text size="sm" weight="medium" numberOfLines={2} leading="tight">
|
|
115
|
+
{row.label}
|
|
116
|
+
</Text>
|
|
117
|
+
{row.meta ? (
|
|
118
|
+
<Text size="xs" color="muted" numberOfLines={1} leading="tight">
|
|
119
|
+
{row.meta}
|
|
120
|
+
</Text>
|
|
121
|
+
) : null}
|
|
122
|
+
</View>
|
|
123
|
+
{row.value ? (
|
|
124
|
+
<Text size="sm" weight="medium" tabular align="right" color={row.valueTone}>
|
|
125
|
+
{row.value}
|
|
126
|
+
</Text>
|
|
127
|
+
) : null}
|
|
128
|
+
</View>
|
|
129
|
+
<View style={[styles.track, { height }]}>
|
|
130
|
+
<View style={[styles.fill, { width: `${share * 100}%` }]}>
|
|
131
|
+
{series
|
|
132
|
+
.filter((s) => (row.values[s.key] ?? 0) > 0)
|
|
133
|
+
.map((s) => (
|
|
134
|
+
<View
|
|
135
|
+
key={s.key}
|
|
136
|
+
style={{ flex: row.values[s.key], backgroundColor: s.color }}
|
|
137
|
+
/>
|
|
138
|
+
))}
|
|
139
|
+
</View>
|
|
140
|
+
</View>
|
|
141
|
+
{row.caption ? (
|
|
142
|
+
<Text size="xs" color="muted">
|
|
143
|
+
{row.caption}
|
|
144
|
+
</Text>
|
|
145
|
+
) : null}
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</View>
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
empty: {
|
|
156
|
+
justifyContent: "center",
|
|
157
|
+
},
|
|
158
|
+
container: {
|
|
159
|
+
gap: SPACE.md,
|
|
160
|
+
},
|
|
161
|
+
legend: {
|
|
162
|
+
flexDirection: "row",
|
|
163
|
+
flexWrap: "wrap",
|
|
164
|
+
gap: SPACE.md,
|
|
165
|
+
},
|
|
166
|
+
// Between two rows, more than between a row's own name, bar and sentence:
|
|
167
|
+
// the three belong to one campaign and have to read as one object.
|
|
168
|
+
rows: {
|
|
169
|
+
gap: SPACE.lg,
|
|
170
|
+
},
|
|
171
|
+
row: {
|
|
172
|
+
gap: 6,
|
|
173
|
+
},
|
|
174
|
+
head: {
|
|
175
|
+
flexDirection: "row",
|
|
176
|
+
alignItems: "flex-start",
|
|
177
|
+
gap: SPACE.md,
|
|
178
|
+
},
|
|
179
|
+
headText: {
|
|
180
|
+
flex: 1,
|
|
181
|
+
minWidth: 0,
|
|
182
|
+
gap: 1,
|
|
183
|
+
},
|
|
184
|
+
// The track is the SHARED ruler; the fill is this row's share of it, and the
|
|
185
|
+
// segments split the fill. Three boxes, because collapsing the middle one is
|
|
186
|
+
// exactly how a stacked bar loses its scale.
|
|
187
|
+
track: {
|
|
188
|
+
borderRadius: 999,
|
|
189
|
+
overflow: "hidden",
|
|
190
|
+
backgroundColor: colors.zinc[100],
|
|
191
|
+
},
|
|
192
|
+
fill: {
|
|
193
|
+
flexDirection: "row",
|
|
194
|
+
height: "100%",
|
|
195
|
+
minWidth: 2,
|
|
196
|
+
},
|
|
197
|
+
});
|
package/src/switch_button.tsx
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import { View } from "react-native";
|
|
2
3
|
import { Text } from "./text";
|
|
3
4
|
import { Switch, type SwitchProps } from "./switch";
|
|
4
5
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
6
|
import { Icon, type IconName } from "./icon";
|
|
6
|
-
import { BrandMark, type BrandName } from "./brand_mark";
|
|
7
7
|
import { CONTROL_RADIUS } from "./control_surface";
|
|
8
8
|
|
|
9
9
|
export interface SwitchButtonProps extends SwitchProps {
|
|
10
10
|
icon?: IconName;
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* other companies' logos. `icon` wins if both are passed.
|
|
12
|
+
* Anything to draw in the leading slot instead of an `Icon` — a product logo
|
|
13
|
+
* a row names, which the caller owns because the kit does not ship other
|
|
14
|
+
* companies' artwork. `icon` wins if both are passed.
|
|
16
15
|
*/
|
|
17
|
-
|
|
16
|
+
leading?: ReactNode;
|
|
18
17
|
title: string;
|
|
19
18
|
tooltip?: string;
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export function SwitchButton(props: SwitchButtonProps) {
|
|
23
|
-
const { title, icon,
|
|
22
|
+
const { title, icon, leading, value, tooltip, onChange } = props;
|
|
24
23
|
|
|
25
24
|
return (
|
|
26
25
|
<PressableHighlight
|
|
@@ -41,7 +40,7 @@ export function SwitchButton(props: SwitchButtonProps) {
|
|
|
41
40
|
aria-checked={!!value}
|
|
42
41
|
>
|
|
43
42
|
<View style={{ flexDirection: "row", gap: 8, alignItems: "center", flex: 1 }}>
|
|
44
|
-
{icon ? <Icon name={icon} size={20} /> :
|
|
43
|
+
{icon ? <Icon name={icon} size={20} /> : (leading ?? null)}
|
|
45
44
|
{!!title && (
|
|
46
45
|
<Text weight="medium" userSelect="none">
|
|
47
46
|
{title}
|