openretailscience 0.45.0__py3-none-any.whl
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.
- openretailscience/__init__.py +3 -0
- openretailscience/analysis/__init__.py +0 -0
- openretailscience/analysis/cohort.py +185 -0
- openretailscience/analysis/composite_rank.py +372 -0
- openretailscience/analysis/cross_shop.py +354 -0
- openretailscience/analysis/customer.py +459 -0
- openretailscience/analysis/customer_decision_hierarchy.py +359 -0
- openretailscience/analysis/gain_loss.py +340 -0
- openretailscience/analysis/haversine.py +50 -0
- openretailscience/analysis/product_association.py +418 -0
- openretailscience/analysis/revenue_tree.py +553 -0
- openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
- openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
- openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
- openretailscience/constants.py +295 -0
- openretailscience/metrics/__init__.py +0 -0
- openretailscience/metrics/base.py +29 -0
- openretailscience/metrics/distribution/__init__.py +0 -0
- openretailscience/metrics/distribution/acv.py +81 -0
- openretailscience/metrics/distribution/pct_of_stores.py +118 -0
- openretailscience/options.py +842 -0
- openretailscience/plots/__init__.py +0 -0
- openretailscience/plots/area.py +114 -0
- openretailscience/plots/bar.py +375 -0
- openretailscience/plots/broken_timeline.py +197 -0
- openretailscience/plots/cohort.py +94 -0
- openretailscience/plots/heatmap.py +134 -0
- openretailscience/plots/histogram.py +276 -0
- openretailscience/plots/index.py +505 -0
- openretailscience/plots/line.py +379 -0
- openretailscience/plots/period_on_period.py +144 -0
- openretailscience/plots/price.py +275 -0
- openretailscience/plots/scatter.py +382 -0
- openretailscience/plots/styles/__init__.py +1 -0
- openretailscience/plots/styles/colors.py +215 -0
- openretailscience/plots/styles/font_utils.py +62 -0
- openretailscience/plots/styles/graph_utils.py +834 -0
- openretailscience/plots/styles/styling_helpers.py +155 -0
- openretailscience/plots/time.py +160 -0
- openretailscience/plots/tree_diagram.py +813 -0
- openretailscience/plots/venn.py +127 -0
- openretailscience/plots/waterfall.py +217 -0
- openretailscience/plugin.py +238 -0
- openretailscience/segmentation/__init__.py +0 -0
- openretailscience/segmentation/hml.py +97 -0
- openretailscience/segmentation/nlr.py +218 -0
- openretailscience/segmentation/rfm.py +307 -0
- openretailscience/segmentation/segstats.py +1177 -0
- openretailscience/segmentation/threshold.py +165 -0
- openretailscience/utils/__init__.py +0 -0
- openretailscience/utils/date.py +226 -0
- openretailscience/utils/filter_and_label.py +52 -0
- openretailscience/utils/label.py +79 -0
- openretailscience/utils/validation.py +45 -0
- openretailscience-0.45.0.dist-info/METADATA +364 -0
- openretailscience-0.45.0.dist-info/RECORD +60 -0
- openretailscience-0.45.0.dist-info/WHEEL +4 -0
- openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
"""Tree Diagram Module.
|
|
2
|
+
|
|
3
|
+
This module implements tree visualization components using matplotlib for creating
|
|
4
|
+
hierarchical tree diagrams with custom node types and grid-based layouts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import matplotlib.patches as mpatches
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
import numpy as np
|
|
13
|
+
from matplotlib.axes import Axes
|
|
14
|
+
from matplotlib.path import Path
|
|
15
|
+
|
|
16
|
+
from openretailscience.options import PlotStyleHelper
|
|
17
|
+
from openretailscience.plots.styles.colors import get_named_color
|
|
18
|
+
from openretailscience.plots.styles.font_utils import get_font_properties
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseRoundedBox(mpatches.PathPatch):
|
|
22
|
+
"""Base patch with independent corner rounding for top and bottom."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
xy: tuple[float, float],
|
|
27
|
+
width: float,
|
|
28
|
+
height: float,
|
|
29
|
+
top_radius: float = 0.3,
|
|
30
|
+
bottom_radius: float = 0.3,
|
|
31
|
+
**kwargs: Any, # noqa: ANN401
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Initialize the custom rounded box.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
xy: Bottom-left corner coordinates (x, y).
|
|
37
|
+
width: Width of the box.
|
|
38
|
+
height: Height of the box.
|
|
39
|
+
top_radius: Radius for top corners.
|
|
40
|
+
bottom_radius: Radius for bottom corners.
|
|
41
|
+
**kwargs: Additional keyword arguments for PathPatch.
|
|
42
|
+
|
|
43
|
+
"""
|
|
44
|
+
x, y = xy
|
|
45
|
+
|
|
46
|
+
# Define the path with different corner radii
|
|
47
|
+
verts = []
|
|
48
|
+
codes = []
|
|
49
|
+
|
|
50
|
+
# Define corner configurations: (center_x, center_y, radius, start_angle, end_angle, fallback_point, is_first)
|
|
51
|
+
corners = [
|
|
52
|
+
# Bottom left corner
|
|
53
|
+
(x + bottom_radius, y + bottom_radius, bottom_radius, np.pi, 3 * np.pi / 2, (x, y), True),
|
|
54
|
+
# Bottom right corner
|
|
55
|
+
(
|
|
56
|
+
x + width - bottom_radius,
|
|
57
|
+
y + bottom_radius,
|
|
58
|
+
bottom_radius,
|
|
59
|
+
3 * np.pi / 2,
|
|
60
|
+
2 * np.pi,
|
|
61
|
+
(x + width, y),
|
|
62
|
+
False,
|
|
63
|
+
),
|
|
64
|
+
# Top right corner
|
|
65
|
+
(x + width - top_radius, y + height - top_radius, top_radius, 0, np.pi / 2, (x + width, y + height), False),
|
|
66
|
+
# Top left corner
|
|
67
|
+
(x + top_radius, y + height - top_radius, top_radius, np.pi / 2, np.pi, (x, y + height), False),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Generate all corners
|
|
71
|
+
for center_x, center_y, radius, start_angle, end_angle, fallback_point, is_first in corners:
|
|
72
|
+
corner_verts, corner_codes = self._generate_corner(
|
|
73
|
+
center_x=center_x,
|
|
74
|
+
center_y=center_y,
|
|
75
|
+
radius=radius,
|
|
76
|
+
start_angle=start_angle,
|
|
77
|
+
end_angle=end_angle,
|
|
78
|
+
fallback_point=fallback_point,
|
|
79
|
+
is_first=is_first,
|
|
80
|
+
)
|
|
81
|
+
verts.extend(corner_verts)
|
|
82
|
+
codes.extend(corner_codes)
|
|
83
|
+
|
|
84
|
+
codes.append(Path.CLOSEPOLY)
|
|
85
|
+
verts.append((0, 0))
|
|
86
|
+
|
|
87
|
+
path = Path(verts, codes)
|
|
88
|
+
super().__init__(path, **kwargs)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _generate_corner(
|
|
92
|
+
center_x: float,
|
|
93
|
+
center_y: float,
|
|
94
|
+
radius: float,
|
|
95
|
+
start_angle: float,
|
|
96
|
+
end_angle: float,
|
|
97
|
+
fallback_point: tuple[float, float],
|
|
98
|
+
is_first: bool,
|
|
99
|
+
) -> tuple[list[tuple[float, float]], list[int]]:
|
|
100
|
+
"""Generate vertices and codes for a single corner.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
center_x: X-coordinate of the corner's center.
|
|
104
|
+
center_y: Y-coordinate of the corner's center.
|
|
105
|
+
radius: Radius of the corner.
|
|
106
|
+
start_angle: Starting angle for the corner arc (in radians).
|
|
107
|
+
end_angle: Ending angle for the corner arc (in radians).
|
|
108
|
+
fallback_point: Point to use if radius is 0.
|
|
109
|
+
is_first: Whether this is the first corner (uses MOVETO instead of LINETO).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (vertices, codes) for this corner.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
arc_points = 10 # Number of points to use for the corner arc
|
|
116
|
+
|
|
117
|
+
if radius > 0:
|
|
118
|
+
theta = np.linspace(start_angle, end_angle, arc_points)
|
|
119
|
+
|
|
120
|
+
verts = [(center_x + radius * np.cos(t), center_y + radius * np.sin(t)) for t in theta]
|
|
121
|
+
codes = [Path.MOVETO] + [Path.LINETO] * (arc_points - 1) if is_first else [Path.LINETO] * arc_points
|
|
122
|
+
|
|
123
|
+
return verts, codes
|
|
124
|
+
|
|
125
|
+
verts = [fallback_point]
|
|
126
|
+
codes = [Path.MOVETO if is_first else Path.LINETO]
|
|
127
|
+
|
|
128
|
+
return verts, codes
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TreeNode(ABC):
|
|
132
|
+
"""Abstract base class for tree nodes.
|
|
133
|
+
|
|
134
|
+
All TreeNode subclasses must define NODE_WIDTH and NODE_HEIGHT class attributes
|
|
135
|
+
and implement the render() method.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
# Subclasses must define these class attributes
|
|
139
|
+
NODE_WIDTH: float
|
|
140
|
+
NODE_HEIGHT: float
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
data: dict[str, Any],
|
|
145
|
+
x: float,
|
|
146
|
+
y: float,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Initialize the tree node.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
data: Dictionary containing node data. Each subclass defines required keys.
|
|
152
|
+
x: X-coordinate of bottom-left corner.
|
|
153
|
+
y: Y-coordinate of bottom-left corner.
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
self._data = data
|
|
157
|
+
self.x = x
|
|
158
|
+
self.y = y
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def render(self, ax: Axes) -> None:
|
|
162
|
+
"""Render the node on the given axes.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
ax: Matplotlib axes object to render on.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
...
|
|
169
|
+
|
|
170
|
+
def get_width(self) -> float:
|
|
171
|
+
"""Return the node width.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
float: Node width.
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
return self.NODE_WIDTH
|
|
178
|
+
|
|
179
|
+
def get_height(self) -> float:
|
|
180
|
+
"""Return the node height.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
float: Node height.
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
return self.NODE_HEIGHT
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SimpleTreeNode(TreeNode):
|
|
190
|
+
"""Simple tree node implementation with header and data sections.
|
|
191
|
+
|
|
192
|
+
Required data keys:
|
|
193
|
+
header (str): The header text
|
|
194
|
+
percent (float): Percentage change value
|
|
195
|
+
value1 (str): First value text
|
|
196
|
+
value2 (str): Second value text
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
NODE_WIDTH = 3.75
|
|
200
|
+
NODE_HEIGHT = 1.75
|
|
201
|
+
|
|
202
|
+
# Color thresholds for percent change
|
|
203
|
+
GREEN_THRESHOLD = 1.0 # Percent change at or above this shows green
|
|
204
|
+
RED_THRESHOLD = -1.0 # Percent change at or below this shows red
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _get_color(percent_change: float) -> str:
|
|
208
|
+
"""Return color based on percent change thresholds.
|
|
209
|
+
|
|
210
|
+
Green if >= GREEN_THRESHOLD, Red if <= RED_THRESHOLD, Gray otherwise.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
percent_change: Percentage change value.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
str: Hex color code as string.
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
if percent_change >= SimpleTreeNode.GREEN_THRESHOLD:
|
|
220
|
+
return get_named_color("positive")
|
|
221
|
+
if percent_change <= SimpleTreeNode.RED_THRESHOLD:
|
|
222
|
+
return get_named_color("negative")
|
|
223
|
+
return get_named_color("neutral")
|
|
224
|
+
|
|
225
|
+
def render(self, ax: Axes) -> None:
|
|
226
|
+
"""Render the node on the given axes.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
ax: Matplotlib axes object to render on.
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
# Extract data from the data dict
|
|
233
|
+
header = self._data["header"]
|
|
234
|
+
percent = self._data["percent"]
|
|
235
|
+
value1 = self._data["value1"]
|
|
236
|
+
value2 = self._data["value2"]
|
|
237
|
+
|
|
238
|
+
# Styling constants
|
|
239
|
+
corner_radius = 0.15
|
|
240
|
+
header_height_ratio = 0.4
|
|
241
|
+
header_color = get_named_color("primary") # Default primary color
|
|
242
|
+
text_color = "white"
|
|
243
|
+
value_vertical_offset = 0.175
|
|
244
|
+
|
|
245
|
+
# Positioning fractions
|
|
246
|
+
header_text_x_fraction = 1 / 2
|
|
247
|
+
percent_text_x_fraction = 4 / 16
|
|
248
|
+
value_text_x_fraction = 11 / 16
|
|
249
|
+
|
|
250
|
+
style = PlotStyleHelper()
|
|
251
|
+
|
|
252
|
+
# Get standard font properties
|
|
253
|
+
semi_bold_font = get_font_properties(style.title_font)
|
|
254
|
+
regular_font = get_font_properties(style.label_font)
|
|
255
|
+
|
|
256
|
+
# Determine color based on percent change
|
|
257
|
+
color = self._get_color(percent)
|
|
258
|
+
|
|
259
|
+
# Header section
|
|
260
|
+
header_height = self.NODE_HEIGHT * header_height_ratio
|
|
261
|
+
header_box = BaseRoundedBox(
|
|
262
|
+
(self.x, self.y + self.NODE_HEIGHT - header_height),
|
|
263
|
+
self.NODE_WIDTH,
|
|
264
|
+
header_height,
|
|
265
|
+
top_radius=corner_radius,
|
|
266
|
+
bottom_radius=0,
|
|
267
|
+
facecolor=header_color,
|
|
268
|
+
edgecolor="none",
|
|
269
|
+
linewidth=0,
|
|
270
|
+
)
|
|
271
|
+
ax.add_patch(header_box)
|
|
272
|
+
|
|
273
|
+
# Data section
|
|
274
|
+
data_height = self.NODE_HEIGHT - header_height
|
|
275
|
+
data_box = BaseRoundedBox(
|
|
276
|
+
(self.x, self.y),
|
|
277
|
+
self.NODE_WIDTH,
|
|
278
|
+
data_height,
|
|
279
|
+
top_radius=0,
|
|
280
|
+
bottom_radius=corner_radius,
|
|
281
|
+
facecolor=color,
|
|
282
|
+
edgecolor="none",
|
|
283
|
+
linewidth=0,
|
|
284
|
+
)
|
|
285
|
+
ax.add_patch(data_box)
|
|
286
|
+
|
|
287
|
+
ax.text(
|
|
288
|
+
self.x + self.NODE_WIDTH * header_text_x_fraction,
|
|
289
|
+
self.y + self.NODE_HEIGHT - header_height / 2,
|
|
290
|
+
header,
|
|
291
|
+
ha="center",
|
|
292
|
+
va="center",
|
|
293
|
+
fontproperties=semi_bold_font,
|
|
294
|
+
fontsize=style.label_size,
|
|
295
|
+
color=text_color,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
ax.text(
|
|
299
|
+
self.x + self.NODE_WIDTH * percent_text_x_fraction,
|
|
300
|
+
self.y + data_height / 2,
|
|
301
|
+
f"{percent:+.1f}%",
|
|
302
|
+
ha="center",
|
|
303
|
+
va="center",
|
|
304
|
+
fontproperties=semi_bold_font,
|
|
305
|
+
fontsize=style.title_size,
|
|
306
|
+
color=text_color,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
ax.text(
|
|
310
|
+
self.x + self.NODE_WIDTH * value_text_x_fraction,
|
|
311
|
+
self.y + data_height / 2 + value_vertical_offset,
|
|
312
|
+
value1,
|
|
313
|
+
ha="left",
|
|
314
|
+
va="center",
|
|
315
|
+
fontproperties=regular_font,
|
|
316
|
+
fontsize=style.label_size,
|
|
317
|
+
color=text_color,
|
|
318
|
+
)
|
|
319
|
+
ax.text(
|
|
320
|
+
self.x + self.NODE_WIDTH * value_text_x_fraction,
|
|
321
|
+
self.y + data_height / 2 - value_vertical_offset,
|
|
322
|
+
value2,
|
|
323
|
+
ha="left",
|
|
324
|
+
va="center",
|
|
325
|
+
fontproperties=regular_font,
|
|
326
|
+
fontsize=style.label_size,
|
|
327
|
+
color=text_color,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class TreeGrid:
|
|
332
|
+
"""Grid-based tree diagram renderer with configurable node types."""
|
|
333
|
+
|
|
334
|
+
# Connection styling constants
|
|
335
|
+
CONNECTION_CURVE_RADIUS = 0.15
|
|
336
|
+
CONNECTION_LINE_WIDTH = 2
|
|
337
|
+
CONNECTION_LINE_COLOR = "black"
|
|
338
|
+
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
tree_structure: dict[str, dict],
|
|
342
|
+
num_rows: int,
|
|
343
|
+
num_cols: int,
|
|
344
|
+
node_class: type[TreeNode],
|
|
345
|
+
vertical_spacing: float | None = None,
|
|
346
|
+
horizontal_spacing: float | None = None,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Initialize the tree grid.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
tree_structure: Dictionary mapping node IDs to node data with required keys
|
|
352
|
+
depending on the node_class being used.
|
|
353
|
+
num_rows: Number of rows in the grid.
|
|
354
|
+
num_cols: Number of columns in the grid.
|
|
355
|
+
node_class: The TreeNode subclass to use for rendering nodes.
|
|
356
|
+
vertical_spacing: Vertical spacing between rows. If None, automatically calculated as
|
|
357
|
+
node_height + 0.6 gap.
|
|
358
|
+
horizontal_spacing: Horizontal spacing between columns. If None, automatically calculated as
|
|
359
|
+
node_width - 1.0 overlap for compact layout.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
ValueError: If grid dimensions are not positive, if tree_structure is empty,
|
|
363
|
+
or if node positions are out of bounds.
|
|
364
|
+
TypeError: If node_class is not a TreeNode subclass.
|
|
365
|
+
|
|
366
|
+
"""
|
|
367
|
+
# Validate grid dimensions
|
|
368
|
+
if num_rows <= 0 or num_cols <= 0:
|
|
369
|
+
error_msg = f"Grid dimensions must be positive: num_rows={num_rows}, num_cols={num_cols}"
|
|
370
|
+
raise ValueError(error_msg)
|
|
371
|
+
|
|
372
|
+
# Validate node_class is a TreeNode subclass
|
|
373
|
+
if not issubclass(node_class, TreeNode):
|
|
374
|
+
error_msg = f"node_class must be a TreeNode subclass, got {node_class}"
|
|
375
|
+
raise TypeError(error_msg)
|
|
376
|
+
|
|
377
|
+
# Validate tree_structure is not empty
|
|
378
|
+
if not tree_structure:
|
|
379
|
+
raise ValueError("tree_structure cannot be empty")
|
|
380
|
+
|
|
381
|
+
self.tree_structure = tree_structure
|
|
382
|
+
self.num_rows = num_rows
|
|
383
|
+
self.num_cols = num_cols
|
|
384
|
+
self.node_class = node_class
|
|
385
|
+
|
|
386
|
+
# Get node dimensions from the node class
|
|
387
|
+
self.node_width = node_class.NODE_WIDTH
|
|
388
|
+
self.node_height = node_class.NODE_HEIGHT
|
|
389
|
+
|
|
390
|
+
# Auto-calculate spacing if not provided
|
|
391
|
+
self.vertical_spacing = vertical_spacing if vertical_spacing is not None else self.node_height + 0.6
|
|
392
|
+
self.horizontal_spacing = horizontal_spacing if horizontal_spacing is not None else self.node_width - 1.0
|
|
393
|
+
|
|
394
|
+
# Validate positions are within grid bounds
|
|
395
|
+
for node_id, node_data in tree_structure.items():
|
|
396
|
+
if "position" not in node_data:
|
|
397
|
+
error_msg = f"Node '{node_id}' is missing required 'position' key"
|
|
398
|
+
raise ValueError(error_msg)
|
|
399
|
+
|
|
400
|
+
col_idx, row_idx = node_data["position"]
|
|
401
|
+
if not (0 <= col_idx < num_cols):
|
|
402
|
+
error_msg = f"Node '{node_id}' column index {col_idx} is out of bounds [0, {num_cols})"
|
|
403
|
+
raise ValueError(error_msg)
|
|
404
|
+
if not (0 <= row_idx < num_rows):
|
|
405
|
+
error_msg = f"Node '{node_id}' row index {row_idx} is out of bounds [0, {num_rows})"
|
|
406
|
+
raise ValueError(error_msg)
|
|
407
|
+
|
|
408
|
+
# Generate row and column positions
|
|
409
|
+
# Row 0 is at the top, increasing downward (reversed from matplotlib's default bottom-up)
|
|
410
|
+
self.row = {i: (num_rows - 1 - i) * self.vertical_spacing for i in range(num_rows)}
|
|
411
|
+
self.col = {i: i * self.horizontal_spacing for i in range(num_cols)}
|
|
412
|
+
|
|
413
|
+
def render(self, ax: Axes | None = None) -> Axes:
|
|
414
|
+
"""Render the tree diagram.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
ax: Optional matplotlib axes object. If None, creates a new figure and axes.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
The matplotlib axes object.
|
|
421
|
+
|
|
422
|
+
"""
|
|
423
|
+
if ax is None:
|
|
424
|
+
# Calculate plot dimensions based on layout
|
|
425
|
+
plot_width = self.col[self.num_cols - 1] + self.node_width
|
|
426
|
+
# Row 0 is at the top with the highest y-value after coordinate inversion
|
|
427
|
+
plot_height = self.row[0] + self.node_height
|
|
428
|
+
|
|
429
|
+
_, ax = plt.subplots(figsize=(plot_width, plot_height))
|
|
430
|
+
ax.set_xlim(0, plot_width)
|
|
431
|
+
ax.set_ylim(0, plot_height)
|
|
432
|
+
ax.axis("off")
|
|
433
|
+
|
|
434
|
+
# First pass: create all nodes and store their centers
|
|
435
|
+
node_centers = {}
|
|
436
|
+
for node_id, node_data in self.tree_structure.items():
|
|
437
|
+
# Convert grid coordinates to absolute positions
|
|
438
|
+
col_idx, row_idx = node_data["position"]
|
|
439
|
+
x = self.col[col_idx]
|
|
440
|
+
y = self.row[row_idx]
|
|
441
|
+
|
|
442
|
+
# Extract data for the node (exclude position and children which are structural)
|
|
443
|
+
data_dict = {k: v for k, v in node_data.items() if k not in ("position", "children")}
|
|
444
|
+
|
|
445
|
+
# Create and render the node
|
|
446
|
+
node = self.node_class(
|
|
447
|
+
data=data_dict,
|
|
448
|
+
x=x,
|
|
449
|
+
y=y,
|
|
450
|
+
)
|
|
451
|
+
node.render(ax)
|
|
452
|
+
|
|
453
|
+
# Store center positions for connections
|
|
454
|
+
node_centers[node_id] = {
|
|
455
|
+
"x": x + self.node_width / 2,
|
|
456
|
+
"y_bottom": y,
|
|
457
|
+
"y_top": y + self.node_height,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
# Second pass: draw connections
|
|
461
|
+
for node_id, node_data in self.tree_structure.items():
|
|
462
|
+
if "children" in node_data and len(node_data["children"]) > 0:
|
|
463
|
+
parent = node_centers[node_id]
|
|
464
|
+
for child_id in node_data["children"]:
|
|
465
|
+
if child_id not in node_centers:
|
|
466
|
+
available_nodes = list(node_centers.keys())
|
|
467
|
+
error_msg = (
|
|
468
|
+
f"Child node '{child_id}' referenced by '{node_id}' not found in tree_structure. "
|
|
469
|
+
f"Available nodes: {available_nodes}"
|
|
470
|
+
)
|
|
471
|
+
raise ValueError(error_msg)
|
|
472
|
+
child = node_centers[child_id]
|
|
473
|
+
self._draw_connection(
|
|
474
|
+
ax=ax,
|
|
475
|
+
x1=parent["x"],
|
|
476
|
+
y1=parent["y_bottom"],
|
|
477
|
+
x2=child["x"],
|
|
478
|
+
y2=child["y_top"],
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
return ax
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _add_curve(
|
|
485
|
+
verts: list[tuple[float, float]],
|
|
486
|
+
codes: list[int],
|
|
487
|
+
x: float,
|
|
488
|
+
y: float,
|
|
489
|
+
x_offset: float,
|
|
490
|
+
y_offset: float,
|
|
491
|
+
) -> None:
|
|
492
|
+
"""Add Bezier curve control points to the path.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
verts: List of vertices to append to.
|
|
496
|
+
codes: List of path codes to append to.
|
|
497
|
+
x: X-coordinate of the curve start point.
|
|
498
|
+
y: Y-coordinate of the curve start point.
|
|
499
|
+
x_offset: X offset for the curve end point.
|
|
500
|
+
y_offset: Y offset for the curve end point.
|
|
501
|
+
|
|
502
|
+
"""
|
|
503
|
+
verts.append((x, y))
|
|
504
|
+
codes.append(Path.CURVE3)
|
|
505
|
+
verts.append((x + x_offset, y + y_offset))
|
|
506
|
+
codes.append(Path.CURVE3)
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def _draw_connection(ax: Axes, x1: float, y1: float, x2: float, y2: float) -> None:
|
|
510
|
+
"""Draw connection line between nodes with curved corners.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
ax: Matplotlib axes object.
|
|
514
|
+
x1: X-coordinate of first point.
|
|
515
|
+
y1: Y-coordinate of first point.
|
|
516
|
+
x2: X-coordinate of second point.
|
|
517
|
+
y2: Y-coordinate of second point.
|
|
518
|
+
|
|
519
|
+
"""
|
|
520
|
+
# Use class constants for connection styling
|
|
521
|
+
curve_radius = TreeGrid.CONNECTION_CURVE_RADIUS
|
|
522
|
+
line_width = TreeGrid.CONNECTION_LINE_WIDTH
|
|
523
|
+
line_color = TreeGrid.CONNECTION_LINE_COLOR
|
|
524
|
+
|
|
525
|
+
mid_y = (y1 + y2) / 2
|
|
526
|
+
curve_sign = 1 if x2 > x1 else -1
|
|
527
|
+
|
|
528
|
+
# Create path with curved corners using Bezier curves
|
|
529
|
+
verts = []
|
|
530
|
+
codes = []
|
|
531
|
+
|
|
532
|
+
# Start point (bottom of parent node)
|
|
533
|
+
verts.append((x1, y1))
|
|
534
|
+
codes.append(Path.MOVETO)
|
|
535
|
+
|
|
536
|
+
# Vertical line down to curve start
|
|
537
|
+
verts.append((x1, mid_y + curve_radius))
|
|
538
|
+
codes.append(Path.LINETO)
|
|
539
|
+
|
|
540
|
+
# Curve from vertical to horizontal (first corner)
|
|
541
|
+
TreeGrid._add_curve(verts, codes, x1, mid_y, curve_sign * curve_radius, 0)
|
|
542
|
+
|
|
543
|
+
# Horizontal line
|
|
544
|
+
verts.append((x2 - (curve_sign * curve_radius), mid_y))
|
|
545
|
+
codes.append(Path.LINETO)
|
|
546
|
+
|
|
547
|
+
# Curve from horizontal to vertical (second corner)
|
|
548
|
+
TreeGrid._add_curve(verts, codes, x2, mid_y, 0, -curve_radius)
|
|
549
|
+
|
|
550
|
+
# Vertical line up to child node
|
|
551
|
+
verts.append((x2, y2))
|
|
552
|
+
codes.append(Path.LINETO)
|
|
553
|
+
|
|
554
|
+
# Create and draw the path
|
|
555
|
+
path = Path(verts, codes)
|
|
556
|
+
patch = mpatches.PathPatch(path, facecolor="none", edgecolor=line_color, linewidth=line_width)
|
|
557
|
+
ax.add_patch(patch)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class DetailedTreeNode(TreeNode):
|
|
561
|
+
"""Detailed tree node with current period, previous period, diff, pct diff, and contribution.
|
|
562
|
+
|
|
563
|
+
Required data keys:
|
|
564
|
+
header: str - Node header text
|
|
565
|
+
percent: float - Percentage change value
|
|
566
|
+
current_period: str - Current period value text
|
|
567
|
+
previous_period: str - Previous period value text
|
|
568
|
+
diff: str - Absolute difference text
|
|
569
|
+
|
|
570
|
+
Optional data keys:
|
|
571
|
+
contribution: str - Contribution value text (if not provided, row is left blank)
|
|
572
|
+
current_label: str - Label for current period (default: "Current Period")
|
|
573
|
+
previous_label: str - Label for previous period (default: "Previous Period")
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
NODE_WIDTH = 3.5
|
|
577
|
+
NODE_HEIGHT = 2.5
|
|
578
|
+
|
|
579
|
+
# Color thresholds for percent change
|
|
580
|
+
GREEN_THRESHOLD = 1.0 # Percent change at or above this shows green
|
|
581
|
+
RED_THRESHOLD = -1.0 # Percent change at or below this shows red
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def _get_color(percent_change: float) -> str:
|
|
585
|
+
"""Return color based on percent change thresholds.
|
|
586
|
+
|
|
587
|
+
Green if >= GREEN_THRESHOLD, Red if <= RED_THRESHOLD, Gray otherwise.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
percent_change: Percentage change value.
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
str: Hex color code as string.
|
|
594
|
+
|
|
595
|
+
"""
|
|
596
|
+
if percent_change >= DetailedTreeNode.GREEN_THRESHOLD:
|
|
597
|
+
return get_named_color("positive")
|
|
598
|
+
if percent_change <= DetailedTreeNode.RED_THRESHOLD:
|
|
599
|
+
return get_named_color("negative")
|
|
600
|
+
return get_named_color("neutral")
|
|
601
|
+
|
|
602
|
+
def render(self, ax: Axes) -> None:
|
|
603
|
+
"""Render the detailed node on the given axes.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
ax: Matplotlib axes object to render on.
|
|
607
|
+
|
|
608
|
+
"""
|
|
609
|
+
# Extract data from the data dict
|
|
610
|
+
header = self._data["header"]
|
|
611
|
+
percent = self._data["percent"]
|
|
612
|
+
current_period = self._data["current_period"]
|
|
613
|
+
previous_period = self._data["previous_period"]
|
|
614
|
+
diff = self._data["diff"]
|
|
615
|
+
contribution = self._data.get("contribution")
|
|
616
|
+
# Styling constants
|
|
617
|
+
corner_radius = 0.15
|
|
618
|
+
header_height_ratio = 0.25
|
|
619
|
+
header_color = self._get_color(percent)
|
|
620
|
+
header_text_color = "white"
|
|
621
|
+
data_bg_color = "white"
|
|
622
|
+
data_text_color = get_named_color("context") # Dark gray for better readability
|
|
623
|
+
label_color = get_named_color("context") # Medium gray for labels
|
|
624
|
+
border_color = get_named_color("context") # Light gray for borders
|
|
625
|
+
|
|
626
|
+
style = PlotStyleHelper()
|
|
627
|
+
|
|
628
|
+
# Get standard font properties
|
|
629
|
+
semi_bold_font = get_font_properties(style.title_font)
|
|
630
|
+
regular_font = get_font_properties(style.label_font)
|
|
631
|
+
|
|
632
|
+
# Title section (colored header)
|
|
633
|
+
title_section_height = self.NODE_HEIGHT * header_height_ratio
|
|
634
|
+
data_section_height = self.NODE_HEIGHT - title_section_height
|
|
635
|
+
|
|
636
|
+
# Layout configuration based on visual structure (all values are fixed/absolute)
|
|
637
|
+
layout = {
|
|
638
|
+
"node": {
|
|
639
|
+
"padding_left": 0.2,
|
|
640
|
+
"padding_right": 0.2,
|
|
641
|
+
},
|
|
642
|
+
"data_section": {
|
|
643
|
+
"padding_top": 0.2, # Space above period labels
|
|
644
|
+
"padding_bottom": 0.1, # Space below last info row
|
|
645
|
+
"period_subsection": {
|
|
646
|
+
"previous_period_x": (self.NODE_WIDTH / 2) + 0.2, # X position for "Previous Period" column
|
|
647
|
+
"label_row_gap": 0.25, # Vertical gap from labels to metrics
|
|
648
|
+
"bottom_margin": 0.25, # Space below period metrics before divider
|
|
649
|
+
},
|
|
650
|
+
"divider": {
|
|
651
|
+
"horizontal_inset": 0.2, # Padding on left/right of divider line
|
|
652
|
+
"vertical_offset": 0.025, # Fine-tune vertical position
|
|
653
|
+
},
|
|
654
|
+
"info_subsection": {
|
|
655
|
+
"row_count": 3, # Number of info rows
|
|
656
|
+
"row_spacing_scale": 0.75, # Scale factor for vertical spacing between rows
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
}
|
|
660
|
+
# Render title section box (border matches header color for seamless appearance)
|
|
661
|
+
title_box = BaseRoundedBox(
|
|
662
|
+
xy=(self.x, self.y + self.NODE_HEIGHT - title_section_height),
|
|
663
|
+
width=self.NODE_WIDTH,
|
|
664
|
+
height=title_section_height,
|
|
665
|
+
top_radius=corner_radius,
|
|
666
|
+
bottom_radius=0,
|
|
667
|
+
facecolor=header_color,
|
|
668
|
+
edgecolor=header_color,
|
|
669
|
+
linewidth=1,
|
|
670
|
+
)
|
|
671
|
+
ax.add_patch(title_box)
|
|
672
|
+
|
|
673
|
+
# Render data section box
|
|
674
|
+
data_section_box = BaseRoundedBox(
|
|
675
|
+
xy=(self.x, self.y),
|
|
676
|
+
width=self.NODE_WIDTH,
|
|
677
|
+
height=data_section_height,
|
|
678
|
+
top_radius=0,
|
|
679
|
+
bottom_radius=corner_radius,
|
|
680
|
+
facecolor=data_bg_color,
|
|
681
|
+
edgecolor=border_color,
|
|
682
|
+
linewidth=1,
|
|
683
|
+
)
|
|
684
|
+
ax.add_patch(data_section_box)
|
|
685
|
+
|
|
686
|
+
# Calculate horizontal positions
|
|
687
|
+
content_left_x = self.x + layout["node"]["padding_left"]
|
|
688
|
+
previous_period_x = self.x + layout["data_section"]["period_subsection"]["previous_period_x"]
|
|
689
|
+
content_right_x = self.x + self.NODE_WIDTH - layout["node"]["padding_right"]
|
|
690
|
+
|
|
691
|
+
# Render title text
|
|
692
|
+
ax.text(
|
|
693
|
+
content_left_x,
|
|
694
|
+
self.y + self.NODE_HEIGHT - title_section_height / 2,
|
|
695
|
+
header,
|
|
696
|
+
ha="left",
|
|
697
|
+
va="center",
|
|
698
|
+
fontproperties=semi_bold_font,
|
|
699
|
+
fontsize=style.label_size,
|
|
700
|
+
color=header_text_color,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Calculate vertical positions for data section
|
|
704
|
+
data_section_top = self.y + data_section_height
|
|
705
|
+
|
|
706
|
+
# Draw horizontal line separator between title and data sections
|
|
707
|
+
title_data_separator_y = self.y + data_section_height
|
|
708
|
+
ax.plot(
|
|
709
|
+
[self.x, self.x + self.NODE_WIDTH],
|
|
710
|
+
[title_data_separator_y, title_data_separator_y],
|
|
711
|
+
color=border_color,
|
|
712
|
+
linewidth=1.5,
|
|
713
|
+
zorder=10,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Period subsection: labels and metrics
|
|
717
|
+
period_labels_y = data_section_top - layout["data_section"]["padding_top"]
|
|
718
|
+
period_metrics_y = period_labels_y - layout["data_section"]["period_subsection"]["label_row_gap"]
|
|
719
|
+
|
|
720
|
+
# Divider line between period and info subsections
|
|
721
|
+
divider_y = (
|
|
722
|
+
period_metrics_y
|
|
723
|
+
- layout["data_section"]["period_subsection"]["bottom_margin"]
|
|
724
|
+
+ layout["data_section"]["divider"]["vertical_offset"]
|
|
725
|
+
)
|
|
726
|
+
divider_inset = layout["data_section"]["divider"]["horizontal_inset"]
|
|
727
|
+
ax.plot(
|
|
728
|
+
[self.x + divider_inset, self.x + self.NODE_WIDTH - divider_inset],
|
|
729
|
+
[divider_y, divider_y],
|
|
730
|
+
color=border_color,
|
|
731
|
+
linewidth=1,
|
|
732
|
+
zorder=10,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
# Get period labels (with defaults)
|
|
736
|
+
current_label = self._data.get("current_label", "Current Period")
|
|
737
|
+
previous_label = self._data.get("previous_label", "Previous Period")
|
|
738
|
+
|
|
739
|
+
# Render period subsection (labels and metrics for current and previous periods)
|
|
740
|
+
period_columns = [
|
|
741
|
+
(current_label, current_period, content_left_x),
|
|
742
|
+
(previous_label, previous_period, previous_period_x),
|
|
743
|
+
]
|
|
744
|
+
|
|
745
|
+
for label, metric, x_pos in period_columns:
|
|
746
|
+
# Period label
|
|
747
|
+
ax.text(
|
|
748
|
+
x_pos,
|
|
749
|
+
period_labels_y,
|
|
750
|
+
label,
|
|
751
|
+
ha="left",
|
|
752
|
+
va="center",
|
|
753
|
+
fontproperties=regular_font,
|
|
754
|
+
fontsize=style.tick_size,
|
|
755
|
+
color=label_color,
|
|
756
|
+
)
|
|
757
|
+
# Period metric
|
|
758
|
+
ax.text(
|
|
759
|
+
x_pos,
|
|
760
|
+
period_metrics_y,
|
|
761
|
+
metric,
|
|
762
|
+
ha="left",
|
|
763
|
+
va="center",
|
|
764
|
+
fontproperties=semi_bold_font,
|
|
765
|
+
fontsize=style.label_size,
|
|
766
|
+
color=data_text_color,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# Calculate remaining height for info subsection
|
|
770
|
+
info_section_height = (
|
|
771
|
+
data_section_height
|
|
772
|
+
- layout["data_section"]["padding_top"]
|
|
773
|
+
- layout["data_section"]["period_subsection"]["label_row_gap"]
|
|
774
|
+
- layout["data_section"]["period_subsection"]["bottom_margin"]
|
|
775
|
+
- layout["data_section"]["padding_bottom"]
|
|
776
|
+
)
|
|
777
|
+
info_row_height = info_section_height / layout["data_section"]["info_subsection"]["row_count"]
|
|
778
|
+
|
|
779
|
+
# Render info subsection rows (Diff, Pct Diff, Contribution)
|
|
780
|
+
info_rows = [
|
|
781
|
+
("Diff", diff),
|
|
782
|
+
("Pct Diff", f"{percent:.1f}%"),
|
|
783
|
+
]
|
|
784
|
+
# Only add contribution row if it's provided
|
|
785
|
+
if contribution is not None:
|
|
786
|
+
info_rows.append(("Contribution", contribution))
|
|
787
|
+
|
|
788
|
+
for i, (label, metric) in enumerate(info_rows):
|
|
789
|
+
info_row_y = divider_y - info_row_height * (
|
|
790
|
+
i + layout["data_section"]["info_subsection"]["row_spacing_scale"]
|
|
791
|
+
)
|
|
792
|
+
# Label on left
|
|
793
|
+
ax.text(
|
|
794
|
+
content_left_x,
|
|
795
|
+
info_row_y,
|
|
796
|
+
label,
|
|
797
|
+
ha="left",
|
|
798
|
+
va="center",
|
|
799
|
+
fontproperties=regular_font,
|
|
800
|
+
fontsize=style.tick_size,
|
|
801
|
+
color=label_color,
|
|
802
|
+
)
|
|
803
|
+
# Metric on right
|
|
804
|
+
ax.text(
|
|
805
|
+
content_right_x,
|
|
806
|
+
info_row_y,
|
|
807
|
+
metric,
|
|
808
|
+
ha="right",
|
|
809
|
+
va="center",
|
|
810
|
+
fontproperties=semi_bold_font,
|
|
811
|
+
fontsize=style.tick_size,
|
|
812
|
+
color=data_text_color,
|
|
813
|
+
)
|