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/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
+ }