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.
Files changed (32) hide show
  1. {plotlet-0.1.3/src/plotlet.egg-info → plotlet-0.2.0}/PKG-INFO +2 -2
  2. {plotlet-0.1.3 → plotlet-0.2.0}/README.md +1 -1
  3. {plotlet-0.1.3 → plotlet-0.2.0}/pyproject.toml +1 -1
  4. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/__init__.py +1 -1
  5. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/chart.py +42 -12
  6. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/core.py +315 -15
  7. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/layout.py +180 -65
  8. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/legend.py +28 -14
  9. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/spec.json +2 -2
  10. {plotlet-0.1.3 → plotlet-0.2.0/src/plotlet.egg-info}/PKG-INFO +2 -2
  11. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/SOURCES.txt +2 -1
  12. {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_chart.py +43 -3
  13. {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_legend.py +17 -16
  14. {plotlet-0.1.3 → plotlet-0.2.0}/tests/test_subplots.py +68 -47
  15. plotlet-0.2.0/tests/test_units.py +217 -0
  16. {plotlet-0.1.3 → plotlet-0.2.0}/LICENSE +0 -0
  17. {plotlet-0.1.3 → plotlet-0.2.0}/setup.cfg +0 -0
  18. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/_cm_data.py +0 -0
  19. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/_png.py +0 -0
  20. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/_spec.py +0 -0
  21. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/artists.py +0 -0
  22. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/builtin_artists.py +0 -0
  23. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/colormaps.py +0 -0
  24. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/colors.py +0 -0
  25. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/font.py +0 -0
  26. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/fonts/DejaVuSans.ttf +0 -0
  27. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/registry.py +0 -0
  28. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet/scales.py +0 -0
  29. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/dependency_links.txt +0 -0
  30. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/requires.txt +0 -0
  31. {plotlet-0.1.3 → plotlet-0.2.0}/src/plotlet.egg-info/top_level.txt +0 -0
  32. {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.1.3
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`, `width`, `height`
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`, `width`, `height`
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "plotlet"
7
- version = "0.1.3"
7
+ version = "0.2.0"
8
8
  description = "Small, hackable Python library that emits matplotlib-style SVG plots."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,4 +26,4 @@ from .registry import ArtistSpec, add_artist
26
26
  __all__ = ["chart", "Chart", "figure", "Figure", "SPEC", "TAB10", "colors",
27
27
  "colormap", "list_colormaps", "grid", "legend",
28
28
  "ArtistSpec", "add_artist"]
29
- __version__ = "0.1.3"
29
+ __version__ = "0.2.0"
@@ -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, *, width: int | None = None, height: int | None = 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
- self._fig = Figure(width=width, height=height, margin=margin)
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
- width: int | None = None, height: int | None = None) -> "Chart":
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=` / `width=` /
112
- `height=` forwarded to the constructor. Grids raise — place
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
- width=width, height=height)
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 width is not None or height is not None):
160
+ or canvas_width is not None or canvas_height is not None):
132
161
  raise TypeError(
133
- "names=, group_by_chart=, width=, height= are layout-level "
134
- "options for parent.legend(); on a leaf, chart.legend() takes "
135
- "an optional bool. To attach a layout-level legend to a single "
136
- "chart, compose first: (chart | pt.legend()).show()."
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, width: int | None = None, height: int | None = None,
136
- margin: dict | None = None):
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._width = width if width is not None else _SIZESPEC["width"]
139
- self._height = height if height is not None else _SIZESPEC["height"]
140
- self._margin = margin if margin is not None else dict(_SIZESPEC["margin"])
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
- return _render(self._replay(), self._width, self._height, self._margin)
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(width: int | None = None, height: int | None = None, **opts) -> Figure:
227
- return Figure(width=width, height=height, **opts)
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 panels, with a per-side floor so tick labels
273
- and titles still fit. Floors live in `spec.size.margin_floor`; base
274
- margins (defaulted from spec, overridable via `pt.chart(margin=...)`)
275
- scale by `min(1, panel_size / spec_size)` per axis."""
276
- fw = min(1.0, W / _SIZESPEC["width"])
277
- fh = min(1.0, H / _SIZESPEC["height"])
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
- M = _scaled_margin(M, W, H)
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 (