11from __future__ import annotations
22
3+ import datetime as dt
34import re
45import sys
56
67from collections .abc import Mapping
7- from typing import TYPE_CHECKING , Any , ClassVar
8+ from typing import (
9+ TYPE_CHECKING , Any , ClassVar , Literal ,
10+ )
811
912import numpy as np
1013import param
1417
1518from ..util import lazy_load
1619from .base import ModelPane
20+ from .image import PDF , SVG , Image
21+ from .markup import HTML , JSON
1722
1823if 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
2432def 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