"""
GlyphX BubbleSeries — scatter plot with a fourth size encoding variable.
A bubble chart is a scatter plot where each point has an additional
dimension encoded as circle area. It beats Matplotlib's awkward
``scatter(s=size_array)`` interface, Seaborn (which has no bubble chart),
and Plotly's verbose ``go.Scatter(mode="markers", marker=dict(size=...))``.
from glyphx import Figure
from glyphx.bubble import BubbleSeries
fig = Figure(title="GDP vs Life Expectancy", auto_display=False)
fig.add(BubbleSeries(
x=gdp,
y=life_exp,
size=population, # raw values — auto-scaled to pixel radii
color="#3b82f6",
labels=country_names, # shown in tooltip
label="Countries",
))
fig.show()
"""
from __future__ import annotations
import math
import numpy as np
from .series import BaseSeries
from .utils import svg_escape, _format_tick
from .colormaps import apply_colormap
[docs]
class BubbleSeries(BaseSeries):
"""
Scatter plot with circle area encoding a fourth variable.
Unlike ``ScatterSeries(size=fixed_int)``, this accepts a *per-point*
size array and scales radii so the smallest bubble is always
``min_radius`` pixels and the largest is ``max_radius`` pixels.
Args:
x: X-axis values.
y: Y-axis values.
size: Per-point numeric values mapped to bubble area.
Can also be a fixed scalar to get uniform-sized
bubbles without the colormap overhead.
color: Flat fill color, or ``None`` when ``c=`` is used.
c: Per-point values for color encoding (colormap).
cmap: Colormap name (default ``"viridis"``).
alpha: Fill opacity 0–1 (default ``0.65``).
min_radius: Pixel radius of the smallest bubble (default 4).
max_radius: Pixel radius of the largest bubble (default 40).
labels: Per-point tooltip labels (list of str).
label: Legend label for the series.
stroke: Bubble outline color (default ``"#fff"``).
stroke_width: Bubble outline width in pixels (default ``0.8``).
"""
def __init__(
self,
x,
y,
size,
color: str | None = None,
c=None,
cmap: str = "viridis",
alpha: float = 0.65,
min_radius: float = 4.0,
max_radius: float = 40.0,
labels: list | None = None,
label: str | None = None,
stroke: str = "#ffffff",
stroke_width: float = 0.8,
title: str | None = None,
) -> None:
super().__init__(x=list(x), y=list(y), color=color or "#3b82f6",
label=label, title=title)
self.c = c
self.cmap = cmap
self.alpha = float(alpha)
self.min_radius = float(min_radius)
self.max_radius = float(max_radius)
self.labels = labels
self.stroke = stroke
self.stroke_width = float(stroke_width)
# Normalise size array to pixel radii
size_arr = np.asarray(size, dtype=float)
if size_arr.ndim == 0:
# Scalar — uniform size
self._radii = np.full(len(self.x), float(size_arr))
else:
s_min, s_max = size_arr.min(), size_arr.max()
if s_max == s_min:
self._radii = np.full(len(self.x),
(self.min_radius + self.max_radius) / 2)
else:
# Scale by area (proportional to sqrt of value)
norm = np.sqrt((size_arr - s_min) / (s_max - s_min))
self._radii = (
self.min_radius
+ norm * (self.max_radius - self.min_radius)
)
# Colour array (for colormap mode)
if self.c is not None:
c_arr = np.asarray(self.c, dtype=float)
c_min, c_max = c_arr.min(), c_arr.max()
span = c_max - c_min or 1.0
self._c_norm = ((c_arr - c_min) / span).tolist()
else:
self._c_norm = None
# ------------------------------------------------------------------
[docs]
def to_svg(self, ax: object, use_y2: bool = False) -> str:
scale_y = ax.scale_y2 if use_y2 else ax.scale_y # type: ignore
x_vals = getattr(self, "_numeric_x", self.x)
elements: list[str] = []
# Draw largest bubbles first so small ones aren't hidden
order = np.argsort(self._radii)[::-1]
for idx in order:
x_val = x_vals[idx]
y_val = self.y[idx]
radius = float(self._radii[idx])
px = ax.scale_x(x_val) # type: ignore
py = scale_y(y_val)
# Colour
if self._c_norm is not None:
fill = apply_colormap(self._c_norm[idx], self.cmap)
else:
fill = self.color
# Tooltip label
point_label = (
self.labels[idx]
if self.labels and idx < len(self.labels)
else (self.label or "")
)
tooltip = (
f'data-x="{svg_escape(str(self.x[idx]))}" '
f'data-y="{svg_escape(str(y_val))}" '
f'data-label="{svg_escape(str(point_label))}" '
f'data-size="{svg_escape(_format_tick(radius))}"'
)
elements.append(
f'<circle class="glyphx-point {self.css_class}" '
f'cx="{px:.2f}" cy="{py:.2f}" r="{radius:.2f}" '
f'fill="{fill}" fill-opacity="{self.alpha}" '
f'stroke="{self.stroke}" stroke-width="{self.stroke_width}" '
f'{tooltip}/>'
)
# Colorbar if using c= encoding
if self._c_norm is not None and self.c is not None:
from .colormaps import render_colorbar_svg
c_arr = np.asarray(self.c, dtype=float)
elements.append(render_colorbar_svg(
cmap=self.cmap,
vmin=float(c_arr.min()),
vmax=float(c_arr.max()),
x=ax.width - 30, # type: ignore
y=ax.padding, # type: ignore
width=12,
height=ax.height - 2 * ax.padding, # type: ignore
font=ax.theme.get("font", "sans-serif"), # type: ignore
text_color=ax.theme.get("text_color", "#000"), # type: ignore
))
return "\n".join(elements)
def _size_legend(self, ax: object) -> str:
"""Render a small 3-bubble size guide in the bottom-right corner."""
size_arr = np.asarray([self.min_radius, (self.min_radius + self.max_radius) / 2,
self.max_radius])
x_base = ax.width - 60 # type: ignore
y_base = ax.height - 20 # type: ignore
font = ax.theme.get("font", "sans-serif") # type: ignore
tc = ax.theme.get("text_color", "#000") # type: ignore
items: list[str] = []
for r in size_arr:
items.append(
f'<circle cx="{x_base:.0f}" cy="{y_base - r:.0f}" '
f'r="{r:.1f}" fill="none" stroke="{tc}" stroke-width="0.8" opacity="0.5"/>'
)
x_base += r * 2 + 6
return "\n".join(items)