Source code for wolfhece.assets.bar.zones_asset

"""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.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 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