fjader 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fjader/__init__.py +98 -0
- fjader/_accessor.py +74 -0
- fjader/_convert.py +36 -0
- fjader/_data.py +86 -0
- fjader/_display.py +97 -0
- fjader/_from_spec.py +131 -0
- fjader/_streamlit.py +137 -0
- fjader/_types.py +82 -0
- fjader/_version.py +1 -0
- fjader/_widget.py +59 -0
- fjader/data.py +12 -0
- fjader/fields.py +177 -0
- fjader/interactors.py +154 -0
- fjader/layout.py +163 -0
- fjader/marks/__init__.py +718 -0
- fjader/marks/_base.py +175 -0
- fjader/marks/area.py +54 -0
- fjader/marks/bar.py +47 -0
- fjader/marks/dot.py +24 -0
- fjader/marks/line.py +29 -0
- fjader/marks/rect.py +69 -0
- fjader/marks/text.py +30 -0
- fjader/pandas.py +20 -0
- fjader/plot.py +210 -0
- fjader/polars.py +20 -0
- fjader/static/THIRD_PARTY_LICENSES.txt +2919 -0
- fjader/static/widget.css +1 -0
- fjader/static/widget.js +40824 -0
- fjader/streamlit_frontend/THIRD_PARTY_LICENSES.txt +3313 -0
- fjader/streamlit_frontend/assets/index-BQspEPyb.css +1 -0
- fjader/streamlit_frontend/assets/index-CAUOm_8A.js +1887 -0
- fjader/streamlit_frontend/index.html +29 -0
- fjader/transforms/__init__.py +56 -0
- fjader/transforms/_base.py +25 -0
- fjader/transforms/js.py +40 -0
- fjader/transforms/sql.py +35 -0
- fjader-0.2.0.dist-info/METADATA +181 -0
- fjader-0.2.0.dist-info/RECORD +41 -0
- fjader-0.2.0.dist-info/WHEEL +4 -0
- fjader-0.2.0.dist-info/licenses/LICENSE +28 -0
- fjader-0.2.0.dist-info/licenses/NOTICE +114 -0
fjader/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Fjader — GPU-accelerated data visualization for Python.
|
|
2
|
+
|
|
3
|
+
Phase 1: Spec generation. Produces JSON specs consumed by the Fjader
|
|
4
|
+
JavaScript renderer.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
import fjader as fj
|
|
8
|
+
|
|
9
|
+
chart = fj.dot(table='penguins', x='bill_length', y='bill_depth', fill='species')
|
|
10
|
+
chart.to_dict()
|
|
11
|
+
|
|
12
|
+
# Overlay (two marks, one chart)
|
|
13
|
+
overlay = fj.dot(table='t', x='a', y='b') * fj.line(table='t', x='a', y='b')
|
|
14
|
+
|
|
15
|
+
# Layout (two charts side by side)
|
|
16
|
+
layout = fj.dot(table='t', x='a', y='b') + fj.bar_y(table='t', x='species', y='count()')
|
|
17
|
+
|
|
18
|
+
# Chart-level config
|
|
19
|
+
fj.plot(fj.dot(table='t', x='a', y='b'), width=600, x_label='X')
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
# Mark factory functions
|
|
25
|
+
from .marks import dot, bar_x, bar_y, line, area_y, area_x, rect, rect_x, rect_y, text
|
|
26
|
+
|
|
27
|
+
# Chart container
|
|
28
|
+
from .plot import plot
|
|
29
|
+
|
|
30
|
+
# Interactors
|
|
31
|
+
from .interactors import interval_x, interval_y, interval_2d, toggle, derived_interval
|
|
32
|
+
|
|
33
|
+
# Layout helpers
|
|
34
|
+
from .layout import hconcat, vconcat
|
|
35
|
+
|
|
36
|
+
# Transforms
|
|
37
|
+
from .transforms import (
|
|
38
|
+
filter,
|
|
39
|
+
having,
|
|
40
|
+
limit,
|
|
41
|
+
orderby,
|
|
42
|
+
topN,
|
|
43
|
+
stack_y,
|
|
44
|
+
stack_x,
|
|
45
|
+
normalize,
|
|
46
|
+
dodge,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Field spec helpers
|
|
50
|
+
from .fields import count, sum, avg, min_, max_, median, bin, window, sql_expr
|
|
51
|
+
|
|
52
|
+
# Data helpers
|
|
53
|
+
from .data import sql
|
|
54
|
+
|
|
55
|
+
# Display helpers
|
|
56
|
+
from ._display import show
|
|
57
|
+
|
|
58
|
+
# Spec import
|
|
59
|
+
from ._from_spec import from_spec
|
|
60
|
+
|
|
61
|
+
# Lazy submodule access — import only when accessed (avoids hard dep on streamlit)
|
|
62
|
+
import importlib as _importlib
|
|
63
|
+
|
|
64
|
+
def __getattr__(name: str):
|
|
65
|
+
if name == "streamlit":
|
|
66
|
+
return _importlib.import_module("._streamlit", __name__)
|
|
67
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
68
|
+
|
|
69
|
+
# Types (for type annotations in user code)
|
|
70
|
+
from ._types import ColorScheme, CurveType, ScaleType, SortConfig, TextAnchor, LineAnchor
|
|
71
|
+
from .fields import FieldSpec
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"__version__",
|
|
75
|
+
# Marks
|
|
76
|
+
"dot", "bar_x", "bar_y", "line", "area_y", "area_x",
|
|
77
|
+
"rect", "rect_x", "rect_y", "text",
|
|
78
|
+
# Chart
|
|
79
|
+
"plot",
|
|
80
|
+
# Interactors
|
|
81
|
+
"interval_x", "interval_y", "interval_2d", "toggle", "derived_interval",
|
|
82
|
+
# Layout
|
|
83
|
+
"hconcat", "vconcat",
|
|
84
|
+
# Transforms
|
|
85
|
+
"filter", "having", "limit", "orderby",
|
|
86
|
+
"topN", "stack_y", "stack_x", "normalize", "dodge",
|
|
87
|
+
# Fields
|
|
88
|
+
"count", "sum", "avg", "min_", "max_", "median", "bin", "window", "sql_expr",
|
|
89
|
+
# Data
|
|
90
|
+
"sql",
|
|
91
|
+
# Display
|
|
92
|
+
"show",
|
|
93
|
+
# Spec import
|
|
94
|
+
"from_spec",
|
|
95
|
+
# Types
|
|
96
|
+
"FieldSpec", "ColorScheme", "CurveType", "ScaleType",
|
|
97
|
+
"SortConfig", "TextAnchor", "LineAnchor",
|
|
98
|
+
]
|
fjader/_accessor.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Shared DataFrame accessor for Fjader.
|
|
2
|
+
|
|
3
|
+
Provides ``df.fj.dot(x='a', y='b')`` syntax for quick exploration.
|
|
4
|
+
Returns anywidget instances that auto-display in Jupyter and Marimo,
|
|
5
|
+
and support composition via ``*`` (overlay), ``+`` (layout), and ``.opts()``.
|
|
6
|
+
|
|
7
|
+
Registration is done by importing the appropriate module::
|
|
8
|
+
|
|
9
|
+
import fjader.pandas as fj # registers .fj on pandas DataFrames
|
|
10
|
+
import fjader.polars as fj # registers .fj on polars DataFrames
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import marks as _marks
|
|
18
|
+
from ._display import show
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FjaderAccessor:
|
|
22
|
+
"""DataFrame accessor — ``df.fj.dot(x='a', y='b', fill='species')``."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, df: Any) -> None:
|
|
25
|
+
self._df = df
|
|
26
|
+
|
|
27
|
+
def _call(self, factory: Any, **kwargs: Any) -> Any:
|
|
28
|
+
for key in ("data", "table"):
|
|
29
|
+
if key in kwargs:
|
|
30
|
+
raise TypeError(
|
|
31
|
+
f"Cannot pass '{key}' through the .fj accessor — "
|
|
32
|
+
f"data is implicitly the DataFrame itself."
|
|
33
|
+
)
|
|
34
|
+
return show(factory(data=self._df, **kwargs))
|
|
35
|
+
|
|
36
|
+
def dot(self, **kwargs: Any) -> Any:
|
|
37
|
+
"""Scatter/dot mark. See ``fj.dot()`` for parameters."""
|
|
38
|
+
return self._call(_marks.dot, **kwargs)
|
|
39
|
+
|
|
40
|
+
def bar_x(self, **kwargs: Any) -> Any:
|
|
41
|
+
"""Horizontal bar mark. See ``fj.bar_x()`` for parameters."""
|
|
42
|
+
return self._call(_marks.bar_x, **kwargs)
|
|
43
|
+
|
|
44
|
+
def bar_y(self, **kwargs: Any) -> Any:
|
|
45
|
+
"""Vertical bar mark. See ``fj.bar_y()`` for parameters."""
|
|
46
|
+
return self._call(_marks.bar_y, **kwargs)
|
|
47
|
+
|
|
48
|
+
def line(self, **kwargs: Any) -> Any:
|
|
49
|
+
"""Line mark. See ``fj.line()`` for parameters."""
|
|
50
|
+
return self._call(_marks.line, **kwargs)
|
|
51
|
+
|
|
52
|
+
def area_y(self, **kwargs: Any) -> Any:
|
|
53
|
+
"""Vertical area mark. See ``fj.area_y()`` for parameters."""
|
|
54
|
+
return self._call(_marks.area_y, **kwargs)
|
|
55
|
+
|
|
56
|
+
def area_x(self, **kwargs: Any) -> Any:
|
|
57
|
+
"""Horizontal area mark. See ``fj.area_x()`` for parameters."""
|
|
58
|
+
return self._call(_marks.area_x, **kwargs)
|
|
59
|
+
|
|
60
|
+
def rect(self, **kwargs: Any) -> Any:
|
|
61
|
+
"""Rectangle mark. See ``fj.rect()`` for parameters."""
|
|
62
|
+
return self._call(_marks.rect, **kwargs)
|
|
63
|
+
|
|
64
|
+
def rect_x(self, **kwargs: Any) -> Any:
|
|
65
|
+
"""Horizontal rect mark. See ``fj.rect_x()`` for parameters."""
|
|
66
|
+
return self._call(_marks.rect_x, **kwargs)
|
|
67
|
+
|
|
68
|
+
def rect_y(self, **kwargs: Any) -> Any:
|
|
69
|
+
"""Vertical rect mark. See ``fj.rect_y()`` for parameters."""
|
|
70
|
+
return self._call(_marks.rect_y, **kwargs)
|
|
71
|
+
|
|
72
|
+
def text(self, **kwargs: Any) -> Any:
|
|
73
|
+
"""Text mark. See ``fj.text()`` for parameters."""
|
|
74
|
+
return self._call(_marks.text, **kwargs)
|
fjader/_convert.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""snake_case to camelCase conversion and serialization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_camel_case(name: str) -> str:
|
|
9
|
+
"""Convert snake_case to camelCase.
|
|
10
|
+
|
|
11
|
+
stroke_width -> strokeWidth
|
|
12
|
+
fill_opacity -> fillOpacity
|
|
13
|
+
preferred_x_scale_type -> preferredXScaleType
|
|
14
|
+
"""
|
|
15
|
+
parts = name.split("_")
|
|
16
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def serialize_value(value: Any) -> Any:
|
|
20
|
+
"""Recursively serialize a value for JSON output."""
|
|
21
|
+
if value is None:
|
|
22
|
+
return None
|
|
23
|
+
if isinstance(value, (str, int, float, bool)):
|
|
24
|
+
return value
|
|
25
|
+
if isinstance(value, (list, tuple)):
|
|
26
|
+
return [serialize_value(v) for v in value]
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
return {to_camel_case(k): serialize_value(v) for k, v in value.items()}
|
|
29
|
+
if hasattr(value, "to_dict"):
|
|
30
|
+
return value.to_dict()
|
|
31
|
+
raise TypeError(f"Cannot serialize {type(value).__name__}: {value!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clean_dict(d: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
"""Remove keys whose values are None (unset optional props)."""
|
|
36
|
+
return {k: v for k, v in d.items() if v is not None}
|
fjader/_data.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""DataFrame → Arrow IPC serialization + content-hash deduplication.
|
|
2
|
+
|
|
3
|
+
Uses narwhals for DataFrame-library-agnostic handling. Accepts pandas,
|
|
4
|
+
polars, cudf, modin, etc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import io
|
|
11
|
+
import weakref
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Module-level cache: weakref to df object → (hash, ipc_bytes).
|
|
16
|
+
# Uses weak references so cache entries are evicted when the DataFrame
|
|
17
|
+
# is garbage-collected, preventing unbounded memory growth in long sessions.
|
|
18
|
+
_hash_cache: dict[int, tuple[weakref.ref, str, bytes]] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cleanup_ref(obj_id: int) -> None:
|
|
22
|
+
"""Remove cache entry when the DataFrame is garbage-collected."""
|
|
23
|
+
_hash_cache.pop(obj_id, None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def df_to_arrow_ipc(df: Any) -> bytes:
|
|
27
|
+
"""Convert a DataFrame to Arrow IPC stream bytes.
|
|
28
|
+
|
|
29
|
+
Library-agnostic via narwhals. Polars uses native IPC writer (no pyarrow
|
|
30
|
+
needed); pandas and others go through narwhals → pyarrow IPC.
|
|
31
|
+
"""
|
|
32
|
+
# Polars: native IPC stream writer — zero-copy, no pyarrow needed
|
|
33
|
+
# Must use write_ipc_stream (not write_ipc) because DuckDB-WASM's
|
|
34
|
+
# insertArrowFromIPCStream expects Arrow IPC stream format, not file format.
|
|
35
|
+
if hasattr(df, "write_ipc_stream"):
|
|
36
|
+
buf = io.BytesIO()
|
|
37
|
+
df.write_ipc_stream(buf)
|
|
38
|
+
return buf.getvalue()
|
|
39
|
+
|
|
40
|
+
# Pandas/others: convert to Arrow via narwhals → pyarrow IPC
|
|
41
|
+
import narwhals as nw
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
import pyarrow as pa
|
|
45
|
+
import pyarrow.ipc as ipc
|
|
46
|
+
except ImportError as e:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"fjader needs pyarrow for pandas and other non-polars DataFrames. "
|
|
49
|
+
"Install with: pip install 'fjader[pyarrow]'"
|
|
50
|
+
) from e
|
|
51
|
+
|
|
52
|
+
nf = nw.from_native(df, eager_only=True)
|
|
53
|
+
table = nf.to_arrow()
|
|
54
|
+
sink = pa.BufferOutputStream()
|
|
55
|
+
writer = ipc.new_stream(sink, table.schema)
|
|
56
|
+
writer.write_table(table)
|
|
57
|
+
writer.close()
|
|
58
|
+
return sink.getvalue().to_pybytes()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def content_hash(data: bytes) -> str:
|
|
62
|
+
"""SHA-256 hex prefix for deduplication."""
|
|
63
|
+
return hashlib.sha256(data).hexdigest()[:16]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def df_to_ipc_cached(df: Any) -> tuple[str, bytes]:
|
|
67
|
+
"""Serialize DataFrame to Arrow IPC with caching by object identity.
|
|
68
|
+
|
|
69
|
+
The same DataFrame object serialized multiple times reuses cached bytes.
|
|
70
|
+
Cache entries are automatically evicted when the DataFrame is garbage-collected.
|
|
71
|
+
|
|
72
|
+
Returns (content_hash, ipc_bytes).
|
|
73
|
+
"""
|
|
74
|
+
obj_id = id(df)
|
|
75
|
+
cached = _hash_cache.get(obj_id)
|
|
76
|
+
if cached is not None:
|
|
77
|
+
ref, h, ipc_bytes = cached
|
|
78
|
+
# Verify the weakref is still alive (same object, not reused id)
|
|
79
|
+
if ref() is df:
|
|
80
|
+
return h, ipc_bytes
|
|
81
|
+
|
|
82
|
+
ipc_bytes = df_to_arrow_ipc(df)
|
|
83
|
+
h = content_hash(ipc_bytes)
|
|
84
|
+
ref = weakref.ref(df, lambda _: _cleanup_ref(obj_id))
|
|
85
|
+
_hash_cache[obj_id] = (ref, h, ipc_bytes)
|
|
86
|
+
return h, ipc_bytes
|
fjader/_display.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Display wiring: Mark/Plot/Layout → FjaderWidget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from ._data import df_to_ipc_cached
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class Displayable(Protocol):
|
|
13
|
+
"""Protocol for objects that can be rendered as Fjader widgets."""
|
|
14
|
+
|
|
15
|
+
def _to_full_spec(self) -> tuple[dict[str, Any], dict[str, Any]]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _serialize_dataframes(
|
|
19
|
+
dataframes: dict[str, Any],
|
|
20
|
+
) -> tuple[list[dict[str, Any]], bytes]:
|
|
21
|
+
"""Serialize DataFrames to a manifest + concatenated Arrow IPC blob.
|
|
22
|
+
|
|
23
|
+
Returns (manifest, blob) where manifest is a list of
|
|
24
|
+
{name, hash, offset, length} dicts and blob is the concatenated IPC bytes.
|
|
25
|
+
"""
|
|
26
|
+
manifest: list[dict[str, Any]] = []
|
|
27
|
+
chunks: list[bytes] = []
|
|
28
|
+
offset = 0
|
|
29
|
+
|
|
30
|
+
for table_name, df in dataframes.items():
|
|
31
|
+
h, ipc_bytes = df_to_ipc_cached(df)
|
|
32
|
+
length = len(ipc_bytes)
|
|
33
|
+
manifest.append({
|
|
34
|
+
"name": table_name,
|
|
35
|
+
"hash": h,
|
|
36
|
+
"offset": offset,
|
|
37
|
+
"length": length,
|
|
38
|
+
})
|
|
39
|
+
chunks.append(ipc_bytes)
|
|
40
|
+
offset += length
|
|
41
|
+
|
|
42
|
+
return manifest, b"".join(chunks)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_DEFAULT_WIDGET_HEIGHT = 400
|
|
46
|
+
_LAYOUT_GAP = 8 # matches .fjader-vconcat/.fjader-hconcat { gap: 8px }
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _inject_default_height(spec: dict[str, Any], height: int) -> None:
|
|
50
|
+
"""Set height on all plot specs and compute total container height.
|
|
51
|
+
|
|
52
|
+
Without an explicit pixel height, PlotFromSpec falls back to
|
|
53
|
+
``aspect-ratio: 4/3`` which doesn't size correctly inside
|
|
54
|
+
anywidget containers in marimo / Jupyter.
|
|
55
|
+
|
|
56
|
+
Also computes ``_containerHeight`` — the exact pixel height the
|
|
57
|
+
anywidget ``el`` needs so that vconcat layouts aren't clipped.
|
|
58
|
+
"""
|
|
59
|
+
if "plot" in spec:
|
|
60
|
+
spec["plot"].setdefault("height", height)
|
|
61
|
+
spec["_containerHeight"] = spec["plot"]["height"]
|
|
62
|
+
elif "vconcat" in spec:
|
|
63
|
+
for item in spec["vconcat"]:
|
|
64
|
+
item.setdefault("height", height)
|
|
65
|
+
heights = [item["height"] for item in spec["vconcat"]]
|
|
66
|
+
spec["_containerHeight"] = sum(heights) + (len(heights) - 1) * _LAYOUT_GAP
|
|
67
|
+
elif "hconcat" in spec:
|
|
68
|
+
for item in spec["hconcat"]:
|
|
69
|
+
item.setdefault("height", height)
|
|
70
|
+
heights = [item["height"] for item in spec["hconcat"]]
|
|
71
|
+
spec["_containerHeight"] = max(heights) if heights else height
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def make_widget(obj: Displayable) -> Any:
|
|
75
|
+
"""Convert Mark/Plot/Overlay/Layout to a renderable FjaderWidget."""
|
|
76
|
+
from ._widget import FjaderWidget
|
|
77
|
+
|
|
78
|
+
spec_dict, dataframes = obj._to_full_spec()
|
|
79
|
+
_inject_default_height(spec_dict, _DEFAULT_WIDGET_HEIGHT)
|
|
80
|
+
manifest, blob = _serialize_dataframes(dataframes)
|
|
81
|
+
|
|
82
|
+
return FjaderWidget(
|
|
83
|
+
spec=json.dumps(spec_dict),
|
|
84
|
+
data_manifest=json.dumps(manifest),
|
|
85
|
+
data_blob=blob,
|
|
86
|
+
_source=obj,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def show(obj: Displayable) -> Any:
|
|
91
|
+
"""Display a Fjader object in a Jupyter notebook.
|
|
92
|
+
|
|
93
|
+
Convenience function — equivalent to calling ``make_widget(obj)``
|
|
94
|
+
and displaying it. In most cases you can just let the ``_repr_mimebundle_``
|
|
95
|
+
protocol handle display automatically.
|
|
96
|
+
"""
|
|
97
|
+
return make_widget(obj)
|
fjader/_from_spec.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Render a Fjader chart from a raw JSON spec (e.g. exported from the palette)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SpecPlot:
|
|
10
|
+
"""Wraps a raw FjaderSpec dict for rendering in Jupyter or Streamlit.
|
|
11
|
+
|
|
12
|
+
Created by :func:`from_spec`. Implements the ``Displayable`` protocol
|
|
13
|
+
so it can be passed to ``fj.show()``, ``fj.streamlit.render()``, or
|
|
14
|
+
displayed directly in Jupyter/Marimo.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
spec: dict[str, Any],
|
|
20
|
+
data: dict[str, Any] | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._spec = spec
|
|
23
|
+
self._dataframes = data or {}
|
|
24
|
+
|
|
25
|
+
def _to_full_spec(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
26
|
+
return self._spec, self._dataframes
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return self._spec
|
|
30
|
+
|
|
31
|
+
def to_json(self, **kwargs: Any) -> str:
|
|
32
|
+
return json.dumps(self._spec, **kwargs)
|
|
33
|
+
|
|
34
|
+
def _repr_mimebundle_(self, **kwargs: Any) -> Any:
|
|
35
|
+
from ._display import make_widget
|
|
36
|
+
|
|
37
|
+
return make_widget(self)._repr_mimebundle_(**kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_spec(spec: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
"""Wrap a bare PlotSpec in a FjaderSpec if needed.
|
|
42
|
+
|
|
43
|
+
The palette exports bare PlotSpec (``{"marks": [...]}``) without
|
|
44
|
+
the outer ``{"plot": ...}`` wrapper. The renderer expects the full form.
|
|
45
|
+
"""
|
|
46
|
+
if "plot" in spec or "hconcat" in spec or "vconcat" in spec:
|
|
47
|
+
return spec
|
|
48
|
+
# Bare PlotSpec — wrap it
|
|
49
|
+
return {"plot": spec}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def from_spec(
|
|
53
|
+
spec: dict[str, Any] | str,
|
|
54
|
+
data: dict[str, Any] | None = None,
|
|
55
|
+
) -> SpecPlot:
|
|
56
|
+
"""Create a renderable chart from a raw JSON spec.
|
|
57
|
+
|
|
58
|
+
Use this to render specs exported from the Fjader command palette
|
|
59
|
+
(Cmd+E), or any hand-written spec dict.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
spec: A FjaderSpec dict or JSON string. Accepts both the full form
|
|
63
|
+
(``{"plot": {"marks": [...]}}``) and the bare PlotSpec form
|
|
64
|
+
(``{"marks": [...]}``) exported by the palette.
|
|
65
|
+
data: Optional dict mapping table names to DataFrames. Required when
|
|
66
|
+
the spec references tables backed by in-memory data rather than
|
|
67
|
+
file URLs. Example: ``{"flights": df}``.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A ``SpecPlot`` object that can be displayed in Jupyter, passed to
|
|
71
|
+
``fj.show()``, or rendered with ``fj.streamlit.render()``.
|
|
72
|
+
|
|
73
|
+
Examples::
|
|
74
|
+
|
|
75
|
+
# Paste a spec exported from the palette
|
|
76
|
+
chart = fj.from_spec({
|
|
77
|
+
"marks": [{
|
|
78
|
+
"mark": "dot",
|
|
79
|
+
"table": "penguins",
|
|
80
|
+
"x": "bill_length",
|
|
81
|
+
"y": "bill_depth",
|
|
82
|
+
"fill": "species"
|
|
83
|
+
}]
|
|
84
|
+
}, data={"penguins": df})
|
|
85
|
+
|
|
86
|
+
# Full FjaderSpec with file-based data sources
|
|
87
|
+
chart = fj.from_spec({
|
|
88
|
+
"data": {"flights": {"url": "flights.parquet", "type": "parquet"}},
|
|
89
|
+
"plot": {
|
|
90
|
+
"marks": [{"mark": "barY", "table": "flights", "x": "carrier", "y": "count()"}]
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(spec, str):
|
|
95
|
+
spec = json.loads(spec)
|
|
96
|
+
|
|
97
|
+
spec = _normalize_spec(spec)
|
|
98
|
+
|
|
99
|
+
# Wire up DataFrames: assign hash-based table names matching what the
|
|
100
|
+
# marks reference, so the serialization pipeline can ship them as Arrow IPC.
|
|
101
|
+
dataframes: dict[str, Any] = {}
|
|
102
|
+
if data:
|
|
103
|
+
from ._data import df_to_ipc_cached
|
|
104
|
+
|
|
105
|
+
for table_name, df in data.items():
|
|
106
|
+
h, _ = df_to_ipc_cached(df)
|
|
107
|
+
hashed_name = f"_df_{h}"
|
|
108
|
+
# Rewrite table references in the spec so marks point to the
|
|
109
|
+
# hashed name that the widget data-loading pipeline expects.
|
|
110
|
+
_rewrite_table_refs(spec, table_name, hashed_name)
|
|
111
|
+
dataframes[hashed_name] = df
|
|
112
|
+
|
|
113
|
+
return SpecPlot(spec, dataframes)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rewrite_table_refs(spec: dict[str, Any], old: str, new: str) -> None:
|
|
117
|
+
"""Replace ``table: old`` with ``table: new`` in all marks."""
|
|
118
|
+
for plot_spec in _iter_plot_specs(spec):
|
|
119
|
+
for mark in plot_spec.get("marks", []):
|
|
120
|
+
if mark.get("table") == old:
|
|
121
|
+
mark["table"] = new
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _iter_plot_specs(spec: dict[str, Any]):
|
|
125
|
+
"""Yield all PlotSpec dicts from a FjaderSpec."""
|
|
126
|
+
if "plot" in spec:
|
|
127
|
+
yield spec["plot"]
|
|
128
|
+
for item in spec.get("hconcat", []):
|
|
129
|
+
yield item
|
|
130
|
+
for item in spec.get("vconcat", []):
|
|
131
|
+
yield item
|
fjader/_streamlit.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Native Streamlit custom component for Fjader.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import streamlit as st
|
|
6
|
+
import fjader as fj
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
df = pl.DataFrame({"x": range(50), "y": [i**0.5 for i in range(50)]})
|
|
10
|
+
fj.streamlit.render(fj.dot(data=df, x="x", y="y"))
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import narwhals as nw
|
|
20
|
+
|
|
21
|
+
from ._data import df_to_ipc_cached
|
|
22
|
+
from ._display import Displayable
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import streamlit.components.v1 as components
|
|
26
|
+
except ImportError as e:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"Fjader Streamlit support requires streamlit. "
|
|
29
|
+
"Install with: pip install 'fjader[streamlit]'"
|
|
30
|
+
) from e
|
|
31
|
+
|
|
32
|
+
_FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "streamlit_frontend")
|
|
33
|
+
if not os.path.isfile(os.path.join(_FRONTEND_DIR, "index.html")):
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"fjader's Streamlit bundle is missing. In a source checkout, build it with "
|
|
36
|
+
"`npm run build:all` from the repository root."
|
|
37
|
+
)
|
|
38
|
+
_component = components.declare_component("fjader", path=_FRONTEND_DIR)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_pandas(df: Any) -> Any:
|
|
42
|
+
"""Convert any DataFrame to pandas for Streamlit's native Arrow serialization."""
|
|
43
|
+
return nw.from_native(df, eager_only=True).to_pandas()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render(
|
|
47
|
+
obj: Displayable,
|
|
48
|
+
height: int = 500,
|
|
49
|
+
key: str | None = None,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Render a Fjader chart in a Streamlit app.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
obj: A Fjader mark, plot, or layout object.
|
|
55
|
+
height: Component height in pixels.
|
|
56
|
+
key: Streamlit component key for state management.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The component value (None unless selections are implemented).
|
|
60
|
+
"""
|
|
61
|
+
spec_dict, dataframes = obj._to_full_spec()
|
|
62
|
+
|
|
63
|
+
# Inject heights into the spec and compute the actual iframe height.
|
|
64
|
+
iframe_height = _inject_height(spec_dict, height)
|
|
65
|
+
|
|
66
|
+
manifest: list[dict[str, str]] = []
|
|
67
|
+
table_args: dict[str, Any] = {}
|
|
68
|
+
|
|
69
|
+
for i, (table_name, df) in enumerate(dataframes.items()):
|
|
70
|
+
h, _ = df_to_ipc_cached(df)
|
|
71
|
+
arg_key = f"_df_{i}"
|
|
72
|
+
table_args[arg_key] = _to_pandas(df)
|
|
73
|
+
manifest.append({"name": table_name, "hash": h, "arg": arg_key})
|
|
74
|
+
|
|
75
|
+
return _component(
|
|
76
|
+
spec=json.dumps(spec_dict),
|
|
77
|
+
data_manifest=json.dumps(manifest),
|
|
78
|
+
height=iframe_height,
|
|
79
|
+
key=key,
|
|
80
|
+
default=None,
|
|
81
|
+
**table_args,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_VCONCAT_GAP = 8 # matches CSS gap in LayoutFromSpec.svelte
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_layout(item: dict[str, Any]) -> bool:
|
|
89
|
+
"""True if the item is a nested layout (has hconcat or vconcat key)."""
|
|
90
|
+
return "hconcat" in item or "vconcat" in item
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _inject_height(spec: dict[str, Any], height: int) -> int:
|
|
94
|
+
"""Set height on plot specs and return the iframe height to use.
|
|
95
|
+
|
|
96
|
+
For a single plot, uses ``height`` as the default.
|
|
97
|
+
For vconcat: if all items have explicit heights the iframe expands to fit;
|
|
98
|
+
otherwise the remaining space from ``height`` is split among unset items.
|
|
99
|
+
For hconcat: each item gets the full ``height`` (CSS flex splits width).
|
|
100
|
+
Nested layouts are handled recursively.
|
|
101
|
+
"""
|
|
102
|
+
if "plot" in spec:
|
|
103
|
+
spec["plot"].setdefault("height", height)
|
|
104
|
+
return height
|
|
105
|
+
|
|
106
|
+
items = spec.get("vconcat", [])
|
|
107
|
+
if items:
|
|
108
|
+
unset = [item for item in items if not _is_layout(item) and "height" not in item]
|
|
109
|
+
explicit_total = 0
|
|
110
|
+
for item in items:
|
|
111
|
+
if _is_layout(item):
|
|
112
|
+
# Recurse: nested layout gets remaining height budget, returns its own.
|
|
113
|
+
explicit_total += _inject_height(item, height)
|
|
114
|
+
elif "height" in item:
|
|
115
|
+
explicit_total += item["height"]
|
|
116
|
+
total_gap = _VCONCAT_GAP * (len(items) - 1)
|
|
117
|
+
|
|
118
|
+
if not unset:
|
|
119
|
+
return explicit_total + total_gap
|
|
120
|
+
remaining = height - explicit_total - total_gap
|
|
121
|
+
per_item = max(remaining // len(unset), 50)
|
|
122
|
+
for item in unset:
|
|
123
|
+
item["height"] = per_item
|
|
124
|
+
return height
|
|
125
|
+
|
|
126
|
+
items = spec.get("hconcat", [])
|
|
127
|
+
if items:
|
|
128
|
+
max_h = 0
|
|
129
|
+
for item in items:
|
|
130
|
+
if _is_layout(item):
|
|
131
|
+
max_h = max(max_h, _inject_height(item, height))
|
|
132
|
+
else:
|
|
133
|
+
item.setdefault("height", height)
|
|
134
|
+
max_h = max(max_h, item.get("height", height))
|
|
135
|
+
return max_h
|
|
136
|
+
|
|
137
|
+
return height
|