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.
Files changed (60) hide show
  1. openretailscience/__init__.py +3 -0
  2. openretailscience/analysis/__init__.py +0 -0
  3. openretailscience/analysis/cohort.py +185 -0
  4. openretailscience/analysis/composite_rank.py +372 -0
  5. openretailscience/analysis/cross_shop.py +354 -0
  6. openretailscience/analysis/customer.py +459 -0
  7. openretailscience/analysis/customer_decision_hierarchy.py +359 -0
  8. openretailscience/analysis/gain_loss.py +340 -0
  9. openretailscience/analysis/haversine.py +50 -0
  10. openretailscience/analysis/product_association.py +418 -0
  11. openretailscience/analysis/revenue_tree.py +553 -0
  12. openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
  13. openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
  14. openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
  15. openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
  16. openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
  17. openretailscience/constants.py +295 -0
  18. openretailscience/metrics/__init__.py +0 -0
  19. openretailscience/metrics/base.py +29 -0
  20. openretailscience/metrics/distribution/__init__.py +0 -0
  21. openretailscience/metrics/distribution/acv.py +81 -0
  22. openretailscience/metrics/distribution/pct_of_stores.py +118 -0
  23. openretailscience/options.py +842 -0
  24. openretailscience/plots/__init__.py +0 -0
  25. openretailscience/plots/area.py +114 -0
  26. openretailscience/plots/bar.py +375 -0
  27. openretailscience/plots/broken_timeline.py +197 -0
  28. openretailscience/plots/cohort.py +94 -0
  29. openretailscience/plots/heatmap.py +134 -0
  30. openretailscience/plots/histogram.py +276 -0
  31. openretailscience/plots/index.py +505 -0
  32. openretailscience/plots/line.py +379 -0
  33. openretailscience/plots/period_on_period.py +144 -0
  34. openretailscience/plots/price.py +275 -0
  35. openretailscience/plots/scatter.py +382 -0
  36. openretailscience/plots/styles/__init__.py +1 -0
  37. openretailscience/plots/styles/colors.py +215 -0
  38. openretailscience/plots/styles/font_utils.py +62 -0
  39. openretailscience/plots/styles/graph_utils.py +834 -0
  40. openretailscience/plots/styles/styling_helpers.py +155 -0
  41. openretailscience/plots/time.py +160 -0
  42. openretailscience/plots/tree_diagram.py +813 -0
  43. openretailscience/plots/venn.py +127 -0
  44. openretailscience/plots/waterfall.py +217 -0
  45. openretailscience/plugin.py +238 -0
  46. openretailscience/segmentation/__init__.py +0 -0
  47. openretailscience/segmentation/hml.py +97 -0
  48. openretailscience/segmentation/nlr.py +218 -0
  49. openretailscience/segmentation/rfm.py +307 -0
  50. openretailscience/segmentation/segstats.py +1177 -0
  51. openretailscience/segmentation/threshold.py +165 -0
  52. openretailscience/utils/__init__.py +0 -0
  53. openretailscience/utils/date.py +226 -0
  54. openretailscience/utils/filter_and_label.py +52 -0
  55. openretailscience/utils/label.py +79 -0
  56. openretailscience/utils/validation.py +45 -0
  57. openretailscience-0.45.0.dist-info/METADATA +364 -0
  58. openretailscience-0.45.0.dist-info/RECORD +60 -0
  59. openretailscience-0.45.0.dist-info/WHEEL +4 -0
  60. 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
+ )