Source code for charted.charts.area

"""Area chart: line chart with filled area underneath.

Shows one or more series as filled regions under the line.
"""

from __future__ import annotations

import re
from typing import TYPE_CHECKING

from charted.charts.chart import Chart
from charted.constants import DEFAULT_CHART_HEIGHT, DEFAULT_CHART_WIDTH
from charted.html.element import G, Path
from charted.themes.core import Theme
from charted.utils.curves import VALID_CURVES, curve_path
from charted.utils.types import (
    Labels,
    ReferenceLineDict,
    SeriesStyleConfig,
    Vector,
    Vector2D,
)

if TYPE_CHECKING:
    from charted.charts.chart import _Annotation


def _fmt(value: float) -> str:
    """Format a coordinate the way curve_path does (trailing .0 preserved)."""
    return f"{value:g}" if value != int(value) else f"{int(value)}"


def _clamp_path_y(path: str, lo: float, hi: float) -> str:
    """Clamp every y coordinate of an absolute SVG path string to ``[lo, hi]``.

    Handles the M/L/C/H/V commands emitted by ``curve_path``. The x values and
    command structure are left untouched. When no y exceeds the bounds the
    output is identical to the input, so in-bounds curves stay byte-for-byte
    unchanged. Returns the original string verbatim if nothing was clamped.
    """

    def clamp(y: float) -> float:
        return max(lo, min(hi, y))

    out: list[str] = []
    changed = False
    # Split into (command, args) groups; curve_path only emits M/L/C/H/V.
    for cmd, args in re.findall(r"([MLCHV])([^MLCHVZ]*)", path):
        nums = re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", args)
        vals = [float(n) for n in nums]
        if cmd in ("M", "L"):
            # x y pairs
            new_vals = []
            for k in range(0, len(vals), 2):
                cy = clamp(vals[k + 1])
                changed = changed or cy != vals[k + 1]
                new_vals.extend([vals[k], cy])
            out.append(cmd + " ".join(_match_token(nums, idx, v) for idx, v in enumerate(new_vals)))
        elif cmd == "C":
            # x1 y1 x2 y2 x y (every odd index is a y)
            new_vals = []
            for idx, v in enumerate(vals):
                if idx % 2 == 1:
                    cy = clamp(v)
                    changed = changed or cy != v
                    new_vals.append(cy)
                else:
                    new_vals.append(v)
            out.append(cmd + " ".join(_match_token(nums, idx, v) for idx, v in enumerate(new_vals)))
        elif cmd == "V":
            new_vals = []
            for idx, v in enumerate(vals):
                cy = clamp(v)
                changed = changed or cy != v
                new_vals.append(cy)
            out.append(cmd + " ".join(_match_token(nums, idx, v) for idx, v in enumerate(new_vals)))
        else:  # H: x only, no y
            out.append(cmd + args.strip())
    if not changed:
        return path
    return " ".join(s for s in out)


def _match_token(originals: list[str], idx: int, value: float) -> str:
    """Render ``value`` reusing the original token text when it is unchanged.

    Keeps untouched coordinates byte-identical to ``curve_path``'s output and
    only reformats the ones the clamp actually moved.
    """
    if idx < len(originals) and float(originals[idx]) == value:
        return originals[idx]
    return _fmt(value)


[docs] class AreaChart(Chart): """Area chart showing filled regions under lines. Args: data: Single series (list of values) or multi-series (list of lists). x_data: Optional x-axis values. labels: Optional x-axis labels. width, height: Chart dimensions in pixels. fill_opacity: Opacity of the area fill (default 0.3). title: Optional chart title. theme: Optional theme configuration. series_names: Names for each series (shown in legend). series_styles: Per-series style overrides. annotations: Optional list of annotation objects (LineAnnotation, BoxAnnotation, LabelAnnotation) drawn in the plot area. Example: >>> chart = AreaChart( ... data=[[10, 20, 30], [15, 25, 35]], ... labels=['A', 'B', 'C'], ... ) """ fill_opacity: float = 0.3 pad_x_labels: bool = False curve: str = "linear" y_stacked: bool = True def __init__( self, data: Vector | Vector2D, x_data: Vector | Vector2D | None = None, labels: Labels | None = None, width: float = DEFAULT_CHART_WIDTH, height: float = DEFAULT_CHART_HEIGHT, fill_opacity: float = 0.3, 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, curve: str = "linear", stacked: bool = True, x_scale: object | None = None, y_scale: object | 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, domain_padding: float | None = None, ): if curve not in VALID_CURVES: raise ValueError( f"Unknown curve {curve!r}. Valid options: {', '.join(VALID_CURVES)}" ) self.fill_opacity = fill_opacity self.curve = curve # Set before super().__init__ so the base Chart anchors the y-domain to # the stacked totals. Stacked is the sensible default for multi-series # area; pass stacked=False for overlapping translucent areas. self.y_stacked = stacked super().__init__( y_data=data, x_data=x_data, x_labels=labels, width=width, height=height, title=title, subtitle=subtitle, subtitle_leading=subtitle_leading, theme=theme, series_names=series_names, series_styles=series_styles, chart_type="area", x_scale=x_scale, y_scale=y_scale, x_label=x_label, y_label=y_label, h_lines=h_lines, v_lines=v_lines, annotations=annotations, reference_lines=reference_lines, colors=colors, domain_padding=domain_padding, ) @property def x_offset(self) -> float: """Area charts use direct x positions, no label-padding offset.""" return 0.0 @property def representation(self) -> G: """Render area chart series as filled paths.""" g = G() plot_h = self.plot_height plot_w = self.plot_width n = self.x_count pad_y = self.top_padding pad_x = self.left_padding # Compute x positions spanning the full plot area # Labels sit at i/(n-1) * plot_w, from 0 to plot_w if n > 1: x_positions = [i / (n - 1) * plot_w for i in range(n)] else: x_positions = [plot_w / 2] # The filled polygon must stay within the plot box: clamp every vertex # to [plot_top, plot_bottom] so a value sitting at (or interpolating # toward) the axis minimum can't bleed below the floor into the x-label # row, nor exceed the top edge. The clamp is a no-op for points already # inside the plot, so normal charts render byte-for-byte identically. plot_top = pad_y plot_bottom = pad_y + plot_h for i, (y_vals, y_offs) in enumerate(zip(self.y_values, self.y_offsets)): color = self.colors[i] points = [] for j in range(len(y_vals)): x = pad_x + x_positions[j] y = y_vals[j] + y_offs[j] if self.y_stacked else y_vals[j] py = pad_y + plot_h - y py = max(plot_top, min(plot_bottom, py)) points.append((x, py)) if not points: continue if self.curve == "linear": # Preserve the exact historical linear path output. Vertices are # already clamped above, so the linear boundary cannot bleed. top = [f"M{points[0][0]} {points[0][1]}"] for px, py in points[1:]: top.append(f"L{px} {py}") top_d = " ".join(top) else: # Smooth/step the top boundary through the (clamped) points. # Smooth curves derive cubic control points from the vertices and # can still overshoot past the plot edge between them, so clamp # the y of every coordinate in the generated path. This stays a # no-op (byte-identical) when nothing overshoots. top_d = _clamp_path_y(curve_path(self.curve, points), plot_top, plot_bottom) baseline = pad_y + plot_h d = " ".join( [ top_d, f"L{points[-1][0]} {baseline}", f"L{points[0][0]} {baseline}z", ] ) g.add_child( Path( d=d, fill=color, fill_opacity=self.fill_opacity, stroke=color, stroke_width=1.5, ) ) return g