@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.
- package/AGENTS.md +70 -137
- package/docs/ai_patterns.md +88 -150
- package/docs/catalog.md +243 -285
- package/docs/composition.md +455 -465
- package/docs/data_entry.md +109 -155
- package/docs/reviewing.md +47 -55
- package/docs/templates.md +480 -371
- package/docs/testing.md +3 -7
- package/package.json +3 -1
- package/src/bar_chart.tsx +20 -2
- 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/pressable_row.tsx +7 -5
- package/src/stacked_bar_chart.tsx +197 -0
- package/src/table.tsx +15 -14
- package/src/waterfall_chart.tsx +398 -0
|
@@ -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
|
+
});
|