Source code for glyphx.sunburst

"""
GlyphX SunburstSeries — multi-ring hierarchical pie chart.

A sunburst shows hierarchical data as concentric rings, each ring
representing one level of the hierarchy.  It is the natural companion
to the TreemapSeries and is currently a Plotly-exclusive feature.

    from glyphx import Figure
    from glyphx.sunburst import SunburstSeries

    fig = Figure(width=550, height=550, auto_display=False)
    fig.add(SunburstSeries(
        labels=["Total", "Sales", "APAC", "EMEA", "Engineering", "Marketing"],
        parents=["",      "Total","Sales", "Sales", "Total",       "Total"],
        values= [0,        0,      4200,    3100,    2800,          1500],
    ))
    fig.show()
"""
from __future__ import annotations

import math
from collections import defaultdict

from .colormaps import apply_colormap, colormap_colors
from .utils import svg_escape, _format_tick


[docs] class SunburstSeries: """ Multi-ring sunburst chart. The root node (parent == ``""``) is drawn as the centre circle. Each subsequent ring represents one level of the hierarchy. Args: labels: Node labels (unique identifiers). parents: Parent label for each node. Root node has ``""``. values: Numeric value for each leaf node. Internal nodes can be 0 — their size is auto-summed from children. colors: Per-label hex colors. If ``None``, ``cmap`` is used. cmap: Colormap for auto-coloring (default ``"viridis"``). inner_radius: Inner radius of the first ring (default 40 px). ring_width: Width of each ring in pixels (default 60). padding_angle: Gap between segments in degrees (default 1.0). show_labels: Render text inside each segment. min_label_arc: Minimum arc length in px to show a label. label: Legend label (unused but kept for API consistency). """ def __init__( self, labels: list[str], parents: list[str], values: list[float], colors: list[str] | None = None, cmap: str = "viridis", inner_radius: float = 40.0, ring_width: float = 60.0, padding_angle: float = 1.0, show_labels: bool = True, min_label_arc: float = 14.0, label: str | None = None, ) -> None: if len(labels) != len(parents) or len(labels) != len(values): raise ValueError( "labels, parents, and values must all have the same length." ) self.labels = labels self.parents = parents self.raw_values = list(values) self.cmap = cmap self.inner_radius = float(inner_radius) self.ring_width = float(ring_width) self.padding_angle = float(padding_angle) self.show_labels = show_labels self.min_label_arc = float(min_label_arc) self.label = label self.css_class = f"series-{id(self) % 100000}" # Build tree self._children: dict[str, list[str]] = defaultdict(list) self._value: dict[str, float] = {} self._color: dict[str, str] = {} for lbl, par, val in zip(labels, parents, values): self._children[par].append(lbl) self._value[lbl] = float(val) # Find root self._root = next(lbl for lbl, par in zip(labels, parents) if par == "") # Sum internal node values bottom-up self._summed: dict[str, float] = {} self._sum_tree(self._root) # Assign colors to top-level children (auto or explicit) top_children = self._children.get(self._root, []) if colors: color_map = dict(zip(labels, colors)) self._base_colors = {lbl: color_map.get(lbl, "#888") for lbl in top_children} else: palette = colormap_colors(cmap, max(len(top_children), 1)) self._base_colors = dict(zip(top_children, palette)) # x/y stubs (sunburst is axis-free) self.x = None self.y = None def _sum_tree(self, node: str) -> float: children = self._children.get(node, []) if not children: s = self._value.get(node, 0.0) else: s = sum(self._sum_tree(c) for c in children) if self._value.get(node, 0.0) > 0: s = self._value[node] # explicit value overrides sum self._summed[node] = s return s def _get_color(self, node: str, parent: str) -> str: """Inherit/derive color from top-level ancestor.""" if node in self._base_colors: return self._base_colors[node] if parent in self._base_colors: return self._base_colors[parent] # Walk up to find base color for top, col in self._base_colors.items(): if self._is_descendant(node, top): # Lighten slightly for depth return col return "#888888" def _is_descendant(self, node: str, ancestor: str) -> bool: visited: set[str] = set() cur = node parent_map = dict(zip(self.labels, self.parents)) while cur and cur not in visited: visited.add(cur) if cur == ancestor: return True cur = parent_map.get(cur, "") return False @staticmethod def _arc_path(cx: float, cy: float, r_inner: float, r_outer: float, a_start: float, a_end: float) -> str: """SVG path for an annular sector (ring segment).""" def pt(r: float, a: float): rad = math.radians(a) return cx + r * math.cos(rad), cy + r * math.sin(rad) large = 1 if (a_end - a_start) > 180 else 0 x1o, y1o = pt(r_outer, a_start) x2o, y2o = pt(r_outer, a_end) x1i, y1i = pt(r_inner, a_end) x2i, y2i = pt(r_inner, a_start) return ( f"M {x1o:.2f},{y1o:.2f} " f"A {r_outer:.2f},{r_outer:.2f} 0 {large},1 {x2o:.2f},{y2o:.2f} " f"L {x1i:.2f},{y1i:.2f} " f"A {r_inner:.2f},{r_inner:.2f} 0 {large},0 {x2i:.2f},{y2i:.2f} " "Z" )
[docs] def to_svg(self, ax: object = None) -> str: # type: ignore[override] if ax is None: cx, cy = 275, 275 font, tc = "sans-serif", "#000" else: cx = ax.width // 2 # type: ignore cy = ax.height // 2 # type: ignore font = ax.theme.get("font", "sans-serif") # type: ignore tc = ax.theme.get("text_color", "#000") # type: ignore elements: list[str] = [] # Centre dot / root label cr = self.inner_radius * 0.6 root_total = self._summed[self._root] root_lbl = self.labels[self.labels.index(self._root)] if self._root in self.labels else "" elements.append( f'<circle cx="{cx}" cy="{cy}" r="{cr:.1f}" ' f'fill="#f0f0f0" stroke="#ccc" stroke-width="1"/>' ) if root_lbl: elements.append( f'<text x="{cx}" y="{cy + 4}" text-anchor="middle" ' f'font-size="11" font-family="{font}" fill="{tc}" font-weight="600">' f'{svg_escape(root_lbl)}</text>' ) # BFS to render rings level by level from collections import deque queue: deque[tuple[str, str, float, float, float, int]] = deque() # (node, parent, angle_start, angle_end, depth) top_children = self._children.get(self._root, []) angle_per_val = (360.0 - self.padding_angle * len(top_children)) / root_total if root_total else 0 cur_angle = -90.0 # start at top for child in top_children: span = self._summed[child] * angle_per_val queue.append((child, self._root, cur_angle, cur_angle + span, 1)) cur_angle += span + self.padding_angle while queue: node, parent, a_start, a_end, depth = queue.popleft() r_inner = self.inner_radius + (depth - 1) * self.ring_width r_outer = r_inner + self.ring_width - 1 color = self._get_color(node, parent) # Slightly lighten for deeper levels if depth > 1: opacity = max(0.55, 1.0 - depth * 0.12) fill_attr = f'fill="{color}" fill-opacity="{opacity:.2f}"' else: fill_attr = f'fill="{color}"' path = self._arc_path(cx, cy, r_inner, r_outer, a_start, a_end) val = self._summed[node] tooltip = ( f'data-label="{svg_escape(node)}" ' f'data-value="{svg_escape(_format_tick(val))}"' ) elements.append( f'<path class="glyphx-point {self.css_class}" ' f'd="{path}" {fill_attr} stroke="#fff" stroke-width="0.8" ' f'{tooltip}/>' ) # Label — only if arc is wide enough arc_len = math.radians(a_end - a_start) * (r_inner + r_outer) / 2 if self.show_labels and arc_len >= self.min_label_arc: mid_rad = math.radians((a_start + a_end) / 2) mid_r = (r_inner + r_outer) / 2 lx = cx + mid_r * math.cos(mid_rad) ly = cy + mid_r * math.sin(mid_rad) font_sz = max(8, min(12, int(arc_len / 6))) # Truncate long labels display_lbl = node if len(node) <= 12 else node[:10] + "…" elements.append( f'<text x="{lx:.1f}" y="{ly:.1f}" text-anchor="middle" ' f'dominant-baseline="middle" font-size="{font_sz}" ' f'font-family="{font}" fill="#fff" ' f'transform="rotate({(a_start+a_end)/2+90},{lx:.1f},{ly:.1f})">' f'{svg_escape(display_lbl)}</text>' ) # Enqueue children children = self._children.get(node, []) if children and val > 0: child_angle_per = ( (a_end - a_start - self.padding_angle * len(children)) / val ) cur = a_start for ch in children: ch_span = self._summed[ch] * child_angle_per queue.append((ch, node, cur, cur + ch_span, depth + 1)) cur += ch_span + self.padding_angle return "\n".join(elements)