"""Bar chart asset factory producing native Zones for mapviewer rendering.
Generates stacked bar chart geometry with legend support, similar to pie but
using rectangular segments instead of pie slices.
"""
from __future__ import annotations
from typing import Sequence
from ...PyVertexvectors import Zones, zone, vector, getIfromRGB
from .distribution import BarDistributionModel, BarSegment
[docs]
class BarZonesAsset:
"""Bar chart asset factory producing native Zones for mapviewer rendering."""
[docs]
DEFAULT_COLORS: tuple[tuple[int, int, int, int], ...] = (
(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],
position: tuple[float, float] = (0.0, 0.0),
width: float = 10.0,
height: float = 2.0,
*,
orientation: str = "horizontal",
labels: Sequence[str] | None = None,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None,
legend_visible: bool = True,
legend_show_percent: bool = True,
legend_show_value: bool = False,
legend_offset_factor: float = 0.3,
legend_position_mode: str = "auto",
legend_positions: Sequence[tuple[float, float] | None] | None = None,
legend_text_color: tuple[int, int, int] = (0, 0, 0),
border_enabled: bool = True,
border_width: float = 1.0,
border_color: tuple[int, int, int] = (25, 25, 25),
idx: str = "bar_chart",
parent=None,
mapviewer=None,
) -> None:
[docs]
self.model = BarDistributionModel(values, labels=labels)
[docs]
self.position_x = float(position[0])
[docs]
self.position_y = float(position[1])
[docs]
self.width = float(width)
[docs]
self.height = float(height)
if self.width <= 0.0 or self.height <= 0.0:
raise ValueError("width and height must be > 0")
[docs]
self.orientation = str(orientation).strip().lower()
if self.orientation not in {"horizontal", "vertical"}:
raise ValueError("orientation must be 'horizontal' or 'vertical'")
[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.border_enabled = bool(border_enabled)
[docs]
self.border_width = float(border_width)
[docs]
self.border_color = border_color
[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
pos_list = list(positions)
if len(pos_list) < count:
pos_list.extend([None] * (count - len(pos_list)))
return pos_list[:count]
[docs]
def _normalize_colors(
self,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None,
) -> list[tuple[int, int, int, int]]:
result = []
count = len(self.model.values)
if colors is None:
for i in range(count):
base = self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
result.append(base)
else:
for i, c in enumerate(colors):
if i >= count:
break
if len(c) == 3:
result.append((int(c[0]), int(c[1]), int(c[2]), 235))
else:
result.append((int(c[0]), int(c[1]), int(c[2]), int(c[3])))
# Pad with defaults if needed
while len(result) < count:
base = self.DEFAULT_COLORS[len(result) % len(self.DEFAULT_COLORS)]
result.append(base)
return result
[docs]
def to_zones(self) -> Zones:
"""Build and return a Zones object containing bar chart geometry."""
segments = self.model.segments()
# Create Zones object
result = Zones(idx=self.idx, parent=self.parent, mapviewer=self.mapviewer)
# Create single zone containing all segments
single_zone = zone(name=f"{self.idx}_all_segments", parent=result)
result.add_zone(single_zone, forceparent=True)
# Build segment rectangles
for seg_idx, segment in enumerate(segments):
if self.orientation == "horizontal":
# Horizontal bar: segment spans left-to-right proportionally
x1 = self.position_x + segment.start_position * self.width
x2 = self.position_x + segment.end_position * self.width
y1 = self.position_y
y2 = self.position_y + self.height
else: # vertical
# Vertical bar: segment spans bottom-to-top proportionally
x1 = self.position_x
x2 = self.position_x + self.width
y1 = self.position_y + segment.start_position * self.height
y2 = self.position_y + segment.end_position * self.height
# Create rectangle as filled polygon via factory method
coords = [
(x1, y1, segment.fraction),
(x2, y1, segment.fraction),
(x2, y2, segment.fraction),
(x1, y2, segment.fraction),
]
seg_vector = vector.make_from_list(coords, name=f"segment_{seg_idx + 1:03d}",
parentzone=single_zone)
seg_vector.close_force()
# Keep bars as simple single polygons in the vector model
seg_vector._simplified_geometry = True
# Apply segment color and properties
color_rgba = self._colors[seg_idx]
color_idx = getIfromRGB((color_rgba[0], color_rgba[1], color_rgba[2]))
seg_vector.myprop.color = color_idx
seg_vector.myprop.filled = True
seg_vector.myprop.transparent = color_rgba[3] < 255
seg_vector.myprop.alpha = color_rgba[3]
# Border if enabled
if self.border_enabled and self.border_width > 0:
seg_vector.myprop.contour_enabled = True
border_color_idx = getIfromRGB(self.border_color)
seg_vector.myprop.contour_color = border_color_idx
seg_vector.myprop.contour_width = self.border_width
# Add legend if visible
if self.legend_visible:
# Build legend text
label = segment.label
if self.legend_show_percent:
label += f" ({segment.fraction * 100:.1f}%)"
if self.legend_show_value:
label += f" ({segment.value:.1f})"
# Compute legend position (auto or manual)
if (self.legend_position_mode == "manual" and
seg_idx < len(self.legend_positions) and
self.legend_positions[seg_idx] is not None):
lx, ly = self.legend_positions[seg_idx]
alignment = 'left'
else:
# Auto-placement: legend near segment center,
# offset outward from bar edge.
seg_mid = (segment.start_position + segment.end_position) / 2.0
if self.orientation == "horizontal":
# Segments span left-to-right → legend below,
# centered horizontally on each segment.
lx = self.position_x + seg_mid * self.width
ly = self.position_y - self.height * self.legend_offset_factor
alignment = 'center'
else:
# Segments span bottom-to-top → legend to the right,
# centered vertically on each segment.
lx = self.position_x + self.width + self.height * self.legend_offset_factor
ly = self.position_y + seg_mid * self.height
alignment = 'left'
# Scale legend text size from the bar's long axis
ref_size = self.width if self.orientation == "horizontal" else self.height
legend_h = ref_size * 0.03
legend_l = len(label) * ref_size * 0.018
seg_vector.myprop.legendvisible = True
seg_vector.set_legend_text(label)
seg_vector.set_legend_position(lx, ly)
seg_vector.myprop.legend_alignment = alignment
seg_vector.myprop.legendpriority = 1
seg_vector.myprop.legendheight = legend_h
seg_vector.myprop.legendlength = legend_l
legend_color_idx = getIfromRGB(self.legend_text_color)
seg_vector.myprop.legendcolor = legend_color_idx
# Store segment value and fraction in vector metadata
seg_vector.add_value("value", segment.value)
seg_vector.add_value("fraction", segment.fraction)
# Add vector to zone
single_zone.add_vector(seg_vector, forceparent=True)
# Update bounds
result.find_minmax(update=True)
return result