Source code for endgame.visualization.spiral_plot

"""Spiral plot visualizer.

Interactive spiral plots for time-series data, periodic patterns,
and sequential data visualization along an Archimedean spiral.

Particularly useful for showing cyclical patterns in data (daily,
weekly, seasonal) or for displaying long sequences compactly.

Example
-------
>>> from endgame.visualization import SpiralPlotVisualizer
>>> import numpy as np
>>> values = np.sin(np.linspace(0, 6 * np.pi, 200)) + 1.5
>>> viz = SpiralPlotVisualizer(values, title="Periodic Signal")
>>> viz.save("spiral.html")
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

import numpy as np

from endgame.visualization._base import BaseVisualizer
from endgame.visualization._palettes import DEFAULT_SEQUENTIAL


[docs] class SpiralPlotVisualizer(BaseVisualizer): """Interactive spiral plot visualizer. Parameters ---------- values : array-like Sequential data values to plot along the spiral. labels : list of str, optional Labels for each data point (shown on hover). n_turns : float, optional Number of spiral turns. If None, auto-computed. color_by_value : bool, default=True If True, color points by value. Otherwise use position. cmap : str, default='viridis_seq' Color palette for value mapping. title : str, optional Chart title. width : int, default=650 Chart width. height : int, default=650 Chart height. theme : str, default='dark' 'dark' or 'light'. """ def __init__( self, values: Any, *, labels: Sequence[str] | None = None, n_turns: float | None = None, color_by_value: bool = True, cmap: str = DEFAULT_SEQUENTIAL, title: str = "", width: int = 650, height: int = 650, theme: str = "dark", ): super().__init__(title=title, palette=cmap, width=width, height=height, theme=theme) self._values = np.asarray(values, dtype=float).ravel() self._labels = list(labels) if labels else None self.n_turns = n_turns self.color_by_value = color_by_value
[docs] @classmethod def from_time_series( cls, values: Any, timestamps: Sequence[str] | None = None, **kwargs, ) -> SpiralPlotVisualizer: """Create from a time series. Parameters ---------- values : array-like Time series values. timestamps : list of str, optional Timestamp labels for each point. **kwargs Additional keyword arguments. """ kwargs.setdefault("title", "Time Series (Spiral)") return cls(values, labels=timestamps, **kwargs)
def _build_data(self) -> dict[str, Any]: vals = self._values n = len(vals) if n == 0: return {"points": [], "vMin": 0, "vMax": 1} clean = vals[~np.isnan(vals)] v_min = float(clean.min()) if len(clean) > 0 else 0 v_max = float(clean.max()) if len(clean) > 0 else 1 n_turns = self.n_turns or max(2, n / 30) points = [] for i in range(n): t = i / (n - 1) if n > 1 else 0 angle = t * n_turns * 2 * np.pi # Archimedean spiral: r = a + b*theta radius = 0.1 + t * 0.4 x = 0.5 + radius * np.cos(angle) y = 0.5 + radius * np.sin(angle) v = vals[i] label = self._labels[i] if self._labels and i < len(self._labels) else f"#{i}" points.append({ "x": round(float(x), 5), "y": round(float(y), 5), "value": None if np.isnan(v) else round(float(v), 6), "label": label, "idx": i, }) return { "points": points, "vMin": round(v_min, 6), "vMax": round(v_max, 6), "colorByValue": self.color_by_value, } def _chart_type(self) -> str: return "spiral" def _get_chart_js(self) -> str: return _SPIRAL_JS
_SPIRAL_JS = r""" function renderChart(data, config) { const container = document.getElementById('chart-container'); const size = Math.min(config.width, config.height); const svg = EG.svg('svg', {width: size, height: size}); container.appendChild(svg); container.style.width = size + 'px'; container.style.height = size + 'px'; const palette = config.palette; const points = data.points; const n = points.length; if (n === 0) return; const colorFn = data.colorByValue ? EG.colorScale(palette, data.vMin, data.vMax) : EG.colorScale(palette, 0, n - 1); // Draw connecting line if (n > 1) { let d = ''; for (let i = 0; i < n; i++) { const px = points[i].x * size; const py = points[i].y * size; d += (i === 0 ? 'M' : ' L') + px + ' ' + py; } svg.appendChild(EG.svg('path', { d: d, fill: 'none', stroke: 'var(--text-muted)', 'stroke-width': 1, opacity: 0.3 })); } // Draw points const dotR = Math.max(2, Math.min(5, 200 / n)); points.forEach(function(p, i) { if (p.value === null) return; const px = p.x * size; const py = p.y * size; const color = data.colorByValue ? colorFn(p.value) : colorFn(i); const dot = EG.svg('circle', { cx: px, cy: py, r: dotR, fill: color, opacity: 0.85 }); dot.addEventListener('mouseenter', function(e) { dot.setAttribute('r', String(dotR + 3)); dot.setAttribute('opacity', '1'); EG.tooltip.show(e, '<b>' + EG.esc(p.label) + '</b><br>Value: ' + EG.fmt(p.value, 4) + '<br>Index: ' + p.idx); }); dot.addEventListener('mouseleave', function() { dot.setAttribute('r', String(dotR)); dot.setAttribute('opacity', '0.85'); EG.tooltip.hide(); }); svg.appendChild(dot); }); // Color legend bar const cbH = size * 0.5, cbW = 12; const cbX = size - 30, cbY = (size - cbH) / 2; const cbG = EG.svg('g', {transform: `translate(${cbX},${cbY})`}); svg.appendChild(cbG); const nSteps = 30; for (let i = 0; i < nSteps; i++) { const v = data.vMax - (i / nSteps) * (data.vMax - data.vMin); cbG.appendChild(EG.svg('rect', { x: 0, y: i * (cbH / nSteps), width: cbW, height: cbH / nSteps + 1, fill: colorFn(v) })); } cbG.appendChild(EG.svg('text', {x: cbW + 4, y: 10, fill: 'var(--text-muted)', 'font-size': '9px'})).textContent = EG.fmt(data.vMax); cbG.appendChild(EG.svg('text', {x: cbW + 4, y: cbH, fill: 'var(--text-muted)', 'font-size': '9px'})).textContent = EG.fmt(data.vMin); } """