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