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.
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/CHANGELOG.md +18 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/PKG-INFO +10 -1
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/README.md +9 -0
- plotlywaterfall-1.1.0/src/plotlywaterfall/__about__.py +1 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/_coloring.py +9 -2
- plotlywaterfall-1.1.0/src/plotlywaterfall/_format.py +84 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/waterfall.py +161 -20
- plotlywaterfall-1.1.0/tests/test_order_and_format.py +209 -0
- plotlywaterfall-1.0.0/src/plotlywaterfall/__about__.py +0 -1
- plotlywaterfall-1.0.0/src/plotlywaterfall/_format.py +0 -46
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/.gitignore +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/LICENSE +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/pyproject.toml +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/__init__.py +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/src/plotlywaterfall/py.typed +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_basic.py +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_colors.py +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_function.py +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_labels.py +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_mixed_signs.py +0 -0
- {plotlywaterfall-1.0.0 → plotlywaterfall-1.1.0}/tests/test_readme.py +0 -0
- {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.
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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={
|
|
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.
|
|
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(
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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([
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|