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 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