plotlywaterfall 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (22) hide show
  1. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/CHANGELOG.md +18 -0
  2. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/PKG-INFO +10 -1
  3. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/README.md +9 -0
  4. plotlywaterfall-1.1.0/src/plotlywaterfall/__about__.py +1 -0
  5. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/_coloring.py +9 -2
  6. plotlywaterfall-1.1.0/src/plotlywaterfall/_format.py +84 -0
  7. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/waterfall.py +161 -20
  8. plotlywaterfall-1.1.0/tests/test_order_and_format.py +209 -0
  9. plotlywaterfall-1.0.0/src/plotlywaterfall/__about__.py +0 -1
  10. plotlywaterfall-1.0.0/src/plotlywaterfall/_format.py +0 -46
  11. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/.gitignore +0 -0
  12. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/LICENSE +0 -0
  13. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/pyproject.toml +0 -0
  14. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/__init__.py +0 -0
  15. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/py.typed +0 -0
  16. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_basic.py +0 -0
  17. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_colors.py +0 -0
  18. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_function.py +0 -0
  19. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_labels.py +0 -0
  20. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_mixed_signs.py +0 -0
  21. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_readme.py +0 -0
  22. {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_regressions.py +0 -0
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.0] - 2026-11-05
6
+
7
+ ### Fixed
8
+ - **Wrong levels with groups whose rows are in a different order**: running levels were accumulated
9
+ in each group's own row order instead of the x-axis order, so such a group's bars started at the
10
+ wrong height. They now always follow the x-axis.
11
+ - **Dates on the x-axis** were labelled `Timestamp('2026-01-31 00:00:00')`; they are now shown as
12
+ `Jan 2026` (dates in different months) or `2026-01-31`, in tick labels and hover.
13
+ - **The order of a pandas `Categorical` x column** was ignored; it now sets the order of the bars
14
+ (and of groups and categories).
15
+
16
+ ### Added
17
+ - `category_orders` (as in plotly express) to set the order of x, group and category values;
18
+ the colour palette is assigned in that order.
19
+ - `x_format`: strftime pattern for date x-values or a function for any x-values.
20
+ - `number_unit` (`"k"`, `"M"`, `"bn"` or `"auto"` = one unit for the whole chart) and
21
+ `negative_style="parentheses"` for accounting-style labels such as `($0.73M)`.
22
+
5
23
  ## [1.0.0] - 2026-11-05
6
24
 
7
25
  First stable release: a one-call `waterfall()` function, mixed signs within a bar, a plotly-express
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plotlywaterfall
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Plotly-express-style waterfall charts with groups, stacked categories, subtotals and custom colors.
5
5
  Project-URL: Homepage, https://github.com/docdru/plotlywaterfall
6
6
  Project-URL: Source, https://github.com/docdru/plotlywaterfall
@@ -107,12 +107,21 @@ the step, its category and the running total.
107
107
  | `value_labels` | `"bar"` (one label per bar, outside), `"segment"` (inside each stacked segment), `"both"`, or `None` / `False` for no labels | `"bar"` |
108
108
  | `number_format` | Python format spec for the absolute value (e.g. `",.1f"`) or a function | thousands separators, up to two decimals |
109
109
  | `number_prefix` / `number_suffix` | Units around the number; the sign stays in front (`-$5M`) | `""` |
110
+ | `number_unit` | `"k"`, `"M"`, `"bn"` (e.g. 12500000 → `12.5M`), or `"auto"` for one unit chosen from the chart's largest value | `None` |
111
+ | `negative_style` | `"minus"` (`-7`, changes `+9`) or `"parentheses"` (accounting: `(7)`, changes unsigned) | `"minus"` |
110
112
  | `title` | Figure title | `None` |
111
113
  | `labels` | Display names for columns, like plotly express (`{"Y": "Cashflow [MUSD]"}`); titles the axes | y-axis titled with the y column |
114
+ | `category_orders` | Order of x, group or category values, like plotly express (`{"X": ["Revenue", "Costs"]}`) | order of a pandas `Categorical`, else of appearance |
115
+ | `x_format` | Labels of the x-values: a strftime pattern for dates (`"%b %Y"`) or a function | dates as `Jan 2026` (one per month) or `2026-01-31` |
112
116
 
113
117
  waterfall(df, x="X", y="Y", total=True, number_prefix="$", number_suffix="M",
114
118
  title="Cash bridge", labels={"Y": "Cashflow [MUSD]"})
115
119
 
120
+ For financial statements: one unit for all labels and accounting negatives,
121
+
122
+ waterfall(df, x="X", y="Y", total=True, number_unit="auto", negative_style="parentheses",
123
+ number_prefix="$") # $1.25M, ($0.73M), …
124
+
116
125
  ### Colors
117
126
 
118
127
  Colours work like in plotly express:
@@ -75,12 +75,21 @@ the step, its category and the running total.
75
75
  | `value_labels` | `"bar"` (one label per bar, outside), `"segment"` (inside each stacked segment), `"both"`, or `None` / `False` for no labels | `"bar"` |
76
76
  | `number_format` | Python format spec for the absolute value (e.g. `",.1f"`) or a function | thousands separators, up to two decimals |
77
77
  | `number_prefix` / `number_suffix` | Units around the number; the sign stays in front (`-$5M`) | `""` |
78
+ | `number_unit` | `"k"`, `"M"`, `"bn"` (e.g. 12500000 → `12.5M`), or `"auto"` for one unit chosen from the chart's largest value | `None` |
79
+ | `negative_style` | `"minus"` (`-7`, changes `+9`) or `"parentheses"` (accounting: `(7)`, changes unsigned) | `"minus"` |
78
80
  | `title` | Figure title | `None` |
79
81
  | `labels` | Display names for columns, like plotly express (`{"Y": "Cashflow [MUSD]"}`); titles the axes | y-axis titled with the y column |
82
+ | `category_orders` | Order of x, group or category values, like plotly express (`{"X": ["Revenue", "Costs"]}`) | order of a pandas `Categorical`, else of appearance |
83
+ | `x_format` | Labels of the x-values: a strftime pattern for dates (`"%b %Y"`) or a function | dates as `Jan 2026` (one per month) or `2026-01-31` |
80
84
 
81
85
  waterfall(df, x="X", y="Y", total=True, number_prefix="$", number_suffix="M",
82
86
  title="Cash bridge", labels={"Y": "Cashflow [MUSD]"})
83
87
 
88
+ For financial statements: one unit for all labels and accounting negatives,
89
+
90
+ waterfall(df, x="X", y="Y", total=True, number_unit="auto", negative_style="parentheses",
91
+ number_prefix="$") # $1.25M, ($0.73M), …
92
+
84
93
  ### Colors
85
94
 
86
95
  Colours work like in plotly express:
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -50,6 +50,7 @@ class Coloring:
50
50
  pattern_shape: str | None,
51
51
  pattern_shape_map: dict | None,
52
52
  total_name: str = "Total",
53
+ orders: dict[str, list] | None = None,
53
54
  ):
54
55
  self._group = group
55
56
  self._category = category
@@ -74,8 +75,14 @@ class Coloring:
74
75
  )
75
76
  self.pattern_shape = pattern_shape
76
77
 
77
- self._color_values = [] if color == DIRECTION else list(df[color].unique())
78
- self._pattern_values = [] if pattern_shape is None else list(df[pattern_shape].unique())
78
+ # display order of the group / category values (palette colours are handed out in it)
79
+ orders = orders or {}
80
+
81
+ def values(column: str) -> list:
82
+ return list(orders[column]) if column in orders else list(df[column].unique())
83
+
84
+ self._color_values = [] if color == DIRECTION else values(color)
85
+ self._pattern_values = [] if pattern_shape is None else values(pattern_shape)
79
86
  self._warn_unknown_keys(df)
80
87
 
81
88
  # ------------------------------------------------------------------ validation
@@ -0,0 +1,84 @@
1
+ """Number formatting shared by value labels and hover texts."""
2
+
3
+ from collections.abc import Callable, Iterable
4
+ from typing import Literal, get_args
5
+
6
+ NumberFormat = str | Callable[[float], str] | None
7
+ NegativeStyle = Literal["minus", "parentheses"]
8
+ NumberUnit = Literal["k", "M", "bn", "auto"]
9
+
10
+ NEGATIVE_STYLES: tuple[str, ...] = get_args(NegativeStyle)
11
+ NUMBER_UNITS: tuple[str, ...] = get_args(NumberUnit)
12
+ UNIT_DIVISORS = {"k": 1e3, "M": 1e6, "bn": 1e9}
13
+
14
+
15
+ def auto_unit(values: Iterable[float]) -> str | None:
16
+ """The unit for a chart whose largest absolute value is max(|values|): one unit for all
17
+ labels, so that a chart never mixes "950k" and "1.2M"."""
18
+ largest = max((abs(v) for v in values), default=0.0)
19
+ for unit in ("bn", "M", "k"):
20
+ if largest >= UNIT_DIVISORS[unit]:
21
+ return unit
22
+ return None
23
+
24
+
25
+ class NumberFormatter:
26
+ """Format numbers as `[sign][prefix]number[unit][suffix]`.
27
+
28
+ `number_format` is a Python format spec applied to the absolute value (e.g. ",.1f"), a
29
+ callable returning the text for the absolute value, or None for the default: thousands
30
+ separators and up to two decimals without trailing zeros (9, 0.5, 1,234.57).
31
+ The sign is written by the formatter itself, so that currency prefixes come out right
32
+ ("-$5", not "$-5"); changes (steps) are always signed ("+9", "-7"), levels and totals only
33
+ when negative. With negative_style="parentheses" negatives are written as in accounting,
34
+ "(7)", and positive changes get no "+".
35
+ `unit` ("k", "M", "bn") divides values by thousand, million or billion and writes the unit
36
+ after the number ("12.5M").
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ number_format: NumberFormat = None,
42
+ prefix: str = "",
43
+ suffix: str = "",
44
+ negative_style: NegativeStyle = "minus",
45
+ unit: str | None = None,
46
+ ):
47
+ if number_format is not None and not callable(number_format):
48
+ try:
49
+ format(1234.5, number_format)
50
+ except (ValueError, TypeError) as exc:
51
+ raise ValueError(f"Invalid number_format {number_format!r}: {exc}") from exc
52
+ if negative_style not in NEGATIVE_STYLES:
53
+ raise ValueError(
54
+ f"negative_style must be one of {NEGATIVE_STYLES}, got {negative_style!r}"
55
+ )
56
+ if unit is not None and unit not in UNIT_DIVISORS:
57
+ raise ValueError(f"unit must be one of {tuple(UNIT_DIVISORS)} or None, got {unit!r}")
58
+ self._format = number_format
59
+ self._prefix = prefix
60
+ self._suffix = suffix
61
+ self._negative_style = negative_style
62
+ self.unit = unit
63
+
64
+ def _body(self, value: float) -> str:
65
+ if self._format is None:
66
+ text = f"{value:,.2f}"
67
+ return text.rstrip("0").rstrip(".") if "." in text else text
68
+ if callable(self._format):
69
+ return self._format(value)
70
+ return format(value, self._format)
71
+
72
+ def __call__(self, value: float, signed: bool = False) -> str:
73
+ divisor = UNIT_DIVISORS[self.unit] if self.unit else 1.0
74
+ body = self._body(abs(value) / divisor)
75
+ is_zero = body.strip("0.,") == ""
76
+ number = f"{self._prefix}{body}{self.unit or ''}{self._suffix}"
77
+ negative = value < 0 and not is_zero
78
+ if self._negative_style == "parentheses":
79
+ return f"({number})" if negative else number
80
+ if negative:
81
+ return f"-{number}"
82
+ if signed and value > 0 and not is_zero:
83
+ return f"+{number}"
84
+ return number
@@ -1,3 +1,5 @@
1
+ import datetime
2
+ from collections.abc import Callable
1
3
  from typing import Any, Literal, get_args
2
4
 
3
5
  import numpy as np
@@ -5,7 +7,14 @@ import pandas as pd
5
7
  import plotly.graph_objects as go
6
8
 
7
9
  from plotlywaterfall._coloring import Coloring
8
- from plotlywaterfall._format import NumberFormat, NumberFormatter
10
+ from plotlywaterfall._format import (
11
+ NUMBER_UNITS,
12
+ NegativeStyle,
13
+ NumberFormat,
14
+ NumberFormatter,
15
+ NumberUnit,
16
+ auto_unit,
17
+ )
9
18
 
10
19
  DEFAULT_CONNECTOR_LINE: dict[str, Any] = {"color": "darkgrey", "width": 0.75}
11
20
  TotalType = Literal["category", "sumtotal"]
@@ -21,6 +30,8 @@ DEFAULT_NET_GHOST: dict[str, Any] = {
21
30
  }
22
31
  # Share of a sub-step's slot that is filled by its bar; the rest is a small visual gap
23
32
  SUBSTEP_FILL = 0.9
33
+ # helper column with each row's position in the original DataFrame (order of appearance)
34
+ ROW = "__plotlywaterfall_row__"
24
35
  VALUE_LABELS: tuple[str, ...] = get_args(ValueLabels)
25
36
 
26
37
 
@@ -51,8 +62,12 @@ class Waterfall:
51
62
  number_format: NumberFormat = None,
52
63
  number_prefix: str = "",
53
64
  number_suffix: str = "",
65
+ number_unit: NumberUnit | None = None,
66
+ negative_style: NegativeStyle = "minus",
54
67
  title: str | None = None,
55
68
  labels: dict | None = None,
69
+ category_orders: dict[str, list] | None = None,
70
+ x_format: str | Callable[[Any], str] | None = None,
56
71
  ):
57
72
  """Instanciate Waterfall object. All configuration is done by handing the correct kwargs.
58
73
 
@@ -110,11 +125,26 @@ class Waterfall:
110
125
  number_prefix (str, optional): Text before numbers, after the sign, e.g. "$"
111
126
  gives "-$5". Defaults to "".
112
127
  number_suffix (str, optional): Text after numbers, e.g. " M€". Defaults to "".
128
+ number_unit (str | None, optional): Show numbers in thousands ("k"), millions
129
+ ("M") or billions ("bn"), e.g. 12500000 as "12.5M"; "auto" picks one unit for
130
+ the whole chart from its largest value. The unit is written after the number,
131
+ before number_suffix. Defaults to None (no scaling).
132
+ negative_style (str, optional): "minus" ("-7", changes signed "+9") or
133
+ "parentheses" (accounting style: "(7)", changes unsigned). Defaults to "minus".
113
134
  title (str | None, optional): Figure title. Defaults to None.
114
135
  labels (dict | None, optional): Display names for columns, as in plotly express,
115
136
  e.g. {"Y": "Cashflow [MUSD]"}. Used for the axis titles. The y-axis is titled
116
137
  with the (renamed) y column; the x-axis only gets a title if x is in labels.
117
138
  Defaults to None.
139
+ category_orders (dict | None, optional): Order of the values of the x, group or
140
+ category column, as in plotly express, e.g. {"X": ["Revenue", "Costs"]}.
141
+ Listed values come first, others follow in order of appearance. Without it,
142
+ the order of a pandas Categorical column is used, else the order of
143
+ appearance. Defaults to None.
144
+ x_format (str | callable | None, optional): Labels of the x-values: a strftime
145
+ pattern for dates (e.g. "%b %Y"), or a callable taking an x-value and returning
146
+ its label. Defaults to None: dates are shown as "Jan 2026" when they are in
147
+ different months, else as "2026-01-31"; other values as str(value).
118
148
 
119
149
  Raises:
120
150
  TypeError: If df is not a DataFrame or subtotals is not a dict.
@@ -158,10 +188,30 @@ class Waterfall:
158
188
  f"value_labels must be one of {VALUE_LABELS}, None or False, got {value_labels!r}"
159
189
  )
160
190
  self._value_labels: str | None = value_labels
161
- self._fmt = NumberFormatter(number_format, number_prefix, number_suffix)
162
191
  self._title = title
163
192
  self._labels = dict(labels or {})
193
+ if number_unit is not None and number_unit not in NUMBER_UNITS:
194
+ raise ValueError(
195
+ f"number_unit must be one of {NUMBER_UNITS} or None, got {number_unit!r}"
196
+ )
197
+ self._number_unit = number_unit
198
+ self._fmt = NumberFormatter(
199
+ number_format,
200
+ number_prefix,
201
+ number_suffix,
202
+ negative_style=negative_style,
203
+ unit=None if number_unit == "auto" else number_unit,
204
+ )
164
205
  self._test_df()
206
+ self._category_orders = dict(category_orders or {})
207
+ dimensions = [c for c in (x, group, category) if c is not None]
208
+ unknown = [c for c in self._category_orders if c not in dimensions]
209
+ if unknown:
210
+ raise ValueError(
211
+ f"category_orders keys must be the x, group or category column; got {unknown}"
212
+ )
213
+ self._x_values = self._ordered_values(x)
214
+ self._x_label = self._x_labeller(x_format)
165
215
  self._coloring = Coloring(
166
216
  df,
167
217
  group=group,
@@ -172,6 +222,7 @@ class Waterfall:
172
222
  pattern_shape=pattern_shape,
173
223
  pattern_shape_map=pattern_shape_map,
174
224
  total_name=total_column_name,
225
+ orders={c: self._ordered_values(c) for c in (group, category) if c is not None},
175
226
  )
176
227
  if subtotals is None:
177
228
  subtotals = {}
@@ -186,7 +237,7 @@ class Waterfall:
186
237
  self._total = total
187
238
  self._total_type = total_type
188
239
  if total and (total_type == "category"):
189
- self._subtotals[self._df[self._x].unique()[-1]] = total_column_name
240
+ self._subtotals[self._x_values[-1]] = total_column_name
190
241
 
191
242
  def get_fig(self) -> go.Figure:
192
243
  """Creates Plotly Figure instance
@@ -198,8 +249,13 @@ class Waterfall:
198
249
  Figure: Configured plotly.graph_objects.Figure instance
199
250
  """
200
251
 
201
- tdf = self._df.copy()
252
+ # rows in x-axis order (stable), so that running levels follow the axis in every group
253
+ position = {value: i for i, value in enumerate(self._x_values)}
254
+ tdf = self._df.assign(**{ROW: np.arange(len(self._df))})
255
+ tdf = tdf.iloc[np.argsort(tdf[self._x].map(position).to_numpy(), kind="stable")]
202
256
  X = self._get_x_values(tdf)
257
+ if self._number_unit == "auto":
258
+ self._fmt.unit = auto_unit(self._chart_values(tdf))
203
259
  self._coloring.resolve()
204
260
  self._used_directions: set[str] = set()
205
261
  self._label_ys: dict[str, list[float]] = {"above": [], "below": []}
@@ -210,8 +266,9 @@ class Waterfall:
210
266
  self.__totalgroups = 1
211
267
  self._add_group(fig, X, 0, None, tdf)
212
268
  else:
213
- self.__totalgroups = len(tdf[self._group].unique())
214
- for groupno, (groupname, group) in enumerate(tdf.groupby(self._group, sort=False)):
269
+ groups = self._groups(tdf)
270
+ self.__totalgroups = len(groups)
271
+ for groupno, (groupname, group) in enumerate(groups):
215
272
  self._add_group(fig, X, groupno, groupname, group)
216
273
 
217
274
  if self._total and (self._total_type == "sumtotal"):
@@ -224,7 +281,14 @@ class Waterfall:
224
281
  fig.add_bar(**legend_entry)
225
282
 
226
283
  fig.update_layout(
227
- xaxis={"tickmode": "array", "tickvals": X[self._x], "ticktext": X.index},
284
+ xaxis={
285
+ "tickmode": "array",
286
+ "tickvals": X[self._x],
287
+ "ticktext": [
288
+ self._label(value, bool(total))
289
+ for value, total in zip(X.index, X.makeTotal.fillna(True), strict=True)
290
+ ],
291
+ },
228
292
  yaxis_title_text=self._labels.get(self._y, self._y),
229
293
  )
230
294
  if self._x in self._labels:
@@ -237,6 +301,20 @@ class Waterfall:
237
301
  fig.update_layout(uniformtext_minsize=9, uniformtext_mode="hide")
238
302
  return fig
239
303
 
304
+ def _groups(self, tdf: pd.DataFrame) -> list[tuple[Any, pd.DataFrame]]:
305
+ """(group name, rows) pairs in display order."""
306
+ assert self._group is not None
307
+ return [(name, tdf[tdf[self._group] == name]) for name in self._ordered_values(self._group)]
308
+
309
+ def _chart_values(self, tdf: pd.DataFrame) -> list[float]:
310
+ """Bar values and running levels, to choose number_unit="auto"."""
311
+ frames = [g for _, g in self._groups(tdf)] if self._group is not None else [tdf]
312
+ values: list[float] = []
313
+ for frame in frames:
314
+ sums = frame.groupby(self._x, sort=False, observed=True)[self._y].sum()
315
+ values += [*sums.abs(), *sums.cumsum().abs()]
316
+ return values
317
+
240
318
  def _test_df(self) -> None:
241
319
  for argument in ("x", "y", "group", "category"):
242
320
  column = getattr(self, f"_{argument}")
@@ -266,10 +344,53 @@ class Waterfall:
266
344
  Raises:
267
345
  ValueError: If `x` is not an x-value of the data.
268
346
  """
269
- if x not in self._df[self._x].unique():
347
+ if x not in self._x_values:
270
348
  raise ValueError(f"Cannot find value {x!r} in data column {self._x!r}")
271
349
  self._subtotals[x] = name
272
350
 
351
+ def _ordered_values(self, column: str) -> list:
352
+ """Values of a column in display order: category_orders first, then a Categorical's
353
+ order, else the order of appearance."""
354
+ series = self._df[column]
355
+ values = list(series.unique())
356
+ if column in self._category_orders:
357
+ present = set(values)
358
+ given = [v for v in self._category_orders[column] if v in present]
359
+ listed = set(given)
360
+ return given + [v for v in values if v not in listed]
361
+ if isinstance(series.dtype, pd.CategoricalDtype):
362
+ present = set(values)
363
+ return [v for v in series.cat.categories if v in present]
364
+ return values
365
+
366
+ def _x_labeller(self, x_format: str | Callable[[Any], str] | None) -> Callable[[Any], str]:
367
+ """Function that turns an x-value into its axis / hover label."""
368
+ if callable(x_format):
369
+ return x_format
370
+ dates = [v for v in self._x_values if isinstance(v, (datetime.date, np.datetime64))]
371
+ if x_format is not None:
372
+ if len(dates) != len(self._x_values):
373
+ raise ValueError(
374
+ "x_format as a string is a strftime pattern for date x-values; "
375
+ "use a callable for other values"
376
+ )
377
+ pattern = x_format
378
+ elif dates and len(dates) == len(self._x_values):
379
+ stamps = pd.to_datetime(pd.Series(dates))
380
+ if (stamps != stamps.dt.normalize()).any():
381
+ pattern = "%Y-%m-%d %H:%M"
382
+ elif stamps.dt.to_period("M").nunique() == len(stamps):
383
+ pattern = "%b %Y"
384
+ else:
385
+ pattern = "%Y-%m-%d"
386
+ else:
387
+ return str
388
+ return lambda value: pd.Timestamp(value).strftime(pattern)
389
+
390
+ def _label(self, value: Any, is_total: bool) -> str:
391
+ """Label of an x-axis position; totals and subtotals carry their name."""
392
+ return str(value) if is_total else self._x_label(value)
393
+
273
394
  def _offset(self, groupno: int) -> float:
274
395
  """Horizontal offset of the left edge of group `groupno`'s bars from the x-value."""
275
396
  step = self._barwidth + self._gap / 2
@@ -277,7 +398,7 @@ class Waterfall:
277
398
 
278
399
  def _get_x_values(self, tdf: pd.DataFrame) -> pd.DataFrame:
279
400
  # Define x axis and include subtotals and totals
280
- labels = list(tdf[self._x].unique())
401
+ labels = list(self._x_values)
281
402
  mt = [False] * len(labels)
282
403
  for total in self._subtotals:
283
404
  pos = labels.index(total) + 1
@@ -294,17 +415,15 @@ class Waterfall:
294
415
  def _add_group(
295
416
  self, fig: go.Figure, X: pd.DataFrame, groupno: int, groupname: Any, group: pd.DataFrame
296
417
  ) -> None:
297
- level = group.groupby([self._x], sort=False)[self._y].sum().cumsum().shift(1).fillna(0)
298
- level = X.merge(pd.DataFrame(level), left_index=True, right_index=True, how="left")[
299
- self._y
300
- ].fillna(0)
418
+ # running level before each x-position, in x-axis order; totals start at 0
419
+ sums = group.groupby([self._x], sort=False)[self._y].sum()
420
+ steps = X.merge(pd.DataFrame(sums), left_index=True, right_index=True, how="left")[self._y]
421
+ # (positions without data of this group have no bar; their base stays 0 as before)
422
+ level = steps.fillna(0).cumsum().shift(1).fillna(0).where(steps.notna(), 0)
301
423
 
302
424
  # (category name, rows) pairs; a single unnamed "category" without a category column
303
425
  categories: list[tuple[Any, pd.DataFrame]]
304
- if self._category is None:
305
- categories = [(None, group)]
306
- else:
307
- categories = list(group.groupby(self._category, sort=False, observed=True))
426
+ categories = [(None, group)] if self._category is None else self._categories(group)
308
427
  # values[p, c]: height of category c's segment at x-position p (NaN = no segment)
309
428
  values = np.column_stack([self._category_values(X, category) for _, category in categories])
310
429
  level_values = level.to_numpy(dtype=float)
@@ -372,12 +491,12 @@ class Waterfall:
372
491
  name = categoryname if categoryname is not None else ("Total" if total else "Change")
373
492
  lines = [f"{name}: {self._fmt(value, signed=not total)}"]
374
493
  if total and n_segments[p] > 1:
375
- lines.append(f"{label}: {self._fmt(net[p])}")
494
+ lines.append(f"{self._label(label, total)}: {self._fmt(net[p])}")
376
495
  elif not total:
377
496
  if n_segments[p] > 1:
378
497
  lines.append(f"Step: {self._fmt(net[p], signed=True)}")
379
498
  lines.append(f"Running total: {self._fmt(level_after[p])}")
380
- hover.append([str(label), "<br>".join(lines)])
499
+ hover.append([self._label(label, total), "<br>".join(lines)])
381
500
  texts.append(self._fmt(value, signed=not total))
382
501
  extra = "" if groupname is None else str(groupname)
383
502
  kwargs: dict = {
@@ -455,6 +574,20 @@ class Waterfall:
455
574
  if (steps < 0).any():
456
575
  self._used_directions.add("decrease")
457
576
 
577
+ def _categories(self, group: pd.DataFrame) -> list[tuple[Any, pd.DataFrame]]:
578
+ """(category name, rows) pairs of one group in stacking order: category_orders or a
579
+ Categorical's order if given, else the order of appearance in the original data."""
580
+ assert self._category is not None
581
+ column = self._category
582
+ explicit = column in self._category_orders or isinstance(
583
+ self._df[column].dtype, pd.CategoricalDtype
584
+ )
585
+ if explicit:
586
+ names = [v for v in self._ordered_values(column) if (group[column] == v).any()]
587
+ else:
588
+ names = list(group.sort_values(ROW, kind="stable")[column].unique())
589
+ return [(name, group[group[column] == name]) for name in names]
590
+
458
591
  def _category_values(self, X: pd.DataFrame, category: pd.DataFrame) -> np.ndarray:
459
592
  """Segment heights of one category per x-position; totals hold the running sum."""
460
593
  values = category.groupby([self._x])[self._y].sum()
@@ -556,7 +689,7 @@ class Waterfall:
556
689
  y = tdf[self._y].sum()
557
690
  self._add_total_bar_line(fig, x, y, 0, None)
558
691
  else:
559
- for groupno, (groupname, group) in enumerate(tdf.groupby(self._group, sort=False)):
692
+ for groupno, (groupname, group) in enumerate(self._groups(tdf)):
560
693
  y = group[self._y].sum()
561
694
  self._add_total_bar_line(fig, x, y, groupno, groupname)
562
695
 
@@ -609,8 +742,12 @@ def waterfall(
609
742
  number_format: NumberFormat = None,
610
743
  number_prefix: str = "",
611
744
  number_suffix: str = "",
745
+ number_unit: NumberUnit | None = None,
746
+ negative_style: NegativeStyle = "minus",
612
747
  title: str | None = None,
613
748
  labels: dict | None = None,
749
+ category_orders: dict[str, list] | None = None,
750
+ x_format: str | Callable[[Any], str] | None = None,
614
751
  mixed_signs: MixedSigns = "steps",
615
752
  net_ghost: bool | dict = True,
616
753
  step_order: StepOrder = "data",
@@ -646,8 +783,12 @@ def waterfall(
646
783
  number_format=number_format,
647
784
  number_prefix=number_prefix,
648
785
  number_suffix=number_suffix,
786
+ number_unit=number_unit,
787
+ negative_style=negative_style,
649
788
  title=title,
650
789
  labels=labels,
790
+ category_orders=category_orders,
791
+ x_format=x_format,
651
792
  mixed_signs=mixed_signs,
652
793
  net_ghost=net_ghost,
653
794
  step_order=step_order,
@@ -0,0 +1,209 @@
1
+ """x-axis order, date labels, accounting negatives and number units (1.1.0)."""
2
+
3
+ import datetime
4
+
5
+ import pandas as pd
6
+ import pytest
7
+
8
+ from plotlywaterfall import Waterfall, waterfall
9
+ from plotlywaterfall._format import NumberFormatter, auto_unit
10
+
11
+
12
+ def _ticks(fig):
13
+ return list(fig.layout.xaxis.ticktext)
14
+
15
+
16
+ def _labels(fig):
17
+ return [a["text"] for a in fig.layout.annotations]
18
+
19
+
20
+ # --- running levels follow the x-axis in every group ---------------------------------------
21
+
22
+
23
+ def test_group_rows_in_different_order_get_the_same_levels():
24
+ # regression: levels were accumulated in each group's own row order
25
+ df = pd.DataFrame(
26
+ {
27
+ "X": ["A", "B", "C", "C", "A", "B"],
28
+ "Y": [10, -3, 2, 2, 10, -3],
29
+ "group": ["G1"] * 3 + ["G2"] * 3,
30
+ }
31
+ )
32
+ fig = waterfall(df, x="X", y="Y", group="group", total=True)
33
+ bases = {t["name"]: list(t["base"]) for t in fig.data}
34
+ assert bases["G1"] == bases["G2"] == [0, 10, 7, 0]
35
+ connectors = [s["y0"] for s in fig.layout.shapes]
36
+ assert connectors == [10, 7, 9] * 2
37
+
38
+
39
+ # --- order of x, groups and categories -----------------------------------------------------
40
+
41
+
42
+ def test_categorical_order_of_x_is_used():
43
+ df = pd.DataFrame(
44
+ {"X": pd.Categorical(["B", "A", "C"], categories=["A", "B", "C", "unused"]), "Y": [1, 2, 3]}
45
+ )
46
+ fig = waterfall(df, x="X", y="Y", total=True)
47
+ assert _ticks(fig) == ["A", "B", "C", "Total"]
48
+ assert list(fig.data[0]["base"]) == [0, 2, 3, 0] # A=2 first, then B=1, C=3
49
+
50
+
51
+ def test_category_orders_for_x():
52
+ df = pd.DataFrame({"X": ["B", "A", "C"], "Y": [1, 2, 3]})
53
+ fig = waterfall(df, x="X", y="Y", category_orders={"X": ["C", "A"]})
54
+ assert _ticks(fig) == ["C", "A", "B"] # listed values first, the rest in order of appearance
55
+
56
+
57
+ def test_total_goes_after_the_last_x_in_display_order():
58
+ df = pd.DataFrame({"X": ["B", "A"], "Y": [1, 2]})
59
+ fig = waterfall(df, x="X", y="Y", total=True, category_orders={"X": ["B", "A"]})
60
+ assert _ticks(fig) == ["B", "A", "Total"]
61
+ fig = waterfall(df, x="X", y="Y", total=True, category_orders={"X": ["A", "B"]})
62
+ assert _ticks(fig) == ["A", "B", "Total"]
63
+
64
+
65
+ def test_category_orders_for_groups_and_categories():
66
+ df = pd.DataFrame(
67
+ {
68
+ "X": ["A"] * 4,
69
+ "Y": [1, 2, 3, 4],
70
+ "category": ["c1", "c2", "c1", "c2"],
71
+ "group": ["G1", "G1", "G2", "G2"],
72
+ }
73
+ )
74
+ fig = waterfall(
75
+ df,
76
+ x="X",
77
+ y="Y",
78
+ category="category",
79
+ group="group",
80
+ category_orders={"group": ["G2", "G1"], "category": ["c2", "c1"]},
81
+ )
82
+ assert [(t["legendgroup"], t["name"]) for t in fig.data] == [
83
+ ("G2", "c2"),
84
+ ("G2", "c1"),
85
+ ("G1", "c2"),
86
+ ("G1", "c1"),
87
+ ]
88
+ stack = {(t["legendgroup"], t["name"]): t["base"][0] for t in fig.data}
89
+ assert stack[("G2", "c2")] == 0 # c2 is stacked first
90
+ assert stack[("G2", "c1")] == 4
91
+
92
+
93
+ def test_palette_follows_category_orders():
94
+ df = pd.DataFrame({"X": ["A", "A"], "Y": [1, 2], "category": ["c1", "c2"]})
95
+ fig = waterfall(
96
+ df,
97
+ x="X",
98
+ y="Y",
99
+ category="category",
100
+ category_orders={"category": ["c2", "c1"]},
101
+ color_discrete_sequence=["red", "blue"],
102
+ )
103
+ assert {t["name"]: t["marker"]["color"] for t in fig.data} == {"c2": "red", "c1": "blue"}
104
+
105
+
106
+ def test_category_orders_unknown_column():
107
+ df = pd.DataFrame({"X": ["A"], "Y": [1]})
108
+ with pytest.raises(ValueError, match="category_orders keys"):
109
+ waterfall(df, x="X", y="Y", category_orders={"Region": ["EU"]})
110
+
111
+
112
+ def test_subtotal_after_categorical_x():
113
+ df = pd.DataFrame({"X": pd.Categorical(["B", "A"], categories=["A", "B"]), "Y": [1, 2]})
114
+ wf = Waterfall(df, x="X", y="Y")
115
+ wf.add_subtotal("A", "Sub")
116
+ assert _ticks(wf.get_fig()) == ["A", "Sub", "B"]
117
+
118
+
119
+ # --- date labels ---------------------------------------------------------------------------
120
+
121
+
122
+ def test_month_end_dates_are_shown_as_months():
123
+ df = pd.DataFrame({"X": pd.date_range("2026-01-31", periods=3, freq="ME"), "Y": [5, -2, 3]})
124
+ fig = waterfall(df, x="X", y="Y", total=True)
125
+ assert _ticks(fig) == ["Jan 2026", "Feb 2026", "Mar 2026", "Total"]
126
+ assert fig.data[0]["customdata"][0][0] == "Jan 2026" # hover uses the same label
127
+
128
+
129
+ def test_days_in_one_month_are_shown_as_dates():
130
+ df = pd.DataFrame({"X": pd.to_datetime(["2026-01-05", "2026-01-12"]), "Y": [1, 2]})
131
+ assert _ticks(waterfall(df, x="X", y="Y")) == ["2026-01-05", "2026-01-12"]
132
+
133
+
134
+ def test_dates_with_times_and_python_dates():
135
+ df = pd.DataFrame({"X": pd.to_datetime(["2026-01-05 09:30", "2026-01-05 17:00"]), "Y": [1, 2]})
136
+ assert _ticks(waterfall(df, x="X", y="Y")) == ["2026-01-05 09:30", "2026-01-05 17:00"]
137
+
138
+ df = pd.DataFrame({"X": [datetime.date(2026, 1, 1), datetime.date(2026, 2, 1)], "Y": [1, 2]})
139
+ assert _ticks(waterfall(df, x="X", y="Y")) == ["Jan 2026", "Feb 2026"]
140
+
141
+
142
+ def test_x_format():
143
+ df = pd.DataFrame({"X": pd.date_range("2026-01-31", periods=2, freq="ME"), "Y": [1, 2]})
144
+ assert _ticks(waterfall(df, x="X", y="Y", x_format="%m/%y")) == ["01/26", "02/26"]
145
+
146
+ df = pd.DataFrame({"X": [2025, 2026], "Y": [1, 2]})
147
+ assert _ticks(waterfall(df, x="X", y="Y", x_format=lambda v: f"FY{v}")) == ["FY2025", "FY2026"]
148
+ with pytest.raises(ValueError, match="strftime pattern"):
149
+ waterfall(df, x="X", y="Y", x_format="%Y")
150
+
151
+
152
+ # --- accounting negatives and units --------------------------------------------------------
153
+
154
+
155
+ @pytest.mark.parametrize(
156
+ ("value", "signed", "expected"),
157
+ [(-7, True, "(7)"), (9, True, "9"), (-1234.5, False, "(1,234.5)"), (0, True, "0")],
158
+ )
159
+ def test_parentheses(value, signed, expected):
160
+ assert NumberFormatter(negative_style="parentheses")(value, signed=signed) == expected
161
+
162
+
163
+ def test_parentheses_wrap_prefix_unit_and_suffix():
164
+ fmt = NumberFormatter(prefix="$", suffix=" USD", negative_style="parentheses", unit="M")
165
+ assert fmt(-2_500_000) == "($2.5M USD)"
166
+
167
+
168
+ @pytest.mark.parametrize(
169
+ ("unit", "expected"),
170
+ [("k", "+1,250k"), ("M", "+1.25M"), ("bn", "0bn")], # 0.00125bn rounds to zero: no sign
171
+ )
172
+ def test_units(unit, expected):
173
+ assert NumberFormatter(unit=unit)(1_250_000, signed=True) == expected
174
+
175
+
176
+ @pytest.mark.parametrize(
177
+ ("values", "unit"),
178
+ [([5, 999], None), ([1_000, 3], "k"), ([-2_500_000], "M"), ([3e9, 1], "bn"), ([], None)],
179
+ )
180
+ def test_auto_unit(values, unit):
181
+ assert auto_unit(values) == unit
182
+
183
+
184
+ def test_auto_unit_uses_one_unit_for_the_whole_chart():
185
+ df = pd.DataFrame({"X": ["Sales", "Costs", "Tax"], "Y": [1_250_000, -730_000, -95_000]})
186
+ fig = waterfall(df, x="X", y="Y", total=True, number_unit="auto")
187
+ assert _labels(fig) == ["+1.25M", "-0.73M", "-0.1M", "0.42M"]
188
+
189
+
190
+ def test_accounting_chart():
191
+ df = pd.DataFrame({"X": ["Sales", "Costs"], "Y": [1_250_000, -730_000]})
192
+ fig = waterfall(
193
+ df,
194
+ x="X",
195
+ y="Y",
196
+ total=True,
197
+ number_unit="k",
198
+ negative_style="parentheses",
199
+ number_prefix="$",
200
+ )
201
+ assert _labels(fig) == ["$1,250k", "($730k)", "$520k"]
202
+
203
+
204
+ def test_invalid_number_options():
205
+ df = pd.DataFrame({"X": ["A"], "Y": [1]})
206
+ with pytest.raises(ValueError, match="number_unit"):
207
+ waterfall(df, x="X", y="Y", number_unit="mio") # type: ignore[arg-type]
208
+ with pytest.raises(ValueError, match="negative_style"):
209
+ waterfall(df, x="X", y="Y", negative_style="red") # type: ignore[arg-type]
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
@@ -1,46 +0,0 @@
1
- """Number formatting shared by value labels and hover texts."""
2
-
3
- from collections.abc import Callable
4
-
5
- NumberFormat = str | Callable[[float], str] | None
6
-
7
-
8
- class NumberFormatter:
9
- """Format numbers as `[sign][prefix]number[suffix]`.
10
-
11
- `number_format` is a Python format spec applied to the absolute value (e.g. ",.1f"), a
12
- callable returning the text for the absolute value, or None for the default: thousands
13
- separators and up to two decimals without trailing zeros (9, 0.5, 1,234.57).
14
- The sign is written by the formatter itself, so that currency prefixes come out right
15
- ("-$5", not "$-5"); changes (steps) are always signed ("+9", "-7"), levels and totals only
16
- when negative.
17
- """
18
-
19
- def __init__(self, number_format: NumberFormat = None, prefix: str = "", suffix: str = ""):
20
- if number_format is not None and not callable(number_format):
21
- try:
22
- format(1234.5, number_format)
23
- except (ValueError, TypeError) as exc:
24
- raise ValueError(f"Invalid number_format {number_format!r}: {exc}") from exc
25
- self._format = number_format
26
- self._prefix = prefix
27
- self._suffix = suffix
28
-
29
- def _body(self, value: float) -> str:
30
- if self._format is None:
31
- text = f"{value:,.2f}"
32
- return text.rstrip("0").rstrip(".") if "." in text else text
33
- if callable(self._format):
34
- return self._format(value)
35
- return format(value, self._format)
36
-
37
- def __call__(self, value: float, signed: bool = False) -> str:
38
- body = self._body(abs(value))
39
- is_zero = body.strip("0.,") == ""
40
- if value < 0 and not is_zero:
41
- sign = "-"
42
- elif signed and value > 0 and not is_zero:
43
- sign = "+"
44
- else:
45
- sign = ""
46
- return f"{sign}{self._prefix}{body}{self._suffix}"
File without changes