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)
@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