Source code for charted.charts.bar

from __future__ import annotations

from typing import TYPE_CHECKING, cast

from charted.charts.chart import Chart
from charted.config import get_bar_gap
from charted.constants import DEFAULT_CHART_HEIGHT, DEFAULT_CHART_WIDTH
from charted.html.element import Element, G, Path, Text
from charted.themes.core import Theme
from charted.utils.exceptions import NoDataError
from charted.utils.types import (
    Labels,
    ReferenceLineDict,
    SeriesStyleConfig,
    Vector,
    Vector2D,
)

if TYPE_CHECKING:
    from charted.charts.chart import _Annotation


[docs] class BarChart(Chart): """Horizontal bar chart for comparing categorical data. Displays data as horizontal bars where the length of each bar represents the value. Supports single and multi-series data, with optional stacking and side-by-side layouts. Args: data: Single series (list of values) or multi-series (list of lists) labels: Category labels for the y-axis bar_gap: Gap between bars as ratio of bar height (default from config) width, height: Chart dimensions in pixels zero_index: Whether to include zero on the x-axis title: Optional chart title theme: Optional theme configuration series_names: Names for each series (shown in legend) series_styles: Per-series style overrides x_stacked: If True, stack bars horizontally instead of side-by-side category_label_max_width: Max pixel width for a category label before it wraps onto multiple lines. None (default) keeps the full label on a single line and grows the left gutter to fit it. Set this to wrap long names (no truncation) and keep the plot area wide. Example: >>> from charted import BarChart >>> chart = BarChart(data=[120, 180, 210], labels=['Q1', 'Q2', 'Q3']) >>> chart.save('sales.svg') """ def __init__( self, data: Vector | Vector2D, labels: Labels | None = None, bar_gap: float | None = None, width: float = DEFAULT_CHART_WIDTH, height: float = DEFAULT_CHART_HEIGHT, zero_index: bool = True, title: str | None = None, subtitle: str | None = None, subtitle_leading: float = 8.0, theme: Theme | None = None, series_names: list[str] | None = None, series_styles: list[SeriesStyleConfig] | None = None, x_stacked: bool = False, data_labels: list[str] | list[list[str]] | None = None, x_label: str | None = None, y_label: str | None = None, h_lines: list[float] | None = None, v_lines: list[float] | None = None, annotations: list[_Annotation] | None = None, reference_lines: list[ReferenceLineDict] | None = None, colors: list[str] | None = None, value_labels: bool | str | dict[str, object] | None = None, legend: str = "none", category_label_max_width: float | None = None, category_patterns: list[str] | bool | None = None, domain_padding: float | None = None, ): self._bar_data_labels = data_labels if bar_gap is None: bar_gap = get_bar_gap() self.bar_gap = bar_gap self.x_stacked = x_stacked x_data: Vector2D if not isinstance(data, list) or not data or isinstance(data[0], (int, float)): x_data = cast("Vector2D", [data]) else: x_data = data if not x_data or not x_data[0]: raise NoDataError("No data was provided to the BarChart element.") num_bars = len(x_data[0]) if x_data else 0 num_series = len(x_data) if x_data else 0 y_data: Vector2D if num_bars <= 1: y_data = [[0, 1] for _ in range(num_series)] if num_series > 0 else [[0, 1]] else: y_data = [[i for i in range(num_bars)] for _ in range(num_series)] super().__init__( width=width, height=height, x_data=x_data, y_data=y_data, y_labels=labels, title=title, subtitle=subtitle, subtitle_leading=subtitle_leading, zero_index=zero_index, theme=theme, series_names=series_names, x_stacked=x_stacked, chart_type="bar", series_styles=series_styles, data_labels=data_labels, x_label=x_label, y_label=y_label, h_lines=h_lines, v_lines=v_lines, annotations=annotations, reference_lines=reference_lines, colors=colors, value_labels=value_labels, legend=legend, category_label_max_width=category_label_max_width, category_patterns=category_patterns, domain_padding=domain_padding, ) # Refresh axes grid_lines after parent is fully initialized. # During Chart.__init__, left_padding returns h_pad (25.0) because # y_axis doesn't exist yet. After initialization, it correctly # calculates from labels (32.0). We need to recreate the grid Path # with correct values. if self.y_axis and self.y_axis.config: self.y_axis.children[0] = cast("Element", self.y_axis.grid_lines) self.y_axis.children[1] = self.y_axis.axis_labels if self.x_axis and self.x_axis.config: self.x_axis.children[0] = cast("Element", self.x_axis.grid_lines) self.x_axis.children[1] = self.x_axis.axis_labels def _value_label_data(self) -> Vector2D: """Bar values live on the x-axis, not y.""" return self.x_data @property def y_height(self) -> float: return self.plot_height / (self.y_count + (self.y_count + 1) * self.bar_gap)
[docs] def get_base_transform(self) -> list[str]: return []
@property def representation(self) -> G: slot_height = self.y_height gap = slot_height * self.bar_gap start_y = gap num_series = len(self.x_values) if self.x_values else 1 series_thickness = ( slot_height / num_series if (num_series > 0 and not self.x_stacked) else slot_height ) # Mirror of ColumnChart's dy: for stacked charts with negative values, # shift the drawing group right so negative-magnitude bars (which draw # leftward from x_offset) land at the correct zero-relative position. # Non-stacked uses absolute zero_x from the reprojection and doesn't # need this compensation. dx: float = 0 if self.x_stacked and self.x_axis.axis_dimension.min_value < 0: dx = self.x_axis.reproject(abs(self.x_axis.axis_dimension.min_value)) bars_g = G( opacity="0.8", transform=f"translate({self.left_padding + dx}, {self.top_padding})", ) outline = self._filled_outline_attrs() if self.x_stacked: # Mirror ColumnChart: iterate series, accumulate offsets along the # value axis (x here, y in ColumnChart). x_offsets is pre-computed # and already reprojected by Chart.x_offsets setter. for series_idx, (x_values_series, x_offsets_series, color) in enumerate( zip( self.x_values, cast("Vector2D", self.x_offsets), self.colors, ) ): # Apply fill override from series_styles fill = color if self.series_styles and series_idx < len(self.series_styles): style = self.series_styles[series_idx] or {} if style.get("fill"): fill = cast("str", style["fill"]) paths = [] for bar_idx, (x, x_offset_val) in enumerate( zip(x_values_series, x_offsets_series) ): slot_y = start_y + bar_idx * (slot_height + gap) # x_offset_val is the reprojected cumulative start position # and x is the reprojected signed value. Use the leftmost # point and positive width regardless of sign so that a # positive value stacked on top of a negative cumulative # offset (or vice versa) renders correctly. left_x = min(x_offset_val, x_offset_val + x) width = abs(x) paths.append(Path.get_path(left_x, slot_y, width, series_thickness)) draw_fill = ( self._category_fill(series_idx, fill) if fill == color else fill ) bars_g.add_child(Path(d=paths, fill=draw_fill, **outline)) else: # Anchor the bars at the zero line, but clamp it into the plot. When # the data is all-negative (or all-positive), zero falls outside the # domain and reproject(0) lands beyond the plot edge, so bars would # hang off the frame. Clamping to [0, plot_width] anchors them at the # near edge (the domain max for all-negative data) instead. zero_x = max(0.0, min(self.x_axis.zero, self.plot_width)) for series_idx, (x_values_series, color) in enumerate( zip(self.x_values, self.colors) ): # Apply fill override from series_styles fill = color if self.series_styles and series_idx < len(self.series_styles): style = self.series_styles[series_idx] or {} if style.get("fill"): fill = cast("str", style["fill"]) has_fill_override = fill != color per_bar = ( len(self.x_values) == 1 and len(self.colors) > 1 and not has_fill_override ) paths = [] for bar_idx, x in enumerate(x_values_series): slot_y = start_y + bar_idx * (slot_height + gap) bar_y = slot_y + series_idx * series_thickness if x >= zero_x: bar_path = Path.get_path( zero_x, bar_y, x - zero_x, series_thickness ) else: bar_path = Path.get_path(x, bar_y, zero_x - x, series_thickness) if per_bar: bar_fill = self._category_fill( bar_idx, self.colors[bar_idx % len(self.colors)] ) bars_g.add_child(Path(d=[bar_path], fill=bar_fill, **outline)) else: paths.append(bar_path) if not per_bar: series_fill = ( self._category_fill(series_idx, fill) if fill == color else fill ) bars_g.add_child(Path(d=paths, fill=series_fill, **outline)) # Render data labels at end of bars. Explicit data_labels take # precedence; otherwise fall back to synthesized value labels. bar_labels = self._bar_data_labels or self._build_value_labels() if bar_labels: from charted.utils.colors import get_contrast_color from charted.utils.helpers import calculate_text_dimensions labels = bar_labels if labels and not isinstance(labels[0], list): labels = [labels] font_size = max(8, self.theme.title_font_size - 4) font_family = self.theme.title_font_family font_color = self.theme.resolved_axis_title_color single_series = len(self.x_values) == 1 and len(self.colors) > 1 for series_idx, label_row in enumerate(labels): if series_idx >= len(self.x_values): break x_vals = self.x_values[series_idx] for bar_idx, label_text in enumerate(label_row): if bar_idx >= len(x_vals) or not label_text: continue x = x_vals[bar_idx] if single_series: bar_color = self.colors[bar_idx % len(self.colors)] else: bar_color = self.colors[series_idx % len(self.colors)] if self.x_stacked: # In stacked mode every series shares the bar's full # vertical slot, so the label's y-center is the slot # center (independent of series_idx). Center the label # horizontally inside its own segment, which runs from # the cumulative offset to offset+value. slot_y = start_y + bar_idx * (slot_height + gap) x_offset_val = cast("Vector2D", self.x_offsets)[series_idx][ bar_idx ] seg_left = min(x_offset_val, x_offset_val + x) seg_width = abs(x) bars_g.add_child( Text( text=str(label_text), x=seg_left + seg_width / 2, y=slot_y + slot_height / 2 + font_size / 3, fill=get_contrast_color(bar_color), font_size=font_size, font_family=font_family, text_anchor="middle", ) ) continue slot_y = start_y + bar_idx * (slot_height + gap) bar_y = slot_y + series_idx * series_thickness # Check if label would overflow the chart boundary measured = calculate_text_dimensions( str(label_text), font=font_family, font_size=font_size, ) label_right = x + 4 + measured.width if label_right > self.plot_width: bars_g.add_child( Text( text=str(label_text), x=x - 4, y=bar_y + series_thickness / 2 + font_size / 3, fill=get_contrast_color(bar_color), font_size=font_size, font_family=font_family, text_anchor="end", ) ) else: bars_g.add_child( Text( text=str(label_text), x=x + 4, y=bar_y + series_thickness / 2 + font_size / 3, fill=font_color, font_size=font_size, font_family=font_family, text_anchor="start", ) ) # Plot borders: all four sides. grid_color = self.theme.resolved_grid_color border_transform = f"translate({self.left_padding}, {self.top_padding})" borders = [ Path( stroke=grid_color, stroke_dasharray="None", d=[f"M0 {self.plot_height} h{self.plot_width}"], transform=border_transform, ), Path( stroke=grid_color, stroke_dasharray="None", d=[f"M0 0 h{self.plot_width}"], transform=border_transform, ), Path( stroke=grid_color, stroke_dasharray="None", d=[f"M0 0 v{self.plot_height}"], transform=border_transform, ), Path( stroke=grid_color, stroke_dasharray="None", d=[f"M{self.plot_width} 0 v{self.plot_height}"], transform=border_transform, ), ] result = G() result.add_children(bars_g, *borders) return result