Source code for glyphx.vega_lite

"""
GlyphX -> Vega-Lite JSON export.

Converts a GlyphX :class:`~glyphx.Figure` to a Vega-Lite v5 specification
dict.  The spec can be used in Observable, any Vega-compatible tool, or
saved as a portable ``.vl.json`` file.

No Python plotting library currently produces Vega-Lite output -- this
makes GlyphX the first to close the Python ↔ Observable interoperability gap.

    from glyphx import Figure
    from glyphx.series import LineSeries, BarSeries
    from glyphx.vega_lite import to_vega_lite
    import json

    fig = (Figure()
           .add(LineSeries(months, revenue, label="Revenue"))
           .add(BarSeries(months, costs,   label="Costs")))

    spec = to_vega_lite(fig)
    json.dump(spec, open("chart.vl.json","w"), indent=2)

    # Or use the Figure method directly
    fig.to_vega_lite("chart.vl.json")
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any


# ---------------------------------------------------------------------------
# Series-level converters
# ---------------------------------------------------------------------------

def _series_to_layer(series, use_y2: bool = False) -> dict | None:
    """Convert a single GlyphX series to a Vega-Lite layer dict."""
    cls = series.__class__.__name__

    # Base data
    x_vals = getattr(series, "_numeric_x", series.x) if series.x else []
    y_vals = series.y or []

    if not x_vals or not y_vals:
        return None

    # Build inline data records
    x_raw   = series.x or x_vals   # prefer original labels for categorical
    records = []
    for x, y in zip(x_raw, y_vals):
        records.append({"x": x, "y": float(y) if isinstance(y, (int, float)) else y})

    if cls == "LineSeries":
        return {
            "data":   {"values": records},
            "mark":   {"type": "line",
                       "color": series.color,
                       "strokeWidth": getattr(series, "width", 2),
                       "strokeDash": _dash_to_vl(getattr(series, "linestyle", "solid"))},
            "encoding": {
                "x": {"field": "x", "type": _infer_type(x_raw), "title": ""},
                "y": {"field": "y", "type": "quantitative",
                      **({"axis": {"orient": "right"}} if use_y2 else {})},
            },
            **({"name": series.label} if series.label else {}),
        }

    if cls == "BarSeries":
        return {
            "data":   {"values": records},
            "mark":   {"type": "bar", "color": series.color},
            "encoding": {
                "x": {"field": "x", "type": _infer_type(x_raw), "title": ""},
                "y": {"field": "y", "type": "quantitative",
                      **({"axis": {"orient": "right"}} if use_y2 else {})},
            },
        }

    if cls == "ScatterSeries":
        enc: dict[str, Any] = {
            "x": {"field": "x", "type": "quantitative"},
            "y": {"field": "y", "type": "quantitative"},
            "color": {"value": series.color},
        }
        # colormap encoding
        c = getattr(series, "c", None)
        if c is not None:
            for i, rec in enumerate(records):
                if i < len(c):
                    rec["c"] = float(c[i])
            enc["color"] = {
                "field": "c", "type": "quantitative",
                "scale": {"scheme": _cmap_to_vl(getattr(series, "cmap", "viridis"))},
            }
        return {
            "data":     {"values": records},
            "mark":     {"type": "point", "size": getattr(series, "size", 5) ** 2},
            "encoding": enc,
        }

    if cls == "HistogramSeries":
        raw_data = getattr(series, "data", [])
        recs = [{"v": float(v)} for v in raw_data]
        return {
            "data":   {"values": recs},
            "mark":   {"type": "bar", "color": series.color},
            "encoding": {
                "x": {"field": "v", "type": "quantitative",
                      "bin": {"maxbins": len(x_raw)}, "title": ""},
                "y": {"aggregate": "count", "type": "quantitative"},
            },
        }

    # Fallback: generic point layer
    return {
        "data":     {"values": records},
        "mark":     {"type": "point", "color": series.color},
        "encoding": {
            "x": {"field": "x", "type": _infer_type(x_raw)},
            "y": {"field": "y", "type": "quantitative"},
        },
    }


def _dash_to_vl(style: str) -> list[int]:
    mapping = {
        "solid":    [],
        "dashed":   [6, 3],
        "dotted":   [2, 2],
        "longdash": [12, 4],
    }
    return mapping.get(style, [])


def _cmap_to_vl(cmap: str) -> str:
    """Map GlyphX colormap names to Vega-Lite scheme names."""
    mapping = {
        "viridis":  "viridis",
        "plasma":   "plasma",
        "inferno":  "inferno",
        "magma":    "magma",
        "cividis":  "cividis",
        "coolwarm": "blueorange",
        "rdbu":     "redblue",
        "spectral": "spectral",
        "greys":    "greys",
    }
    return mapping.get(cmap, "viridis")


def _infer_type(values: list) -> str:
    """Guess Vega-Lite field type from a list of values."""
    if not values:
        return "nominal"
    v = values[0]
    if isinstance(v, str):
        try:
            float(v)
            return "quantitative"
        except ValueError:
            pass
        import re
        if re.match(r"\d{4}-\d{2}-\d{2}", str(v)):
            return "temporal"
        return "nominal"
    if isinstance(v, (int, float)):
        return "quantitative"
    return "nominal"


# ---------------------------------------------------------------------------
# Figure-level converter
# ---------------------------------------------------------------------------

[docs] def to_vega_lite( fig, width: int | None = None, height: int | None = None, ) -> dict[str, Any]: """ Convert a :class:`~glyphx.Figure` to a Vega-Lite v5 specification dict. The resulting specification can be: - Saved as a ``.vl.json`` file and opened in the Vega editor - Embedded in Observable notebooks - Rendered with ``altair`` (``alt.Chart.from_dict(spec)``) - Shared as a portable, renderer-agnostic chart format Args: fig: GlyphX :class:`~glyphx.Figure` instance. width: Override canvas width (defaults to ``fig.width``). height: Override canvas height (defaults to ``fig.height``). Returns: Vega-Lite v5 specification as a nested dict. Example:: from glyphx import Figure from glyphx.series import LineSeries from glyphx.vega_lite import to_vega_lite import json fig = Figure().add(LineSeries(months, revenue, label="Revenue")) spec = to_vega_lite(fig) print(json.dumps(spec, indent=2)) """ W = width or fig.width H = height or fig.height layers = [] resolve: dict = {} for series, use_y2 in fig.series: layer = _series_to_layer(series, use_y2=use_y2) if layer is not None: if series.label: layer["name"] = series.label layers.append(layer) if len(layers) == 1: # Single layer -- use flat spec spec = layers[0] else: spec = {"layer": layers} # Add dual-Y resolve if needed has_y2 = any(use_y2 for _, use_y2 in fig.series) if has_y2: spec["resolve"] = {"scale": {"y": "independent"}} # Top-level properties spec["$schema"] = "https://vega.github.io/schema/vega-lite/v5.json" spec["width"] = W - 100 # leave room for axis labels spec["height"] = H - 80 spec["title"] = fig.title or "" # Axis labels axes = fig.axes if getattr(axes, "xlabel", ""): for layer in (layers if "layer" in spec else [spec]): if "encoding" in layer and "x" in layer["encoding"]: layer["encoding"]["x"]["title"] = axes.xlabel if getattr(axes, "ylabel", ""): for layer in (layers if "layer" in spec else [spec]): if "encoding" in layer and "y" in layer["encoding"]: layer["encoding"]["y"]["title"] = axes.ylabel # Theme approximation theme_dict = fig.theme bg = theme_dict.get("background", "#fff") tc = theme_dict.get("text_color", "#000") spec["config"] = { "background": bg, "title": {"color": tc, "fontSize": 16}, "axis": {"labelColor": tc, "titleColor": tc, "gridColor": theme_dict.get("grid_color","#ddd")}, "view": {"stroke": "transparent"}, } return spec
[docs] def save_vega_lite(fig, path: str | Path, **kwargs) -> None: """ Serialize a figure's Vega-Lite spec to a JSON file. Args: fig: GlyphX :class:`~glyphx.Figure`. path: Output path (conventionally ``.vl.json``). Example:: from glyphx.vega_lite import save_vega_lite save_vega_lite(fig, "chart.vl.json") """ spec = to_vega_lite(fig, **kwargs) Path(path).write_text(json.dumps(spec, indent=2), encoding="utf-8")