@lotics/ui 46.1.0 → 46.3.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 +24 -0
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +34 -14
- package/docs/catalog.md +109 -22
- package/docs/composition.md +48 -6
- package/docs/templates.md +3 -1
- package/docs/testing.md +6 -0
- package/package.json +3 -2
- package/src/agent_run.tsx +74 -58
- package/src/agent_run_pane.tsx +7 -5
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/line_chart.tsx +2 -2
- package/src/locale.tsx +4 -4
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/src/line_chart_labels.ts +0 -32
package/src/agent_run.tsx
CHANGED
|
@@ -8,7 +8,6 @@ import { Callout, CalloutActions, CalloutText } from "./callout";
|
|
|
8
8
|
import { Markdown } from "./markdown";
|
|
9
9
|
import { JsonPanel, stringifyData } from "./json_panel";
|
|
10
10
|
import { PressableHighlight } from "./pressable_highlight";
|
|
11
|
-
import { FollowScroll } from "./follow_scroll";
|
|
12
11
|
import { Marker, type StepStatus } from "./stepper";
|
|
13
12
|
import { NODE } from "./stepper_layout";
|
|
14
13
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
@@ -86,7 +85,7 @@ export interface AgentRunProps {
|
|
|
86
85
|
// ── Tool → display meta ───────────────────────────────────────────────────────
|
|
87
86
|
// The ICON per platform tool. The LABEL lives in the locale (`agentRun.tools`)
|
|
88
87
|
// because it is language, and this component already renders localized chrome
|
|
89
|
-
// around these rows — an English tool row under a Vietnamese "Đang suy
|
|
88
|
+
// around these rows — an English tool row under a Vietnamese "Đang suy nghĩ"
|
|
90
89
|
// was the kit contradicting itself. An icon is not language, so it stays here.
|
|
91
90
|
// A tool neither map knows falls back to a prettified name + a neutral icon.
|
|
92
91
|
const TOOL_ICONS: Record<string, IconName> = {
|
|
@@ -149,6 +148,37 @@ function stepLabel(
|
|
|
149
148
|
|
|
150
149
|
const INK = colors.zinc[700];
|
|
151
150
|
|
|
151
|
+
// The "still working" breath, and the ONE rhythm every unsettled row shares.
|
|
152
|
+
// Half-cycle 750ms — so a full breath is the 1500ms the `Marker`'s live halo
|
|
153
|
+
// already runs, and a row's dot and its label breathe together instead of
|
|
154
|
+
// drifting apart into two unrelated animations on one line. The floor is
|
|
155
|
+
// `Skeleton`'s: the kit says "in progress" with an opacity rhythm, in one
|
|
156
|
+
// vocabulary, whether the thing pulsing is a placeholder tile or a live label.
|
|
157
|
+
const BREATH_HALF = 750;
|
|
158
|
+
const BREATH_FLOOR = 0.6;
|
|
159
|
+
|
|
160
|
+
/** Breathe `children` while the work behind them is unsettled. Settled, it
|
|
161
|
+
* holds full opacity and runs no animation — a finished row is not working,
|
|
162
|
+
* and neither is one parked `awaiting` a human decision. */
|
|
163
|
+
function Breathing({ active, children }: { active: boolean; children: ReactNode }) {
|
|
164
|
+
const v = useRef(new Animated.Value(1)).current;
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!active) {
|
|
167
|
+
v.setValue(1);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const loop = Animated.loop(
|
|
171
|
+
Animated.sequence([
|
|
172
|
+
Animated.timing(v, { toValue: BREATH_FLOOR, duration: BREATH_HALF, useNativeDriver: true }),
|
|
173
|
+
Animated.timing(v, { toValue: 1, duration: BREATH_HALF, useNativeDriver: true }),
|
|
174
|
+
]),
|
|
175
|
+
);
|
|
176
|
+
loop.start();
|
|
177
|
+
return () => loop.stop();
|
|
178
|
+
}, [active, v]);
|
|
179
|
+
return <Animated.View style={{ opacity: v }}>{children}</Animated.View>;
|
|
180
|
+
}
|
|
181
|
+
|
|
152
182
|
/**
|
|
153
183
|
* A live feed of an AI agent's work as a TIMELINE — the prose it streams and the
|
|
154
184
|
* tools it calls, in the order they happened. While the agent is mid-tools the
|
|
@@ -197,9 +227,9 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
197
227
|
// answer, and hiding THAT behind a row until settle would be the real loss.
|
|
198
228
|
//
|
|
199
229
|
// SETTLED, the collapse covers the work and the answer stands below it.
|
|
200
|
-
// Expanding
|
|
201
|
-
//
|
|
202
|
-
//
|
|
230
|
+
// Expanding is the same either way — the timeline opens IN FULL, and what
|
|
231
|
+
// keeps the newest row in view is the host's own scroller, which every host
|
|
232
|
+
// of a live run already has.
|
|
203
233
|
//
|
|
204
234
|
// The settled clauses are cases where collapsing would hide something needed:
|
|
205
235
|
// · no answer — the run stopped ON a tool call, so the fold would leave an
|
|
@@ -211,7 +241,7 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
211
241
|
const folded = managed && (streaming ? process.length > 0 : process.length > 1 && steps.length > 0 && result.length > 0);
|
|
212
242
|
|
|
213
243
|
// What the row SAYS right now: the tool in flight, or — when the model is
|
|
214
|
-
// writing rather than calling — "Thinking
|
|
244
|
+
// writing rather than calling — "Thinking". Naming the last finished action
|
|
215
245
|
// while the agent composes prose reports a moment that has passed.
|
|
216
246
|
const thinking = streaming && !lastRunningStep(segments);
|
|
217
247
|
|
|
@@ -415,29 +445,19 @@ function StepRow({ s, label, renderToolOutput }: { s: AgentStep; label: string;
|
|
|
415
445
|
|
|
416
446
|
// Thinking — a muted, COLLAPSED "Thinking" disclosure (revealed on press), so the
|
|
417
447
|
// agent's reasoning never crowds the answer. Pulses while it's still streaming.
|
|
418
|
-
/** The empty-live feed row: the live dot's halo plus a BREATHING label
|
|
419
|
-
*
|
|
420
|
-
* reads at a glance even before the first token. */
|
|
448
|
+
/** The empty-live feed row: the live dot's halo plus a BREATHING label, so
|
|
449
|
+
* "work has started" reads at a glance even before the first token. */
|
|
421
450
|
function StartingRow({ label }: { label: string }) {
|
|
422
|
-
const v = useRef(new Animated.Value(0.45)).current;
|
|
423
|
-
useEffect(() => {
|
|
424
|
-
const loop = Animated.loop(
|
|
425
|
-
Animated.sequence([
|
|
426
|
-
Animated.timing(v, { toValue: 1, duration: 700, useNativeDriver: false }),
|
|
427
|
-
Animated.timing(v, { toValue: 0.45, duration: 700, useNativeDriver: false }),
|
|
428
|
-
]),
|
|
429
|
-
);
|
|
430
|
-
loop.start();
|
|
431
|
-
return () => loop.stop();
|
|
432
|
-
}, [v]);
|
|
433
451
|
return (
|
|
434
452
|
<View style={styles.row}>
|
|
435
453
|
<View style={styles.dotCol}>
|
|
436
454
|
<Marker status="current" color={colors.zinc[400]} live />
|
|
437
455
|
</View>
|
|
438
|
-
<
|
|
439
|
-
<
|
|
440
|
-
|
|
456
|
+
<View style={styles.rowBody}>
|
|
457
|
+
<Breathing active>
|
|
458
|
+
<Text size="sm" color="muted">{label}</Text>
|
|
459
|
+
</Breathing>
|
|
460
|
+
</View>
|
|
441
461
|
</View>
|
|
442
462
|
);
|
|
443
463
|
}
|
|
@@ -452,9 +472,11 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
|
|
|
452
472
|
<Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
|
|
453
473
|
</View>
|
|
454
474
|
<View style={styles.rowBody}>
|
|
455
|
-
<
|
|
456
|
-
|
|
457
|
-
|
|
475
|
+
<Breathing active={!!streaming}>
|
|
476
|
+
<Text size="sm" color="muted">
|
|
477
|
+
{streaming ? locale.agentRun.thinkingStreaming : locale.agentRun.thinking}
|
|
478
|
+
</Text>
|
|
479
|
+
</Breathing>
|
|
458
480
|
</View>
|
|
459
481
|
{chevron(expanded ? "up" : "down")}
|
|
460
482
|
</PressableHighlight>
|
|
@@ -514,7 +536,7 @@ function WorkSummary(props: {
|
|
|
514
536
|
const { steps, live, thinking, expanded, onToggle, labelForCall, summarizeRun, children } = props;
|
|
515
537
|
const locale = useLoticsLocale();
|
|
516
538
|
const final = steps[steps.length - 1];
|
|
517
|
-
// "Thinking
|
|
539
|
+
// "Thinking" WINS over a host summary while the model writes: `summarizeRun`
|
|
518
540
|
// names what the run DID, and mid-compose that is a moment already past.
|
|
519
541
|
const label = thinking
|
|
520
542
|
? locale.agentRun.thinkingStreaming
|
|
@@ -537,32 +559,30 @@ function WorkSummary(props: {
|
|
|
537
559
|
trailing={chevron(expanded ? "up" : "down")}
|
|
538
560
|
>
|
|
539
561
|
{/* Keyed by what the row is SAYING, so a new call (or the switch to
|
|
540
|
-
"Thinking
|
|
562
|
+
"Thinking") rises + fades into the SAME row — the label swaps, the
|
|
541
563
|
row does not move. Exactly what a settled `ToolGroup` does mid-burst,
|
|
542
|
-
held for the whole run.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
564
|
+
held for the whole run.
|
|
565
|
+
The breath sits OUTSIDE that key: inside it, the rhythm would restart
|
|
566
|
+
from full opacity on every label swap and fight the fade's own — two
|
|
567
|
+
animations writing one opacity. Outside, the row breathes steadily
|
|
568
|
+
for the whole run while its label changes underneath. */}
|
|
569
|
+
<Breathing active={live}>
|
|
570
|
+
<AnimationFadeIn key={live ? label : "settled"} translateY={4}>
|
|
571
|
+
<SummaryLabel label={label} />
|
|
572
|
+
</AnimationFadeIn>
|
|
573
|
+
</Breathing>
|
|
546
574
|
</ActivityRow>
|
|
547
575
|
{/* The run's own 10px rhythm, not the group's 2px: what rolls out here is
|
|
548
576
|
whole segments — prose, thinking, tool groups — not sibling step rows.
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
the
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
<FollowScroll style={styles.liveFrame}>
|
|
559
|
-
<View style={{ gap: 10 }}>{children}</View>
|
|
560
|
-
</FollowScroll>
|
|
561
|
-
</View>
|
|
562
|
-
) : (
|
|
563
|
-
<View testID="agent-run-work" style={styles.work}>{children}</View>
|
|
564
|
-
)
|
|
565
|
-
) : null}
|
|
577
|
+
It opens IN FULL, live or settled, and the HOST owns the scrolling. A
|
|
578
|
+
self-imposed window here was a scroller nested inside whatever scroller
|
|
579
|
+
the run already sits in — in chat, an inverted list inside an inverted
|
|
580
|
+
list — which paints stale content over its neighbours and captures the
|
|
581
|
+
wheel from the surface the reader is actually on. A component in
|
|
582
|
+
document flow cannot know whether it is inside a scroll container, so
|
|
583
|
+
it must not act as though it were one; a host that needs a bound frames
|
|
584
|
+
the run itself (`collapseProcess={false}` + `FollowScroll`). */}
|
|
585
|
+
{expanded ? <View testID="agent-run-work" style={styles.work}>{children}</View> : null}
|
|
566
586
|
</View>
|
|
567
587
|
);
|
|
568
588
|
}
|
|
@@ -597,9 +617,11 @@ function ToolGroup(props: {
|
|
|
597
617
|
return (
|
|
598
618
|
<View style={styles.group}>
|
|
599
619
|
<ActivityRow markerStatus="current" live>
|
|
600
|
-
<
|
|
601
|
-
<
|
|
602
|
-
|
|
620
|
+
<Breathing active>
|
|
621
|
+
<AnimationFadeIn key={current.id} translateY={4}>
|
|
622
|
+
<StepBody label={resolve(current)} />
|
|
623
|
+
</AnimationFadeIn>
|
|
624
|
+
</Breathing>
|
|
603
625
|
</ActivityRow>
|
|
604
626
|
</View>
|
|
605
627
|
);
|
|
@@ -654,12 +676,6 @@ const styles = StyleSheet.create({
|
|
|
654
676
|
// The folded work, opened: segment spacing (the run's own gap), and a small
|
|
655
677
|
// lead-in under the header so the rolled-out work reads as its content.
|
|
656
678
|
work: { gap: 10, paddingTop: 6 },
|
|
657
|
-
// The cap on work OPENED mid-run. ~5 rows: enough to read what is happening,
|
|
658
|
-
// short enough that opening a p90 94s run does not push the composer off the
|
|
659
|
-
// screen for the rest of it. No border and no background — the run sits flush
|
|
660
|
-
// in the consumer's gutter by law, and a frame that appeared only while
|
|
661
|
-
// streaming would be the loudest thing on the surface.
|
|
662
|
-
liveFrame: { maxHeight: 220 },
|
|
663
679
|
row: {
|
|
664
680
|
flexDirection: "row",
|
|
665
681
|
alignItems: "center",
|
package/src/agent_run_pane.tsx
CHANGED
|
@@ -174,14 +174,16 @@ export function AgentRunPane<TLanding>(props: AgentRunPaneProps<TLanding>) {
|
|
|
174
174
|
// 24` here is a fifth copy of a number that has to agree with four others.
|
|
175
175
|
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: gutter }}>
|
|
176
176
|
{/* No empty-state slot on purpose. `AgentRun` renders its own breathing
|
|
177
|
-
"Starting
|
|
177
|
+
"Starting" row while streaming with zero parts, localized through the
|
|
178
178
|
`agentRun` locale slice — and ai_patterns states the law outright:
|
|
179
179
|
never hand-roll a placeholder in front of the feed. A slot here would
|
|
180
180
|
invite exactly that, and every app would localize it again. */}
|
|
181
|
-
{/* collapseProcess=false:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
{/* collapseProcess=false: the run IS this pane's subject. A dialog opened
|
|
182
|
+
to watch one run, that then folds it behind a row the reader has to
|
|
183
|
+
press, hides the only thing it exists to show — the fold is for a run
|
|
184
|
+
sharing a surface with other content (a chat thread), not for one that
|
|
185
|
+
owns the surface. The FollowScroll above is the bound, sized by the
|
|
186
|
+
dialog; taking the framing job is what earns the right to add one. */}
|
|
185
187
|
<AgentRun
|
|
186
188
|
parts={run.parts}
|
|
187
189
|
state={run.status === "error" ? "error" : run.status === "streaming" ? "streaming" : "done"}
|
package/src/alert.css
CHANGED
package/src/alert.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import "./alert.css";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { Button, ButtonColor } from "./button";
|
|
6
6
|
import { useOverlayScope } from "./overlay_scope";
|
|
7
|
+
import { OVERLAY_Z_ABOVE } from "./overlay_layer";
|
|
7
8
|
|
|
8
9
|
export type AlertButtonStyle = "default" | "cancel" | "destructive";
|
|
9
10
|
|
|
@@ -27,6 +28,13 @@ class Alert {
|
|
|
27
28
|
if (!this.container) {
|
|
28
29
|
this.container = document.createElement("div");
|
|
29
30
|
this.container.id = "alert-container";
|
|
31
|
+
// The rung lives here rather than on the overlay's CSS class, because it
|
|
32
|
+
// is a fact about where an alert sits among the OTHER body-level layers —
|
|
33
|
+
// one table answers that (`overlay_layer.tsx`), and a stylesheet cannot
|
|
34
|
+
// read it. The box is zero-height, so making it a stacking context is
|
|
35
|
+
// free; the fixed overlay inside paints in it.
|
|
36
|
+
this.container.style.position = "relative";
|
|
37
|
+
this.container.style.zIndex = String(OVERLAY_Z_ABOVE);
|
|
30
38
|
document.body.appendChild(this.container);
|
|
31
39
|
}
|
|
32
40
|
if (!this.root) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a CATEGORICAL AXIS's positions get a printed label.
|
|
3
|
+
*
|
|
4
|
+
* The rule belongs to the axis, not to one chart: a line's x labels and a bar
|
|
5
|
+
* chart's per-bar labels have the same failure — a label is roughly
|
|
6
|
+
* `minLabelWidth` wide, so only so many fit across the track, and past that the
|
|
7
|
+
* axis either overlaps or ellipsises its own labels. Neither is a typography
|
|
8
|
+
* problem to solve by shortening the data; the labels are what thin out.
|
|
9
|
+
*
|
|
10
|
+
* ONE model serves both geometries. How many labels fit is a function of the
|
|
11
|
+
* TRACK and the label width — `floor(track / minLabelWidth)` — and that is the
|
|
12
|
+
* same question whether the labels are points on the track (a line's, at
|
|
13
|
+
* `i/(count-1)`) or slot centres (a bar's, at `(i+0.5)·track/count`). The two
|
|
14
|
+
* geometries differ only in the distance from position 0 to the first kept
|
|
15
|
+
* index, and by less than one slot; the count and the step are shared.
|
|
16
|
+
*
|
|
17
|
+
* What the caller must NOT assume is that thinning alone widens a label. On a
|
|
18
|
+
* SLOT geometry the survivor still sits in its own slot, so the space the
|
|
19
|
+
* dropped neighbours freed has to be handed to it explicitly — see
|
|
20
|
+
* {@link axisLabelStep} and `BarChart`'s label box.
|
|
21
|
+
*
|
|
22
|
+
* The rule that matters is WHERE the thinning is anchored: stepping forwards
|
|
23
|
+
* from the first position and then forcing the last one in collides whenever the
|
|
24
|
+
* count minus one is not a multiple of the step — the newest label lands a few
|
|
25
|
+
* pixels from the one before it while every other pair is a full step apart.
|
|
26
|
+
* Anchoring on the LAST position makes the spacing uniform by construction, and
|
|
27
|
+
* the last position is the one a reader looks up first. The first position is
|
|
28
|
+
* then kept only when it clears the same distance.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* How many positions the axis skips between printed labels — 1 when every label
|
|
32
|
+
* fits, `count` when the track has room for only one. Exported because a SLOT
|
|
33
|
+
* geometry needs it twice: once to pick the labels, and once to give a survivor
|
|
34
|
+
* the width of the span its dropped neighbours vacated. Deriving it a second
|
|
35
|
+
* time at the call site is how the two drift.
|
|
36
|
+
*
|
|
37
|
+
* The floor is ONE label, not two. Forcing a second onto a track that fits one
|
|
38
|
+
* breaks this module's own rule — the two then land closer together than a label
|
|
39
|
+
* is wide, which is the overlap the thinning exists to prevent — and it is the
|
|
40
|
+
* narrow end that needs the rule most, because that is where a track can go to
|
|
41
|
+
* zero or below.
|
|
42
|
+
*/
|
|
43
|
+
export function axisLabelStep(count: number, chartWidth: number, minLabelWidth = 50): number {
|
|
44
|
+
if (count <= 1) return 1;
|
|
45
|
+
const maxLabels = Math.max(1, Math.floor(chartWidth / minLabelWidth));
|
|
46
|
+
return Math.max(1, Math.ceil(count / maxLabels));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The positions to print, thinned to what the track fits.
|
|
51
|
+
*
|
|
52
|
+
* THE CONTRACT: the result is empty ONLY when there is nothing to label
|
|
53
|
+
* (`count <= 0`). An axis that HAS positions always prints at least one of them
|
|
54
|
+
* — the last, the one a reader looks up — however little room it has, so a card
|
|
55
|
+
* too narrow to fit a second label shows one rather than none.
|
|
56
|
+
*
|
|
57
|
+
* Whether the track has been MEASURED yet is the caller's fact, not this
|
|
58
|
+
* function's: a host with no layout pass reports 0 exactly as a 0px track does,
|
|
59
|
+
* and only the caller knows which it is holding. It answers that itself (show
|
|
60
|
+
* every label until measured) before asking here. Returning `[]` for both a
|
|
61
|
+
* zero width and a zero count made one value mean two things, and the two call
|
|
62
|
+
* sites duly read it two ways — the line chart as "not measured", the bar chart
|
|
63
|
+
* as "kept nothing", which is how a narrow card came to erase its whole label
|
|
64
|
+
* row.
|
|
65
|
+
*/
|
|
66
|
+
export function axisLabelIndices(
|
|
67
|
+
count: number,
|
|
68
|
+
chartWidth: number,
|
|
69
|
+
minLabelWidth = 50,
|
|
70
|
+
): number[] {
|
|
71
|
+
if (count <= 0) return [];
|
|
72
|
+
if (count === 1) return [0];
|
|
73
|
+
|
|
74
|
+
const last = count - 1;
|
|
75
|
+
const step = axisLabelStep(count, chartWidth, minLabelWidth);
|
|
76
|
+
|
|
77
|
+
const kept: number[] = [];
|
|
78
|
+
for (let i = last; i >= 0; i -= step) kept.unshift(i);
|
|
79
|
+
|
|
80
|
+
const firstKept = kept[0] ?? 0;
|
|
81
|
+
if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
|
|
82
|
+
|
|
83
|
+
return kept;
|
|
84
|
+
}
|
package/src/bar_chart.tsx
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import { View, StyleSheet, type ViewStyle } from "react-native";
|
|
2
|
-
import { useMemo } from "react";
|
|
1
|
+
import { View, StyleSheet, type LayoutChangeEvent, type ViewStyle } from "react-native";
|
|
2
|
+
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { axisLabelIndices } from "./axis_label_indices";
|
|
5
6
|
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BAR_COLOR = colors.blue[500];
|
|
8
9
|
|
|
10
|
+
/** The horizontal orientation's label column, in px, when the caller names none. */
|
|
11
|
+
const DEFAULT_LABEL_WIDTH = 80;
|
|
12
|
+
|
|
13
|
+
/** The value axis's own column — the gutter the vertical bars start after. */
|
|
14
|
+
const AXIS_LABEL_WIDTH = 56;
|
|
15
|
+
|
|
16
|
+
/** The gap between bar slots, and therefore between label slots. Named because
|
|
17
|
+
* the label box's width is computed from the slot PITCH (slot + gap), and a
|
|
18
|
+
* second literal there would silently stop matching the row it measures. */
|
|
19
|
+
const LABEL_GAP = 8;
|
|
20
|
+
|
|
9
21
|
const defaultFormatNumber = (n: number): string =>
|
|
10
22
|
new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
|
|
11
23
|
|
|
@@ -13,6 +25,17 @@ export interface BarChartItem {
|
|
|
13
25
|
label: string;
|
|
14
26
|
value: number;
|
|
15
27
|
color?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The entity's own mark, before its name — a `BrandMark`, an `Avatar`, a
|
|
30
|
+
* status dot. A bar is one entity, and a chart beside a table over the same
|
|
31
|
+
* entities has to draw them the same way; the bar's COLOUR belongs to the
|
|
32
|
+
* measure, so it cannot carry identity as a legend swatch does.
|
|
33
|
+
*
|
|
34
|
+
* Horizontal: in the label column, ahead of the name. Vertical: above it,
|
|
35
|
+
* where the axis label sits — and thinned out with that label when the axis
|
|
36
|
+
* cannot fit them all.
|
|
37
|
+
*/
|
|
38
|
+
leading?: ReactNode;
|
|
16
39
|
}
|
|
17
40
|
|
|
18
41
|
export interface BarChartProps {
|
|
@@ -20,6 +43,13 @@ export interface BarChartProps {
|
|
|
20
43
|
orientation?: "vertical" | "horizontal";
|
|
21
44
|
formatNumber?: (n: number) => string;
|
|
22
45
|
emptyLabel?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The horizontal orientation's fixed label column, in px (default 80). It is
|
|
48
|
+
* fixed because every row's bar has to start on one left edge; it is a prop
|
|
49
|
+
* because 80 fits a date and not a company name, and how long an entity's
|
|
50
|
+
* name runs is the caller's fact, not the chart's.
|
|
51
|
+
*/
|
|
52
|
+
labelWidth?: number;
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
function useAxisTicks(maxValue: number) {
|
|
@@ -62,9 +92,13 @@ function useAxisTicks(maxValue: number) {
|
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
/**
|
|
65
|
-
* The canonical SVG bar chart over `data: { label, value, color? }[]` —
|
|
66
|
-
* vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels
|
|
67
|
-
*
|
|
95
|
+
* The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
|
|
96
|
+
* `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
|
|
97
|
+
* `labelWidth` for the horizontal label column. (No recharts.) For one inline trend use
|
|
98
|
+
* `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
|
|
99
|
+
*
|
|
100
|
+
* The vertical axis prints as many labels as it fits and thins the rest, so the number of
|
|
101
|
+
* bars is never decided by how wide a label happens to be.
|
|
68
102
|
*/
|
|
69
103
|
export function BarChart(props: BarChartProps) {
|
|
70
104
|
const locale = useLoticsLocale();
|
|
@@ -73,12 +107,73 @@ export function BarChart(props: BarChartProps) {
|
|
|
73
107
|
orientation = "vertical",
|
|
74
108
|
formatNumber = defaultFormatNumber,
|
|
75
109
|
emptyLabel = locale.chart.noData,
|
|
110
|
+
labelWidth = DEFAULT_LABEL_WIDTH,
|
|
76
111
|
} = props;
|
|
77
112
|
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
78
113
|
const axisTicks = useAxisTicks(maxValue);
|
|
79
114
|
const axisMax = axisTicks[axisTicks.length - 1] || maxValue;
|
|
80
115
|
const barAreaHeight = 200;
|
|
81
116
|
|
|
117
|
+
// The vertical axis prints as many labels as the row fits and thins the rest —
|
|
118
|
+
// the same rule `LineChart` runs, because it is the axis's rule and not one
|
|
119
|
+
// chart's. Without it the label box IS the bar slot: 13 bars in a 1000px card
|
|
120
|
+
// give each label 38px, so a `dd/MM` ellipsises and the axis stops being
|
|
121
|
+
// readable at exactly the density that makes a bar chart worth drawing. The
|
|
122
|
+
// BARS never thin — dropping data to fit typography is the wrong trade.
|
|
123
|
+
const [labelsWidth, setLabelsWidth] = useState(0);
|
|
124
|
+
const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
|
|
125
|
+
setLabelsWidth(event.nativeEvent.layout.width);
|
|
126
|
+
}, []);
|
|
127
|
+
// ONE derivation from the ONE measurement — which labels survive AND how wide
|
|
128
|
+
// the box each survivor gets. They are two answers about the same row, and a
|
|
129
|
+
// second pass over the same numbers is exactly how they came to disagree about
|
|
130
|
+
// it.
|
|
131
|
+
//
|
|
132
|
+
// THE SPACE THE THINNING FREED, HANDED TO THE LABEL THAT SURVIVED.
|
|
133
|
+
//
|
|
134
|
+
// Dropping a neighbour is only half the fix: the survivor still sits in its
|
|
135
|
+
// own bar slot, and a one-line `Text` is capped at that slot's width and
|
|
136
|
+
// ellipsises inside it — so the axis kept fewer labels and clipped them just
|
|
137
|
+
// the same, with a blank slot beside each one. The label box therefore spans
|
|
138
|
+
// the STEP: `step` slots and the gaps between them, centred on its bar, which
|
|
139
|
+
// is exactly the room the dropped labels vacated. The bar slots themselves
|
|
140
|
+
// never move, so an unlabelled bar stays a blank slot rather than a narrower
|
|
141
|
+
// one — the box overflows the slot instead of resizing it.
|
|
142
|
+
const axisLabels = useMemo(() => {
|
|
143
|
+
// Unmeasured (first paint, and any host with no layout pass) shows every
|
|
144
|
+
// label, which is where this started — thinning is what measurement BUYS,
|
|
145
|
+
// never a precondition for rendering an axis at all.
|
|
146
|
+
if (labelsWidth <= 0 || data.length === 0) return null;
|
|
147
|
+
// `labelsRow` holds the axis spacer AND one slot per bar, so its N slots
|
|
148
|
+
// carry N gaps rather than N-1: `track` is already `sum(slots) + N·gap`, and
|
|
149
|
+
// the slot PITCH is therefore `track / N` — the gaps net out exactly. The
|
|
150
|
+
// survivor's box spans `step` slots and the `step - 1` gaps between them,
|
|
151
|
+
// which is `step · pitch - gap`.
|
|
152
|
+
const track = labelsWidth - AXIS_LABEL_WIDTH;
|
|
153
|
+
const pitch = track / data.length;
|
|
154
|
+
const keptIndices = axisLabelIndices(data.length, track);
|
|
155
|
+
// The span comes from the KEPT SET, never from `axisLabelStep`. The two are
|
|
156
|
+
// allowed to disagree — `axisLabelIndices` re-adds index 0 when there is
|
|
157
|
+
// room for it, so a step of `count` can still yield two survivors — and
|
|
158
|
+
// sizing off the step then hands EACH of them a box spanning the whole
|
|
159
|
+
// track, so the two labels overlap. Reading one source of truth cannot
|
|
160
|
+
// drift, whatever the thinning rule becomes later.
|
|
161
|
+
const gaps = keptIndices.slice(1).map((v, i) => v - keptIndices[i]);
|
|
162
|
+
const span = gaps.length > 0 ? Math.min(...gaps) : data.length;
|
|
163
|
+
const boxWidth = span > 1 ? span * pitch - LABEL_GAP : 0;
|
|
164
|
+
return {
|
|
165
|
+
kept: new Set(keptIndices),
|
|
166
|
+
// Undefined when nothing was dropped (`step === 1`): the slot IS the box,
|
|
167
|
+
// and an explicit width there would only re-state it. Undefined too when
|
|
168
|
+
// the span is not a positive number of pixels — a card narrower than the
|
|
169
|
+
// value axis's own gutter leaves a track of zero or less, and a `width: 0`
|
|
170
|
+
// box would ERASE the label the thinning just chose to keep. Letting it
|
|
171
|
+
// overflow its slot is the same trade the row makes everywhere else: the
|
|
172
|
+
// axis thins labels, it never deletes them.
|
|
173
|
+
boxWidth: boxWidth > 0 ? boxWidth : undefined,
|
|
174
|
+
};
|
|
175
|
+
}, [data.length, labelsWidth]);
|
|
176
|
+
|
|
82
177
|
if (data.length === 0) {
|
|
83
178
|
return (
|
|
84
179
|
<View style={styles.chartContainer}>
|
|
@@ -97,7 +192,8 @@ export function BarChart(props: BarChartProps) {
|
|
|
97
192
|
|
|
98
193
|
return (
|
|
99
194
|
<View key={index} style={styles.horizontalBarContainer}>
|
|
100
|
-
<View style={styles.horizontalBarLabel}>
|
|
195
|
+
<View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
|
|
196
|
+
{item.leading !== undefined ? item.leading : null}
|
|
101
197
|
<Text size="sm" numberOfLines={1}>
|
|
102
198
|
{item.label}
|
|
103
199
|
</Text>
|
|
@@ -122,7 +218,7 @@ export function BarChart(props: BarChartProps) {
|
|
|
122
218
|
);
|
|
123
219
|
})}
|
|
124
220
|
<View style={styles.horizontalAxisContainer}>
|
|
125
|
-
<View style={
|
|
221
|
+
<View style={{ width: labelWidth }} />
|
|
126
222
|
<View style={styles.horizontalAxisTrack}>
|
|
127
223
|
{axisTicks.map((tick, index) => (
|
|
128
224
|
<View
|
|
@@ -186,14 +282,27 @@ export function BarChart(props: BarChartProps) {
|
|
|
186
282
|
})}
|
|
187
283
|
</View>
|
|
188
284
|
</View>
|
|
189
|
-
<View style={styles.labelsRow}>
|
|
285
|
+
<View style={styles.labelsRow} onLayout={handleLabelsLayout}>
|
|
190
286
|
<View style={styles.axisLabelSpacer} />
|
|
191
287
|
{data.map((item, index) => (
|
|
288
|
+
// The slot stays whatever the axis prints, so the bars above it keep
|
|
289
|
+
// their positions — an unlabelled bar is a blank slot, never a
|
|
290
|
+
// narrower one.
|
|
192
291
|
<View key={index} style={styles.labelContainer}>
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
292
|
+
{axisLabels === null || axisLabels.kept.has(index) ? (
|
|
293
|
+
<View
|
|
294
|
+
style={[
|
|
295
|
+
styles.labelBox,
|
|
296
|
+
axisLabels?.boxWidth !== undefined && { width: axisLabels.boxWidth },
|
|
297
|
+
]}
|
|
298
|
+
>
|
|
299
|
+
{item.leading !== undefined ? item.leading : null}
|
|
300
|
+
<Text numberOfLines={1} weight="medium">
|
|
301
|
+
{item.label}
|
|
302
|
+
</Text>
|
|
303
|
+
<Text color="muted">{formatNumber(item.value)}</Text>
|
|
304
|
+
</View>
|
|
305
|
+
) : null}
|
|
197
306
|
</View>
|
|
198
307
|
))}
|
|
199
308
|
</View>
|
|
@@ -215,7 +324,7 @@ const styles = StyleSheet.create({
|
|
|
215
324
|
gap: 8,
|
|
216
325
|
},
|
|
217
326
|
verticalAxis: {
|
|
218
|
-
width:
|
|
327
|
+
width: AXIS_LABEL_WIDTH,
|
|
219
328
|
position: "relative",
|
|
220
329
|
},
|
|
221
330
|
verticalAxisTick: {
|
|
@@ -243,15 +352,23 @@ const styles = StyleSheet.create({
|
|
|
243
352
|
},
|
|
244
353
|
labelsRow: {
|
|
245
354
|
flexDirection: "row",
|
|
246
|
-
gap:
|
|
355
|
+
gap: LABEL_GAP,
|
|
247
356
|
},
|
|
248
357
|
labelContainer: {
|
|
249
358
|
flex: 1,
|
|
250
359
|
alignItems: "center",
|
|
251
360
|
gap: 2,
|
|
252
361
|
},
|
|
362
|
+
// Wider than its slot when the axis thinned, and centred on the bar by the
|
|
363
|
+
// slot's own `alignItems` — a flex item wider than its cross-axis space
|
|
364
|
+
// overflows both edges equally, which is what puts the label over the blank
|
|
365
|
+
// slots beside it rather than clipping it inside its own.
|
|
366
|
+
labelBox: {
|
|
367
|
+
alignItems: "center",
|
|
368
|
+
gap: 2,
|
|
369
|
+
},
|
|
253
370
|
axisLabelSpacer: {
|
|
254
|
-
width:
|
|
371
|
+
width: AXIS_LABEL_WIDTH,
|
|
255
372
|
},
|
|
256
373
|
horizontalChart: {
|
|
257
374
|
gap: 8,
|
|
@@ -264,8 +381,12 @@ const styles = StyleSheet.create({
|
|
|
264
381
|
alignItems: "center",
|
|
265
382
|
gap: 8,
|
|
266
383
|
},
|
|
384
|
+
// The width is the instance's `labelWidth`; what lives here is the row shape —
|
|
385
|
+
// the entity's mark, then its name, on one line.
|
|
267
386
|
horizontalBarLabel: {
|
|
268
|
-
|
|
387
|
+
flexDirection: "row",
|
|
388
|
+
alignItems: "center",
|
|
389
|
+
gap: 6,
|
|
269
390
|
},
|
|
270
391
|
horizontalBarTrack: {
|
|
271
392
|
flex: 1,
|