Source code for dartwork_mpl.templates.diverging_bar

"""Diverging bar chart visualization module.

Provides functions for creating horizontal bar charts that display
positive and negative values extending in opposite directions from
a central axis.
"""

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from matplotlib.transforms import blended_transform_factory

import dartwork_mpl as dm


[docs] def plot_diverging_bar( labels: list[str] | None = None, neg_values: np.ndarray | None = None, pos_values: np.ndarray | None = None, add_total: bool = True, figsize: tuple[float, float] | None = None, dpi: int = 300, title: str | None = None, neg_label: str = "Review & Refactoring overhead", pos_label: str = "Code Generation savings", colors: dict[str, str] | None = None, hbar_height: float = 0.5, hbar_spacing_factor: float = 1.6, left_margin: float = 0.35, right_margin: float = 0.95, figure_bottom: float = 0.03, base_x: float = 0.02, title_y: float = 0.95, title_to_legend_gap: float = 0.05, legend_to_figure_gap: float = 0.06, ) -> tuple[Figure, Axes]: """Create a diverging bar chart with positive and negative values. Generates a horizontal bar chart where negative values extend left and positive values extend right from a central axis. Uses a cascading layout with title, legend, and figure stacked vertically. Parameters ---------- labels : list[str] | None, optional Category labels shown on the left. Labels are displayed from top to bottom in reverse order. If None, default sample data is used. Default is None. neg_values : np.ndarray | None, optional Array of negative values (one per label). Values should be negative. If None, default sample data is used. Default is None. pos_values : np.ndarray | None, optional Array of positive values (one per label). Values should be positive. If None, default sample data is used. Default is None. add_total : bool, optional If True, appends a "Total" row with mean values. Default is True. figsize : tuple[float, float] | None, optional Figure size (width, height) in inches. If None, (12cm, 12cm) is used. Default is None. dpi : int, optional Figure resolution in dots per inch. Default is 300. title : str | None, optional Title text shown at the top. If None, a default title is used. Default is None. neg_label : str, optional Legend label for negative bars. Default is "Review & Refactoring overhead". pos_label : str, optional Legend label for positive bars. Default is "Code Generation savings". colors : dict[str, str] | None, optional Dictionary with 'neg' and 'pos' keys. If None, default colors (MidnightBlue for negative, CornflowerBlue for positive) are used. Default is None. hbar_height : float, optional Height of each horizontal bar. Default is 0.5. hbar_spacing_factor : float, optional Bar spacing as a multiple of ``hbar_height``. Default is 1.6. left_margin : float, optional Left margin of the Axes in figure coordinates (0–1). Default is 0.35. right_margin : float, optional Right margin of the Axes in figure coordinates (0–1). Default is 0.95. figure_bottom : float, optional Bottom margin of the Axes in figure coordinates (0–1). Default is 0.03. base_x : float, optional Common x-coordinate for title, legend, and labels in figure coordinates (0–1). Default is 0.02. title_y : float, optional Starting y-coordinate of the title in figure coordinates (0–1). Default is 0.95. title_to_legend_gap : float, optional Gap between title and legend in figure coordinates (0–1). Default is 0.05. legend_to_figure_gap : float, optional Gap between legend and figure area in figure coordinates (0–1). Default is 0.06. Returns ------- fig : matplotlib.figure.Figure The created Figure object. ax : matplotlib.axes.Axes The Axes containing the chart. Examples -------- >>> import numpy as np >>> import dartwork_mpl as dm >>> dm.style.use('scientific') >>> >>> # Minimal setup — uses default sample data >>> fig, ax = plot_diverging_bar() >>> dm.save_and_show(fig) >>> >>> # Custom data >>> labels = [ ... "Frontend Development", ... "Backend Architecture", ... "Data Engineering", ... "API Integration", ... "Quality Assurance", ... "DevOps & Infrastructure", ... "Security Compliance", ... "Technical Documentation", ... ] >>> neg_values = np.array([-5, -8, -10, -10, -8, -9, -10, -7]) >>> pos_values = np.array([20, 35, 32, 40, 20, 28, 38, 30]) >>> fig, ax = plot_diverging_bar( ... labels, ... neg_values, ... pos_values ... ) >>> dm.save_and_show(fig) >>> >>> # Customize title and colors without Total row >>> fig, ax = plot_diverging_bar( ... labels, ... neg_values, ... pos_values, ... add_total=False, ... title="Custom Title", ... colors={'neg': 'oc.red5', 'pos': 'oc.green5'} ... ) >>> dm.save_and_show(fig) Notes ----- - This function uses a cascading layout where the title, legend, and chart are spaced automatically from top to bottom. - Labels are positioned using ``blended_transform_factory`` which blends figure x-coordinates with data y-coordinates. - The "Total" row (if enabled) is automatically bolded via ``dm.fw(1)``. - Value labels are placed inside the bars (left for negative, right for positive). See Also -------- dartwork_mpl.style.use : Apply a dartwork-mpl style preset dartwork_mpl.simple_layout : Optimize figure layout matplotlib.transforms.blended_transform_factory : Create blended transforms """ # Use default sample data if not provided if labels is None: labels = [ "Frontend Development", "Backend Architecture", "Data Engineering", "API Integration", "Quality Assurance", "DevOps & Infrastructure", "Security Compliance", "Technical Documentation", ] if neg_values is None: neg_values = np.array([-5, -8, -10, -10, -8, -9, -10, -7]) if pos_values is None: pos_values = np.array([20, 35, 32, 40, 20, 28, 38, 30]) # Prepare data: copy to avoid modifying input labels_list = labels.copy() neg_vals = neg_values.copy() pos_vals = pos_values.copy() # Add Total row if requested if add_total: labels_list.append("Total") neg_vals = np.append(neg_vals, np.mean(neg_vals)) pos_vals = np.append(pos_vals, np.mean(pos_vals)) # Reverse order for barh (top to bottom display) labels_list = labels_list[::-1] neg_vals = neg_vals[::-1] pos_vals = pos_vals[::-1] # Set default figure size using cm2in for unit conversion if figsize is None: figsize = (dm.cm2in(12), dm.cm2in(12)) # Set default colors if colors is None: colors = { "neg": "#191970", # MidnightBlue-like "pos": "#6495ED", # CornflowerBlue-like } # Set default title if title is None: title = ( "Engineering hours shifted by AI assistants, % of sprint capacity" ) # Create figure with publication-ready settings fig = plt.figure(figsize=figsize, dpi=dpi) # Cascading layout calculation # Vertical positioning from top to bottom: # title_y -> legend_y -> figure_top legend_y = title_y - title_to_legend_gap figure_top = legend_y - legend_to_figure_gap # Set up GridSpec for precise layout control # left_margin reserves space for labels on the left # Labels are drawn at figure x=base_x (0.02) gs = fig.add_gridspec( nrows=1, ncols=1, left=left_margin, right=right_margin, top=figure_top, bottom=figure_bottom, ) ax = fig.add_subplot(gs[0, 0]) # Calculate y positions for bars # Spacing between bars = hbar_height * hbar_spacing_factor y_pos = np.arange(len(labels_list)) * hbar_height * hbar_spacing_factor # Plot horizontal bars # Negative values extend to the left bars_neg = ax.barh( y_pos, neg_vals, height=hbar_height, color=colors["neg"], label=neg_label, ) # Positive values extend to the right bars_pos = ax.barh( y_pos, pos_vals, height=hbar_height, color=colors["pos"], label=pos_label, ) # Styling: remove all spines and ticks ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_visible(False) ax.spines["bottom"].set_visible(False) # Remove x and y ticks ax.set_xticks([]) ax.set_yticks([]) # Add vertical grid line at x=0 for reference ax.axvline(0, color="lightgray", linewidth=0.8) # Create blended transform for labels # x-coordinate in figure space, y-coordinate in data space # This allows labels to be positioned at base_x (figure) while # aligning with bar positions (data) transform = blended_transform_factory(fig.transFigure, ax.transData) # Add text labels on the left side # Labels are positioned at base_x (figure x-coord) and y_pos (data # y-coord) for i, label in enumerate(labels_list): # Bold 'Total' label using dm.fw weight = dm.fw(1) if label == "Total" else dm.fw(0) ax.text( base_x, y_pos[i], label, ha="left", va="center", transform=transform, fontsize=dm.fs(0), fontweight=weight, wrap=True, ) # Add value labels on bars # Negative values: label on the left side of the bar for rect in bars_neg: width = rect.get_width() ax.text( width - 1, # Offset 1 unit to the left rect.get_y() + rect.get_height() / 2, # Center vertically f"{int(width)}", ha="right", va="center", fontsize=dm.fs(-1), ) # Positive values: label on the right side of the bar for rect in bars_pos: width = rect.get_width() ax.text( width + 1, # Offset 1 unit to the right rect.get_y() + rect.get_height() / 2, # Center vertically f"{int(width)}", ha="left", va="center", fontsize=dm.fs(-1), ) # Add title at the top # Positioned at base_x (figure x-coord) and title_y (figure y-coord) fig.text( base_x, title_y, title, fontsize=dm.fs(2), fontweight=dm.fw(1), ha="left", ) # Add custom legend # Positioned below title at base_x (figure x-coord) and legend_y # (figure y-coord) fig.legend( loc="upper left", bbox_to_anchor=(base_x, legend_y), ncol=2, frameon=False, fontsize=dm.fs(0), borderaxespad=0, columnspacing=1.5, ) # Apply simple_layout for automatic margin optimization # Use bbox to optimize only the axes area, protecting title/legend # bbox format: (left, right, bottom, top) in figure coordinates # This ensures title (at title_y) and legend (at legend_y) are not # affected by the optimization # Use minimal settings to preserve original layout as much as possible: # - Zero margins to match original exactly # - Very low importance weights to minimize optimization # - Very small bound_margin to limit GridSpec parameter changes # - High gtol to allow early convergence dm.simple_layout( fig, gs=gs, bbox=(left_margin, right_margin, figure_bottom, figure_top), margins=( 0.0, 0.0, 0.0, 0.0, ), # Zero margins to preserve original# Extremely low weights to minimize changes bound_margin=0.001, # Very small bound margin to limit changes gtol=1e-1, # Higher tolerance for early convergence use_all_axes=False, # Only optimize axes in this GridSpec ) return fig, ax
[docs] def get_source_code() -> str: """Return the source code of this module as a string. Intended for providing source code as input to coding agents (AI) for further development or modification. Returns ------- str The complete source code of this module. Examples -------- >>> source = get_source_code() >>> print(source) """ import importlib import inspect # Get the current module module_name = __name__ module = importlib.import_module(module_name) # Get the source file path source_file = inspect.getfile(module) # Read and return the source code with open(source_file, encoding="utf-8") as f: return f.read()