Source code for glyphx.bump_chart

"""
GlyphX BumpChartSeries — rank-over-time visualization.

Bump charts show how items change rank over time using smooth cubic
Bézier curves between rank positions.  Seaborn cannot produce them.
Plotly has no native bump chart.  Matplotlib requires manual assembly.

    from glyphx import Figure
    from glyphx.bump_chart import BumpChartSeries

    fig = Figure(width=800, height=500, auto_display=False)
    fig.add(BumpChartSeries(
        x=["2019","2020","2021","2022","2023"],
        rankings={
            "GlyphX":    [5, 4, 3, 1, 1],
            "Matplotlib": [1, 1, 1, 2, 2],
            "Plotly":    [3, 2, 2, 3, 3],
            "Seaborn":   [2, 3, 4, 4, 4],
            "Bokeh":     [4, 5, 5, 5, 5],
        },
    ))
    fig.show()
"""
from __future__ import annotations

import math
from .utils  import svg_escape
from .themes import themes as _themes
from .colormaps import colormap_colors


[docs] class BumpChartSeries: """ Rank-over-time chart drawn with smooth Bézier curves. Lower rank number = higher position (rank 1 is at the top). Args: x: Time-axis labels (columns). rankings: ``{series_name: [rank_at_each_x]}`` mapping. colors: Per-series colors. Auto-assigned if None. line_width: Stroke width of each ribbon. dot_radius: Radius of the rank-position dots. show_labels: Draw series name at leftmost and rightmost position. label: Legend label (unused; individual series labeled directly). """ def __init__( self, x: list, rankings: dict[str, list[int]], colors: list[str] | None = None, line_width: float = 3.0, dot_radius: float = 6.0, show_labels: bool = True, label: str | None = None, ) -> None: self.x_labels = list(x) self.rankings = rankings self.line_width = float(line_width) self.dot_radius = float(dot_radius) self.show_labels = show_labels self.label = label self.css_class = f"series-{id(self) % 100000}" n_series = len(rankings) self.colors = colors or colormap_colors("viridis", max(n_series, 2)) # Max rank across all series and all time points all_ranks = [r for ranks in rankings.values() for r in ranks] self._max_rank = max(all_ranks) if all_ranks else 5 # BaseSeries stubs (axis-free) self.x = None self.y = None
[docs] def to_svg(self, ax: object = None) -> str: # type: ignore if ax is None: pad_x, pad_y = 80, 40 w, h = 780, 480 font, tc = "sans-serif", "#000" else: pad_x = getattr(ax, "padding", 50) + 30 # type: ignore pad_y = getattr(ax, "padding", 50) # type: ignore w = ax.width # type: ignore h = ax.height # type: ignore font = ax.theme.get("font", "sans-serif") # type: ignore tc = ax.theme.get("text_color", "#000") # type: ignore n_periods = len(self.x_labels) n_ranks = self._max_rank plot_w = w - 2 * pad_x plot_h = h - 2 * pad_y # Map period index → pixel x def px(period_i: int) -> float: if n_periods <= 1: return pad_x + plot_w / 2 return pad_x + period_i * plot_w / (n_periods - 1) # Map rank → pixel y (rank 1 = top) def py(rank: int) -> float: if n_ranks <= 1: return pad_y + plot_h / 2 return pad_y + (rank - 1) * plot_h / (n_ranks - 1) elements: list[str] = [] # Period label columns at top for i, period in enumerate(self.x_labels): elements.append( f'<text x="{px(i):.1f}" y="{pad_y - 10}" ' f'text-anchor="middle" font-size="12" font-weight="600" ' f'font-family="{font}" fill="{tc}">' f'{svg_escape(str(period))}</text>' ) # Rank labels on left (1 = top) for rank in range(1, n_ranks + 1): elements.append( f'<text x="{pad_x - 10}" y="{py(rank) + 4:.1f}" ' f'text-anchor="end" font-size="10" ' f'font-family="{font}" fill="{tc}" opacity="0.5">#{rank}</text>' ) # Horizontal reference lines for each rank for rank in range(1, n_ranks + 1): ry = py(rank) elements.append( f'<line x1="{pad_x}" x2="{pad_x + plot_w}" ' f'y1="{ry:.1f}" y2="{ry:.1f}" ' f'stroke="#ddd" stroke-width="1" stroke-dasharray="3,3"/>' ) # Draw each series for series_i, (name, ranks) in enumerate(self.rankings.items()): color = self.colors[series_i % len(self.colors)] # Build smooth cubic Bézier path through rank positions pts = [(px(i), py(r)) for i, r in enumerate(ranks)] if len(pts) < 2: continue path_parts = [f"M {pts[0][0]:.2f},{pts[0][1]:.2f}"] for j in range(len(pts) - 1): x0, y0 = pts[j] x1, y1 = pts[j + 1] # Horizontal control points for smooth S-curve cx_mid = (x0 + x1) / 2 path_parts.append( f"C {cx_mid:.2f},{y0:.2f} {cx_mid:.2f},{y1:.2f} {x1:.2f},{y1:.2f}" ) path_d = " ".join(path_parts) elements.append( f'<path d="{path_d}" fill="none" stroke="{color}" ' f'stroke-width="{self.line_width}" ' f'class="glyphx-point {self.css_class}" ' f'data-label="{svg_escape(name)}"/>' ) # Dots at each position for i, (dot_x, dot_y) in enumerate(pts): rank_val = ranks[i] elements.append( f'<circle cx="{dot_x:.2f}" cy="{dot_y:.2f}" ' f'r="{self.dot_radius}" fill="{color}" ' f'stroke="#fff" stroke-width="1.5" ' f'data-label="{svg_escape(name)}" data-rank="{rank_val}" ' f'data-period="{svg_escape(str(self.x_labels[i]))}"/>' ) # Labels at start and end if self.show_labels: # Left label x_left = pts[0][0] - self.dot_radius - 4 elements.append( f'<text x="{x_left:.1f}" y="{pts[0][1] + 4:.1f}" ' f'text-anchor="end" font-size="11" ' f'font-family="{font}" fill="{color}" font-weight="600">' f'{svg_escape(name)}</text>' ) # Right label x_right = pts[-1][0] + self.dot_radius + 4 elements.append( f'<text x="{x_right:.1f}" y="{pts[-1][1] + 4:.1f}" ' f'text-anchor="start" font-size="11" ' f'font-family="{font}" fill="{color}" font-weight="600">' f'{svg_escape(name)}</text>' ) return "\n".join(elements)