@lotics/ui 45.9.0 → 45.10.1

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,398 @@
1
+ import { useMemo } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors, solid } from "./colors";
4
+ import { useLoticsLocale } from "./locale";
5
+ import { SPACE } from "./spacing";
6
+ import { Text } from "./text";
7
+
8
+ /**
9
+ * What a bar STANDS for, which is also what decides where it starts.
10
+ *
11
+ * `basis` and `total` are LEVELS — they stand on zero, so their `value` is read
12
+ * as an absolute height. `delta` is a STEP — it floats between the level before
13
+ * it and the level after, so its `value` is read as a signed contribution.
14
+ */
15
+ export type WaterfallKind = "basis" | "delta" | "total";
16
+
17
+ export interface WaterfallItem {
18
+ key: string;
19
+ /** The step's name, under the bar ("Giá vốn", "Ad spend"). */
20
+ label: string;
21
+ /** `basis`/`total`: the level the bar reaches. `delta`: the signed step. */
22
+ value: number;
23
+ /** Default `delta`. */
24
+ kind?: WaterfallKind;
25
+ /**
26
+ * A neutral QUALIFIER under the label — a count, a period, a basis of
27
+ * calculation. Never a problem or a state: one bar's meta being a fact and
28
+ * another's a complaint is what makes the row read inconsistent.
29
+ */
30
+ meta?: string;
31
+ /**
32
+ * Override the derived colour. The defaults already carry the meaning —
33
+ * a step that ADDS is `emerald`, one that SUBTRACTS is `red`, a basis is
34
+ * neutral, and a closing total takes the colour of its own sign — so reach
35
+ * for this only when the domain colours a step against its arithmetic.
36
+ */
37
+ color?: string;
38
+ }
39
+
40
+ /** One bar's resolved geometry, in value units and as a fraction of the span. */
41
+ export interface WaterfallBar {
42
+ key: string;
43
+ label: string;
44
+ meta?: string;
45
+ kind: WaterfallKind;
46
+ value: number;
47
+ color: string;
48
+ /** The lower and upper level this bar spans, in value units. */
49
+ from: number;
50
+ to: number;
51
+ /** Where the bar starts, as a 0–1 fraction of the plotted span. */
52
+ offset: number;
53
+ /** How much of the plotted span the bar covers, 0–1. */
54
+ size: number;
55
+ /** The running level this bar leaves behind — where its connector sits. */
56
+ level: number;
57
+ /** That same level as a 0–1 fraction of the plotted span. */
58
+ levelFraction: number;
59
+ }
60
+
61
+ export interface WaterfallLayout {
62
+ bars: WaterfallBar[];
63
+ /** The plotted span, which always includes zero. */
64
+ min: number;
65
+ max: number;
66
+ /** Zero as a 0–1 fraction of the span — where the baseline is drawn. */
67
+ zeroFraction: number;
68
+ }
69
+
70
+ function derivedColor(kind: WaterfallKind, value: number, level: number): string {
71
+ if (kind === "basis") return colors.zinc[400];
72
+ if (kind === "total") return level < 0 ? solid("red") : solid("emerald");
73
+ return value < 0 ? solid("red") : solid("emerald");
74
+ }
75
+
76
+ /**
77
+ * Walk the items into bar geometry. Pure, exported and separately tested,
78
+ * because a bridge is only worth drawing if the floating bars land on the
79
+ * levels the arithmetic says they do — and that is the one part of the chart
80
+ * no screenshot can confirm.
81
+ */
82
+ export function waterfallLayout(items: WaterfallItem[]): WaterfallLayout {
83
+ let level = 0;
84
+ const walked = items.map((item) => {
85
+ const kind = item.kind ?? "delta";
86
+ const from = kind === "delta" ? level : 0;
87
+ const to = kind === "delta" ? level + item.value : item.value;
88
+ level = to;
89
+ return { item, kind, from, to, level };
90
+ });
91
+
92
+ // Zero is always in the span: a bridge is read against its baseline, and a
93
+ // set of bars that never touches it would otherwise float on a scale that
94
+ // exaggerates every step.
95
+ const levels = walked.flatMap((w) => [w.from, w.to]);
96
+ const min = Math.min(0, ...levels);
97
+ const max = Math.max(0, ...levels);
98
+ const span = max - min || 1;
99
+
100
+ const bars: WaterfallBar[] = walked.map((w) => {
101
+ const low = Math.min(w.from, w.to);
102
+ const high = Math.max(w.from, w.to);
103
+ return {
104
+ key: w.item.key,
105
+ label: w.item.label,
106
+ meta: w.item.meta,
107
+ kind: w.kind,
108
+ value: w.item.value,
109
+ color: w.item.color ?? derivedColor(w.kind, w.item.value, w.level),
110
+ from: low,
111
+ to: high,
112
+ offset: (low - min) / span,
113
+ size: (high - low) / span,
114
+ level: w.level,
115
+ levelFraction: (w.level - min) / span,
116
+ };
117
+ });
118
+
119
+ return { bars, min, max, zeroFraction: (0 - min) / span };
120
+ }
121
+
122
+ export interface WaterfallChartProps {
123
+ items: WaterfallItem[];
124
+ /**
125
+ * `vertical` is the classic bridge — columns left to right, the shape most
126
+ * readers already know. `horizontal` turns it into rows, which is what a
127
+ * narrow container or long step names need: a column 70px wide cannot hold
128
+ * "Giá vốn và phí giao hàng" and a rotated label is not an answer.
129
+ */
130
+ orientation?: "vertical" | "horizontal";
131
+ /** Formats every figure on the chart. Pass the surface's own money formatter. */
132
+ formatNumber?: (n: number) => string;
133
+ /** Plot height in px for `vertical`; bar thickness for `horizontal`. */
134
+ height?: number;
135
+ emptyLabel?: string;
136
+ }
137
+
138
+ const defaultFormatNumber = (n: number): string =>
139
+ new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
140
+
141
+ /**
142
+ * The BRIDGE: an opening level, the signed steps that move it, and the level
143
+ * they close at — revenue walked down to net, budget walked to actual, opening
144
+ * balance walked to closing. Each step floats between the level before it and
145
+ * the level after, connectors carry the eye across, and the run of colour says
146
+ * which way the money went without the reader adding anything up.
147
+ *
148
+ * Pick it over `Ledger` when the reader is scanning for SHAPE — which step is
149
+ * the big one, does the total survive — and over `BarChart` whenever the bars
150
+ * are steps in one arithmetic rather than independent quantities: a bar chart
151
+ * of a P&L draws five columns that all start at zero and states a comparison
152
+ * nobody meant. `Ledger` stays right where the figures must be read exactly and
153
+ * reconciled line by line; a dashboard wants this.
154
+ *
155
+ * The steps must be the arithmetic, all of it — a bridge earns its trust by
156
+ * closing on the total it draws, so derive the steps and the close from ONE
157
+ * computation rather than from two that can drift apart.
158
+ */
159
+ export function WaterfallChart(props: WaterfallChartProps) {
160
+ const locale = useLoticsLocale();
161
+ const {
162
+ items,
163
+ orientation = "vertical",
164
+ formatNumber = defaultFormatNumber,
165
+ height = orientation === "vertical" ? 180 : 14,
166
+ emptyLabel = locale.chart.noData,
167
+ } = props;
168
+
169
+ const layout = useMemo(() => waterfallLayout(items), [items]);
170
+
171
+ if (items.length === 0) {
172
+ return (
173
+ <View style={styles.empty}>
174
+ <Text color="muted">{emptyLabel}</Text>
175
+ </View>
176
+ );
177
+ }
178
+
179
+ // Rows: the step's name and its figure on one line, the bar on the FULL width
180
+ // beneath them. Not a label column / track / figure column — three fixed
181
+ // columns leave the track a sliver of a card, which is the one part of a
182
+ // bridge that has to be seen rather than read, and it truncates the figure
183
+ // that is the other part.
184
+ if (orientation === "horizontal") {
185
+ return (
186
+ <View style={styles.rows}>
187
+ {layout.bars.map((bar, i) => (
188
+ <View key={bar.key} style={styles.row}>
189
+ <View style={styles.head}>
190
+ <View style={styles.headText}>
191
+ <Text
192
+ size="sm"
193
+ weight={bar.kind === "delta" ? "regular" : "medium"}
194
+ numberOfLines={2}
195
+ leading="tight"
196
+ >
197
+ {bar.label}
198
+ </Text>
199
+ {bar.meta ? (
200
+ <Text size="xs" color="muted" numberOfLines={1} leading="tight">
201
+ {bar.meta}
202
+ </Text>
203
+ ) : null}
204
+ </View>
205
+ <Text
206
+ size="sm"
207
+ tabular
208
+ align="right"
209
+ weight={bar.kind === "delta" ? "regular" : "medium"}
210
+ >
211
+ {formatNumber(bar.kind === "delta" ? bar.value : bar.level)}
212
+ </Text>
213
+ </View>
214
+ <View style={[styles.rowTrack, { height }]}>
215
+ {layout.min < 0 ? (
216
+ <View
217
+ style={[styles.baselineVertical, { left: `${layout.zeroFraction * 100}%` }]}
218
+ />
219
+ ) : null}
220
+ <View
221
+ style={[
222
+ styles.bar,
223
+ styles.barHorizontal,
224
+ {
225
+ left: `${bar.offset * 100}%`,
226
+ width: `${bar.size * 100}%`,
227
+ backgroundColor: bar.color,
228
+ },
229
+ ]}
230
+ />
231
+ {i < layout.bars.length - 1 ? (
232
+ <View
233
+ style={[styles.connectorVertical, { left: `${bar.levelFraction * 100}%` }]}
234
+ />
235
+ ) : null}
236
+ </View>
237
+ </View>
238
+ ))}
239
+ </View>
240
+ );
241
+ }
242
+
243
+ return (
244
+ <View style={styles.columns}>
245
+ <View style={[styles.plot, { height }]}>
246
+ {layout.min < 0 ? (
247
+ <View
248
+ style={[styles.baselineHorizontal, { bottom: `${layout.zeroFraction * 100}%` }]}
249
+ />
250
+ ) : null}
251
+ {layout.bars.map((bar, i) => (
252
+ <View key={bar.key} style={styles.column}>
253
+ <View
254
+ style={[
255
+ styles.bar,
256
+ styles.barVertical,
257
+ {
258
+ bottom: `${bar.offset * 100}%`,
259
+ height: `${bar.size * 100}%`,
260
+ backgroundColor: bar.color,
261
+ },
262
+ ]}
263
+ />
264
+ {i < layout.bars.length - 1 ? (
265
+ <View
266
+ style={[styles.connectorHorizontal, { bottom: `${bar.levelFraction * 100}%` }]}
267
+ />
268
+ ) : null}
269
+ </View>
270
+ ))}
271
+ </View>
272
+ <View style={styles.captions}>
273
+ {layout.bars.map((bar) => (
274
+ <View key={bar.key} style={styles.caption}>
275
+ <Text
276
+ size="sm"
277
+ tabular
278
+ align="center"
279
+ weight={bar.kind === "delta" ? "regular" : "medium"}
280
+ numberOfLines={1}
281
+ >
282
+ {formatNumber(bar.kind === "delta" ? bar.value : bar.level)}
283
+ </Text>
284
+ <Text size="xs" color="muted" align="center" leading="tight" numberOfLines={2}>
285
+ {bar.label}
286
+ </Text>
287
+ {bar.meta ? (
288
+ <Text size="xs" color="muted" align="center" leading="tight" numberOfLines={1}>
289
+ {bar.meta}
290
+ </Text>
291
+ ) : null}
292
+ </View>
293
+ ))}
294
+ </View>
295
+ </View>
296
+ );
297
+ }
298
+
299
+ // The connector is a hairline in the SAME ink as a table's rules: it joins two
300
+ // bars, it is not a third quantity, so it must never read as one.
301
+ const CONNECTOR = colors.zinc[300];
302
+
303
+ /** The gap between two steps in `horizontal` — and the connector's own length. */
304
+ const ROW_GAP = SPACE.lg;
305
+
306
+ const styles = StyleSheet.create({
307
+ empty: {
308
+ justifyContent: "center",
309
+ },
310
+ columns: {
311
+ gap: SPACE.sm,
312
+ },
313
+ plot: {
314
+ flexDirection: "row",
315
+ alignItems: "stretch",
316
+ gap: SPACE.sm,
317
+ },
318
+ column: {
319
+ flex: 1,
320
+ position: "relative",
321
+ },
322
+ // The bar is absolute in BOTH orientations, and the axis it does NOT float on
323
+ // has to be pinned — a horizontal bar given only `left`/`width` collapses to
324
+ // its `minHeight` and draws a hairline where a bar was asked for.
325
+ bar: {
326
+ position: "absolute",
327
+ borderRadius: 2,
328
+ },
329
+ barVertical: {
330
+ left: 0,
331
+ right: 0,
332
+ minHeight: 2,
333
+ },
334
+ barHorizontal: {
335
+ top: 0,
336
+ bottom: 0,
337
+ minWidth: 2,
338
+ },
339
+ // Bridges the gap to the NEXT column, drawn from inside this one so the
340
+ // connector cannot drift out of step with the bar whose level it leaves.
341
+ connectorHorizontal: {
342
+ position: "absolute",
343
+ left: "100%",
344
+ width: SPACE.sm,
345
+ height: 1,
346
+ backgroundColor: CONNECTOR,
347
+ },
348
+ baselineHorizontal: {
349
+ position: "absolute",
350
+ left: 0,
351
+ right: 0,
352
+ height: 1,
353
+ backgroundColor: CONNECTOR,
354
+ },
355
+ captions: {
356
+ flexDirection: "row",
357
+ gap: SPACE.sm,
358
+ },
359
+ caption: {
360
+ flex: 1,
361
+ gap: 1,
362
+ },
363
+ // Between two steps, more than between a step's own name, figure and bar:
364
+ // those three are one object, and the connector spans exactly this gap.
365
+ rows: {
366
+ gap: ROW_GAP,
367
+ },
368
+ row: {
369
+ gap: SPACE.xs,
370
+ },
371
+ head: {
372
+ flexDirection: "row",
373
+ alignItems: "flex-start",
374
+ gap: SPACE.md,
375
+ },
376
+ headText: {
377
+ flex: 1,
378
+ minWidth: 0,
379
+ gap: 1,
380
+ },
381
+ rowTrack: {
382
+ position: "relative",
383
+ },
384
+ connectorVertical: {
385
+ position: "absolute",
386
+ top: "100%",
387
+ height: ROW_GAP,
388
+ width: 1,
389
+ backgroundColor: CONNECTOR,
390
+ },
391
+ baselineVertical: {
392
+ position: "absolute",
393
+ top: 0,
394
+ bottom: 0,
395
+ width: 1,
396
+ backgroundColor: CONNECTOR,
397
+ },
398
+ });