"""Combo (mixed) chart: bar/column and line/area on shared axes.
Composes a column representation and a line representation against one shared
coordinate system. Series can optionally be assigned to a secondary y-axis
that scales to its own range and renders tick labels on the right.
"""
from __future__ import annotations
from typing import cast
from charted.charts.axes import XAxis, YAxis, _AxisParent
from charted.charts.chart import Chart
from charted.charts.column import ColumnChart
from charted.constants import DEFAULT_CHART_HEIGHT, DEFAULT_CHART_WIDTH
from charted.html.element import G, Path, Text
from charted.themes.core import Theme
from charted.utils.defaults import DEFAULT_FONT, DEFAULT_FONT_SIZE
from charted.utils.layout_engine import LayoutEngine
from charted.utils.line_renderer import LineRenderer, _LineHost
from charted.utils.transform import translate
from charted.utils.types import ComboSeriesDict, Labels, SeriesStyleConfig, Vector2D
_BAR_TYPES = {"bar", "column"}
_LINE_TYPES = {"line", "area"}
_VALID_TYPES = _BAR_TYPES | _LINE_TYPES
class _SeriesProxy:
"""Lightweight view over a ComboChart exposing a subset of its series.
Reuses the parent's layout, axes, padding and theme while presenting only
the y-values/colors for the series of a given representation type. This lets
the existing column and line renderers run unchanged against the shared
coordinate system.
"""
x_axis: XAxis
y_axis: YAxis
def __init__(self, parent: "ComboChart", indices: list[int], y_axis: YAxis) -> None:
self._parent = parent
self._indices = indices
self.y_axis: YAxis = y_axis
self.x_axis: XAxis = parent.x_axis
self.theme: Theme = parent.theme
self.layout: LayoutEngine = parent.layout
self.series_styles: list[SeriesStyleConfig] | None = (
[parent.series_styles[i] for i in indices] if parent.series_styles else None
)
# --- subset data ---
@property
def y_values(self) -> Vector2D:
return [self._parent._reproject_series(i) for i in self._indices]
@property
def y_offsets(self) -> Vector2D:
# Combo never stacks; offsets are zero.
return [[0.0] * len(self._parent.series[i]["data"]) for i in self._indices]
@property
def x_values(self) -> Vector2D:
return [self._parent.x_values[0] for _ in self._indices]
@property
def colors(self) -> list[str]:
return [self._parent.colors[i] for i in self._indices]
# --- pass-through layout/geometry ---
@property
def plot_width(self) -> float:
return self._parent.plot_width
@property
def plot_height(self) -> float:
return self._parent.plot_height
@property
def x_count(self) -> int:
return self._parent.x_count
@property
def x_width(self) -> float:
return self._parent.x_width
@property
def x_offset(self) -> float:
return self._parent.x_offset
@property
def left_padding(self) -> float:
return self._parent.left_padding
@property
def top_padding(self) -> float:
return self._parent.top_padding
def get_base_transform(self) -> list[str]:
return self._parent.get_base_transform()
def _apply_stacking(self, y: float, y_offset: float) -> float:
return y
# attributes the renderers probe with getattr
y_stacked: bool = False
markers: bool = False
_data_labels: list[str] | list[list[str]] | None = None
class _ColumnProxy(_SeriesProxy, ColumnChart):
"""Column representation over a subset of combo series (side-by-side)."""
def __init__(self, parent: "ComboChart", indices: list[int], y_axis: YAxis) -> None:
_SeriesProxy.__init__(self, parent, indices, y_axis)
self.column_gap = getattr(parent, "column_gap", 0.2)
self.y_stacked = False
self.series_names = None
@property
def representation(self) -> G:
"""Render bars centered inside each category band.
ColumnChart.representation steps by its own gap-aware ``x_width``, but
the proxy inherits the parent's gap-less band width (plot_width /
x_count) via _SeriesProxy, so reusing it overflowed the plot. Instead
position bars directly on the band centers that the line renderer uses
(``x_values[i] + x_offset``) and size the bar group to fit within the
band so the rightmost bar never crosses the plot edge.
"""
g = G(opacity="0.8", transform=[*self.get_base_transform()])
y_values = self.y_values
num_series = len(y_values)
if num_series == 0:
return g
# Band spacing is the per-category step the line vertices use; for an
# ordinal axis this equals x_offset (x_axis.reproject(1)).
x_values = self.x_values[0]
if len(x_values) > 1:
band_spacing = (x_values[-1] - x_values[0]) / (len(x_values) - 1)
else:
band_spacing = self.x_offset or self.x_width
# Bar group fits inside the band: total group width leaves column_gap of
# the band as empty margin, split evenly on both sides.
group_width = band_spacing * (1 - self.column_gap)
bar_width = group_width / num_series if num_series else group_width
for series_idx, (y_values_series, color) in enumerate(
zip(y_values, self.colors)
):
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 x_idx, y in enumerate(y_values_series):
center = x_values[x_idx] + self.x_offset
group_left = center - group_width / 2
bar_x = group_left + series_idx * bar_width
if y >= 0:
paths.append(Path.get_path(bar_x, 0, bar_width, y))
else:
paths.append(Path.get_path(bar_x, y, bar_width, -y))
g.add_child(Path(d=paths, fill=fill))
return g
[docs]
class ComboChart(Chart):
"""Mixed bar/line chart on shared axes with optional secondary y-axis.
Args:
series: List of series dicts. Each dict has:
- ``data``: list of y-values
- ``type``: one of "bar", "column", "line", "area"
- ``axis`` (optional): "primary" (default) or "secondary"
- ``name`` (optional): legend label
labels: Category labels for the shared x-axis.
width, height: Chart dimensions in pixels.
title: Optional chart title.
theme: Optional theme configuration.
x_label, y_label: Optional axis titles.
Example:
>>> chart = ComboChart(
... series=[
... {"data": [10, 20, 30], "type": "bar", "name": "Revenue"},
... {"data": [3, 6, 9], "type": "line", "name": "Margin"},
... ],
... labels=["Q1", "Q2", "Q3"],
... )
"""
y_stacked: bool = False
def __init__(
self,
series: list[ComboSeriesDict],
labels: Labels | None = None,
width: float = DEFAULT_CHART_WIDTH,
height: float = DEFAULT_CHART_HEIGHT,
zero_index: bool = True,
title: str | None = None,
theme: Theme | None = None,
column_gap: float = 0.2,
series_styles: list[SeriesStyleConfig] | None = None,
x_label: str | None = None,
y_label: str | None = None,
h_lines: list[float] | None = None,
v_lines: list[float] | None = None,
legend: str = "none",
):
if not series or len(series) < 2:
raise ValueError("ComboChart requires at least two series.")
normalized: list[ComboSeriesDict] = []
for s in series:
stype = s.get("type", "line")
if stype not in _VALID_TYPES:
raise ValueError(
f"Invalid series type {stype!r}. "
f"Must be one of {sorted(_VALID_TYPES)}."
)
normalized.append(
{
"data": list(s["data"]),
"type": stype,
"axis": s.get("axis", "primary"),
"name": s.get("name"),
}
)
self.series = normalized
self.column_gap = column_gap
# Partition series indices by target axis.
self._primary_indices = [
i for i, s in enumerate(self.series) if s["axis"] != "secondary"
]
self._secondary_indices = [
i for i, s in enumerate(self.series) if s["axis"] == "secondary"
]
# Base chart scales the primary y-axis to the primary series only.
primary_data = [self.series[i]["data"] for i in self._primary_indices]
if not primary_data:
# All series on secondary; promote them so the layout has data.
primary_data = [self.series[i]["data"] for i in self._secondary_indices]
self._primary_indices = list(self._secondary_indices)
self._secondary_indices = []
series_names_list = [s["name"] for s in self.series]
# Series names may individually be ``None``; the base chart's
# ``series_names`` contract is ``list[str]`` but tolerates the optional
# entries at runtime (the legend skips unnamed series).
names = cast("list[str] | None", series_names_list)
if all(n is None for n in series_names_list):
names = None
super().__init__(
width=width,
height=height,
y_data=primary_data,
x_labels=labels,
title=title,
zero_index=zero_index,
theme=theme,
series_names=names,
chart_type="column",
series_styles=series_styles,
x_label=x_label,
y_label=y_label,
h_lines=h_lines,
v_lines=v_lines,
legend=legend,
)
# Override series_names so legend lists ALL series in original order.
self.series_names = names
@property
def secondary_y_axis(self) -> YAxis | None:
"""Secondary y-axis, built lazily from secondary series only.
Built on first access (which happens during the base __init__ when it
computes ``representation``) so the axis can use the resolved plot
dimensions and theme.
"""
if not self._secondary_indices:
return None
cached = self.__dict__.get("_secondary_y_axis")
if cached is None:
secondary_data = [self.series[i]["data"] for i in self._secondary_indices]
cached = YAxis(
parent=cast("_AxisParent", self),
data=secondary_data,
labels=None,
stacked=False,
zero_index=self.zero_index,
config=self.theme.resolved_grid_color,
)
self.__dict__["_secondary_y_axis"] = cached
return cached
@property
def has_secondary_axis(self) -> bool:
return bool(self._secondary_indices)
@property
def colors(self) -> list[str]:
"""One color per series, in original order."""
return self._color_manager.ensure_palette_size(len(self.series))
def _axis_for(self, index: int) -> YAxis:
if self.series[index]["axis"] == "secondary" and self.secondary_y_axis:
return self.secondary_y_axis
return self.y_axis
def _reproject_series(self, index: int) -> list[float]:
"""Reproject a series' raw data through its assigned y-axis."""
axis = self._axis_for(index)
return [axis.reproject(v) for v in self.series[index]["data"]]
@property
def representation(self) -> G:
g = G()
bar_indices = [i for i, s in enumerate(self.series) if s["type"] in _BAR_TYPES]
line_indices = [
i for i, s in enumerate(self.series) if s["type"] in _LINE_TYPES
]
# Bars/columns: group by axis so each subset scales correctly, then
# render side-by-side using the existing column representation.
if bar_indices:
for axis_kind in ("primary", "secondary"):
subset = [
i
for i in bar_indices
if (self.series[i]["axis"] == "secondary")
== (axis_kind == "secondary")
]
if not subset:
continue
axis = self._axis_for(subset[0])
proxy = _ColumnProxy(self, subset, axis)
g.add_child(proxy.representation)
# Lines/areas reuse the line renderer over their subset.
if line_indices:
line_proxy = _SeriesProxy(
self, line_indices, self._axis_for(line_indices[0])
)
renderer = LineRenderer(cast("_LineHost", line_proxy))
g.add_child(renderer.render())
# Secondary y-axis tick labels, rendered into the normal element tree so
# .html and .svg agree (the CLI and visual tests render .html).
if self.has_secondary_axis:
g.add_child(self._render_secondary_axis())
return g
def _render_secondary_axis(self) -> G:
"""Render secondary y-axis tick labels anchored on the right side."""
axis = cast("YAxis", self.secondary_y_axis)
right_x = self.left_padding + self.plot_width
group = G(
font_size=DEFAULT_FONT_SIZE,
font_family=DEFAULT_FONT,
fill=(
self.theme.resolved_label_color
if hasattr(self.theme, "resolved_label_color")
else "#444444"
),
transform=translate(x=right_x + 6, y=self.top_padding),
)
for y, label in zip(axis.coordinates, axis.labels):
group.add_child(
Text(
x=0,
y=y,
text=label.text,
transform=translate(x=0, y=label.height / 4),
)
)
return group
[docs]
def describe(self) -> dict[str, object]:
meta = super().describe()
meta["chart_type"] = "ComboChart"
meta["series_count"] = len(self.series)
series_info = []
for i, s in enumerate(self.series):
data = s["data"]
count = len(data)
series_info.append(
{
"name": s["name"],
"type": s["type"],
"axis": s["axis"],
"count": count,
"min": float(min(data)),
"max": float(max(data)),
"mean": float(sum(data)) / count if count else 0.0,
"sum": float(sum(data)),
}
)
meta["series"] = series_info
return meta
[docs]
def to_config(self) -> dict[str, object]:
cfg = super().to_config()
cfg["chart_type"] = "ComboChart"
cfg["series"] = [dict(s) for s in self.series]
cfg["column_gap"] = self.column_gap
# Drop base-chart data keys that don't apply to the series-based API.
for key in ("x_data", "y_data", "series_names", "labels"):
cfg.pop(key, None)
cfg["labels"] = [
label.text if hasattr(label, "text") else str(label)
for label in (self.x_labels or [])
]
return cfg