figfoundry 0.1.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.
- figfoundry/__init__.py +7 -0
- figfoundry/__main__.py +4 -0
- figfoundry/app.py +378 -0
- figfoundry/canvas.py +711 -0
- figfoundry/cli.py +141 -0
- figfoundry/data_io.py +194 -0
- figfoundry/datasets/sample.npz +0 -0
- figfoundry/static/canvas.css +111 -0
- figfoundry/static/canvas.html +144 -0
- figfoundry/static/canvas.js +1355 -0
- figfoundry/static/index.html +113 -0
- figfoundry/static/style.css +338 -0
- figfoundry-0.1.0.dist-info/METADATA +125 -0
- figfoundry-0.1.0.dist-info/RECORD +17 -0
- figfoundry-0.1.0.dist-info/WHEEL +4 -0
- figfoundry-0.1.0.dist-info/entry_points.txt +2 -0
- figfoundry-0.1.0.dist-info/licenses/LICENSE +21 -0
figfoundry/canvas.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Fig Foundry -- canvas composition engine.
|
|
2
|
+
|
|
3
|
+
The atom is DATA. A figure is a free-form scene: a canvas (inches) holding any
|
|
4
|
+
number of plots, each an explicit rectangle ``fig.add_axes([left, bottom, width,
|
|
5
|
+
height])`` in figure-fraction coordinates. Because layout is explicit positions
|
|
6
|
+
(not a subplots grid), plots can be moved, resized and snapped freely, and the
|
|
7
|
+
generated code is a plain list of ``add_axes`` calls.
|
|
8
|
+
|
|
9
|
+
WYSIWYG BY CONSTRUCTION: the preview render *executes the generated code*
|
|
10
|
+
(``build_figure`` runs ``codegen_canvas(spec)``). There is exactly one drawing
|
|
11
|
+
path -- the emitted .py -- so what you see in the browser is byte-for-byte what
|
|
12
|
+
the exported file produces. There is no second, hand-written renderer to drift.
|
|
13
|
+
|
|
14
|
+
Layers:
|
|
15
|
+
spec -- the editable scene (canvas + plots + figure artists). What UI/API edits.
|
|
16
|
+
codegen_canvas(spec) -- serialize the scene to a standalone .py (references the dataset).
|
|
17
|
+
build_figure(spec) -- exec that .py and return the Figure (the preview).
|
|
18
|
+
hotspots_from_fig -- walk the built figure for pixel bboxes (hit-testing).
|
|
19
|
+
"""
|
|
20
|
+
import base64
|
|
21
|
+
import io
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from . import data_io
|
|
27
|
+
|
|
28
|
+
DATA_DIR = data_io.DATA_DIR
|
|
29
|
+
PREVIEW_DPI = 150
|
|
30
|
+
_RED = "#b03a2e"
|
|
31
|
+
_BLUE = "#2f6690"
|
|
32
|
+
|
|
33
|
+
# filename -> ((mtime_ns, size), arrays). The (mtime, size) key invalidates the
|
|
34
|
+
# entry when the file is replaced or shrunk/grown under the same name.
|
|
35
|
+
_ds_cache = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_dataset_arrays(filename):
|
|
39
|
+
"""Load (and cache) a dataset's {signal_name: ndarray} from datasets/<filename>.
|
|
40
|
+
|
|
41
|
+
Cached per filename but invalidated by (mtime, size): a re-upload that
|
|
42
|
+
replaces the file, or a deletion, must not keep serving stale arrays. The
|
|
43
|
+
render path re-reads the file from disk every time, so a stale cache would
|
|
44
|
+
make the signal library (names/lengths) disagree with what the figure
|
|
45
|
+
actually draws, and references would break only at render time.
|
|
46
|
+
"""
|
|
47
|
+
path = os.path.join(DATA_DIR, filename)
|
|
48
|
+
st = os.stat(path) # raises FileNotFoundError if the dataset was deleted
|
|
49
|
+
key = (st.st_mtime_ns, st.st_size)
|
|
50
|
+
hit = _ds_cache.get(filename)
|
|
51
|
+
if hit is not None and hit[0] == key:
|
|
52
|
+
return hit[1]
|
|
53
|
+
arrays = data_io.load_arrays(path)[0]
|
|
54
|
+
_ds_cache[filename] = (key, arrays)
|
|
55
|
+
return arrays
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------- data refs
|
|
59
|
+
def ref_to_code(ref):
|
|
60
|
+
"""Emit the Python expression that reproduces a signal reference (indexes ``data``).
|
|
61
|
+
|
|
62
|
+
Only "@name" strings are signal refs; anything else (None, a bare number, a
|
|
63
|
+
dict) means "no signal" and emits None. Emitting repr(5) would generate
|
|
64
|
+
code that crashes at exec time (e.g. len(5) inside the guards)."""
|
|
65
|
+
if not (isinstance(ref, str) and ref.startswith("@")):
|
|
66
|
+
return "None"
|
|
67
|
+
return f'data[{ref[1:]!r}]'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def dataset_loader_code(filename):
|
|
71
|
+
"""Emit the Python that loads the dataset file into ``data`` (a dict of arrays)."""
|
|
72
|
+
path = os.path.join(DATA_DIR, filename)
|
|
73
|
+
if filename.endswith(".npz"):
|
|
74
|
+
return [
|
|
75
|
+
"import numpy as np",
|
|
76
|
+
f"_ds = np.load({path!r}, allow_pickle=False)",
|
|
77
|
+
"data = {k: _ds[k] for k in _ds.files}",
|
|
78
|
+
]
|
|
79
|
+
if filename.endswith(".csv"):
|
|
80
|
+
return [
|
|
81
|
+
"import numpy as np, csv",
|
|
82
|
+
f"_f = open({path!r}, newline='', encoding='utf-8-sig'); _rows = [r for r in csv.reader(_f) if any(c.strip() for c in r)]; _f.close()",
|
|
83
|
+
"_data = {}",
|
|
84
|
+
"for _i, _c in enumerate(_rows[0] if _rows else []):",
|
|
85
|
+
" _v = []",
|
|
86
|
+
" for _r in _rows[1:]:",
|
|
87
|
+
" if _i < len(_r):",
|
|
88
|
+
" try: _v.append(float(_r[_i]))",
|
|
89
|
+
" except ValueError: pass",
|
|
90
|
+
" if _v: _data[_c.strip() or f'col{_i}'] = np.array(_v)",
|
|
91
|
+
"data = _data",
|
|
92
|
+
]
|
|
93
|
+
# .pkl -- embedded whitelist unpickler (numpy + plain containers; blocks
|
|
94
|
+
# os/subprocess/socket/ctypes and non-container builtins). The numpy
|
|
95
|
+
# whitelist is the exact (module, name) set, matching data_io.SafeUnpickler:
|
|
96
|
+
# a prefix match would let numpy.fromfile (arbitrary file read) through.
|
|
97
|
+
# A single-array pickle (not a dict) is accepted too, as data_io does.
|
|
98
|
+
return [
|
|
99
|
+
"import numpy as np, pickle, importlib, builtins",
|
|
100
|
+
"class _SafeUnpickler(pickle.Unpickler):",
|
|
101
|
+
' _B = {"tuple", "list", "dict", "set"}',
|
|
102
|
+
' _N = {("numpy", "ndarray"), ("numpy", "dtype"), ("numpy.core.multiarray", "_reconstruct"), ("numpy.core.multiarray", "scalar"), ("numpy._core.multiarray", "_reconstruct"), ("numpy._core.multiarray", "scalar")}',
|
|
103
|
+
" def find_class(self, m, n):",
|
|
104
|
+
' if m == "builtins":',
|
|
105
|
+
' if n not in self._B: raise pickle.UnpicklingError("blocked " + m + "." + n)',
|
|
106
|
+
' return getattr(builtins, n)',
|
|
107
|
+
' if (m, n) in self._N:',
|
|
108
|
+
' return getattr(importlib.import_module(m), n)',
|
|
109
|
+
' raise pickle.UnpicklingError("blocked " + m + "." + n)',
|
|
110
|
+
"def _safe_load(p):",
|
|
111
|
+
' with open(p, "rb") as f: return _SafeUnpickler(f).load()',
|
|
112
|
+
f"_obj = _safe_load({path!r})",
|
|
113
|
+
"_items = _obj.items() if isinstance(_obj, dict) else [('data', _obj)]",
|
|
114
|
+
"data = {str(k): np.asarray(v) for k, v in _items if np.asarray(v).dtype != object}",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- scene model
|
|
119
|
+
def blank_spec():
|
|
120
|
+
return {
|
|
121
|
+
"dataset": None,
|
|
122
|
+
"canvas": {"width": 7.4, "height": 4.5, "dpi": 220, "bg": "#ffffff",
|
|
123
|
+
"font_family": "DejaVu Sans", "font_size": 14},
|
|
124
|
+
"layout": {"margin": 0.05, "gap": 0.04},
|
|
125
|
+
"plots": [],
|
|
126
|
+
"figure_artists": [],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _new_plot(pid, rect, x_ref, y_ref, **kw):
|
|
131
|
+
p = {
|
|
132
|
+
"id": pid,
|
|
133
|
+
"rect": [round(float(v), 4) for v in rect],
|
|
134
|
+
"data": {"x": x_ref, "y": y_ref, "z": None, "field": None, "band": None, "errorbar": None},
|
|
135
|
+
"type": "line",
|
|
136
|
+
"style": {"color": _RED, "lw": 1.6, "marker": "o", "ms": 2.4, "alpha": 1.0, "band_alpha": 0.13,
|
|
137
|
+
"width": 0.6, "cmap": "viridis", "bins": 20, "colorbar": False, "elev": 30, "azim": -60, "dist": 10},
|
|
138
|
+
"extra_lines": [],
|
|
139
|
+
"axes": {
|
|
140
|
+
"xticks": "auto", "xticklabels": None, "yticks": "auto", "yticklabels": None,
|
|
141
|
+
"xlabel": "", "ylabel": "", "xlabel_visible": True, "ylabel_visible": True,
|
|
142
|
+
"xlim": None, "ylim": None, "xscale": "linear", "yscale": "linear",
|
|
143
|
+
"spines": {"top": False, "right": False, "bottom": True, "left": True},
|
|
144
|
+
# 12 to match the frontend _blankPlot default (canvas.js); the two
|
|
145
|
+
# factories must stay field-for-field equivalent.
|
|
146
|
+
"tick_labelsize": 12, "labelbottom": True, "labelleft": True,
|
|
147
|
+
},
|
|
148
|
+
"title": {"text": "", "x": 0.0, "y": 1.0, "pad": 1.0, "fontsize": 11, "weight": "normal",
|
|
149
|
+
"color": "#000000", "visible": False},
|
|
150
|
+
"artists": [],
|
|
151
|
+
}
|
|
152
|
+
p.update(kw)
|
|
153
|
+
return p
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _num(v, default):
|
|
157
|
+
"""Coerce a client-supplied value to a finite float, else ``default``.
|
|
158
|
+
|
|
159
|
+
The render API accepts a client-controlled spec, so every numeric field that
|
|
160
|
+
lands in the generated code goes through this (or _numlist) first: junk in a
|
|
161
|
+
saved/POSTed spec must blank a panel, never 500 the render or emit code that
|
|
162
|
+
crashes at exec time."""
|
|
163
|
+
try:
|
|
164
|
+
f = float(v)
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
return default
|
|
167
|
+
return f if np.isfinite(f) else default
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _numlist(v):
|
|
171
|
+
"""Coerce a tick/limit list to a list of finite floats; None if nothing usable."""
|
|
172
|
+
if not isinstance(v, (list, tuple)) or not v:
|
|
173
|
+
return None
|
|
174
|
+
out = []
|
|
175
|
+
for x in v:
|
|
176
|
+
try:
|
|
177
|
+
f = float(x)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
continue
|
|
180
|
+
if np.isfinite(f):
|
|
181
|
+
out.append(f)
|
|
182
|
+
return out or None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _bins(st):
|
|
186
|
+
"""A positive integer bin count from style, defaulting to 20 on junk."""
|
|
187
|
+
b = _num(st.get("bins"), 20)
|
|
188
|
+
return int(b) if b > 0 else 20
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- codegen (the single drawing path)
|
|
192
|
+
def codegen_canvas(spec, detail=True):
|
|
193
|
+
# A spec can be client-controlled (POST /render) or hand-edited; missing or
|
|
194
|
+
# junk top-level fields must degrade to blanks, never a 500.
|
|
195
|
+
c = spec.get("canvas") or blank_spec()["canvas"]
|
|
196
|
+
# clamp to sane positive values: figsize/dpi/font-size reject 0/negative at
|
|
197
|
+
# exec time, and a client spec can carry anything
|
|
198
|
+
width = max(0.1, _num(c.get("width"), 7.4))
|
|
199
|
+
height = max(0.1, _num(c.get("height"), 4.5))
|
|
200
|
+
dpi = max(1, int(_num(c.get("dpi"), PREVIEW_DPI)))
|
|
201
|
+
font_size = max(1.0, _num(c.get("font_size"), 14))
|
|
202
|
+
s = []
|
|
203
|
+
ds = spec.get("dataset")
|
|
204
|
+
ref = f"dataset {ds!r}" if ds else "no dataset (load data first)"
|
|
205
|
+
s.append(f'"""Canvas figure -- generated by Fig Foundry. WYSIWYG: references {ref}."""')
|
|
206
|
+
s.append('import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt')
|
|
207
|
+
s.append("from matplotlib.lines import Line2D")
|
|
208
|
+
s.append("from matplotlib.patches import Rectangle, Circle, Ellipse, Polygon")
|
|
209
|
+
s.append("")
|
|
210
|
+
s.append("# ---- data (dataset) ----")
|
|
211
|
+
if ds:
|
|
212
|
+
s.extend(dataset_loader_code(ds))
|
|
213
|
+
else:
|
|
214
|
+
s.append("data = {} # no dataset loaded")
|
|
215
|
+
s.append("")
|
|
216
|
+
s.append("def build():")
|
|
217
|
+
s.append(f' plt.rcParams["font.family"] = {c.get("font_family", "DejaVu Sans")!r}; plt.rcParams["font.size"] = {font_size!r}')
|
|
218
|
+
s.append(f" fig = plt.figure(figsize=({width!r}, {height!r}), dpi={dpi})")
|
|
219
|
+
s.append(f' fig.patch.set_facecolor({c.get("bg", "#ffffff")!r})')
|
|
220
|
+
for p in spec.get("plots") or []:
|
|
221
|
+
i0 = len(s)
|
|
222
|
+
try:
|
|
223
|
+
s.append(f" # ---- plot {p['id']} ----")
|
|
224
|
+
t = p.get("type", "line")
|
|
225
|
+
if t in ("scatter-3d", "surface", "wireframe"):
|
|
226
|
+
proj = "projection='3d'"
|
|
227
|
+
elif t == "polar":
|
|
228
|
+
proj = "projection='polar'"
|
|
229
|
+
else:
|
|
230
|
+
proj = ""
|
|
231
|
+
s.append(f" ax = fig.add_axes({[float(v) for v in p['rect']]!r}" + (f", {proj})" if proj else ")"))
|
|
232
|
+
if detail:
|
|
233
|
+
_codegen_axes(s, p.get("axes") or {})
|
|
234
|
+
d = p.get("data") or {}
|
|
235
|
+
xcode = ref_to_code(d.get("x"))
|
|
236
|
+
ycode = ref_to_code(d.get("y"))
|
|
237
|
+
zcode = ref_to_code(d.get("z"))
|
|
238
|
+
fcode = ref_to_code(d.get("field"))
|
|
239
|
+
st = p.get("style") or {}
|
|
240
|
+
_codegen_plot(s, p, detail, xcode, ycode, zcode, fcode, st)
|
|
241
|
+
if t in ("line", "scatter", "bar"):
|
|
242
|
+
for el in p.get("extra_lines") or []:
|
|
243
|
+
if not isinstance(el, dict):
|
|
244
|
+
continue
|
|
245
|
+
ex = ref_to_code(el.get("ref"))
|
|
246
|
+
emk = el.get("marker", "") if detail else ""
|
|
247
|
+
# x falls back to the index (like the main line call) so an x-less
|
|
248
|
+
# plot still draws its extra lines; skip if either signal is missing.
|
|
249
|
+
ex_x = f"np.arange({ycode}.shape[0])" if xcode == "None" else xcode
|
|
250
|
+
s.append(f" if {ycode} is not None and {ex} is not None: ax.plot({ex_x}, {ex}, color={el.get('color', _BLUE)!r}, lw={_num(el.get('lw'), 1.5)!r}, marker={emk!r}, ms={_num(el.get('ms'), 0)!r}, alpha={_num(el.get('alpha'), 1.0)!r}, zorder=3, label={el.get('label', '')!r})")
|
|
251
|
+
band = d.get("band")
|
|
252
|
+
if detail and t in ("line", "scatter") and isinstance(band, str) and band.startswith("@") and ycode != "None":
|
|
253
|
+
secode = ref_to_code(band)
|
|
254
|
+
# guard: only draw if the band signal matches the x/y length (else skip,
|
|
255
|
+
# don't break). Compare shapes only for the signals that exist (x may be
|
|
256
|
+
# None).
|
|
257
|
+
conds = [f"{ycode}.shape == _se.shape"]
|
|
258
|
+
band_x = xcode
|
|
259
|
+
if xcode != "None":
|
|
260
|
+
conds.append(f"{xcode}.shape == _se.shape")
|
|
261
|
+
else:
|
|
262
|
+
band_x = f"np.arange({ycode}.shape[0])" # index fallback (fill_between needs an x)
|
|
263
|
+
s.append(f" _se = {secode}")
|
|
264
|
+
s.append(f" if {' and '.join(conds)}: ax.fill_between({band_x}, {ycode} - _se, {ycode} + _se, color={st.get('color', _RED)!r}, alpha={_num(st.get('band_alpha'), 0.13)!r}, lw=0, zorder=2)")
|
|
265
|
+
if detail and t in ("line", "scatter") and ycode != "None":
|
|
266
|
+
eb = d.get("errorbar")
|
|
267
|
+
er = eb.get("ref") if isinstance(eb, dict) else None
|
|
268
|
+
if isinstance(er, str) and er.startswith("@"):
|
|
269
|
+
ecode = ref_to_code(er)
|
|
270
|
+
conds = [f"{ycode}.shape == _eb.shape"]
|
|
271
|
+
eb_x = xcode
|
|
272
|
+
if xcode != "None":
|
|
273
|
+
conds.append(f"{xcode}.shape == _eb.shape")
|
|
274
|
+
else:
|
|
275
|
+
eb_x = f"np.arange({ycode}.shape[0])" # index fallback (errorbar needs an x)
|
|
276
|
+
s.append(f" _eb = {ecode}")
|
|
277
|
+
s.append(f" if {' and '.join(conds)}: ax.errorbar({eb_x}, {ycode}, yerr=_eb, fmt='none', ecolor={eb.get('color', st.get('color', _RED))!r}, elinewidth={_num(eb.get('lw'), 1.0)!r}, capsize=2, zorder=2)")
|
|
278
|
+
for ai, art in enumerate((p.get("artists") if detail else []) or []):
|
|
279
|
+
try:
|
|
280
|
+
s.append(" " + _codegen_artist(art))
|
|
281
|
+
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
|
|
282
|
+
s.append(f" pass # artist {ai} skipped (invalid)")
|
|
283
|
+
if detail:
|
|
284
|
+
tt = p.get("title") or {}
|
|
285
|
+
if tt.get("visible"):
|
|
286
|
+
w = tt.get("weight", "normal")
|
|
287
|
+
w = w if w in ("normal", "bold", "light", "medium", "heavy", "semibold", "black") else "normal"
|
|
288
|
+
s.append(f' ax.set_title({tt.get("text", "")!r}, x={_num(tt.get("x"), 0.0)!r}, y={_num(tt.get("y"), 1.0)!r}, pad={_num(tt.get("pad"), 1.0)!r}, fontsize={_num(tt.get("fontsize"), 11)!r}, fontweight={w!r}, color={tt.get("color", "#000000")!r})')
|
|
289
|
+
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
|
|
290
|
+
# One malformed plot blanks itself out; it must not sink the whole render.
|
|
291
|
+
pid = p.get("id", "?") if isinstance(p, dict) else "?"
|
|
292
|
+
s[i0:] = [f" pass # plot {pid!r} skipped (invalid spec)"]
|
|
293
|
+
if detail:
|
|
294
|
+
fas = spec.get("figure_artists") or []
|
|
295
|
+
if fas:
|
|
296
|
+
s.append(" # ---- figure-level artists ----")
|
|
297
|
+
for j, fa in enumerate(fas):
|
|
298
|
+
try:
|
|
299
|
+
s.append(" " + _codegen_figartist(fa))
|
|
300
|
+
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
|
|
301
|
+
s.append(f" pass # figure artist {j} skipped (invalid)")
|
|
302
|
+
s.append(" return fig")
|
|
303
|
+
s.append("")
|
|
304
|
+
s.append('if __name__ == "__main__":')
|
|
305
|
+
s.append(' import argparse')
|
|
306
|
+
s.append(' ap = argparse.ArgumentParser(description=__doc__)')
|
|
307
|
+
s.append(' ap.add_argument("--format", default="png", choices=["png", "svg", "pdf"])')
|
|
308
|
+
s.append(f" ap.add_argument(\"--dpi\", type=int, default={dpi})")
|
|
309
|
+
s.append(" a = ap.parse_args()")
|
|
310
|
+
s.append(' fig = build(); fig.savefig(f"fig.{a.format}", dpi=a.dpi)')
|
|
311
|
+
return "\n".join(s) + "\n"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _view3d(s, st):
|
|
315
|
+
"""Emit the 3-D camera: rotation (view_init) + zoom (dist). dist uses the
|
|
316
|
+
(private) camera-distance attribute, guarded so a matplotlib change can't
|
|
317
|
+
break the render."""
|
|
318
|
+
s.append(f" ax.view_init(elev={_num(st.get('elev'), 30)!r}, azim={_num(st.get('azim'), -60)!r})")
|
|
319
|
+
s.append(f" try: ax._dist = {_num(st.get('dist'), 10)!r}")
|
|
320
|
+
s.append(" except Exception: pass")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _codegen_plot(s, p, detail, xcode, ycode, zcode, fcode, st):
|
|
324
|
+
"""Emit the primary data call for a plot, dispatched on its type.
|
|
325
|
+
xcode/ycode are 1-D (or 2-D for vector fields), zcode a group/class signal,
|
|
326
|
+
fcode a 2-D field. Each type uses only the codes it needs. Guards keep the
|
|
327
|
+
generated code from crashing on mismatched / empty / all-NaN signals (the UI
|
|
328
|
+
lets any signal drop into any slot): a skipped call leaves a blank panel,
|
|
329
|
+
never a broken render, and preview and export stay identical."""
|
|
330
|
+
t = p.get("type", "line")
|
|
331
|
+
color = st.get("color", _RED)
|
|
332
|
+
lw = _num(st.get("lw"), 1.6)
|
|
333
|
+
ms = _num(st.get("ms"), 0)
|
|
334
|
+
alpha = _num(st.get("alpha"), 1.0)
|
|
335
|
+
width = _num(st.get("width"), 0.6)
|
|
336
|
+
cmap = st.get("cmap", "viridis")
|
|
337
|
+
bins = _bins(st)
|
|
338
|
+
cb = detail and st.get("colorbar")
|
|
339
|
+
# pair guards: user-supplied x and y must agree in length (or shape, for
|
|
340
|
+
# vector fields); z must agree with the plotted points.
|
|
341
|
+
xy_ok = "" if xcode == "None" else f" and len({xcode}) == len({ycode})"
|
|
342
|
+
z_ok = "" if zcode == "None" else f" and len({zcode}) == len({ycode})"
|
|
343
|
+
field2d = f"{fcode} is not None and np.ndim({fcode}) == 2"
|
|
344
|
+
if t == "line":
|
|
345
|
+
mk = st.get("marker", "") if detail else ""
|
|
346
|
+
xy = ycode if xcode == "None" else f"{xcode}, {ycode}"
|
|
347
|
+
s.append(f" if {ycode} is not None{xy_ok}: ax.plot({xy}, color={color!r}, lw={lw!r}, marker={mk!r}, ms={ms!r}, alpha={alpha!r}, zorder=3)")
|
|
348
|
+
elif t == "step":
|
|
349
|
+
# step() (unlike plot()) requires both x and y, so the index fallback must pair them
|
|
350
|
+
xy = f"np.arange({ycode}.shape[0]), {ycode}" if xcode == "None" else f"{xcode}, {ycode}"
|
|
351
|
+
s.append(f" if {ycode} is not None{xy_ok}: ax.step({xy}, color={color!r}, lw={lw!r}, alpha={alpha!r}, where='pre', zorder=3)")
|
|
352
|
+
elif t == "scatter":
|
|
353
|
+
# scatter() requires both x and y as positional args
|
|
354
|
+
xarg = f"np.arange({ycode}.shape[0]), {ycode}" if xcode == "None" else f"{xcode}, {ycode}"
|
|
355
|
+
s.append(f" if {ycode} is not None{xy_ok}: ax.scatter({xarg}, color={color!r}, s=max(1, {ms!r} ** 2), alpha={alpha!r}, zorder=3)")
|
|
356
|
+
elif t == "scatter-grouped":
|
|
357
|
+
xarg = f"np.arange({ycode}.shape[0])" if xcode == "None" else xcode
|
|
358
|
+
g = f"{ycode} is not None{xy_ok}{z_ok}"
|
|
359
|
+
s.append(f" if {g}: _m = ax.scatter({xarg}, {ycode}, c={zcode}, cmap={cmap!r}, s=max(1, {ms!r} ** 2), edgecolors='none', alpha={alpha!r}, zorder=3)")
|
|
360
|
+
if cb:
|
|
361
|
+
s.append(f" if {g}: fig.colorbar(_m, ax=ax, pad=0.02)")
|
|
362
|
+
elif t == "bar":
|
|
363
|
+
xarg = f"np.arange({ycode}.shape[0])" if xcode == "None" else xcode
|
|
364
|
+
s.append(f" if {ycode} is not None{xy_ok}: ax.bar({xarg}, {ycode}, color={color!r}, width={width!r}, alpha={alpha!r})")
|
|
365
|
+
elif t == "barh":
|
|
366
|
+
yarg = f"np.arange({xcode}.shape[0])" if ycode == "None" else ycode
|
|
367
|
+
yx_ok = "" if ycode == "None" else f" and len({ycode}) == len({xcode})"
|
|
368
|
+
s.append(f" if {xcode} is not None{yx_ok}: ax.barh({yarg}, {xcode}, color={color!r}, height={width!r}, alpha={alpha!r})")
|
|
369
|
+
elif t == "histogram":
|
|
370
|
+
# hist() raises when the data range is not finite (all-NaN / +-inf)
|
|
371
|
+
s.append(f" if {ycode} is not None and np.isfinite({ycode}).any(): ax.hist({ycode}, bins={bins!r}, color={color!r}, alpha={alpha!r}, edgecolor='none')")
|
|
372
|
+
elif t in ("boxplot", "violinplot"):
|
|
373
|
+
kw = "patch_artist=True" if t == "boxplot" else "showmedians=True"
|
|
374
|
+
if xcode == "None":
|
|
375
|
+
s.append(f" if {ycode} is not None: ax.{t}({ycode}, {kw})")
|
|
376
|
+
else:
|
|
377
|
+
# group by 1-D keys only (2-D group rows are unhashable; fall back to ungrouped)
|
|
378
|
+
s.append(f" if {ycode} is not None and np.ndim({xcode}) == 1:")
|
|
379
|
+
s.append(" _g = {}")
|
|
380
|
+
s.append(f" for _gx, _gy in zip({xcode}, {ycode}): _g.setdefault(_gx, []).append(_gy)")
|
|
381
|
+
s.append(" _ks = sorted(_g)")
|
|
382
|
+
if t == "boxplot":
|
|
383
|
+
s.append(" ax.boxplot([_g[k] for k in _ks], tick_labels=[str(k) for k in _ks], patch_artist=True)")
|
|
384
|
+
else:
|
|
385
|
+
s.append(" ax.violinplot([_g[k] for k in _ks], showmedians=True)")
|
|
386
|
+
s.append(f" elif {ycode} is not None: ax.{t}({ycode}, {kw})")
|
|
387
|
+
elif t == "hist2d":
|
|
388
|
+
xarg = f"np.arange({ycode}.shape[0])" if xcode == "None" else xcode
|
|
389
|
+
g = (f"{ycode} is not None and np.ndim({xarg}) == 1 and len({xarg}) == len({ycode})"
|
|
390
|
+
f" and np.isfinite({xarg}).any() and np.isfinite({ycode}).any()")
|
|
391
|
+
s.append(f" if {g}: _h = ax.hist2d({xarg}, {ycode}, bins={bins!r}, cmap={cmap!r})")
|
|
392
|
+
if cb:
|
|
393
|
+
s.append(f" if {g}: fig.colorbar(_h[3], ax=ax, pad=0.02)")
|
|
394
|
+
elif t == "hexbin":
|
|
395
|
+
xarg = f"np.arange({ycode}.shape[0])" if xcode == "None" else xcode
|
|
396
|
+
g = f"{ycode} is not None and np.ndim({xarg}) == 1 and len({xarg}) == len({ycode})"
|
|
397
|
+
s.append(f" if {g}: _m = ax.hexbin({xarg}, {ycode}, gridsize={bins!r}, cmap={cmap!r})")
|
|
398
|
+
if cb:
|
|
399
|
+
s.append(f" if {g}: fig.colorbar(_m, ax=ax, pad=0.02)")
|
|
400
|
+
elif t == "imshow":
|
|
401
|
+
g = field2d
|
|
402
|
+
s.append(f" if {g}: _m = ax.imshow({fcode}, cmap={cmap!r}, origin='upper')")
|
|
403
|
+
if cb:
|
|
404
|
+
s.append(f" if {g}: fig.colorbar(_m, ax=ax, pad=0.02)")
|
|
405
|
+
elif t in ("contourf", "contour"):
|
|
406
|
+
# contour() needs at least a (2, 2) field
|
|
407
|
+
g = f"{field2d} and {fcode}.shape[0] >= 2 and {fcode}.shape[1] >= 2"
|
|
408
|
+
s.append(f" if {g}: _m = ax.{t}({fcode}, cmap={cmap!r})")
|
|
409
|
+
if cb:
|
|
410
|
+
s.append(f" if {g}: fig.colorbar(_m, ax=ax, pad=0.02)")
|
|
411
|
+
elif t == "pcolormesh":
|
|
412
|
+
g = field2d
|
|
413
|
+
s.append(f" if {g}: _m = ax.pcolormesh({fcode}, cmap={cmap!r})")
|
|
414
|
+
if cb:
|
|
415
|
+
s.append(f" if {g}: fig.colorbar(_m, ax=ax, pad=0.02)")
|
|
416
|
+
elif t == "quiver":
|
|
417
|
+
# 2-arg quiver(U, V): x slot = U field, y slot = V field (per the UI); U and V
|
|
418
|
+
# must share a shape (1-D: equal length) or matplotlib raises.
|
|
419
|
+
g = f"{xcode} is not None and {ycode} is not None and np.shape({xcode}) == np.shape({ycode})"
|
|
420
|
+
s.append(f" if {g}: ax.quiver({xcode}, {ycode}, color={color!r})")
|
|
421
|
+
elif t == "streamplot":
|
|
422
|
+
g = (f"{xcode} is not None and {ycode} is not None and np.ndim({xcode}) == 2 and np.ndim({ycode}) == 2"
|
|
423
|
+
f" and np.shape({xcode}) == np.shape({ycode})")
|
|
424
|
+
s.append(f" if {g}: _gx, _gy = np.meshgrid(np.arange({xcode}.shape[1]), np.arange({xcode}.shape[0])); ax.streamplot(_gx, _gy, {xcode}, {ycode}, color={color!r})")
|
|
425
|
+
elif t == "pie":
|
|
426
|
+
vals = zcode if zcode != "None" else ycode
|
|
427
|
+
# pie() rejects negative/zero-sum wedges, non-numeric and non-1-D values;
|
|
428
|
+
# skip, don't break
|
|
429
|
+
g = (f"{vals} is not None and len({vals}) > 0 and np.ndim({vals}) == 1"
|
|
430
|
+
f" and np.issubdtype(np.asarray({vals}).dtype, np.number)"
|
|
431
|
+
f" and np.all({vals} >= 0) and np.sum({vals}) > 0")
|
|
432
|
+
s.append(f" if {g}: ax.pie({vals}, autopct='%1.1f%%', startangle=90)")
|
|
433
|
+
elif t == "polar":
|
|
434
|
+
xy = ycode if xcode == "None" else f"{xcode}, {ycode}"
|
|
435
|
+
s.append(f" if {ycode} is not None{xy_ok}: ax.plot({xy}, color={color!r}, lw={lw!r})")
|
|
436
|
+
elif t == "scatter-3d":
|
|
437
|
+
g = f"{xcode} is not None and {ycode} is not None and {zcode} is not None and len({xcode}) == len({ycode}) == len({zcode})"
|
|
438
|
+
s.append(f" if {g}: ax.scatter({xcode}, {ycode}, {zcode}, color={color!r}, s=max(1, {ms!r} ** 2))")
|
|
439
|
+
_view3d(s, st)
|
|
440
|
+
elif t == "surface":
|
|
441
|
+
s.append(f" if {field2d}: _X, _Y = np.meshgrid(np.arange({fcode}.shape[1]), np.arange({fcode}.shape[0])); ax.plot_surface(_X, _Y, {fcode}, cmap={cmap!r})")
|
|
442
|
+
_view3d(s, st)
|
|
443
|
+
elif t == "wireframe":
|
|
444
|
+
s.append(f" if {field2d}: _X, _Y = np.meshgrid(np.arange({fcode}.shape[1]), np.arange({fcode}.shape[0])); ax.plot_wireframe(_X, _Y, {fcode}, color={color!r})")
|
|
445
|
+
_view3d(s, st)
|
|
446
|
+
elif t == "decision-boundary":
|
|
447
|
+
g = f"{field2d} and {fcode}.shape[0] >= 2 and {fcode}.shape[1] >= 2"
|
|
448
|
+
s.append(f" if {g}: ax.contourf({fcode}, cmap='coolwarm', alpha=0.45, zorder=1)")
|
|
449
|
+
sg = f"{xcode} is not None and {ycode} is not None{xy_ok}{z_ok}"
|
|
450
|
+
s.append(f" if {sg}: _m = ax.scatter({xcode}, {ycode}, c={zcode}, cmap='coolwarm', edgecolors='k', linewidths=0.5, s=max(1, {ms!r} ** 2), zorder=3)")
|
|
451
|
+
if cb:
|
|
452
|
+
s.append(f" if {sg}: fig.colorbar(_m, ax=ax, pad=0.02)")
|
|
453
|
+
else:
|
|
454
|
+
# unknown type: fall back to a plain line (skip when there is no y)
|
|
455
|
+
xy = ycode if xcode == "None" else f"{xcode}, {ycode}"
|
|
456
|
+
s.append(f" if {ycode} is not None{xy_ok}: ax.plot({xy}) # type {t!r} not emitted -> line fallback")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _codegen_axes(s, A):
|
|
460
|
+
if not isinstance(A, dict):
|
|
461
|
+
A = {}
|
|
462
|
+
# Ticks/limits are sanitized to finite floats at codegen time (not inside a
|
|
463
|
+
# try in the emitted code): a junk value blanks the setting instead of
|
|
464
|
+
# raising here (500) or at exec time (ConversionError).
|
|
465
|
+
tl = _numlist(A.get("xticks"))
|
|
466
|
+
if tl is not None:
|
|
467
|
+
s.append(f" ax.set_xticks({tl!r})")
|
|
468
|
+
if A.get("xticklabels") is not None:
|
|
469
|
+
s.append(f" ax.set_xticklabels({A['xticklabels']!r})")
|
|
470
|
+
yl = _numlist(A.get("yticks"))
|
|
471
|
+
if yl is not None:
|
|
472
|
+
s.append(f" ax.set_yticks({yl!r})")
|
|
473
|
+
if A.get("yticklabels") is not None:
|
|
474
|
+
s.append(f" ax.set_yticklabels({A['yticklabels']!r})")
|
|
475
|
+
xl = _numlist(A.get("xlim"))
|
|
476
|
+
if xl is not None and len(xl) >= 2:
|
|
477
|
+
s.append(f" ax.set_xlim({xl[0]!r}, {xl[1]!r})")
|
|
478
|
+
yl2 = _numlist(A.get("ylim"))
|
|
479
|
+
if yl2 is not None and len(yl2) >= 2:
|
|
480
|
+
s.append(f" ax.set_ylim({yl2[0]!r}, {yl2[1]!r})")
|
|
481
|
+
if A.get("xscale") in ("log", "symlog", "logit"):
|
|
482
|
+
s.append(f" ax.set_xscale({A['xscale']!r})")
|
|
483
|
+
if A.get("yscale") in ("log", "symlog", "logit"):
|
|
484
|
+
s.append(f" ax.set_yscale({A['yscale']!r})")
|
|
485
|
+
s.append(f" ax.set_xlabel({A.get('xlabel', '')!r}, visible={A.get('xlabel_visible', True)})")
|
|
486
|
+
s.append(f" ax.set_ylabel({A.get('ylabel', '')!r}, visible={A.get('ylabel_visible', True)})")
|
|
487
|
+
sp = A.get("spines")
|
|
488
|
+
if not isinstance(sp, dict):
|
|
489
|
+
sp = {}
|
|
490
|
+
vis = {k: bool(sp.get(k, True)) for k in ("top", "right", "bottom", "left")}
|
|
491
|
+
s.append(f" for _s, _v in {vis!r}.items():")
|
|
492
|
+
s.append(" try: ax.spines[_s].set_visible(_v)")
|
|
493
|
+
s.append(" except KeyError: pass")
|
|
494
|
+
ax_ = [f"labelbottom={A.get('labelbottom', True)!r}", f"labelleft={A.get('labelleft', True)!r}"]
|
|
495
|
+
tsize = _num(A.get("tick_labelsize"), 0)
|
|
496
|
+
if tsize > 0:
|
|
497
|
+
ax_.append(f"labelsize={tsize!r}")
|
|
498
|
+
s.append(" ax.tick_params(" + ", ".join(ax_) + ")")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _codegen_artist(art):
|
|
502
|
+
t = art["type"]
|
|
503
|
+
if t == "vline":
|
|
504
|
+
return f'ax.axvline({float(art["x"])!r}, color={art.get("color", "#000000")!r}, ls={art.get("ls", "-")!r}, lw={_num(art.get("lw"), 1.0)!r}, alpha={_num(art.get("alpha"), 1.0)!r}, zorder={int(_num(art.get("zorder"), 2))!r})'
|
|
505
|
+
if t == "hline":
|
|
506
|
+
return f'ax.axhline({float(art["y"])!r}, color={art.get("color", "#000000")!r}, ls={art.get("ls", "-")!r}, lw={_num(art.get("lw"), 1.0)!r}, alpha={_num(art.get("alpha"), 1.0)!r}, zorder={int(_num(art.get("zorder"), 2))!r})'
|
|
507
|
+
if t == "text":
|
|
508
|
+
base = f'ax.text({float(art["x"])!r}, {float(art["y"])!r}, {art.get("text", "")!r}'
|
|
509
|
+
if art.get("axes_frac"):
|
|
510
|
+
base += ", transform=ax.transAxes"
|
|
511
|
+
base += f', ha={art.get("ha", "left")!r}, va={art.get("va", "bottom")!r}, fontsize={_num(art.get("fontsize"), 10)!r}, color={art.get("color", "#000000")!r})'
|
|
512
|
+
return base
|
|
513
|
+
if t == "legend":
|
|
514
|
+
h = ", ".join(f'Line2D([], [], color={x.get("color", "#000")!r}, ls={x.get("ls", "-")!r}, lw={_num(x.get("lw"), 1.5)!r}, marker={x.get("marker", "")!r}, ms={_num(x.get("ms"), 0)!r})' for x in art.get("handles", []) if isinstance(x, dict))
|
|
515
|
+
return f'ax.legend([{h}], {art.get("labels", [])!r}, loc={art.get("loc", "best")!r}, ncol={int(_num(art.get("ncol"), 1))!r}, frameon={art.get("frameon", True)!r}, fontsize={_num(art.get("fontsize"), 9)!r})'
|
|
516
|
+
if t == "annotation":
|
|
517
|
+
return (f'ax.annotate({art.get("text", "")!r}, xy=({float(art["xy"][0])!r}, {float(art["xy"][1])!r}), '
|
|
518
|
+
f'xytext=({float(art["xytext"][0])!r}, {float(art["xytext"][1])!r}), textcoords="axes fraction", '
|
|
519
|
+
f'arrowprops=dict(arrowstyle="->", color={art.get("color", "#000000")!r}, lw={_num(art.get("lw"), 1.2)!r}), '
|
|
520
|
+
f'color={art.get("color", "#000000")!r}, fontsize={_num(art.get("fontsize"), 10)!r})')
|
|
521
|
+
if t == "box":
|
|
522
|
+
return (f'ax.add_patch(Rectangle(({float(art["x0"])!r}, {float(art["y0"])!r}), {float(art["w"])!r}, {float(art["h"])!r}, '
|
|
523
|
+
f'transform=ax.transAxes, fc={art.get("fc", "none")!r}, alpha={_num(art.get("alpha"), 0.15)!r}, '
|
|
524
|
+
f'ec={art.get("ec", "#000000")!r}, lw={_num(art.get("lw"), 1.0)!r}))')
|
|
525
|
+
if t == "vspan":
|
|
526
|
+
return f'ax.axvspan({float(art["x0"])!r}, {float(art["x1"])!r}, color={art.get("color", "#b03a2e")!r}, alpha={_num(art.get("alpha"), 0.12)!r}, lw=0, zorder={int(_num(art.get("zorder"), 0))!r})'
|
|
527
|
+
if t == "circle":
|
|
528
|
+
return f'ax.add_patch(Circle(({float(art["x"])!r}, {float(art["y"])!r}), {float(art.get("r", 0.1))!r}, fill={art.get("fill", False)!r}, color={art.get("color", "#000000")!r}, lw={_num(art.get("lw"), 1.0)!r}, alpha={_num(art.get("alpha"), 1.0)!r}, zorder={int(_num(art.get("zorder"), 3))!r}))'
|
|
529
|
+
if t == "ellipse":
|
|
530
|
+
return f'ax.add_patch(Ellipse(({float(art["x"])!r}, {float(art["y"])!r}), {float(art.get("w", 0.2))!r}, {float(art.get("h", 0.1))!r}, angle={_num(art.get("angle"), 0)!r}, fill={art.get("fill", False)!r}, color={art.get("color", "#000000")!r}, lw={_num(art.get("lw"), 1.0)!r}, alpha={_num(art.get("alpha"), 1.0)!r}, zorder={int(_num(art.get("zorder"), 3))!r}))'
|
|
531
|
+
if t == "polygon":
|
|
532
|
+
pts = [[float(a), float(b)] for a, b in art.get("points", [[0, 0], [1, 0], [1, 1]])]
|
|
533
|
+
return f'ax.add_patch(Polygon({pts!r}, closed=True, fill={art.get("fill", False)!r}, color={art.get("color", "#000000")!r}, lw={_num(art.get("lw"), 1.0)!r}, alpha={_num(art.get("alpha"), 1.0)!r}, zorder={int(_num(art.get("zorder"), 3))!r}))'
|
|
534
|
+
if t == "arrow":
|
|
535
|
+
return f'ax.annotate("", xy=({float(art.get("x1", 1))!r}, {float(art.get("y1", 1))!r}), xytext=({float(art.get("x0", 0))!r}, {float(art.get("y0", 0))!r}), arrowprops=dict(arrowstyle="-|>", color={art.get("color", "#000000")!r}, lw={_num(art.get("lw"), 1.5)!r}))'
|
|
536
|
+
return f"pass # artist type {t!r} not emitted"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _codegen_figartist(fa):
|
|
540
|
+
t = fa["type"]
|
|
541
|
+
if t == "legend":
|
|
542
|
+
h = ", ".join(f'Line2D([], [], color={x.get("color", "#000")!r}, ls={x.get("ls", "-")!r}, lw={_num(x.get("lw"), 1.5)!r}, marker={x.get("marker", "")!r}, ms={_num(x.get("ms"), 0)!r})' for x in fa.get("handles", []) if isinstance(x, dict))
|
|
543
|
+
loc = fa.get("loc")
|
|
544
|
+
if isinstance(loc, list):
|
|
545
|
+
return f'fig.legend([{h}], {fa.get("labels", [])!r}, bbox_to_anchor=({float(loc[0])!r}, {float(loc[1])!r}), loc="center", ncol={int(_num(fa.get("ncol"), 1))!r}, frameon={fa.get("frameon", True)!r}, fontsize={_num(fa.get("fontsize"), 10)!r})'
|
|
546
|
+
return f'fig.legend([{h}], {fa.get("labels", [])!r}, loc={loc!r}, ncol={int(_num(fa.get("ncol"), 1))!r}, frameon={fa.get("frameon", True)!r}, fontsize={_num(fa.get("fontsize"), 10)!r})'
|
|
547
|
+
if t == "text":
|
|
548
|
+
return f'fig.text({float(fa["x"])!r}, {float(fa["y"])!r}, {fa.get("text", "")!r}, ha={fa.get("ha", "left")!r}, va={fa.get("va", "bottom")!r}, fontsize={_num(fa.get("fontsize"), 12)!r}, color={fa.get("color", "#000000")!r}, rotation={_num(fa.get("rotation"), 0)!r})'
|
|
549
|
+
if t == "suptitle":
|
|
550
|
+
return f'fig.suptitle({fa.get("text", "")!r}, x={_num(fa.get("x"), 0.5)!r}, y={_num(fa.get("y"), 1.0)!r}, fontsize={_num(fa.get("fontsize"), 14)!r}, color={fa.get("color", "#000000")!r})'
|
|
551
|
+
return f"pass # figure artist type {t!r} not emitted"
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# --------------------------------------------------------------------------- build (exec the code) -- the preview
|
|
555
|
+
def build_figure(spec, detail=True):
|
|
556
|
+
"""Build the figure by executing the generated code. This IS the export, so the
|
|
557
|
+
full-quality preview is byte-identical to the downloaded .py by construction.
|
|
558
|
+
detail=False skips decorative artists (markers/bands/vlines) for a fast draft."""
|
|
559
|
+
import matplotlib
|
|
560
|
+
matplotlib.use("Agg")
|
|
561
|
+
code = codegen_canvas(spec, detail=detail)
|
|
562
|
+
ns = {"__name__": "figfoundry_generated", "__file__": os.path.abspath(__file__)}
|
|
563
|
+
exec(compile(code, "<figfoundry>", "exec"), ns)
|
|
564
|
+
return ns["build"]()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _is_data_axes(ax):
|
|
568
|
+
from matplotlib.axes import Axes
|
|
569
|
+
from matplotlib.colorbar import Colorbar
|
|
570
|
+
return isinstance(ax, Axes) and not isinstance(ax, Colorbar)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _plot_creates_axes(p):
|
|
574
|
+
"""Whether codegen emits an add_axes for this plot entry: it must be a dict
|
|
575
|
+
whose id and rect are well-formed. Those are the only fields read before
|
|
576
|
+
add_axes; everything after it is non-raising by construction (sanitized
|
|
577
|
+
numbers, .get everywhere, per-artist nets). hotspots_from_fig uses this to
|
|
578
|
+
stay aligned with the axes build() actually creates."""
|
|
579
|
+
if not isinstance(p, dict):
|
|
580
|
+
return False
|
|
581
|
+
try:
|
|
582
|
+
_ = p["id"]
|
|
583
|
+
[float(v) for v in p["rect"]]
|
|
584
|
+
return True
|
|
585
|
+
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
|
|
586
|
+
return False
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def hotspots_from_fig(fig, spec):
|
|
590
|
+
"""Walk the built figure to get pixel bboxes (top-origin) for each editable element."""
|
|
591
|
+
rend = fig.canvas.get_renderer()
|
|
592
|
+
img_h = round(fig.get_figheight() * fig.dpi)
|
|
593
|
+
hs = []
|
|
594
|
+
|
|
595
|
+
def add(hid, art):
|
|
596
|
+
if art is None:
|
|
597
|
+
return
|
|
598
|
+
try:
|
|
599
|
+
b = art.get_window_extent(rend)
|
|
600
|
+
except Exception:
|
|
601
|
+
return
|
|
602
|
+
hs.append({"id": hid, "x": round(b.x0, 1), "top": round(img_h - b.y1, 1),
|
|
603
|
+
"w": round(b.width, 1), "h": round(b.height, 1)})
|
|
604
|
+
|
|
605
|
+
# axes_list aligns 1:1 with the well-formed plots in spec order (codegen
|
|
606
|
+
# creates one add_axes per such plot), so skip entries codegen skips.
|
|
607
|
+
axes_list = [ax for ax in fig.axes if _is_data_axes(ax)]
|
|
608
|
+
ax_i = 0
|
|
609
|
+
for p in spec.get("plots") or []:
|
|
610
|
+
if not _plot_creates_axes(p):
|
|
611
|
+
continue
|
|
612
|
+
if ax_i >= len(axes_list):
|
|
613
|
+
break
|
|
614
|
+
ax = axes_list[ax_i]
|
|
615
|
+
ax_i += 1
|
|
616
|
+
pid = p.get("id")
|
|
617
|
+
add(f"plot:{pid}", ax)
|
|
618
|
+
if (p.get("title") or {}).get("visible"):
|
|
619
|
+
add(f"title:{pid}", ax.title)
|
|
620
|
+
# per-plot artists / overlaid lines are selected from the Layers list (robust),
|
|
621
|
+
# not by canvas hit-testing (ax.lines is polluted by extra lines + error bars).
|
|
622
|
+
# fig.texts also holds the suptitle (Figure.suptitle appends to it), so exclude it
|
|
623
|
+
# or the text index mapping shifts by one whenever a suptitle is present.
|
|
624
|
+
# (Axes-level legends from plot artists do NOT enter fig.legends in
|
|
625
|
+
# matplotlib >= 3.x, so the figure-legend index below stays aligned.)
|
|
626
|
+
fig_texts = [t for t in fig.texts if t is not getattr(fig, "_suptitle", None)]
|
|
627
|
+
leg_i = txt_i = 0
|
|
628
|
+
for j, fa in enumerate(spec.get("figure_artists") or []):
|
|
629
|
+
t = fa.get("type") if isinstance(fa, dict) else None
|
|
630
|
+
if t == "legend":
|
|
631
|
+
a = fig.legends[leg_i] if leg_i < len(fig.legends) else None
|
|
632
|
+
leg_i += 1
|
|
633
|
+
elif t == "suptitle":
|
|
634
|
+
a = getattr(fig, "_suptitle", None)
|
|
635
|
+
else:
|
|
636
|
+
a = fig_texts[txt_i] if txt_i < len(fig_texts) else None
|
|
637
|
+
txt_i += 1
|
|
638
|
+
add(f"figartist:{j}", a)
|
|
639
|
+
return hs
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# Draft = FULL detail (all markers/bands/vlines/titles/legend) at high-ish DPI + light JPEG,
|
|
643
|
+
# so the figure always looks complete during interaction -- only a touch softer than the full
|
|
644
|
+
# 150-DPI PNG. The matplotlib build is the cost (DPI is nearly free), so this stays fast.
|
|
645
|
+
DRAFT_DPI = 130
|
|
646
|
+
DRAFT_JPEG_Q = 85
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def render_to_png(spec, draft=False):
|
|
650
|
+
"""Render the scene (by executing the generated code) to a preview + hotspots.
|
|
651
|
+
draft=True -> lower-res JPEG (full detail) for live interaction; else full-res PNG."""
|
|
652
|
+
import matplotlib.pyplot as plt
|
|
653
|
+
dpi = DRAFT_DPI if draft else PREVIEW_DPI
|
|
654
|
+
fig = build_figure(spec, detail=True) # always full detail -- never strip the figure
|
|
655
|
+
try:
|
|
656
|
+
fig.set_dpi(dpi)
|
|
657
|
+
# report size at the reference DPI so draft (low-res) and full display at the same
|
|
658
|
+
# on-screen size: the draft is stretched (blurry), the full is sharp.
|
|
659
|
+
img_w = round(fig.get_figwidth() * PREVIEW_DPI)
|
|
660
|
+
img_h = round(fig.get_figheight() * PREVIEW_DPI)
|
|
661
|
+
buf = io.BytesIO()
|
|
662
|
+
if draft:
|
|
663
|
+
# no explicit draw (print_figure draws); no hotspots -> fastest path
|
|
664
|
+
fig.canvas.print_figure(buf, format="jpeg", dpi=dpi, facecolor="white", pil_kwargs={"quality": DRAFT_JPEG_Q})
|
|
665
|
+
mime, hotspots = "image/jpeg", []
|
|
666
|
+
else:
|
|
667
|
+
fig.canvas.draw()
|
|
668
|
+
hotspots = hotspots_from_fig(fig, spec)
|
|
669
|
+
fig.canvas.print_figure(buf, format="png", dpi=dpi)
|
|
670
|
+
mime = "image/png"
|
|
671
|
+
img = base64.b64encode(buf.getvalue()).decode()
|
|
672
|
+
finally:
|
|
673
|
+
plt.close(fig) # no figure leak even if a draw/save step raises
|
|
674
|
+
return {"img": img, "mime": mime, "img_w": img_w, "img_h": img_h, "hotspots": hotspots, "draft": draft, "spec": spec}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def render_to_bytes(spec, fmt="png", dpi=None):
|
|
678
|
+
"""Render the figure (by executing the generated code) to raw bytes.
|
|
679
|
+
fmt is one of png / svg / pdf -- svg and pdf are vector, for publication."""
|
|
680
|
+
import matplotlib.pyplot as plt
|
|
681
|
+
dpi = dpi or PREVIEW_DPI
|
|
682
|
+
fig = build_figure(spec, detail=True)
|
|
683
|
+
try:
|
|
684
|
+
fig.set_dpi(dpi)
|
|
685
|
+
fig.canvas.draw()
|
|
686
|
+
buf = io.BytesIO()
|
|
687
|
+
fig.canvas.print_figure(buf, format=fmt, dpi=dpi)
|
|
688
|
+
out = buf.getvalue()
|
|
689
|
+
finally:
|
|
690
|
+
plt.close(fig) # no figure leak even if a draw/save step raises
|
|
691
|
+
return out
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def get_palettes():
|
|
695
|
+
"""Curated + colormap-sampled palettes for suggesting accessible series colors.
|
|
696
|
+
Okabe-Ito is the standard colorblind-safe palette; the rest are sampled from
|
|
697
|
+
matplotlib's perceptually-uniform colormaps."""
|
|
698
|
+
import matplotlib.pyplot as plt
|
|
699
|
+
|
|
700
|
+
def stops(name, n=8):
|
|
701
|
+
cm = plt.get_cmap(name)
|
|
702
|
+
return [f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
|
|
703
|
+
for r, g, b in (cm(i / (n - 1))[:3] for i in range(n))]
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
"Okabe-Ito (colorblind-safe)": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000"],
|
|
707
|
+
"Bright": ["#0072B2", "#D55E00", "#009E73", "#F0E442", "#E69F00", "#CC79A7", "#56B4E9"],
|
|
708
|
+
"Viridis": stops("viridis"),
|
|
709
|
+
"Plasma": stops("plasma"),
|
|
710
|
+
"Cividis": stops("cividis"),
|
|
711
|
+
}
|