Source code for charted.charts.polar_area

from __future__ import annotations

import math
from typing import cast

from charted.charts.pie import PieChart
from charted.config import get_pie_label_font_size
from charted.constants import DEFAULT_CHART_HEIGHT, DEFAULT_CHART_WIDTH
from charted.html.element import Circle, G, Path, Rect, Text
from charted.themes.core import Theme
from charted.utils.colors import get_contrast_color
from charted.utils.types import Labels, SeriesStyleConfig, Vector
from charted.utils.value_format import format_value

# Default number of concentric scale rings drawn behind polar-area slices.
DEFAULT_RADIAL_LEVELS = 5


[docs] class PolarAreaChart(PieChart): """Polar area chart: a pie where every slice spans an equal angle and the slice radius encodes the value. Unlike a pie chart (slice angle encodes value), here all slices share the same angular width of ``360 / N`` degrees, and each slice's radius is scaled by its value so larger values reach further out. Args: data: Non-negative values, one per slice. labels: Optional labels for each slice. width, height: Chart dimensions in pixels. title: Optional chart title. theme: Optional theme configuration. start_angle: Starting angle in degrees (0 = top, clockwise). series_styles: Optional per-slice styling overrides. show_radial_labels: Draw numeric labels on the radial scale rings (default True). radial_levels: Number of concentric scale rings to draw (default 5). Example: >>> from charted import PolarAreaChart >>> chart = PolarAreaChart( ... data=[10, 20, 30, 15], ... labels=['N', 'E', 'S', 'W'], ... ) >>> chart.save('polar.svg') """ def __init__( self, data: Vector, labels: Labels | None = None, width: float = DEFAULT_CHART_WIDTH, height: float = DEFAULT_CHART_HEIGHT, title: str | None = None, theme: Theme | None = None, start_angle: float = 0, series_styles: list[SeriesStyleConfig] | None = None, show_percentages: bool = False, legend: str = "none", show_radial_labels: bool = True, radial_levels: int = DEFAULT_RADIAL_LEVELS, category_patterns: list[str] | bool | None = None, ): self.show_radial_labels = show_radial_labels self.radial_levels = radial_levels super().__init__( data=data, labels=labels, width=width, height=height, title=title, theme=theme, inner_radius=0, explode=0, start_angle=start_angle, series_styles=series_styles, show_percentages=show_percentages, legend=legend, category_patterns=category_patterns, ) def _plot_rect(self) -> tuple[float, float, float, float]: """Plot rectangle (x, y, w, h) inside the padding. Uses the layout engine so the circle is centred in the area below the title (top_padding includes the title) instead of the full canvas, which otherwise leaves the rings sitting top-heavy under the title. """ layout = self.layout return ( layout.left_padding, layout.top_padding, layout.plot_width, layout.plot_height, ) def _center(self) -> tuple[float, float]: """Centre of the rings and slice fan. Horizontally centred on the canvas (a polar chart has no side-axis labels to offset for), vertically centred in the plot rect so it sits below the title rather than top-heavy on the full canvas. """ _, y, _, h = self._plot_rect() return self.width / 2, y + h / 2 def _max_radius(self) -> float: """Outer radius the largest-valued slice reaches.""" _, _, w, h = self._plot_rect() return min(w, h) / 2 * 0.8
[docs] def slice_angles(self) -> list[tuple[float, float]]: """Return (start, end) angle in degrees for each equal-width slice.""" n = len(self._pie_data) step = 360 / n return [ (self.start_angle + i * step, self.start_angle + (i + 1) * step) for i in range(n) ]
@staticmethod def _nice_step(rough: float, round_to_nearest: bool) -> float: """Round a rough step to a 1/2/5 x 10^n 'nice' value (Heckbert).""" if rough <= 0: return 1.0 exp = math.floor(math.log10(rough)) frac = rough / 10**exp if round_to_nearest: nice = 1 if frac < 1.5 else 2 if frac < 3 else 5 if frac < 7 else 10 else: nice = 1 if frac <= 1 else 2 if frac <= 2 else 5 if frac <= 5 else 10 return cast("float", nice * 10**exp) def _radial_scale(self) -> tuple[float, list[float]]: """Return (axis_max, tick_values) snapped to round numbers. The outer ring sits on a 'nice' maximum at or above the data max, and the tick values are round multiples of a nice step, so slice tips land on the labelled rings instead of arbitrary fractions of the data max. """ data = self._pie_data vmax = max(data) if data else 0 if vmax <= 0: return 1.0, [] target = max(2, self.radial_levels) step = self._nice_step(vmax / target, True) axis_max = math.ceil(vmax / step) * step count = int(round(axis_max / step)) ticks = [round(step * i, 10) for i in range(1, count + 1)] return axis_max, ticks
[docs] def slice_radii(self) -> list[float]: """Return the rendered radius for each slice, scaled by value. Linear from the centre to the nice axis maximum, so a slice of value v reaches exactly the ring labelled v. """ data = self._pie_data outer = self._max_radius() axis_max, _ = self._radial_scale() if axis_max <= 0: return [outer for _ in data] return [(v / axis_max) * outer for v in data]
@property def representation(self) -> G: result = G() cx, cy = self._center() data = self._pie_data labels = self._pie_labels or [str(i) for i in range(len(data))] total = sum(data) angles = self.slice_angles() radii = self.slice_radii() # Radial scale rings sit behind the slices so values can be read off. self._render_radial_grid(result, cx, cy, data) for i, (value, label) in enumerate(zip(data, labels)): start_angle, end_angle = angles[i] radius = radii[i] base_color = self.colors[i % len(self.colors)] slice_color = base_color slice_opacity = 0.8 if self.series_styles and i < len(self.series_styles): style = self.series_styles[i] or {} if style.get("fill"): slice_color = cast("str", style["fill"]) if style.get("fill_opacity"): slice_opacity = cast("float", style["fill_opacity"]) draw_fill = ( self._category_fill(i, slice_color) if slice_color == base_color else slice_color ) outline = self._filled_outline_attrs() # Full-circle edge case (N == 1): the slice spans the whole circle, # so angle_span % 360 == 0 collapses _get_slice_path to a zero-area # sliver. Use the full-circle path instead, matching PieChart. path_data: str | list[str] if (end_angle - start_angle) >= 359.9: path_data = self._get_full_circle_path(cx, cy, radius) else: path_data = self._get_slice_path(cx, cy, radius, start_angle, end_angle) result.add_child( Path(d=path_data, fill=draw_fill, opacity=slice_opacity, **outline) ) # Label near the slice midpoint. Small slices have a tiny radius, so # placing the label at a fraction of their own radius piles every # short slice's label on top of the centre. Floor the label distance # so short slices push their labels outward (where the wider angular # span separates them); when that floor lands the label beyond the # slice arc, colour it for the background instead of the fill. label_angle = (start_angle + end_angle) / 2 label_rad = math.radians(label_angle - 90) inner_label_radius = radius * 0.6 min_label_radius = self._max_radius() * 0.42 if inner_label_radius >= min_label_radius: label_radius = inner_label_radius text_color = get_contrast_color(slice_color) else: label_radius = min_label_radius text_color = self._ring_label_color() label_x = cx + label_radius * math.cos(label_rad) label_y = cy + label_radius * math.sin(label_rad) label_display = str(label) if self.show_percentages and total > 0: pct = (value / total) * 100 label_display = f"{label_display} ({pct:.1f}%)" result.add_child( Text( x=label_x, y=label_y, text=label_display, fill=text_color, font_size=get_pie_label_font_size(), font_family=self.theme.title_font_family, text_anchor="middle", dominant_baseline="middle", ) ) # Numeric ring labels are drawn last so the slices cannot cover them. self._render_radial_labels(result, cx, cy) return result def _ring_color(self) -> str: """Theme-aware colour for the radial ring lines.""" theme = self.theme resolved = getattr(theme, "resolved_grid_color", None) if resolved: return cast("str", resolved) return cast("str", getattr(theme, "grid_color", "#cccccc")) def _ring_label_color(self) -> str: """High-contrast colour for the numeric ring labels. The faint grid colour reads fine for the ring lines but is nearly invisible for text on a dark background. Use the theme's full-opacity label colour so the numbers stay legible on every preset. """ theme = self.theme resolved = getattr(theme, "resolved_label_color", None) if resolved: return cast("str", resolved) return cast("str", getattr(theme, "title_color", "#333")) def _render_radial_grid( self, result: G, cx: float, cy: float, data: list[float] ) -> None: """Draw concentric scale rings behind the slices. Rings are spaced so each one sits on a round value of the linear ``0..axis_max`` scale, giving the otherwise scale-less polar slices a readable magnitude reference. The numeric labels are drawn separately (after the slices) so they cannot be hidden under a slice. """ if self.radial_levels <= 0: return outer = self._max_radius() axis_max, ticks = self._radial_scale() if not ticks or axis_max <= 0: return ring_color = self._ring_color() grid_width = getattr(self.theme, "grid_width", None) or 1 for tick in ticks: radius = (tick / axis_max) * outer result.add_child( Circle( cx=cx, cy=cy, r=radius, fill="none", stroke=ring_color, stroke_width=grid_width, ) ) def _render_radial_labels(self, result: G, cx: float, cy: float) -> None: """Draw the numeric ring labels on top of the slices. Each number sits just outside its ring at 12 o'clock and carries a background-coloured halo (a thick stroke behind the glyphs via ``paint-order: stroke``) so it stays readable even where a slice covers the ring, in a high-contrast colour so it reads on dark themes. """ if self.radial_levels <= 0 or not self.show_radial_labels: return outer = self._max_radius() axis_max, ticks = self._radial_scale() if not ticks or axis_max <= 0: return # Draw each number as a solid badge: a pill filled with the # high-contrast label colour (white on dark themes) carrying the text # in the background colour. Dark-on-light (or light-on-dark) badges # read unambiguously over both the dark canvas and the slices. pill_color = self._ring_label_color() text_color = getattr(self.theme, "background_color", "#ffffff") font_size = get_pie_label_font_size() # Gap (px) between a ring line and its numeric label so they don't touch. label_gap = 3 for tick in ticks: radius = (tick / axis_max) * outer text = format_value(tick) label_x = cx + 3 label_y = cy - radius - label_gap char_w = font_size * 0.62 pad_x = 3 pad_y = 2 box_w = len(text) * char_w + pad_x * 2 box_h = font_size + pad_y * 2 box_x = label_x - pad_x box_y = label_y - font_size + pad_y result.add_child( Rect( x=box_x, y=box_y, width=box_w, height=box_h, rx=2, fill=pill_color, fill_opacity=1, ) ) # Anchor the text at the badge centre so it is centred both ways # regardless of glyph metrics. result.add_child( Text( x=box_x + box_w / 2, y=box_y + box_h / 2, text=text, fill=text_color, font_size=font_size, font_family=self.theme.title_font_family, font_weight="bold", text_anchor="middle", dominant_baseline="central", ) )
[docs] def to_config(self) -> dict[str, object]: cfg = super().to_config() cfg["data"] = list(self._pie_data) cfg["labels"] = [ lbl.text if hasattr(lbl, "text") else str(lbl) for lbl in (self._pie_labels or []) ] or None cfg["start_angle"] = self.start_angle return cfg