Skip to content

Commit b749842

Browse files
authored
Add export method to Vega pane (#8266)
1 parent cfa09d7 commit b749842

File tree

5 files changed

+432
-6
lines changed

5 files changed

+432
-6
lines changed

examples/reference/panes/Vega.ipynb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,54 @@
153153
"vgl_pane.object = vegalite"
154154
]
155155
},
156+
{
157+
"cell_type": "markdown",
158+
"metadata": {},
159+
"source": [
160+
"#### Exporting\n",
161+
"\n",
162+
"You can export the current Vega or Vega-Lite specification using the pane's `export` method.\n",
163+
"\n",
164+
"Supported output formats include:\n",
165+
"\n",
166+
"- 'png' (`Image`)\n",
167+
"- 'jpeg' (`Image`)\n",
168+
"- 'svg' (`SVG`)\n",
169+
"- 'pdf' (`PDF`)\n",
170+
"- 'html' (`HTML`)\n",
171+
"- 'url' (`HTML` to Vega Editor)\n",
172+
"- 'scenegraph' (`JSON`)\n",
173+
"\n",
174+
"Requires `vl-convert`, i.e. `pip install vl-convert-python`."
175+
]
176+
},
177+
{
178+
"cell_type": "code",
179+
"execution_count": null,
180+
"metadata": {},
181+
"outputs": [],
182+
"source": [
183+
"vgl_pane.export('svg')"
184+
]
185+
},
186+
{
187+
"cell_type": "markdown",
188+
"metadata": {},
189+
"source": [
190+
"Additional kwargs may be passed to the [`vl-convert` functions](https://github.com/jonmmease/vl-convert/tree/main).\n",
191+
"\n",
192+
"To cast to a pane, use `as_pane=True`."
193+
]
194+
},
195+
{
196+
"cell_type": "code",
197+
"execution_count": null,
198+
"metadata": {},
199+
"outputs": [],
200+
"source": [
201+
"vgl_pane.export('png', scale=2, ppi=300, as_pane=True)"
202+
]
203+
},
156204
{
157205
"cell_type": "markdown",
158206
"metadata": {},

panel/pane/vega.py

Lines changed: 161 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations
22

3+
import datetime as dt
34
import re
45
import sys
56

67
from collections.abc import Mapping
7-
from typing import TYPE_CHECKING, Any, ClassVar
8+
from typing import (
9+
TYPE_CHECKING, Any, ClassVar, Literal,
10+
)
811

912
import numpy as np
1013
import param
@@ -14,23 +17,35 @@
1417

1518
from ..util import lazy_load
1619
from .base import ModelPane
20+
from .image import PDF, SVG, Image
21+
from .markup import HTML, JSON
1722

1823
if TYPE_CHECKING:
24+
import narwhals as nw
25+
1926
from bokeh.document import Document
2027
from bokeh.model import Model
2128
from pyviz_comms import Comm
2229

30+
VEGA_EXPORT_FORMATS = Literal['png', 'jpeg', 'svg', 'pdf', 'html', 'url', 'scenegraph']
2331

2432
def ds_as_cds(dataset):
2533
"""
26-
Converts Vega dataset into Bokeh ColumnDataSource data
34+
Converts Vega dataset into Bokeh ColumnDataSource data (Narwhals-compatible)
2735
"""
28-
import pandas as pd
29-
if isinstance(dataset, pd.DataFrame):
30-
return {k: dataset[k].values for k in dataset.columns}
36+
import narwhals.stable.v2 as nw
37+
try:
38+
df = nw.from_native(dataset)
39+
except TypeError:
40+
df = None
41+
if isinstance(df, (nw.DataFrame, nw.LazyFrame)):
42+
df = df.collect() if isinstance(df, nw.LazyFrame) else df
43+
return {name: df[name].to_numpy() for name in df.columns}
44+
3145
if len(dataset) == 0:
3246
return {}
33-
# create a list of unique keys from all items as some items may not include optional fields
47+
48+
# Create a list of unique keys from all items as some items may not include optional fields
3449
keys = sorted({k for d in dataset for k in d.keys()})
3550
data = {k: [] for k in keys}
3651
for item in dataset:
@@ -39,6 +54,50 @@ def ds_as_cds(dataset):
3954
data = {k: np.asarray(v) for k, v in data.items()}
4055
return data
4156

57+
def _is_dt_like(v):
58+
return (
59+
isinstance(v, (dt.date, dt.datetime, np.datetime64))
60+
or (hasattr(v, "to_pydatetime") and v.__class__.__module__.startswith("pandas"))
61+
)
62+
63+
def _to_iso(v):
64+
if isinstance(v, (dt.datetime, dt.date)):
65+
return v.isoformat()
66+
if isinstance(v, np.datetime64):
67+
# choose precision to taste: "s", "ms", "us", "ns"
68+
return np.datetime_as_string(v, unit="s")
69+
if hasattr(v, "to_pydatetime") and v.__class__.__module__.startswith("pandas"):
70+
return v.to_pydatetime().isoformat()
71+
return v
72+
73+
def _normalize_temporals_on_frame(df: nw.DataFrame) -> nw.DataFrame:
74+
import narwhals.stable.v2 as nw
75+
overrides = {}
76+
ns = nw.get_native_namespace(df)
77+
for col in df.columns:
78+
dtype = df[col].dtype
79+
if dtype.is_temporal():
80+
overrides[col] = df[col].cast(nw.String)
81+
elif dtype == nw.Object or dtype == nw.Unknown:
82+
vals = df[col].to_list()
83+
if any(_is_dt_like(v) for v in vals):
84+
overrides[col] = nw.new_series(
85+
name=col,
86+
values=[_to_iso(v) for v in vals],
87+
backend=ns
88+
)
89+
if overrides:
90+
return df.with_columns(**overrides)
91+
return df
92+
93+
def ds_to_records(dataset: Any) -> list[dict[str, Any]] | None:
94+
import narwhals.stable.v2 as nw
95+
try:
96+
df = nw.from_native(dataset)
97+
except TypeError:
98+
return None
99+
df = _normalize_temporals_on_frame(df)
100+
return df.rows(named=True)
42101

43102
_containers = ['hconcat', 'vconcat', 'layer']
44103

@@ -218,6 +277,102 @@ def applies(cls, obj: Any) -> float | bool | None:
218277
return True
219278
return cls.is_altair(obj)
220279

280+
def export(
281+
self, fmt: VEGA_EXPORT_FORMATS, as_pane: bool = False, **kwargs: dict
282+
) -> bytes | str | dict | ModelPane:
283+
"""
284+
Exports the Vega spec to various formats.
285+
286+
The export method converts the Vega/Altair specification to different
287+
output formats. It requires vl-convert-python to be installed.
288+
289+
Parameters
290+
----------
291+
fmt : str
292+
The format to export to. Must be one of 'png', 'jpeg', 'svg',
293+
'pdf', 'html', 'url', 'scenegraph'.
294+
as_pane : bool, default False
295+
If True, wraps the exported data in the appropriate Panel pane.
296+
**kwargs : dict
297+
Additional keyword arguments passed to the vl-convert functions.
298+
299+
Returns
300+
-------
301+
bytes | str | ModelPane
302+
The exported data in the requested format, or a Panel pane if
303+
as_pane=True.
304+
305+
Raises
306+
------
307+
ImportError
308+
If vl-convert-python is not installed.
309+
ValueError
310+
If an unsupported format is specified.
311+
312+
Examples
313+
--------
314+
>>> vega_pane = Vega(spec_dict)
315+
>>> png_bytes = vega_pane.export('png')
316+
>>> image_pane = vega_pane.export('png', as_pane=True)
317+
"""
318+
try:
319+
import vl_convert as vlc # type: ignore[import-untyped]
320+
except ImportError:
321+
raise ImportError(
322+
'vl-convert-python is required to export Vega specs. '
323+
'Please install it via `pip install vl-convert-python`.'
324+
) from None
325+
326+
spec = self.object if isinstance(self.object, dict) else self.object.to_dict()
327+
spec = dict(spec)
328+
data = spec.get('data', {})
329+
if isinstance(data, list):
330+
converted = []
331+
for datum in data:
332+
if isinstance(datum, dict) and 'values' in datum:
333+
records = ds_to_records(datum['values'])
334+
if records is not None:
335+
datum = dict(datum, values=records)
336+
converted.append(datum)
337+
spec["data"] = converted
338+
elif isinstance(data, dict) and 'values' in data:
339+
records = ds_to_records(data['values'])
340+
if records is not None:
341+
spec["data"] = dict(data, values=records)
342+
343+
# Get dimensions from container or use spec
344+
spec['width'] = self.width or spec.get("width", 800)
345+
spec['height'] = self.height or spec.get("height", 600)
346+
347+
if 'schema/vega/' in spec.get('$schema', 'schema/vega-lite/'):
348+
src = 'vega'
349+
else:
350+
src = 'vegalite'
351+
fmt_lower = fmt.lower()
352+
func_name = f"{src}_to_{fmt_lower}"
353+
func = getattr(vlc, func_name, None)
354+
if func is None:
355+
raise ValueError(
356+
f'Unsupported format {fmt!r}. Must be one of '
357+
f"'png', 'jpeg', 'svg', 'pdf', 'html', or 'url'."
358+
)
359+
result = func(spec, **kwargs)
360+
if as_pane:
361+
params = {'width': self.width, 'height': self.height, 'sizing_mode': self.sizing_mode}
362+
if fmt_lower == 'svg':
363+
return SVG(result, **params)
364+
elif fmt_lower == 'pdf':
365+
return PDF(result, **params)
366+
elif fmt_lower == 'html':
367+
return HTML(result, **params)
368+
elif fmt_lower == 'url':
369+
iframe_html = f'<iframe src="{result}" width="100%" height="600" frameborder="0"></iframe>'
370+
return HTML(iframe_html, **params)
371+
elif fmt_lower == 'scenegraph':
372+
return JSON(result)
373+
return Image(result, **params)
374+
return result
375+
221376
def _get_sources(self, json, sources=None):
222377
sources = {} if sources is None else dict(sources)
223378
datasets = json.get('datasets', {})

0 commit comments

Comments
 (0)