@lotics/ui 45.9.0 → 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/docs/catalog.md CHANGED
@@ -285,7 +285,11 @@ its max it reads `2,100 / 2,000 (105%)`, because a meter that says "100%" when y
285
285
  tells the reader they are exactly at the limit. Numbers format in the reader's locale, so
286
286
  never hand-format the value you pass in — when display precision differs from the true value
287
287
  (whole credits off a fractional balance), `formatValue` reshapes the caption text and leaves
288
- the fill and the percentage exact) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
288
+ the fill and the percentage exact) / `StackedProgressBar` (ONE whole split across segments,
289
+ filling its own track whatever the total) / `StackedBarChart` (SEVERAL wholes compared — rows
290
+ of stacked bars on one shared scale, so length says how big and the segments say what of) /
291
+ `StepProgress`, `WaterfallChart` (the BRIDGE — an opening level, the signed steps that move
292
+ it, the level they close at; revenue walked down to net), `Breakdown` (a
289
293
  stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
290
294
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
291
295
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
@@ -1780,7 +1784,11 @@ component rather than showing it at zero.
1780
1784
  register wants — a column of them turns "12 numbers per row" into a pattern the eye reads
1781
1785
  without stopping. Reach for a real chart the moment someone needs to read a value off it.
1782
1786
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1783
- `PieChart`: the canonical SVG chart set (no recharts).
1787
+ `PieChart`: the canonical SVG chart set (no recharts). `LineChart` prints as many x labels
1788
+ as the track fits and thins the rest, anchored on the LAST point — the newest reading is the
1789
+ one a reader looks up, and anchoring there is what keeps the spacing uniform. The first
1790
+ point is labelled only when it clears the same distance, so a series whose length does not
1791
+ divide evenly drops its opening label rather than crowding the one beside it.
1784
1792
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1785
1793
  plain sm tabular count beside it.
1786
1794
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
@@ -1792,7 +1800,35 @@ component rather than showing it at zero.
1792
1800
  bare ring renders no text, so it announces nothing — whatever owns it must say what it
1793
1801
  measures.
1794
1802
  - **`stacked_progress_bar`** — `StackedProgressBar`: one whole split across segments (a
1795
- status mix on one bar).
1803
+ status mix on one bar). Its segments size against EACH OTHER, not against `total` — the
1804
+ track is always full — because one whole is the whole of itself. That is the right model for
1805
+ one bar and the wrong one for a column of them: ten unequal totals draw ten equal bars, which
1806
+ reads as a chart and states nothing. Several wholes → `stacked_bar_chart`.
1807
+ - **`stacked_bar_chart`** — `StackedBarChart`: rows of stacked bars on ONE SHARED SCALE
1808
+ (`max` defaults to the largest row total). Bar LENGTH answers "how big is this one", the
1809
+ segment run answers "what is it made of", in one pass — spend against return per campaign,
1810
+ cost make-up per product line. `series` (key/label/colour, one hue family per dimension)
1811
+ drives a legend that is on by default; each `StackedBarRow` takes `label` + optional `meta`,
1812
+ a pre-formatted headline `value` with its `valueTone`, and a `caption` sentence, so colour is
1813
+ never the only channel. Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1814
+ (one whole + ranked share rows beneath it): reach here the moment there are SEVERAL wholes to
1815
+ compare.
1816
+ - **`waterfall_chart`** — `WaterfallChart`: the BRIDGE — an opening level, the signed steps
1817
+ that move it, and the level they close at. Revenue walked down to net, budget to actual,
1818
+ opening balance to closing. `items` carry a `kind`: `basis` and `total` are LEVELS standing on
1819
+ zero, `delta` (the default) is a signed STEP floating between the level before it and the
1820
+ level after; colour derives from that — neutral basis, emerald up, red down, and a close in
1821
+ the ink of its own sign — so a caller passes no colours. `orientation` vertical columns |
1822
+ horizontal rows (take rows for a narrow container or long step names; a 70px column and a
1823
+ rotated label is not an answer). The span always includes zero, so a bridge that closes at a
1824
+ loss hangs below the baseline instead of floating on a scale that exaggerates every step.
1825
+ `waterfallLayout` is the pure geometry, exported and tested on its own: a bridge is only worth
1826
+ drawing if the floating bars land where the arithmetic says, and no screenshot confirms that.
1827
+ Pick it over `ledger` when the reader is scanning for SHAPE (a dashboard), and `ledger` when
1828
+ the figures must be read exactly and reconciled line by line (a record). Over `bar_chart`
1829
+ whenever the bars are steps in ONE arithmetic — five columns all starting at zero state a
1830
+ comparison nobody meant. The steps must be all of the arithmetic and must close on the total
1831
+ drawn; derive them from one computation, never two that can drift.
1796
1832
  - **`step_progress`** — `StepProgress`: N-of-M dots/segments progress. A real `progressbar`
1797
1833
  (valuenow/min/max) because its segments are decoration over ONE quantity — the opposite
1798
1834
  case from `stepper`, whose steps carry content a `progressbar` would hide. Pass the stage
@@ -10,6 +10,40 @@ them produces off-system UI.
10
10
 
11
11
  ## The form comes before the treatment
12
12
 
13
+ **If the domain already has a standard shape, that is the answer — build it.** A parcel's journey
14
+ has looked the same in every carrier's app for twenty years: an ordered set of stages, where it is
15
+ now, when each was stamped, and where it is stuck. A ledger has looked like a ledger since before
16
+ software. A calendar, a seating chart, a pipeline, a boarding pass — each is a form the reader has
17
+ already learned somewhere else, and matching it means they arrive knowing how to read the screen.
18
+ Go and look at how the industry does it before designing something new; a shape that is unfamiliar
19
+ in a domain that has a familiar one is a cost paid by every user, forever, to no end.
20
+
21
+ That is a licence, not a leash. **The rules in this document constrain treatment, not invention.**
22
+ Where the job needs a component the kit does not have, build it — the catalog is where solved
23
+ problems live, not a ceiling on what a screen may be, and a screen assembled only from the parts
24
+ nearest to hand is how everything ends up a register with a header. The bar for a new composition
25
+ is that it is the RIGHT shape for the subject and that it obeys the language below: the kit's type
26
+ ramp, colours, spacing and states. Reach for the shape the work deserves, then dress it in the
27
+ kit's clothes.
28
+
29
+ **Which rules bind, and which advise.** Read every rule here for what it ENCODES, because the two
30
+ kinds are answered differently:
31
+
32
+ - **A rule that prevents a known HARM binds.** A page that scrolls sideways on a phone, an act that
33
+ cannot be undone, a figure that is typed where it should be computed, a machine's guess rendered
34
+ like a person's decision, a control a keyboard cannot reach — these are settled, they were paid
35
+ for once, and re-opening them just re-buys the defect.
36
+ - **A rule that picks among valid FORMS advises.** Which shape fits this subject, how a set is cut,
37
+ what the reader sees first — the space of jobs is unbounded and the best answer is regularly one
38
+ no document here has named. Treat these as the accumulated experience they are, then decide.
39
+
40
+ The distinction is not hard-versus-soft, it is harm-versus-choice — and the second kind is where
41
+ the good work happens. **Most of what reads as inventive is this document's FIRST rule being
42
+ followed well, not escaped**: a set whose tabs are the stages its records pass through is not a
43
+ departure from "name what the subject IS", it is that rule landing. Naming the subject honestly is
44
+ what produces a shape nobody has seen; reaching for the nearest template is what produces the
45
+ fiftieth register with a header.
46
+
13
47
  **Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
14
48
  through ordered places is a rail; work assigned to finite resources over time is a board; a
15
49
  sequence of events is a timeline; a population whose point is its deviant minority is a queue
@@ -563,6 +597,14 @@ read-only drill-downs expand via `Accordion` or glance via `Peek`; every other r
563
597
  surface is the door: register rows are `PressableRow` (full-bleed wash including nested controls),
564
598
  Divider-separated.
565
599
 
600
+ **How MUCH the row reveals decides expand-vs-drawer.** An expanding row is for a few facts the
601
+ reader takes in without losing their place — the rows around it stay visible, which is the whole
602
+ point. Once the reveal is a screenful, it pushes the rest of the register off the fold and the
603
+ reader is now reading a record through a slot cut in a list: they cannot see the set they came for,
604
+ and they have to remember to collapse it. That is a `Drawer`. The threshold is not a row count, it
605
+ is whether the neighbouring rows survive the expansion; if opening one row means the list is gone,
606
+ open it properly.
607
+
566
608
  | `PressableRow` variant | Shape | Use |
567
609
  |---|---|---|
568
610
  | `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash | THE record list |
@@ -687,6 +729,15 @@ The tokens are one step darker for exactly that reason, and they are what `IconB
687
729
  hover language. Bleed the wash outward (`marginHorizontal: -8` against its own padding) so the
688
730
  cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
689
731
 
732
+ **A pressable cell's target is the whole CELL, not the text inside it.** Bind the press and the
733
+ hover to the cell box, so the reader aims at a column rather than at a word. Wrapping only the name
734
+ leaves a target whose shape nobody can predict — it changes with the length of the value, it is a
735
+ sliver on a short one, and the wash it lights stops short of the cell's edges so the highlight
736
+ appears to bleed into the neighbouring thumbnail or supporting line rather than belonging to
737
+ anything. If a cell holds an image and a name and pressing it opens the same thing, the image is
738
+ part of the target too; a cell where only some of the content responds is telling the reader there
739
+ are two things there when there is one.
740
+
690
741
  **Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
691
742
  when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
692
743
  share left behind is white space, and the columns beside it pay for it. Measure the widest row's
@@ -1330,6 +1381,27 @@ A flat register sorted by a column hands the reader every row at equal weight an
1330
1381
  deciding to them: "which of these needs me today" is answered by reading all of them. `TableGroup`
1331
1382
  moves that decision into the screen — a titled band of rows with a count.
1332
1383
 
1384
+ **Flat is the default; grouping is something the reader turns ON.** A register opens as one
1385
+ continuous list, and the control that splits it into bands lives in the filter band beside the
1386
+ other view controls — an explicit "group by" the reader operates, defaulting to none. Authored
1387
+ bands fail three ways at once: the value they group on is unsortable, it is unfilterable, and it
1388
+ vanishes the moment the reader wants a different cut — so the screen holds an opinion the reader
1389
+ cannot inspect, disagree with, or put down. Worse, it decides on their behalf every time they open
1390
+ the screen, including the many times the triage it encodes is not the question they came with.
1391
+
1392
+ **The status COLUMN is the floor.** Whatever the register can group on also exists as a column the
1393
+ reader can see, sort and filter — the column is the fact, the grouping is one way of looking at it.
1394
+ Ship the column first, offer grouping as a control, and let `TableGroup` render whatever the reader
1395
+ picked. Everything below still governs how the bands look and behave once they are on; it just no
1396
+ longer describes the state the screen starts in.
1397
+
1398
+ **A value's filter renders the value the same way its cell does.** If the status cell is a
1399
+ `Badge variant="dot"`, the status filter's options are dot badges too — same vocabulary, same
1400
+ colours, same shapes. The reader is matching what they just saw in the rows against what the
1401
+ control offers; re-rendering it as plain text makes them translate between two presentations of
1402
+ one field, and the two drift the first time either side gains a value. This holds for every
1403
+ custom-rendered value, not just status.
1404
+
1333
1405
  **Group by what implies a different ACTION, never by a category the reader can already see.**
1334
1406
  Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
1335
1407
  the grouping tells the reader something no column does. Grouping by a value that is already a
@@ -1596,6 +1668,37 @@ Type SCALE is the fourth, and it belongs with the others: range = largest ÷ sma
1596
1668
  screen, and under about 2× reads flat *at any amount of colour*. Scale is the one hierarchy device
1597
1669
  colour cannot substitute for. A register with no page band tops out near 1.33×.
1598
1670
 
1671
+ **The test every visual has to pass: does it make the screen CLEARER *and* better-looking?** Both,
1672
+ not either. A visual that only decorates is clutter and goes; a visual that only informs but fights
1673
+ the composition is a chart nobody reads. The ones that pay are the ones where the two are the same
1674
+ act — a status colour that is also the scan path, a product thumbnail that is also the identifier, a
1675
+ trend line that is also the answer. That framing is what keeps "make it less bland" from becoming
1676
+ "add more things", which is the failure this section is most often misread into.
1677
+
1678
+ **And the same test cuts the other way, which the rest of this document does not say often enough.**
1679
+ Nearly every rule here removes something — SUBTRACT is a gate, the trim test hunts restatement, the
1680
+ absence pass looks for what should not be there. Follow only that gradient and you converge on a
1681
+ screen that is clean, defensible, and *does not tell the reader enough to act*. **Minimal is not the
1682
+ same as clear.** A number with no comparison, a status with no age, a total with no composition, a
1683
+ row that forces a click to answer the question it exists to answer — each is a screen that passed
1684
+ every subtraction and failed the reader. Withholding is a defect exactly as much as clutter is; it
1685
+ is simply the one that survives review, because nothing on the screen looks wrong. Ask what the
1686
+ reader must DECIDE, then check that the screen holds every fact that decision needs.
1687
+
1688
+ **Where the data has a history, show the trajectory, not just the latest value.** A figure states
1689
+ where things stand; a line, a bar series or a `Sparkline` states where they are GOING, and the
1690
+ direction is usually the decision — a balance that is falling, a return rate that is climbing, a
1691
+ week that broke the pattern. This is the most common honest source of visual interest in work
1692
+ software, because the shape *is* information. It is also the one most often left out, since the
1693
+ current value is what the query returns first.
1694
+
1695
+ **A register whose entity has ordered stages should render the stage, not just name it.** A word in
1696
+ a cell makes the reader know the vocabulary and its order; a small coloured progress indicator makes
1697
+ position legible at a glance and lets rows be compared down the column — which is the question a
1698
+ register is asked. Colour it by what the stage MEANS (moving / stalled / done / failed), so the
1699
+ column reads as a state of play rather than a palette. Do not spend this on a two-state field: a
1700
+ progress rendering of "open / closed" is a badge wearing a costume.
1701
+
1599
1702
  ## Where the accent goes — "where you are", and nothing else
1600
1703
 
1601
1704
  `colors.accent` paints an active FILTER — the one place a brand hue answers "where the reader is"
@@ -1856,6 +1959,17 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
1856
1959
  canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
1857
1960
  hierarchy, not cramming.
1858
1961
 
1962
+ **Space states what belongs together, so the gap INSIDE a unit is always smaller than the gap
1963
+ BETWEEN units.** A line item's name, its code and its quantity are one thing and sit tight; the
1964
+ next line item starts after a clearly larger gap. Get the ratio backwards — or make both gaps
1965
+ equal — and the reader has to reconstruct the grouping from the content, because the layout is
1966
+ telling them the parts are peers of the wholes. The tell is a stack that reads as an undifferentiated
1967
+ list when you unfocus your eyes, or a supporting line drifting so far from the value it supports
1968
+ that it looks like the next unit's first line. This is the same law the gap-ratio probe in
1969
+ [reviewing.md](./reviewing.md) measures (~2:1 between over inside); it is stated here because it is
1970
+ a composition decision, not a treatment one — no amount of type or colour work fixes a stack whose
1971
+ spacing groups the wrong things.
1972
+
1859
1973
  **A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
1860
1974
  Padding that happens to sum to the band height is a second owner of that number, and it drifts the
1861
1975
  moment the type ramp moves — which the ramp is allowed to do. The signature is one control
package/docs/reviewing.md CHANGED
@@ -269,6 +269,14 @@ spacing distinguishes them.
269
269
  do not rescue it. → [composition.md](./composition.md) §"Numbers, money, dates".
270
270
  - **A slot holding another component's part must be sized by that component's constant** — a
271
271
  24px mark in an 18px column sits 3px outside the surface.
272
+ - **Labels placed along a track: collect each one's HEIGHT and compare across the set.** Anything
273
+ positioned by a fraction of its container — an axis tick, a marker on a timeline, a legend on a
274
+ meter — is sized by the space left to the container's EDGE, so the one at 100% shrink-wraps to a
275
+ few pixels and breaks its own text across two lines while every sibling measures fine. Height,
276
+ not width, is the signature: a wrapped label is *taller* than its neighbours, which no
277
+ alignment check and no screenshot at the width where it happens to fit will show. The same
278
+ collection catches a fixed pixel nudge standing in for centring — spacing between label CENTRES
279
+ that drifts across the track means each one was offset by a guess at its own width.
272
280
  - **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
273
281
  calling any of them right.
274
282
 
package/docs/templates.md CHANGED
@@ -146,6 +146,36 @@ name `Peek`s the dossier). Also exercises the wider chart family (`LineChart`, `
146
146
  filter actually filters the KPI datasets. Start here for any "state of the operation"
147
147
  landing screen.
148
148
 
149
+ **A dashboard is VISUAL first.** Its reader is scanning for shape — is this up or down, where is
150
+ the outlier, which slice dominates — and shape is what a chart states and a number does not. A
151
+ dashboard built as rows of figures is a report wearing a dashboard's name: correct, and it makes
152
+ the reader do the comparing that the screen was supposed to do for them. Lead with the charts, let
153
+ the figures support them. `KPIStrip` earns its place as the headline row above the charts, never as
154
+ the whole screen.
155
+
156
+ The chart family is `BarChart`, `LineChart`, `PieChart`, `Funnel`, `Heatmap`, `Sparkline`,
157
+ `ProgressRing`, `ProgressBar`, `StackedProgressBar`, `StackedBarChart`, `WaterfallChart`,
158
+ `StepProgress` — and **that list is a starting inventory, not the boundary.** When the truth needs
159
+ a shape the kit has no component for — a dual-axis pairing spend against return, a bullet against
160
+ target — **build it into `@lotics/ui`** rather than hand-rolling it inside one app. A chart
161
+ assembled from `View`s in an app screen is invisible to every other app, carries its own spacing
162
+ and colour decisions, and is the exact duplication `src/` exists to prevent; the same component in
163
+ the kit is measured, themed and reusable the day after. Graduating it is the intended path, not an
164
+ imposition on the kit (→ AGENTS.md, "components graduate into `src/` + an `examples/tpl_*` + a
165
+ catalog entry").
166
+
167
+ Read the `dataviz` guidance before choosing a chart type or a palette — picking the form is a
168
+ separate decision from drawing it well.
169
+
170
+ **No AI run belongs on a dashboard.** A dashboard's entire contract is that the answer is already
171
+ there — the reader opens it and knows. A panel that has to be asked, that streams, that costs
172
+ credits and can fail, breaks exactly the property the surface exists for, and it lands the reader
173
+ on a spinner in the one place they came for an instant read. If a figure is worth showing, compute
174
+ it and show it; if the question is open-ended enough to need a model, it belongs in chat, where
175
+ interrogating the data is the point and waiting is understood. The same test disqualifies anything
176
+ else that makes a dashboard *ask before it answers*: a required mode chooser, an empty state
177
+ awaiting a selection, a panel that loads only on click.
178
+
149
179
  ### `tpl_stock` — the drill-down overview (large populations)
150
180
 
151
181
  Monitoring a population too large to browse (10,000+ units). The funnel: `KPIStrip`
@@ -194,7 +224,31 @@ master-detail panel.
194
224
  ### `tpl_item_list` — THE canonical register
195
225
 
196
226
  The one work-execution list shape; it subsumes approvals, dispatch, batch-building, and run
197
- screens — register, per-row action, gated selection, and act-on-many in one. The page:
227
+ screens — register, per-row action, gated selection, and act-on-many in one.
228
+
229
+ **A register is a WORK surface, and that fixes its altitude.** The reader came to narrow a set
230
+ down and act on what is left — filter, scan, check, do. Everything on the screen serves that or it
231
+ is in the way. So the aggregate belongs elsewhere: **no `KPIStrip`, no chart panel, no report band
232
+ above the rows.** The register's whole summary is the one `SummaryLine`, and what it states is
233
+ *this filtered view* — money at stake, how many need a decision, the worst case in view — never a
234
+ count of rows the reader can already see. Aggregates are a DASHBOARD's job, and the two surfaces
235
+ answer different questions: a dashboard says how the operation is doing, a register is where the
236
+ work gets done. Putting a KPI band on a register pushes the rows below the fold to say something
237
+ the reader did not come for. (A per-row micro-visual — a `Sparkline`, a bar in a cell — is row
238
+ DATA, not a panel, and is fine.)
239
+
240
+ The corollaries, each of which a register is routinely missing:
241
+
242
+ - **Filter on the axis its reader actually thinks in**, and make anything transactional filterable
243
+ by DATE. A register you can only scroll is a report.
244
+ - **Sortable columns wherever the reader ranks** — dates and amounts above all. "Which is biggest,
245
+ which is oldest" is the most common question a register is asked.
246
+ - **Put intake ON the register.** The set is added to from the surface that lists it, and one CTA
247
+ per tab covers the tab's own shape. Where a file is the real-world input, the CTA pair is manual
248
+ entry beside a file-drop that proposes rows for review.
249
+ - **If a row can carry a document, the register states whether it has one.** Absence is the
250
+ information — a badge that shows nothing for a row missing its invoice is exactly the column
251
+ that earns its width.
198
252
 
199
253
  - **One toolbar row** — search + a status `Select` + facet `FilterChip`s LEFT, the New CTA
200
254
  RIGHT; then a light `SummaryLine` of the filtered view BELOW it, above the rows — the counts are what the toolbar just produced, so they read after it, not before.
@@ -697,6 +751,25 @@ billing, and quick-capture templates. Top → bottom:
697
751
  below that it collapses to the pinned bar. Sizing the column is the SHELL's job — inner
698
752
  blocks are plain `width: "100%"`, never a second `maxWidth` + `alignSelf: "center"`.
699
753
 
754
+ ### Anything that ADVANCES a record must be able to walk it back
755
+
756
+ This holds wherever the progression is rendered — a stage CTA on a register row, a tick in a
757
+ drawer's checklist, a bulk action over a selection, the handoff compound below. **Every control
758
+ that moves a record forward ships its inverse, in reach of the person who pressed it.**
759
+
760
+ A one-way control makes a misclick permanent, and stage CTAs invite the misclick: they sit on the
761
+ row, they are the fastest thing on the screen, and the row leaves the tab the moment it is pressed
762
+ — so the feedback for "that was wrong" is the record vanishing, with no obvious way back and often
763
+ no obvious place it went. The reader is then hunting through tabs for a record they did not mean
764
+ to move, which is a worse outcome than the one the CTA saved them.
765
+
766
+ The undo belongs **beside the forward act, as its secondary** — the pair reads as one decision with
767
+ two directions, the same pairing the kit already uses for a commit and the control that undoes it.
768
+ Do not bury it in an `ActionMenu`: the person who needs it needs it in the second after the press,
769
+ which is exactly when they will not go looking. Where a stage is genuinely irreversible (money has
770
+ moved, a document has been filed, something left the building), the honest answer is not a missing
771
+ undo — it is a confirmation *before* the act, and copy that says why it cannot be taken back.
772
+
700
773
  ### The handoff shape — a CONNECTED checklist, and where it lives
701
774
 
702
775
  One record walking N ordered positions, where **the position decides what you can see and do**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.9.0",
3
+ "version": "45.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -83,6 +83,8 @@
83
83
  "./gantt": "./src/gantt/index.ts",
84
84
  "./progress_ring": "./src/progress_ring.tsx",
85
85
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
86
+ "./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
87
+ "./waterfall_chart": "./src/waterfall_chart.tsx",
86
88
  "./legend_item": "./src/legend_item.tsx",
87
89
  "./breakdown": "./src/breakdown.tsx",
88
90
  "./funnel": "./src/funnel.tsx",
package/src/bar_chart.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { View, StyleSheet } from "react-native";
1
+ import { View, StyleSheet, type ViewStyle } from "react-native";
2
2
  import { useMemo } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
@@ -293,7 +293,25 @@ const styles = StyleSheet.create({
293
293
  horizontalAxisTick: {
294
294
  position: "absolute",
295
295
  alignItems: "center",
296
- transform: [{ translateX: -12 }],
296
+ /**
297
+ * `max-content` + a half-width shift, so the label sizes to its own text and
298
+ * then centres on its tick at ANY label length.
299
+ *
300
+ * Both halves are load-bearing. An absolutely-positioned box with only
301
+ * `left` set is sized by the space remaining to the container's RIGHT EDGE,
302
+ * so the tick at 100% shrink-wraps to a few pixels and breaks its text
303
+ * across two lines — visible only at the widths where that label happens
304
+ * not to fit, which is why it survives a desktop-only pass. And a fixed
305
+ * pixel nudge cannot centre anything: it assumes one label width, so every
306
+ * tick is off by however far its own label differs from the guess.
307
+ */
308
+ transform: "translateX(-50%)",
309
+ // The one style here RN cannot type: `DimensionValue` covers numbers and
310
+ // percentages, and `max-content` is a real CSS width that react-native-web
311
+ // passes straight through. Nothing typed does this job — a number floor
312
+ // only moves the breaking point to the next longer label, and dropping to
313
+ // the track's own width is what breaks it in the first place.
314
+ ...({ width: "max-content" } as unknown as ViewStyle),
297
315
  },
298
316
  tickMark: {
299
317
  width: 1,
@@ -4,9 +4,11 @@ import { PressableHighlight } from "./pressable_highlight";
4
4
  import { chipSurfaceStyle } from "./control_surface";
5
5
 
6
6
  // One-of-N chips: every option visible, one tap to switch, the row wraps on
7
- // narrow widths. Quiet zinc styling — bordered white at rest, dark fill when
8
- // active (color stays reserved for status + primary actions). Two jobs, one
9
- // control:
7
+ // narrow widths. Quiet zinc styling — bordered white at rest, and the active one
8
+ // keeps that same ground and takes a doubled zinc-900 EDGE instead, so a
9
+ // selection never spends the brand and never speaks the row's language (the
10
+ // reasoning is on `chipSurfaceStyle`). Colour stays reserved for status +
11
+ // primary actions. Two jobs, one control:
10
12
  // - a SMALL, HOT FILTER the user flips between constantly ("narrow this
11
13
  // view") — model the unfiltered state as an explicit option (e.g. "All").
12
14
  // - a small REQUIRED single-select in a form / composer (a call outcome, a
package/src/finding.tsx CHANGED
@@ -142,7 +142,7 @@ export function Finding(props: FindingProps) {
142
142
  {readings.map((reading, index) => (
143
143
  <View key={`${reading.source}-${index}`} style={styles.reading}>
144
144
  <SourceName label={reading.source} onOpen={props.onOpenSource} />
145
- <Text size="sm" weight="medium" tabular numberOfLines={1}>
145
+ <Text size="sm" weight="medium" tabular numberOfLines={1} style={styles.readingValue}>
146
146
  {reading.value}
147
147
  </Text>
148
148
  </View>
@@ -181,13 +181,18 @@ export function Finding(props: FindingProps) {
181
181
  function SourceName({ label, onOpen }: { label: string; onOpen?: (source: string) => void }) {
182
182
  if (!onOpen) {
183
183
  return (
184
- <Text size="sm" color="muted" numberOfLines={1}>
184
+ <Text size="sm" color="muted" numberOfLines={1} style={styles.sourceName}>
185
185
  {label}
186
186
  </Text>
187
187
  );
188
188
  }
189
189
  return (
190
- <PressableHighlight onPress={() => onOpen(label)} accessibilityRole="link" accessibilityLabel={label}>
190
+ <PressableHighlight
191
+ onPress={() => onOpen(label)}
192
+ accessibilityRole="link"
193
+ accessibilityLabel={label}
194
+ style={styles.sourceName}
195
+ >
191
196
  <Text size="sm" weight="medium" decoration="underline" numberOfLines={1}>
192
197
  {label}
193
198
  </Text>
@@ -217,4 +222,14 @@ const styles = StyleSheet.create({
217
222
  // each source with its own value at every width and needs no separator at all.
218
223
  readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
219
224
  reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
225
+ // The same split as `title`/`delta` one row up, for the same reason: the SOURCE
226
+ // is a label and gives way, the VALUE is the figure and never does. Both halves
227
+ // are required on the label — a React Native `Text` is `flexShrink: 0` unlike
228
+ // the web, and `flexShrink` alone then floors it at its longest word. Without
229
+ // this pair the row laid both children out at intrinsic width and simply ran
230
+ // off the card: measured at x=790 in a 390px frame, clipped, with no page
231
+ // scroll to reach it — so on a phone the evidence a Finding exists to show was
232
+ // the part you could not read.
233
+ sourceName: { flexShrink: 1, minWidth: 0 },
234
+ readingValue: { flexShrink: 0 },
220
235
  });
@@ -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
- const minLabelWidth = 50;
100
- const maxLabels = Math.max(2, Math.floor(chartWidth / minLabelWidth));
101
- const step = Math.max(1, Math.ceil(data.length / maxLabels));
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
+ }
@@ -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
- {actions}
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
+ });
@@ -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
+ });