plotlet 0.1.3__tar.gz → 0.2.0__tar.gz
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.
- {plotlet-0.1.3/src/plotlet.egg-info → plotlet-0.2.0}/PKG-INFO +2 -2
- {plotlet-0.1.3 → plotlet-0.2.0}/README.md +1 -1
- {plotlet-0.1.3 → plotlet-0.2.0}/pyproject.toml +1 -1
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/__init__.py +1 -1
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/chart.py +42 -12
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/core.py +315 -15
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/layout.py +180 -65
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/legend.py +28 -14
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/spec.json +2 -2
- {plotlet-0.1.3 → plotlet-0.2.0/src/plotlet.egg-info}/PKG-INFO +2 -2
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/SOURCES.txt +2 -1
- {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_chart.py +43 -3
- {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_legend.py +17 -16
- {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_subplots.py +68 -47
- plotlet-0.2.0/tests/test_units.py +217 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/LICENSE +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/setup.cfg +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/_cm_data.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/_png.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/_spec.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/artists.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/builtin_artists.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/colormaps.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/colors.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/font.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/fonts/DejaVuSans.ttf +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/registry.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/scales.py +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/dependency_links.txt +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/requires.txt +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/top_level.txt +0 -0
- {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_old.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plotlet
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Small, hackable Python library that emits matplotlib-style SVG plots.
|
|
5
5
|
Author: gitbamboo42
|
|
6
6
|
License-Expression: MIT
|
|
@@ -74,7 +74,7 @@ pip install plotlet
|
|
|
74
74
|
|
|
75
75
|
Pass at construction (`pt.chart(data, title=..., grid=True, ...)`) or as chained setters (`c.title(...)`, etc.):
|
|
76
76
|
|
|
77
|
-
`title`, `xlabel`, `ylabel`, `xlim=(a, b)`, `ylim=(a, b)`, `xscale="linear"|"log"|"category"` (chained: `c.xscale("category", order=[...], padding=0)`), `yscale=...`, `grid=True/False`, `legend=True/False`, `
|
|
77
|
+
`title`, `xlabel`, `ylabel`, `xlim=(a, b)`, `ylim=(a, b)`, `xscale="linear"|"log"|"category"` (chained: `c.xscale("category", order=[...], padding=0)`), `yscale=...`, `grid=True/False`, `legend=True/False`, `data_width`, `data_height` (the data region — preferred), or `canvas_width`, `canvas_height` (the full SVG canvas — mutually exclusive with the data form). Sizes accept bare pixels (`400`) or unit-suffixed strings (`"4in"`, `"10cm"`, `"100mm"`, `"72pt"`).
|
|
78
78
|
|
|
79
79
|
String-valued data on either axis (`scatter(["a","b","c"], ...)`, `bar`, …) auto-switches to a categorical scale, alphabetical by default. `padding=0` makes category bands contiguous (heatmap-track look).
|
|
80
80
|
|
|
@@ -45,7 +45,7 @@ pip install plotlet
|
|
|
45
45
|
|
|
46
46
|
Pass at construction (`pt.chart(data, title=..., grid=True, ...)`) or as chained setters (`c.title(...)`, etc.):
|
|
47
47
|
|
|
48
|
-
`title`, `xlabel`, `ylabel`, `xlim=(a, b)`, `ylim=(a, b)`, `xscale="linear"|"log"|"category"` (chained: `c.xscale("category", order=[...], padding=0)`), `yscale=...`, `grid=True/False`, `legend=True/False`, `
|
|
48
|
+
`title`, `xlabel`, `ylabel`, `xlim=(a, b)`, `ylim=(a, b)`, `xscale="linear"|"log"|"category"` (chained: `c.xscale("category", order=[...], padding=0)`), `yscale=...`, `grid=True/False`, `legend=True/False`, `data_width`, `data_height` (the data region — preferred), or `canvas_width`, `canvas_height` (the full SVG canvas — mutually exclusive with the data form). Sizes accept bare pixels (`400`) or unit-suffixed strings (`"4in"`, `"10cm"`, `"100mm"`, `"72pt"`).
|
|
49
49
|
|
|
50
50
|
String-valued data on either axis (`scatter(["a","b","c"], ...)`, `bar`, …) auto-switches to a categorical scale, alphabetical by default. `padding=0` makes category bands contiguous (heatmap-track look).
|
|
51
51
|
|
|
@@ -33,15 +33,31 @@ from .registry import get_artist, all_artist_names
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
class Chart:
|
|
36
|
-
def __init__(self, data=None, *,
|
|
36
|
+
def __init__(self, data=None, *,
|
|
37
|
+
data_width: int | float | str | None = None,
|
|
38
|
+
data_height: int | float | str | None = None,
|
|
39
|
+
canvas_width: int | float | str | None = None,
|
|
40
|
+
canvas_height: int | float | str | None = None,
|
|
37
41
|
margin: dict | None = None,
|
|
38
42
|
title: str | None = None,
|
|
39
43
|
xlabel: str | None = None, ylabel: str | None = None,
|
|
40
44
|
xlim: tuple | None = None, ylim: tuple | None = None,
|
|
41
45
|
xscale: str | None = None, yscale: str | None = None,
|
|
42
46
|
legend: bool | None = None, grid: bool | None = None,
|
|
43
|
-
share_x: "Chart | None" = None, share_y: "Chart | None" = None
|
|
44
|
-
|
|
47
|
+
share_x: "Chart | None" = None, share_y: "Chart | None" = None,
|
|
48
|
+
**kwargs):
|
|
49
|
+
# Migration error — see Figure.__init__ for the rationale.
|
|
50
|
+
if "width" in kwargs or "height" in kwargs:
|
|
51
|
+
raise TypeError(
|
|
52
|
+
"pt.chart() no longer accepts `width=` / `height=` (changed in 0.2.0). "
|
|
53
|
+
"Pass `data_width=` / `data_height=` for the data region (preferred) "
|
|
54
|
+
"or `canvas_width=` / `canvas_height=` for the full SVG canvas."
|
|
55
|
+
)
|
|
56
|
+
if kwargs:
|
|
57
|
+
raise TypeError(f"Chart() got unexpected keyword arguments: {list(kwargs)!r}")
|
|
58
|
+
self._fig = Figure(data_width=data_width, data_height=data_height,
|
|
59
|
+
canvas_width=canvas_width, canvas_height=canvas_height,
|
|
60
|
+
margin=margin)
|
|
45
61
|
self._data = data
|
|
46
62
|
# Composition state. Leaves: _layout_kind is None, _children is empty.
|
|
47
63
|
self._parent: Chart | None = None
|
|
@@ -100,7 +116,9 @@ class Chart:
|
|
|
100
116
|
|
|
101
117
|
def legend(self, *args, names: dict | None = None,
|
|
102
118
|
group_by_chart: bool | None = None,
|
|
103
|
-
|
|
119
|
+
canvas_width: int | float | str | None = None,
|
|
120
|
+
canvas_height: int | float | str | None = None,
|
|
121
|
+
**kwargs) -> "Chart":
|
|
104
122
|
"""Toggle the in-frame overlay (leaf) or attach a layout-level legend (parent).
|
|
105
123
|
|
|
106
124
|
On a leaf, this is the existing `chart.legend([bool])` toggle for
|
|
@@ -108,11 +126,22 @@ class Chart:
|
|
|
108
126
|
|
|
109
127
|
On a parent, this is sugar for the panel form: `parent.legend(*sources)`
|
|
110
128
|
is equivalent to `parent | pt.legend(*sources)` (or `parent / ...` for
|
|
111
|
-
a vertical parent), with `names=` / `group_by_chart=` / `
|
|
112
|
-
`
|
|
129
|
+
a vertical parent), with `names=` / `group_by_chart=` / `canvas_width=` /
|
|
130
|
+
`canvas_height=` forwarded to the constructor. Grids raise — place
|
|
113
131
|
`pt.legend(...)` in an explicit cell instead. Returns `self` for
|
|
114
132
|
chaining; remember that further composition (`|` / `/`) appends
|
|
115
133
|
children *after* the legend, so decorate last."""
|
|
134
|
+
if "width" in kwargs or "height" in kwargs:
|
|
135
|
+
raise TypeError(
|
|
136
|
+
"Chart.legend() no longer accepts `width=` / `height=` "
|
|
137
|
+
"(changed in 0.2.0). Use `canvas_width=` / `canvas_height=` "
|
|
138
|
+
"instead — legend leaves have no data axes, so the canvas "
|
|
139
|
+
"is the only meaningful dimension."
|
|
140
|
+
)
|
|
141
|
+
if kwargs:
|
|
142
|
+
raise TypeError(
|
|
143
|
+
f"Chart.legend() got unexpected keyword arguments: {list(kwargs)!r}"
|
|
144
|
+
)
|
|
116
145
|
if self._is_parent:
|
|
117
146
|
if self._layout_kind == "grid":
|
|
118
147
|
raise ValueError(
|
|
@@ -122,18 +151,19 @@ class Chart:
|
|
|
122
151
|
from .legend import legend as _make_legend
|
|
123
152
|
gbc = True if group_by_chart is None else group_by_chart
|
|
124
153
|
leg = _make_legend(*args, names=names, group_by_chart=gbc,
|
|
125
|
-
|
|
154
|
+
canvas_width=canvas_width, canvas_height=canvas_height)
|
|
126
155
|
self._children.append(leg)
|
|
127
156
|
leg._parent = self
|
|
128
157
|
return self
|
|
129
158
|
# Leaf: today's in-frame overlay toggle. Reject parent-only kwargs.
|
|
130
159
|
if (names is not None or group_by_chart is not None
|
|
131
|
-
or
|
|
160
|
+
or canvas_width is not None or canvas_height is not None):
|
|
132
161
|
raise TypeError(
|
|
133
|
-
"names=, group_by_chart=,
|
|
134
|
-
"options for parent.legend(); on a leaf,
|
|
135
|
-
"an optional bool. To attach a
|
|
136
|
-
"chart, compose first:
|
|
162
|
+
"names=, group_by_chart=, canvas_width=, canvas_height= are "
|
|
163
|
+
"layout-level options for parent.legend(); on a leaf, "
|
|
164
|
+
"chart.legend() takes an optional bool. To attach a "
|
|
165
|
+
"layout-level legend to a single chart, compose first: "
|
|
166
|
+
"(chart | pt.legend()).show()."
|
|
137
167
|
)
|
|
138
168
|
if args and not isinstance(args[0], bool):
|
|
139
169
|
raise TypeError(
|
|
@@ -17,6 +17,7 @@ behaves as before.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import math
|
|
20
|
+
import re
|
|
20
21
|
from dataclasses import dataclass, field
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
|
|
@@ -79,6 +80,10 @@ class _PanelOpts:
|
|
|
79
80
|
`suppress_*_labels` drops tick labels on a side whose axis is shared
|
|
80
81
|
with a neighbor that already labels it; set only on the panel that
|
|
81
82
|
actually shares, never propagated by grid alignment.
|
|
83
|
+
`M_eff`, when set, is the layout-pre-pass-resolved effective margin
|
|
84
|
+
for a body-first leaf — it has already incorporated measure-driven
|
|
85
|
+
growth and per-column/row coordination. Canvas-first leaves leave
|
|
86
|
+
this `None` and fall through to `_scaled_margin` at render time.
|
|
82
87
|
"""
|
|
83
88
|
x_axis: _AxisDescriptor | None = None
|
|
84
89
|
y_axis: _AxisDescriptor | None = None
|
|
@@ -88,6 +93,7 @@ class _PanelOpts:
|
|
|
88
93
|
hide_bottom: bool = False
|
|
89
94
|
suppress_left_labels: bool = False
|
|
90
95
|
suppress_bottom_labels: bool = False
|
|
96
|
+
M_eff: dict | None = None
|
|
91
97
|
|
|
92
98
|
|
|
93
99
|
def _rotated_text(s, x, y, size, angle, axis):
|
|
@@ -131,13 +137,131 @@ def _record_ticks(st, axis, args, kw):
|
|
|
131
137
|
if "marks" in kw: st[f"{axis}_marks"] = bool(kw["marks"])
|
|
132
138
|
|
|
133
139
|
|
|
140
|
+
# Conversion factors to pixels, CSS standard: 1 in = 96 px, 1 in = 2.54 cm,
|
|
141
|
+
# 1 in = 72 pt. Internal layout math is always pixels — string units are
|
|
142
|
+
# parsed once at the constructor boundary and stored as ints, so SVG output
|
|
143
|
+
# stays byte-identical regardless of input form.
|
|
144
|
+
_UNIT_PX = {
|
|
145
|
+
"px": 1.0,
|
|
146
|
+
"in": 96.0,
|
|
147
|
+
"cm": 96.0 / 2.54,
|
|
148
|
+
"mm": 96.0 / 25.4,
|
|
149
|
+
"pt": 96.0 / 72.0,
|
|
150
|
+
}
|
|
151
|
+
_DIM_RE = re.compile(r"^\s*([+-]?\d*\.?\d+)\s*([a-zA-Z]*)\s*$")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _to_px(value):
|
|
155
|
+
"""Resolve a dim value to integer pixels.
|
|
156
|
+
|
|
157
|
+
Accepts:
|
|
158
|
+
- `int` / `float`: bare pixels.
|
|
159
|
+
- `str`: a number with an optional unit suffix
|
|
160
|
+
(`"4in"`, `"10cm"`, `"100mm"`, `"72pt"`, `"30px"` or `"30"`).
|
|
161
|
+
Whitespace and case insensitive (`"5 IN"` works).
|
|
162
|
+
- `None`: passthrough (constructors interpret as "use default").
|
|
163
|
+
"""
|
|
164
|
+
if value is None:
|
|
165
|
+
return None
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
# Guard against `True`/`False` slipping through `int` — almost never
|
|
168
|
+
# what the user meant for a dimension.
|
|
169
|
+
raise TypeError(f"dim value cannot be bool; got {value!r}")
|
|
170
|
+
if isinstance(value, (int, float)):
|
|
171
|
+
return int(round(value))
|
|
172
|
+
if not isinstance(value, str):
|
|
173
|
+
raise TypeError(
|
|
174
|
+
f"dim value must be int, float, or str; got {type(value).__name__}"
|
|
175
|
+
)
|
|
176
|
+
m = _DIM_RE.match(value)
|
|
177
|
+
if not m:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"could not parse dim value {value!r}; expected '<number>[unit]' "
|
|
180
|
+
f"where unit is one of: {', '.join(sorted(_UNIT_PX))}"
|
|
181
|
+
)
|
|
182
|
+
num = float(m.group(1))
|
|
183
|
+
unit = m.group(2).lower() or "px"
|
|
184
|
+
if unit not in _UNIT_PX:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"unknown unit {unit!r} in {value!r}; expected one of: "
|
|
187
|
+
f"{', '.join(sorted(_UNIT_PX))}"
|
|
188
|
+
)
|
|
189
|
+
return int(round(num * _UNIT_PX[unit]))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _spec_canvas_dims() -> tuple[int, int]:
|
|
193
|
+
"""Spec-default canvas size, derived from data region + spec margin.
|
|
194
|
+
|
|
195
|
+
The dimensional primitive is the data region (`spec.size.data_width` /
|
|
196
|
+
`data_height`); this helper rebuilds the implied canvas size so legacy
|
|
197
|
+
canvas-based math (`_scaled_margin`, layout allocation) keeps a single
|
|
198
|
+
well-defined reference point."""
|
|
199
|
+
M = _SIZESPEC["margin"]
|
|
200
|
+
return (_SIZESPEC["data_width"] + M["left"] + M["right"],
|
|
201
|
+
_SIZESPEC["data_height"] + M["top"] + M["bottom"])
|
|
202
|
+
|
|
203
|
+
|
|
134
204
|
class Figure:
|
|
135
|
-
def __init__(self,
|
|
136
|
-
|
|
205
|
+
def __init__(self,
|
|
206
|
+
data_width: int | float | str | None = None,
|
|
207
|
+
data_height: int | float | str | None = None,
|
|
208
|
+
*,
|
|
209
|
+
canvas_width: int | float | str | None = None,
|
|
210
|
+
canvas_height: int | float | str | None = None,
|
|
211
|
+
margin: dict | None = None,
|
|
212
|
+
**kwargs):
|
|
213
|
+
# Migration error: 0.1.x accepted `width=`/`height=` (canvas dims).
|
|
214
|
+
# 0.2.0 splits this into data_* (data region — the new primitive) and
|
|
215
|
+
# canvas_* (full SVG). Surface the rename loudly rather than silently
|
|
216
|
+
# accepting and producing a different-sized figure.
|
|
217
|
+
if "width" in kwargs or "height" in kwargs:
|
|
218
|
+
raise TypeError(
|
|
219
|
+
"Figure no longer accepts `width=` / `height=` (changed in 0.2.0). "
|
|
220
|
+
"For the data region (the new dimensional primitive), pass "
|
|
221
|
+
"`data_width=` / `data_height=` — positional also works: "
|
|
222
|
+
"`Figure(400, 300)`. For the full SVG canvas, pass "
|
|
223
|
+
"`canvas_width=` / `canvas_height=`."
|
|
224
|
+
)
|
|
225
|
+
if kwargs:
|
|
226
|
+
raise TypeError(f"Figure() got unexpected keyword arguments: {list(kwargs)!r}")
|
|
227
|
+
|
|
228
|
+
data_set = (data_width is not None) or (data_height is not None)
|
|
229
|
+
canvas_set = (canvas_width is not None) or (canvas_height is not None)
|
|
230
|
+
if data_set and canvas_set:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"Pass either data_width/data_height (the data region — preferred) "
|
|
233
|
+
"or canvas_width/canvas_height (the full SVG canvas), not both."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Resolve unit-suffixed strings (`"4in"`, `"10cm"`, …) once at the
|
|
237
|
+
# boundary so internal math stays in pixels.
|
|
238
|
+
data_width = _to_px(data_width)
|
|
239
|
+
data_height = _to_px(data_height)
|
|
240
|
+
canvas_width = _to_px(canvas_width)
|
|
241
|
+
canvas_height = _to_px(canvas_height)
|
|
242
|
+
|
|
137
243
|
self._calls: list[tuple[str, list, dict]] = []
|
|
138
|
-
self.
|
|
139
|
-
|
|
140
|
-
|
|
244
|
+
self._margin = dict(margin) if margin is not None else dict(_SIZESPEC["margin"])
|
|
245
|
+
|
|
246
|
+
if canvas_set:
|
|
247
|
+
# Canvas path: user picked the SVG canvas; effective margin scales
|
|
248
|
+
# by canvas/spec_canvas (legacy 0.1.x behavior). Data region falls
|
|
249
|
+
# out as canvas - effective margin.
|
|
250
|
+
spec_cw, spec_ch = _spec_canvas_dims()
|
|
251
|
+
self._canvas_width = canvas_width if canvas_width is not None else spec_cw
|
|
252
|
+
self._canvas_height = canvas_height if canvas_height is not None else spec_ch
|
|
253
|
+
self._canvas_explicit = True
|
|
254
|
+
M_eff = _scaled_margin(self._margin, self._canvas_width, self._canvas_height)
|
|
255
|
+
self._data_width = self._canvas_width - M_eff["left"] - M_eff["right"]
|
|
256
|
+
self._data_height = self._canvas_height - M_eff["top"] - M_eff["bottom"]
|
|
257
|
+
else:
|
|
258
|
+
# Data path (default): user picked the data region exactly. Margin
|
|
259
|
+
# is used unscaled (only floored). Canvas falls out as data + margin.
|
|
260
|
+
self._data_width = data_width if data_width is not None else _SIZESPEC["data_width"]
|
|
261
|
+
self._data_height = data_height if data_height is not None else _SIZESPEC["data_height"]
|
|
262
|
+
self._canvas_width = self._data_width + self._margin["left"] + self._margin["right"]
|
|
263
|
+
self._canvas_height = self._data_height + self._margin["top"] + self._margin["bottom"]
|
|
264
|
+
self._canvas_explicit = False
|
|
141
265
|
|
|
142
266
|
def __getattr__(self, name):
|
|
143
267
|
# Recordable if it's a frame method or a registered artist
|
|
@@ -192,8 +316,38 @@ class Figure:
|
|
|
192
316
|
return st
|
|
193
317
|
|
|
194
318
|
# ------------------------------------------------------------- render
|
|
319
|
+
def _effective_margin(self, st: dict | None = None) -> dict:
|
|
320
|
+
"""Margin actually used at render time.
|
|
321
|
+
|
|
322
|
+
Canvas path: scales by canvas dims (legacy 0.1.x behavior); text
|
|
323
|
+
overflow is the user's responsibility because canvas size is
|
|
324
|
+
promised exactly.
|
|
325
|
+
|
|
326
|
+
Data path: combines `_enforce_floors(spec/user margin)` with the
|
|
327
|
+
content-driven `_required_margin(st, data_w, data_h)` by taking
|
|
328
|
+
the per-side max — so the canvas grows as needed to fit long tick
|
|
329
|
+
labels, titles, and axis labels rather than letting them overflow.
|
|
330
|
+
Caller passes the replayed `st`; callers without one (legacy code
|
|
331
|
+
paths) get only the floor-applied spec margin."""
|
|
332
|
+
if self._canvas_explicit:
|
|
333
|
+
return _scaled_margin(self._margin, self._canvas_width, self._canvas_height)
|
|
334
|
+
M_floor = _enforce_floors(self._margin)
|
|
335
|
+
if st is None:
|
|
336
|
+
return M_floor
|
|
337
|
+
M_req = _required_margin(st, self._data_width, self._data_height)
|
|
338
|
+
return {side: max(M_floor[side], M_req[side]) for side in M_floor}
|
|
339
|
+
|
|
195
340
|
def to_svg(self) -> str:
|
|
196
|
-
|
|
341
|
+
st = self._replay()
|
|
342
|
+
M_eff = self._effective_margin(st)
|
|
343
|
+
# Canvas-path keeps its promised canvas; data-path canvas grows
|
|
344
|
+
# to fit the (possibly measure-driven-expanded) margin.
|
|
345
|
+
if self._canvas_explicit:
|
|
346
|
+
W, H = self._canvas_width, self._canvas_height
|
|
347
|
+
else:
|
|
348
|
+
W = self._data_width + M_eff["left"] + M_eff["right"]
|
|
349
|
+
H = self._data_height + M_eff["top"] + M_eff["bottom"]
|
|
350
|
+
return _render(st, W, H, M_eff)
|
|
197
351
|
|
|
198
352
|
def to_html(self, full_page: bool = False) -> str:
|
|
199
353
|
svg = self.to_svg()
|
|
@@ -223,8 +377,15 @@ class Figure:
|
|
|
223
377
|
return self
|
|
224
378
|
|
|
225
379
|
|
|
226
|
-
def figure(
|
|
227
|
-
|
|
380
|
+
def figure(data_width: int | float | str | None = None,
|
|
381
|
+
data_height: int | float | str | None = None,
|
|
382
|
+
*,
|
|
383
|
+
canvas_width: int | float | str | None = None,
|
|
384
|
+
canvas_height: int | float | str | None = None,
|
|
385
|
+
**opts) -> Figure:
|
|
386
|
+
return Figure(data_width, data_height,
|
|
387
|
+
canvas_width=canvas_width, canvas_height=canvas_height,
|
|
388
|
+
**opts)
|
|
228
389
|
|
|
229
390
|
|
|
230
391
|
# ---------------------------------------------------------------------------
|
|
@@ -268,13 +429,28 @@ def _resolve_domain(lo, hi, user_lim, scale_kind, force_zero=False):
|
|
|
268
429
|
return _nice_domain(lo, hi)
|
|
269
430
|
|
|
270
431
|
|
|
432
|
+
def _enforce_floors(M):
|
|
433
|
+
"""Apply per-side margin floors without any scaling. Used by the
|
|
434
|
+
data-region path: the user (or spec) declared the margin in absolute
|
|
435
|
+
pixels, so we just round and floor — never shrink."""
|
|
436
|
+
return {
|
|
437
|
+
"top": max(_MARGIN_FLOOR["top"], int(round(M["top"]))),
|
|
438
|
+
"bottom": max(_MARGIN_FLOOR["bottom"], int(round(M["bottom"]))),
|
|
439
|
+
"left": max(_MARGIN_FLOOR["left"], int(round(M["left"]))),
|
|
440
|
+
"right": max(_MARGIN_FLOOR["right"], int(round(M["right"]))),
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
271
444
|
def _scaled_margin(M, W, H):
|
|
272
|
-
"""Shrink margins for small
|
|
273
|
-
and titles still fit.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
445
|
+
"""Shrink margins for small canvases, with a per-side floor so tick
|
|
446
|
+
labels and titles still fit. Used by the canvas-explicit path
|
|
447
|
+
(`Figure(canvas_width=…)` and the layout's per-panel allocation),
|
|
448
|
+
where the canvas size is fixed and margins must scale to fit. Floors
|
|
449
|
+
live in `spec.size.margin_floor`; the reference canvas is derived
|
|
450
|
+
from `spec.size.data_width/height + spec margin`."""
|
|
451
|
+
spec_W, spec_H = _spec_canvas_dims()
|
|
452
|
+
fw = min(1.0, W / spec_W)
|
|
453
|
+
fh = min(1.0, H / spec_H)
|
|
278
454
|
return {
|
|
279
455
|
"top": max(_MARGIN_FLOOR["top"], int(round(M["top"] * fh))),
|
|
280
456
|
"bottom": max(_MARGIN_FLOOR["bottom"], int(round(M["bottom"] * fh))),
|
|
@@ -373,6 +549,127 @@ def _y_descriptor(st) -> _AxisDescriptor:
|
|
|
373
549
|
return _AxisDescriptor(kind=st["yscale"], lo=y_min, hi=y_max)
|
|
374
550
|
|
|
375
551
|
|
|
552
|
+
def _rotated_label_bbox(label_w: float, label_h: float, rot_deg: float) -> tuple[float, float]:
|
|
553
|
+
"""Bounding-box (width, height) of a rotated text label. Conservative —
|
|
554
|
+
uses the simple `|cos|·w + |sin|·h` envelope, which is exact for the
|
|
555
|
+
AABB of an axis-aligned rectangle rotated by any angle."""
|
|
556
|
+
if rot_deg == 0:
|
|
557
|
+
return label_w, label_h
|
|
558
|
+
rad = math.radians(abs(rot_deg))
|
|
559
|
+
sin_r = math.sin(rad)
|
|
560
|
+
cos_r = math.cos(rad)
|
|
561
|
+
return (label_w * cos_r + label_h * sin_r,
|
|
562
|
+
label_w * sin_r + label_h * cos_r)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _required_margin(st, dw, dh) -> dict:
|
|
566
|
+
"""Margin a body-first leaf actually needs to fit its title, axis
|
|
567
|
+
labels, and tick labels without overflow.
|
|
568
|
+
|
|
569
|
+
Returns a plain dict with the same keys as `_margin` — the caller
|
|
570
|
+
combines this with the user/spec margin (and the per-side floor) by
|
|
571
|
+
taking max per side. Body-first specifically: data dims are fixed,
|
|
572
|
+
so tick density and labels are deterministic and the computation is
|
|
573
|
+
a single pass (no chicken-and-egg with margin).
|
|
574
|
+
|
|
575
|
+
The geometry mirrors `_render_inner`'s placement formulas — keep them
|
|
576
|
+
in sync if either changes."""
|
|
577
|
+
tick_size = _FONTSPEC["tick_size"]
|
|
578
|
+
label_size = _FONTSPEC["label_size"]
|
|
579
|
+
title_size = _FONTSPEC["title_size"]
|
|
580
|
+
|
|
581
|
+
# Title sits at y = -10 from the data top (see _render_inner), so it
|
|
582
|
+
# needs ≥ title_size + ~4 px of top margin to clear.
|
|
583
|
+
top = title_size + 6 if st["title"] else 0
|
|
584
|
+
|
|
585
|
+
# Provisional scales at the fixed data dims — body-first means iw/ih
|
|
586
|
+
# are decided up front, no iteration needed.
|
|
587
|
+
x_axis = _x_descriptor(st)
|
|
588
|
+
y_axis = _y_descriptor(st)
|
|
589
|
+
x_scale = x_axis.build(0, dw)
|
|
590
|
+
y_scale = y_axis.build(0, dh) if y_axis.kind == "category" else y_axis.build(dh, 0)
|
|
591
|
+
|
|
592
|
+
# Same tick-density rule as `_render_inner`.
|
|
593
|
+
x_n = max(2, min(8, int(dw // 65)))
|
|
594
|
+
y_n = max(2, min(8, int(dh // 40)))
|
|
595
|
+
x_ticks = st["x_ticks"] if st["x_ticks"] is not None else x_scale.ticks(x_n)
|
|
596
|
+
y_ticks = st["y_ticks"] if st["y_ticks"] is not None else y_scale.ticks(y_n)
|
|
597
|
+
x_labels = (st["x_labels"] if st["x_labels"] is not None
|
|
598
|
+
else [_fmt_tick(t) for t in x_ticks])
|
|
599
|
+
y_labels = (st["y_labels"] if st["y_labels"] is not None
|
|
600
|
+
else [_fmt_tick(t) for t in y_ticks])
|
|
601
|
+
|
|
602
|
+
x_size = st["x_fontsize"] if st["x_fontsize"] is not None else tick_size
|
|
603
|
+
y_size = st["y_fontsize"] if st["y_fontsize"] is not None else tick_size
|
|
604
|
+
x_rot = st["x_rotation"] or 0
|
|
605
|
+
y_rot = st["y_rotation"] or 0
|
|
606
|
+
x_dir, y_dir = st["x_direction"], st["y_direction"]
|
|
607
|
+
x_marks, y_marks = st["x_marks"], st["y_marks"]
|
|
608
|
+
|
|
609
|
+
# Outward / inout tick marks reach past the spine; "in" is internal only.
|
|
610
|
+
out_x = _TICK_LEN if x_marks and x_dir != "in" else 0
|
|
611
|
+
out_y = _TICK_LEN if y_marks and y_dir != "in" else 0
|
|
612
|
+
|
|
613
|
+
# X-tick label bbox (after rotation).
|
|
614
|
+
if x_labels:
|
|
615
|
+
max_xtl_w = max((_measure_text(str(l), x_size) for l in x_labels), default=0.0)
|
|
616
|
+
last_xtl_w = _measure_text(str(x_labels[-1]), x_size)
|
|
617
|
+
_, xtl_bbox_h = _rotated_label_bbox(max_xtl_w, x_size, x_rot)
|
|
618
|
+
last_bbox_w, _ = _rotated_label_bbox(last_xtl_w, x_size, x_rot)
|
|
619
|
+
else:
|
|
620
|
+
xtl_bbox_h = 0.0
|
|
621
|
+
last_bbox_w = 0.0
|
|
622
|
+
|
|
623
|
+
# Y-tick label width (after rotation).
|
|
624
|
+
if y_labels:
|
|
625
|
+
max_ytl_w = max((_measure_text(str(l), y_size) for l in y_labels), default=0.0)
|
|
626
|
+
ytl_bbox_w, _ = _rotated_label_bbox(max_ytl_w, y_size, y_rot)
|
|
627
|
+
else:
|
|
628
|
+
ytl_bbox_w = 0.0
|
|
629
|
+
|
|
630
|
+
# Bottom: outward tick + tick_pad + 8 px buffer + tick label bbox + xlabel.
|
|
631
|
+
# The "+8" mirrors the literal in _render_inner's tick-label baseline y.
|
|
632
|
+
bottom = out_x + _TICK_PAD + 8 + xtl_bbox_h
|
|
633
|
+
if st["xlabel"]:
|
|
634
|
+
bottom += label_size + 8
|
|
635
|
+
|
|
636
|
+
# Left: outward tick + tick_pad + tick label bbox + ylabel allowance.
|
|
637
|
+
# ylabel sits at canvas-left + 12 px (rotated -90), so it occupies
|
|
638
|
+
# roughly `label_size` in the horizontal direction.
|
|
639
|
+
left = out_y + _TICK_PAD + ytl_bbox_w
|
|
640
|
+
if st["ylabel"]:
|
|
641
|
+
left += label_size + 8
|
|
642
|
+
|
|
643
|
+
# Right: outward tick OR the rightmost x-tick label's overhang past
|
|
644
|
+
# the spine (centered text extends half its width past the tick).
|
|
645
|
+
right_overhang = last_bbox_w / 2.0
|
|
646
|
+
right = max(out_y, right_overhang)
|
|
647
|
+
|
|
648
|
+
# Long-text overflow: a title / xlabel longer than `dw` is centered on
|
|
649
|
+
# `iw/2`, so it sticks out past the data area on both left and right
|
|
650
|
+
# by `(text_w - dw) / 2`. A ylabel (rotated -90, centered on `ih/2`)
|
|
651
|
+
# is the same story but vertical: text longer than `dh` spills past
|
|
652
|
+
# top and bottom equally. Margins grow by the overhang amount so the
|
|
653
|
+
# rendered text fits inside the canvas.
|
|
654
|
+
if st["title"]:
|
|
655
|
+
title_overhang = max(0.0, (_measure_text(st["title"], title_size) - dw) / 2.0)
|
|
656
|
+
left = max(left, title_overhang)
|
|
657
|
+
right = max(right, title_overhang)
|
|
658
|
+
if st["xlabel"]:
|
|
659
|
+
xlabel_overhang = max(0.0, (_measure_text(st["xlabel"], label_size) - dw) / 2.0)
|
|
660
|
+
left = max(left, xlabel_overhang)
|
|
661
|
+
right = max(right, xlabel_overhang)
|
|
662
|
+
if st["ylabel"]:
|
|
663
|
+
ylabel_overhang = max(0.0, (_measure_text(st["ylabel"], label_size) - dh) / 2.0)
|
|
664
|
+
top = max(top, ylabel_overhang)
|
|
665
|
+
bottom = max(bottom, ylabel_overhang)
|
|
666
|
+
|
|
667
|
+
return {"top": int(round(top)),
|
|
668
|
+
"right": int(round(right)),
|
|
669
|
+
"bottom": int(round(bottom)),
|
|
670
|
+
"left": int(round(left))}
|
|
671
|
+
|
|
672
|
+
|
|
376
673
|
def _build_xy_scales(st, iw, ih, panel_opts: _PanelOpts):
|
|
377
674
|
"""Instantiate pixel-bound scales. `panel_opts.x_axis` / `y_axis` come
|
|
378
675
|
from the layout pre-pass when set; otherwise we compute them from the
|
|
@@ -391,7 +688,10 @@ def _build_xy_scales(st, iw, ih, panel_opts: _PanelOpts):
|
|
|
391
688
|
# ---------------------------------------------------------------------------
|
|
392
689
|
|
|
393
690
|
def _render(st, W, H, M):
|
|
394
|
-
|
|
691
|
+
"""Emit one SVG. (W, H) = canvas dims; M = effective margin already
|
|
692
|
+
resolved by the caller (`Figure._effective_margin` or layout's
|
|
693
|
+
`_effective_margin`). Splitting margin resolution out of `_render` is
|
|
694
|
+
what lets the data-path skip canvas-based scaling."""
|
|
395
695
|
iw = W - M["left"] - M["right"]
|
|
396
696
|
ih = H - M["top"] - M["bottom"]
|
|
397
697
|
return (
|