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.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 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()