Source code for wolfhece.assets.pie.zones_asset

from __future__ import annotations

import math
from typing import Sequence

from ...PyVertexvectors import Zones, zone, vector, wolfvertex, getIfromRGB
from .distribution import PieDistributionModel, PieSlice


[docs] class PieZonesAsset: """Pie asset factory producing native Zones for mapviewer rendering."""
[docs] DEFAULT_COLORS: tuple[tuple[int, int, int, int], ...] = ( # Modern, balanced categorical palette (high separation, print-friendly). (41, 98, 255, 235), (0, 166, 118, 235), (255, 122, 69, 235), (138, 79, 255, 235), (255, 178, 0, 235), (0, 170, 204, 235), (255, 84, 112, 235), (90, 122, 146, 235), (121, 85, 72, 235), )
def __init__( self, values: Sequence[float], center: tuple[float, float] = (0.0, 0.0), radius: float = 10.0, *, labels: Sequence[str] | None = None, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, start_angle_deg: float = 90.0, clockwise: bool = False, sectors_as_independent_zones: bool = False, legend_visible: bool = True, legend_show_percent: bool = True, legend_show_value: bool = False, legend_offset_factor: float = 0.65, legend_position_mode: str = "auto", legend_positions: Sequence[tuple[float, float] | None] | None = None, legend_text_color: tuple[int, int, int] = (0, 0, 0), fill_clock_animation: bool = False, fill_anim_speed: float = 1.0, smoothing: bool = True, border_enabled: bool = True, border_width: float = 1.0, border_color: tuple[int, int, int] = (25, 25, 25), store_fraction_in_z: bool = True, idx: str = "pie_chart", parent=None, mapviewer=None, ) -> None:
[docs] self.model = PieDistributionModel(values, labels=labels)
[docs] self.center_x = float(center[0])
[docs] self.center_y = float(center[1])
[docs] self.radius = float(radius)
if self.radius <= 0.0: raise ValueError("radius must be > 0")
[docs] self.start_angle_deg = float(start_angle_deg)
[docs] self.clockwise = bool(clockwise)
[docs] self.sectors_as_independent_zones = bool(sectors_as_independent_zones)
[docs] self.legend_visible = bool(legend_visible)
[docs] self.legend_show_percent = bool(legend_show_percent)
[docs] self.legend_show_value = bool(legend_show_value)
[docs] self.legend_offset_factor = float(legend_offset_factor)
[docs] self.legend_position_mode = str(legend_position_mode).strip().lower() or "auto"
if self.legend_position_mode not in {"auto", "manual"}: raise ValueError("legend_position_mode must be 'auto' or 'manual'")
[docs] self.legend_positions = self._normalize_legend_positions(legend_positions)
[docs] self.legend_text_color = ( int(max(0, min(255, legend_text_color[0]))), int(max(0, min(255, legend_text_color[1]))), int(max(0, min(255, legend_text_color[2]))), )
[docs] self.fill_clock_animation = bool(fill_clock_animation)
[docs] self.fill_anim_speed = float(fill_anim_speed)
[docs] self.smoothing = bool(smoothing)
[docs] self.border_enabled = bool(border_enabled)
[docs] self.border_width = float(border_width)
[docs] self.border_color = border_color
[docs] self.store_fraction_in_z = bool(store_fraction_in_z)
[docs] self.idx = str(idx)
[docs] self.parent = parent
[docs] self.mapviewer = mapviewer
[docs] self._colors = self._normalize_colors(colors)
[docs] def _normalize_legend_positions( self, positions: Sequence[tuple[float, float] | None] | None, ) -> list[tuple[float, float] | None]: count = len(self.model.values) if positions is None: return [None] * count out: list[tuple[float, float] | None] = [] for pos in positions: if pos is None: out.append(None) continue out.append((float(pos[0]), float(pos[1]))) if len(out) < count: out.extend([None] * (count - len(out))) elif len(out) > count: out = out[:count] return out
[docs] def _to_rgba(self, color: tuple[int, ...]) -> tuple[int, int, int, int]: if len(color) == 3: r, g, b = color a = 255 elif len(color) == 4: r, g, b, a = color else: raise ValueError("Color must have 3 or 4 channels") return ( int(max(0, min(255, r))), int(max(0, min(255, g))), int(max(0, min(255, b))), int(max(0, min(255, a))), )
[docs] def _normalize_colors( self, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None, ) -> list[tuple[int, int, int, int]]: if not colors: return [self._to_rgba(c) for c in self.DEFAULT_COLORS] return [self._to_rgba(c) for c in colors]
[docs] def _slice_color(self, idx: int) -> tuple[int, int, int, int]: return self._colors[idx % len(self._colors)]
[docs] def _legend_text(self, slc: PieSlice) -> str: parts = [slc.label] if self.legend_show_percent: parts.append(f"{100.0 * slc.fraction:.1f}%") if self.legend_show_value: parts.append(f"{slc.value:g}") return " - ".join(parts)
[docs] def _legend_color_for_fill(self, rgba: tuple[int, int, int, int]) -> tuple[int, int, int]: """Return fixed legend text color for exterior positioning. Legends are now outside pie sectors, so contrast with fill is irrelevant. Use the configured legend_text_color for consistency across all legend items. """ return self.legend_text_color
[docs] def _sector_centroid(self, slc: PieSlice) -> tuple[float, float]: """Centroid of a circular sector in world coordinates.""" theta = abs(math.radians(slc.end_angle_deg - slc.start_angle_deg)) if theta < 1e-9: # Degenerate case: fallback near center. r_centroid = 0.5 * self.radius else: # Distance from center for circular sector centroid. r_centroid = (4.0 * self.radius * math.sin(theta / 2.0)) / (3.0 * theta) mid = math.radians(0.5 * (slc.start_angle_deg + slc.end_angle_deg)) lx = self.center_x + r_centroid * math.cos(mid) ly = self.center_y + r_centroid * math.sin(mid) return lx, ly
[docs] def _slice_mid_angle_rad(self, slc: PieSlice) -> float: return math.radians(0.5 * (slc.start_angle_deg + slc.end_angle_deg))
[docs] def _legend_alignment_for_x(self, x: float) -> str: if x >= self.center_x: return 'left' return 'right'
[docs] def _legend_world_height(self, slc: PieSlice) -> float: """Legend text height in world units, scaled to slice size.""" # Scale with both pie size and relative slice area. h = 0.42 * self.radius * math.sqrt(max(0.0, float(slc.fraction))) # Keep values readable on tiny/large slices. h_min = 0.12 * self.radius h_max = 0.55 * self.radius return max(h_min, min(h_max, h))
[docs] def _legend_width_factor(self, legend_text: str) -> float: """Approximate width/height ratio of rendered legend text.""" return max(3.0, 0.55 * len(legend_text))
[docs] def _legend_world_length(self, slc: PieSlice, legend_text: str) -> float: base_height = self._legend_world_height(slc) width_factor = self._legend_width_factor(legend_text) legend_len_raw = max(base_height * width_factor, base_height) return min(self.radius * 1.8, legend_len_raw)
[docs] def _legend_bbox(self, x: float, y: float, width: float, height: float, alignment: str) -> tuple[float, float, float, float]: if alignment == 'right': xmin = x - width xmax = x elif alignment == 'center': xmin = x - 0.5 * width xmax = x + 0.5 * width else: xmin = x xmax = x + width ymin = y - 0.5 * height ymax = y + 0.5 * height return xmin, ymin, xmax, ymax
[docs] def _bbox_overlap_area( self, bbox1: tuple[float, float, float, float], bbox2: tuple[float, float, float, float], ) -> float: dx = min(bbox1[2], bbox2[2]) - max(bbox1[0], bbox2[0]) dy = min(bbox1[3], bbox2[3]) - max(bbox1[1], bbox2[1]) if dx <= 0.0 or dy <= 0.0: return 0.0 return dx * dy
[docs] def _bbox_circle_penalty(self, bbox: tuple[float, float, float, float]) -> float: nearest_x = min(max(self.center_x, bbox[0]), bbox[2]) nearest_y = min(max(self.center_y, bbox[1]), bbox[3]) dist = math.hypot(nearest_x - self.center_x, nearest_y - self.center_y) if dist >= self.radius: return 0.0 return (self.radius - dist) * max(bbox[2] - bbox[0], bbox[3] - bbox[1])
[docs] def _auto_legend_radial_factor(self) -> float: # Keep older assets usable: previous defaults were inside the sector (< 1). if self.legend_offset_factor <= 1.0: return 1.15 + 0.35 * self.legend_offset_factor return self.legend_offset_factor
[docs] def _auto_legend_layout(self, slices: Sequence[PieSlice]) -> list[tuple[float, float, str, float, float]]: placements: list[tuple[float, float, str, float, float] | None] = [None] * len(slices) occupied: list[tuple[float, float, float, float]] = [] radial_base = self._auto_legend_radial_factor() order = sorted(range(len(slices)), key=lambda idx: float(slices[idx].fraction), reverse=True) for idx in order: slc = slices[idx] legend_text = self._legend_text(slc) height = self._legend_world_height(slc) width = self._legend_world_length(slc, legend_text) mid = self._slice_mid_angle_rad(slc) ux = math.cos(mid) uy = math.sin(mid) tx = -uy ty = ux best: tuple[float, float, str, float, float] | None = None best_score: float | None = None for radial_factor in (radial_base, radial_base + 0.18, radial_base + 0.36): base_x = self.center_x + ux * self.radius * radial_factor base_y = self.center_y + uy * self.radius * radial_factor for tangential_scale in (0.0, 0.75, -0.75, 1.5, -1.5): x = base_x + tx * tangential_scale * height y = base_y + ty * tangential_scale * height alignment = self._legend_alignment_for_x(x) bbox = self._legend_bbox(x, y, width, height, alignment) overlap_penalty = 0.0 for used_bbox in occupied: overlap_penalty += self._bbox_overlap_area(bbox, used_bbox) circle_penalty = self._bbox_circle_penalty(bbox) distance_penalty = math.hypot(x - base_x, y - base_y) score = overlap_penalty * 500.0 + circle_penalty * 250.0 + distance_penalty if best_score is None or score < best_score: best_score = score best = (x, y, alignment, width, height) if best is None: x = self.center_x + ux * self.radius * radial_base y = self.center_y + uy * self.radius * radial_base best = (x, y, self._legend_alignment_for_x(x), width, height) placements[idx] = best occupied.append(self._legend_bbox(best[0], best[1], best[3], best[4], best[2])) return [placement for placement in placements if placement is not None]
[docs] def _legend_layout(self, slices: Sequence[PieSlice]) -> list[tuple[float, float, str, float, float]]: if not self.legend_visible: return [(self.center_x, self.center_y, 'center', 0.0, 0.0) for _ in slices] if self.legend_position_mode == 'manual': out: list[tuple[float, float, str, float, float]] = [] for idx, slc in enumerate(slices): legend_text = self._legend_text(slc) height = self._legend_world_height(slc) width = self._legend_world_length(slc, legend_text) manual = self.legend_positions[idx] if idx < len(self.legend_positions) else None if manual is None: x, y = self._sector_centroid(slc) else: x, y = manual out.append((x, y, self._legend_alignment_for_x(x), width, height)) return out return self._auto_legend_layout(slices)
[docs] def _nb_arc_points(self, angle_delta_deg: float) -> int: # 96 points for full circle, clamped for tiny sectors. return max(3, int(math.ceil(abs(angle_delta_deg) / 360.0 * 96.0)))
[docs] def _sector_vertices(self, start_deg: float, end_deg: float, z_value: float) -> list[wolfvertex]: delta = end_deg - start_deg n = self._nb_arc_points(delta) pts = [wolfvertex(self.center_x, self.center_y, z_value)] for i in range(n + 1): a = math.radians(start_deg + delta * (i / n)) x = self.center_x + self.radius * math.cos(a) y = self.center_y + self.radius * math.sin(a) pts.append(wolfvertex(x, y, z_value)) return pts
[docs] def _configure_vector_style( self, vec: vector, rgba: tuple[int, int, int, int], slc: PieSlice, legend_layout: tuple[float, float, str, float, float] | None = None, ) -> None: rgb = (rgba[0], rgba[1], rgba[2]) # Keep pie slices as simple single polygons in the vector model. vec._simplified_geometry = True vec.myprop.color = getIfromRGB(rgb) vec.myprop.filled = True vec.myprop.transparent = rgba[3] < 255 vec.myprop.alpha = rgba[3] if self.border_enabled: vec.myprop.contour_enabled = True vec.myprop.contour_color = getIfromRGB(self.border_color) vec.myprop.contour_width = self.border_width if self.fill_clock_animation: vec.myprop.fill_anim_mode = 5 if self.clockwise else 6 vec.myprop.fill_anim_speed = self.fill_anim_speed vec.myprop.fill_anim_center_index = 0 vec.myprop.fill_anim_start_angle = self.start_angle_deg vec.myprop.legendvisible = self.legend_visible if self.legend_visible: legend_text = self._legend_text(slc) if legend_layout is None: lx, ly = self._sector_centroid(slc) alignment = 'center' legend_length = self._legend_world_length(slc, legend_text) legend_height = self._legend_world_height(slc) else: lx, ly, alignment, legend_length, legend_height = legend_layout vec.set_legend_text(legend_text) vec.set_legend_position(lx, ly) vec.myprop.legend_alignment = alignment vec.myprop.legendpriority = 1 vec.myprop.legendheight = legend_height vec.myprop.legendlength = legend_length vec.myprop.legendcolor = getIfromRGB(self._legend_color_for_fill(rgba)) vec.myprop.legend_smoothing = 1.0 if self.smoothing else 0.0 vec.add_value("value", slc.value) vec.add_value("fraction", slc.fraction)
[docs] def to_zones(self) -> Zones: """Build and return a native Zones object representing the pie chart.""" zones = Zones(idx=self.idx, parent=self.parent, mapviewer=self.mapviewer) if self.store_fraction_in_z: zones.force3D = True slices = self.model.slices(start_angle_deg=self.start_angle_deg, clockwise=self.clockwise) # All sectors share a single zone so legends are rendered after every fill. cur_zone = zone(name=f"{self.idx}_all_sectors", parent=zones) zones.add_zone(cur_zone, forceparent=True) legend_layouts = self._legend_layout(slices) for i, slc in enumerate(slices): cur_vec = vector(name=f"sector_{i + 1:03d}", parentzone=cur_zone) vertices = self._sector_vertices( slc.start_angle_deg, slc.end_angle_deg, slc.fraction if self.store_fraction_in_z else 0.0, ) cur_vec.add_vertex(vertices) cur_vec.close_force() self._configure_vector_style(cur_vec, self._slice_color(i), slc, legend_layouts[i]) cur_zone.add_vector(cur_vec, forceparent=True) zones.find_minmax(update=True) return zones
[docs] def add_to_mapviewer(self, mapviewer, id: str | None = None, ToCheck: bool = True) -> Zones: """Build and add the pie as a vector object in a mapviewer.""" zones = self.to_zones() mapviewer.add_object('vector', newobj=zones, id=(id or self.idx), ToCheck=ToCheck) return zones
[docs] def build_pie_zones( values: Sequence[float], center: tuple[float, float], radius: float, **kwargs, ) -> Zones: """Convenience function returning a pie chart as Zones.""" return PieZonesAsset(values=values, center=center, radius=radius, **kwargs).to_zones()