figfoundry 0.2.1__tar.gz → 0.3.1__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.
- {figfoundry-0.2.1 → figfoundry-0.3.1}/PKG-INFO +1 -1
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/canvas.py +55 -18
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/static/canvas.css +12 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/static/canvas.html +10 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/static/canvas.js +394 -90
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/static/index.html +1 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/static/style.css +17 -1
- {figfoundry-0.2.1 → figfoundry-0.3.1}/pyproject.toml +1 -1
- {figfoundry-0.2.1 → figfoundry-0.3.1}/uv.lock +1 -1
- {figfoundry-0.2.1 → figfoundry-0.3.1}/.gitattributes +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/.gitignore +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/LICENSE +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/README.md +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/__init__.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/__main__.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/app.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/cli.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/data_io.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.1}/figfoundry/datasets/sample.npz +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: figfoundry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: WYSIWYG matplotlib figure composer: load data (.npz/.pkl), drag signals onto a canvas to build plots, and export clean reproducible code.
|
|
5
5
|
Project-URL: Homepage, https://github.com/ductai199x/figfoundry
|
|
6
6
|
Project-URL: Repository, https://github.com/ductai199x/figfoundry
|
|
@@ -73,22 +73,34 @@ def dataset_loader_code(filename):
|
|
|
73
73
|
if filename.endswith(".npz"):
|
|
74
74
|
return [
|
|
75
75
|
"import numpy as np",
|
|
76
|
-
f"
|
|
77
|
-
"
|
|
76
|
+
f"DATA_DEFAULT = {path!r}",
|
|
77
|
+
"def _load_data(p):",
|
|
78
|
+
" _ds = np.load(p, allow_pickle=False)",
|
|
79
|
+
" return {k: _ds[k] for k in _ds.files}",
|
|
80
|
+
'if __name__ == "__main__":',
|
|
81
|
+
" data = None # loaded in the __main__ block below (via --data)",
|
|
82
|
+
"else:",
|
|
83
|
+
" data = _load_data(DATA_DEFAULT)",
|
|
78
84
|
]
|
|
79
85
|
if filename.endswith(".csv"):
|
|
80
86
|
return [
|
|
81
87
|
"import numpy as np, csv",
|
|
82
|
-
f"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
" for
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
88
|
+
f"DATA_DEFAULT = {path!r}",
|
|
89
|
+
"def _load_data(p):",
|
|
90
|
+
" _f = open(p, newline='', encoding='utf-8-sig'); _rows = [r for r in csv.reader(_f) if any(c.strip() for c in r)]; _f.close()",
|
|
91
|
+
" _data = {}",
|
|
92
|
+
" for _i, _c in enumerate(_rows[0] if _rows else []):",
|
|
93
|
+
" _v = []",
|
|
94
|
+
" for _r in _rows[1:]:",
|
|
95
|
+
" if _i < len(_r):",
|
|
96
|
+
" try: _v.append(float(_r[_i]))",
|
|
97
|
+
" except ValueError: pass",
|
|
98
|
+
" if _v: _data[_c.strip() or f'col{_i}'] = np.array(_v)",
|
|
99
|
+
" return _data",
|
|
100
|
+
'if __name__ == "__main__":',
|
|
101
|
+
" data = None # loaded in the __main__ block below (via --data)",
|
|
102
|
+
"else:",
|
|
103
|
+
" data = _load_data(DATA_DEFAULT)",
|
|
92
104
|
]
|
|
93
105
|
# .pkl -- embedded whitelist unpickler (numpy + plain containers; blocks
|
|
94
106
|
# os/subprocess/socket/ctypes and non-container builtins). The numpy
|
|
@@ -109,9 +121,15 @@ def dataset_loader_code(filename):
|
|
|
109
121
|
' raise pickle.UnpicklingError("blocked " + m + "." + n)',
|
|
110
122
|
"def _safe_load(p):",
|
|
111
123
|
' with open(p, "rb") as f: return _SafeUnpickler(f).load()',
|
|
112
|
-
f"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
124
|
+
f"DATA_DEFAULT = {path!r}",
|
|
125
|
+
"def _load_data(p):",
|
|
126
|
+
" _obj = _safe_load(p)",
|
|
127
|
+
" _items = _obj.items() if isinstance(_obj, dict) else [('data', _obj)]",
|
|
128
|
+
" return {str(k): np.asarray(v) for k, v in _items if np.asarray(v).dtype != object}",
|
|
129
|
+
'if __name__ == "__main__":',
|
|
130
|
+
" data = None # loaded in the __main__ block below (via --data)",
|
|
131
|
+
"else:",
|
|
132
|
+
" data = _load_data(DATA_DEFAULT)",
|
|
115
133
|
]
|
|
116
134
|
|
|
117
135
|
|
|
@@ -286,6 +304,9 @@ def codegen_canvas(spec, detail=True):
|
|
|
286
304
|
w = tt.get("weight", "normal")
|
|
287
305
|
w = w if w in ("normal", "bold", "light", "medium", "heavy", "semibold", "black") else "normal"
|
|
288
306
|
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})')
|
|
307
|
+
_trot = _num(tt.get("rotation"), 0)
|
|
308
|
+
if _trot:
|
|
309
|
+
s.append(f" ax.title.set_rotation({_trot!r})")
|
|
289
310
|
except (KeyError, TypeError, ValueError, IndexError, AttributeError):
|
|
290
311
|
# One malformed plot blanks itself out; it must not sink the whole render.
|
|
291
312
|
pid = p.get("id", "?") if isinstance(p, dict) else "?"
|
|
@@ -306,7 +327,12 @@ def codegen_canvas(spec, detail=True):
|
|
|
306
327
|
s.append(' ap = argparse.ArgumentParser(description=__doc__)')
|
|
307
328
|
s.append(' ap.add_argument("--format", default="png", choices=["png", "svg", "pdf"])')
|
|
308
329
|
s.append(f" ap.add_argument(\"--dpi\", type=int, default={dpi})")
|
|
330
|
+
if ds:
|
|
331
|
+
s.append(' ap.add_argument("--data", default=DATA_DEFAULT, help="path to the dataset file (default is where this figure was made)")')
|
|
309
332
|
s.append(" a = ap.parse_args()")
|
|
333
|
+
if ds:
|
|
334
|
+
s.append(" try: data = _load_data(a.data)")
|
|
335
|
+
s.append(' except FileNotFoundError as _e: raise SystemExit(f"dataset not found: {_e}; pass --data /path/to/your/dataset")')
|
|
310
336
|
s.append(' fig = build(); fig.savefig(f"fig.{a.format}", dpi=a.dpi)')
|
|
311
337
|
return "\n".join(s) + "\n"
|
|
312
338
|
|
|
@@ -508,7 +534,7 @@ def _codegen_artist(art):
|
|
|
508
534
|
base = f'ax.text({float(art["x"])!r}, {float(art["y"])!r}, {art.get("text", "")!r}'
|
|
509
535
|
if art.get("axes_frac"):
|
|
510
536
|
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})'
|
|
537
|
+
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}, rotation={_num(art.get("rotation"), 0)!r})'
|
|
512
538
|
return base
|
|
513
539
|
if t == "legend":
|
|
514
540
|
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))
|
|
@@ -517,7 +543,7 @@ def _codegen_artist(art):
|
|
|
517
543
|
return (f'ax.annotate({art.get("text", "")!r}, xy=({float(art["xy"][0])!r}, {float(art["xy"][1])!r}), '
|
|
518
544
|
f'xytext=({float(art["xytext"][0])!r}, {float(art["xytext"][1])!r}), textcoords="axes fraction", '
|
|
519
545
|
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})')
|
|
546
|
+
f'color={art.get("color", "#000000")!r}, fontsize={_num(art.get("fontsize"), 10)!r}, rotation={_num(art.get("rotation"), 0)!r})')
|
|
521
547
|
if t == "box":
|
|
522
548
|
return (f'ax.add_patch(Rectangle(({float(art["x0"])!r}, {float(art["y0"])!r}), {float(art["w"])!r}, {float(art["h"])!r}, '
|
|
523
549
|
f'transform=ax.transAxes, fc={art.get("fc", "none")!r}, alpha={_num(art.get("alpha"), 0.15)!r}, '
|
|
@@ -547,7 +573,11 @@ def _codegen_figartist(fa):
|
|
|
547
573
|
if t == "text":
|
|
548
574
|
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
575
|
if t == "suptitle":
|
|
550
|
-
|
|
576
|
+
_srot = _num(fa.get("rotation"), 0)
|
|
577
|
+
_st = 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})'
|
|
578
|
+
if _srot:
|
|
579
|
+
_st = "_st = " + _st + f"; _st.set_rotation({_srot!r})"
|
|
580
|
+
return _st
|
|
551
581
|
return f"pass # figure artist type {t!r} not emitted"
|
|
552
582
|
|
|
553
583
|
|
|
@@ -617,6 +647,13 @@ def hotspots_from_fig(fig, spec):
|
|
|
617
647
|
add(f"plot:{pid}", ax)
|
|
618
648
|
if (p.get("title") or {}).get("visible"):
|
|
619
649
|
add(f"title:{pid}", ax.title)
|
|
650
|
+
# Per-plot TEXT artists: ax.texts holds exactly these, in spec order (tick
|
|
651
|
+
# labels and the title are not in ax.texts), so they get canvas overlays
|
|
652
|
+
# (draggable / rotatable / snappable) keyed by their spec index j.
|
|
653
|
+
titer = iter(ax.texts)
|
|
654
|
+
for j, art in enumerate(p.get("artists") or []):
|
|
655
|
+
if isinstance(art, dict) and art.get("type") == "text":
|
|
656
|
+
add(f"artist:{pid}:{j}", next(titer, None))
|
|
620
657
|
# per-plot artists / overlaid lines are selected from the Layers list (robust),
|
|
621
658
|
# not by canvas hit-testing (ax.lines is polluted by extra lines + error bars).
|
|
622
659
|
# fig.texts also holds the suptitle (Figure.suptitle appends to it), so exclude it
|
|
@@ -46,6 +46,18 @@
|
|
|
46
46
|
.mov:hover { outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); }
|
|
47
47
|
.mov.selected { outline: 2px solid var(--accent); }
|
|
48
48
|
.mov.inspect-only { cursor: default; }
|
|
49
|
+
/* rotation handle: a circle above the element (shows on hover / when selected) */
|
|
50
|
+
.mov .rot-handle { display: none; position: absolute; top: -20px; left: 50%; width: 13px; height: 13px; margin-left: -6.5px; border-radius: 50%; background: var(--accent); border: 2px solid #fff; box-shadow: var(--shadow-sm); cursor: grab; z-index: 6; }
|
|
51
|
+
.mov .rot-handle::after { content: ""; position: absolute; top: 13px; left: 50%; width: 1.5px; height: 9px; background: var(--accent); transform: translateX(-50%); }
|
|
52
|
+
.mov .rot-handle:active { cursor: grabbing; }
|
|
53
|
+
.mov:hover .rot-handle, .mov.selected .rot-handle { display: block; }
|
|
54
|
+
.marquee { position: absolute; border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); pointer-events: none; z-index: 20; display: none; }
|
|
55
|
+
.render-busy { position: absolute; top: 10px; right: 12px; z-index: 30; display: none; font-size: 11.5px; font-weight: 600; color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 3px 10px; box-shadow: var(--shadow-sm); pointer-events: none; }
|
|
56
|
+
.confirm-msg { color: var(--muted); margin: 10px 0 18px; line-height: 1.5; }
|
|
57
|
+
.help-list { margin: 14px 0; display: grid; grid-template-columns: auto 1fr; gap: 7px 16px; font-size: 13px; }
|
|
58
|
+
.help-list .k { font-family: var(--mono); font-size: 12px; color: var(--ink); white-space: nowrap; }
|
|
59
|
+
.help-list .d { color: var(--muted); }
|
|
60
|
+
#groupBox { position: absolute; border: 1.5px dashed var(--accent); pointer-events: none; z-index: 4; display: none; }
|
|
49
61
|
|
|
50
62
|
/* ---- snap guides ---- */
|
|
51
63
|
.guides { position: absolute; inset: 0; pointer-events: none; z-index: 5; }
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
})();
|
|
17
17
|
</script>
|
|
18
18
|
<title>Fig Foundry — Canvas</title>
|
|
19
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='2' y='2' width='60' height='60' rx='15' fill='%232f6690'/%3E%3Cpolyline points='16,45 27,31 35,39 49,20' fill='none' stroke='%23fff' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
|
|
19
20
|
<link rel="stylesheet" href="/static/style.css">
|
|
20
21
|
<link rel="stylesheet" href="/static/canvas.css">
|
|
21
22
|
</head>
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
<button id="zoomFit" class="textbtn">Fit</button>
|
|
41
42
|
</div>
|
|
42
43
|
<button id="themeBtn" class="iconbtn" title="Theme (light / dark / system)" aria-label="Toggle theme">☾</button>
|
|
44
|
+
<button id="helpBtn" class="iconbtn" title="Help & shortcuts" aria-label="Help and shortcuts">?</button>
|
|
43
45
|
<button id="exportBtn" class="primary">Export code</button>
|
|
44
46
|
</header>
|
|
45
47
|
|
|
@@ -137,6 +139,14 @@
|
|
|
137
139
|
</div>
|
|
138
140
|
</div>
|
|
139
141
|
|
|
142
|
+
<div id="helpModal" class="modal hidden" role="dialog" aria-modal="true" aria-label="Help and shortcuts">
|
|
143
|
+
<div class="modal-card" style="max-width:540px">
|
|
144
|
+
<div class="modal-head"><h3>Help & shortcuts</h3><button id="helpClose" class="iconbtn" title="Close" aria-label="Close">✕</button></div>
|
|
145
|
+
<div class="help-list" id="helpList"></div>
|
|
146
|
+
<div class="modal-actions modal-actions-right"><button id="helpOk" class="primary">Got it</button></div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
140
150
|
<div id="toast" class="toast hidden" role="status"></div>
|
|
141
151
|
|
|
142
152
|
<script src="/static/canvas.js"></script>
|
|
@@ -10,7 +10,7 @@ const $ = (s) => document.querySelector(s);
|
|
|
10
10
|
const figImg = $("#figImg"), overlay = $("#overlay"), guides = $("#guides"), stage = $("#stage"), stageInner = $("#stageInner");
|
|
11
11
|
const SNAP_PX = 8;
|
|
12
12
|
|
|
13
|
-
const state = { slug: null, name: null, spec: null, render: null, signals: [], selected: null, selection: new Set(), zoom: 1, pan: { x: 0, y: 0 }, gridCols: 3, nudging: false, history: [], future: [], sliderSnapshot: null, signalSel: new Set() };
|
|
13
|
+
const state = { slug: null, name: null, spec: null, render: null, signals: [], selected: null, selection: new Set(), zoom: 1, pan: { x: 0, y: 0 }, gridCols: 3, nudging: false, history: [], future: [], sliderSnapshot: null, signalSel: new Set(), clipboard: null };
|
|
14
14
|
|
|
15
15
|
/* ---------------- api ---------------- */
|
|
16
16
|
async function api(path, opts) {
|
|
@@ -86,15 +86,23 @@ async function doRender(draft) {
|
|
|
86
86
|
if (renderInFlight) { renderPending = true; renderPendingDraft = draft; return; }
|
|
87
87
|
renderInFlight = true;
|
|
88
88
|
const seq = ++renderSeq;
|
|
89
|
+
setRenderBusy(true);
|
|
89
90
|
try {
|
|
90
91
|
const r = await post(`/api/canvases/${state.slug}/render`, { spec: state.spec, draft });
|
|
91
92
|
if (seq === renderSeq) { state.render = r; applyRender(); }
|
|
92
93
|
} catch (e) { if (seq === renderSeq) toast("Render failed: " + e.message, true); }
|
|
93
94
|
finally {
|
|
94
95
|
renderInFlight = false;
|
|
96
|
+
setRenderBusy(false);
|
|
95
97
|
if (renderPending) { const d = renderPendingDraft; renderPending = false; doRender(d); }
|
|
96
98
|
}
|
|
97
99
|
}
|
|
100
|
+
// A subtle "Rendering…" pill in the stage corner while a render is in flight.
|
|
101
|
+
function setRenderBusy(b) {
|
|
102
|
+
let el = document.getElementById("renderBusy");
|
|
103
|
+
if (b) { if (!el) { el = document.createElement("div"); el.id = "renderBusy"; el.className = "render-busy"; el.textContent = "Rendering…"; stage.appendChild(el); } el.style.display = "block"; }
|
|
104
|
+
else if (el) el.style.display = "none";
|
|
105
|
+
}
|
|
98
106
|
// fast low-res render during interaction; full-res when things settle
|
|
99
107
|
function scheduleDraft() { clearTimeout(fullTimer); fullTimer = null; if (draftTimer) return; draftTimer = setTimeout(() => { draftTimer = null; doRender(true); }, 50); }
|
|
100
108
|
function scheduleFull() { clearTimeout(draftTimer); draftTimer = null; if (fullTimer) return; fullTimer = setTimeout(() => { fullTimer = null; doRender(false); }, 250); }
|
|
@@ -115,7 +123,7 @@ function applyRender() {
|
|
|
115
123
|
? `<b style="color:var(--danger)">${oc} element${oc > 1 ? "s" : ""} off the canvas</b> — select it in Layers and drag it back, or enlarge the canvas`
|
|
116
124
|
: "Drag a signal to add a plot · Drag to move, handles to resize (snaps) · Shift+click to multi-select · Arrows to nudge (Shift = bigger) · Drag empty space / space+drag to pan";
|
|
117
125
|
const ae = document.activeElement;
|
|
118
|
-
const editing = ae && (ae.tagName === "
|
|
126
|
+
const editing = ae && (ae.tagName === "TEXTAREA" || ae.tagName === "SELECT" || (ae.tagName === "INPUT" && /text|number|search|email|tel|url|password/.test(ae.type)));
|
|
119
127
|
if (!editing) buildInspector();
|
|
120
128
|
} catch (err) {
|
|
121
129
|
console.error("applyRender:", err);
|
|
@@ -158,6 +166,32 @@ function drawGuides(gs) {
|
|
|
158
166
|
guides.appendChild(el);
|
|
159
167
|
}
|
|
160
168
|
}
|
|
169
|
+
// Unified snap model: every element's bbox in figure fractions. Plots come from the
|
|
170
|
+
// spec (they have a rect); titles/labels/legends come from the server hotspots. This is
|
|
171
|
+
// what lets guides work on text, not just plots.
|
|
172
|
+
function snapModel(excludeId) {
|
|
173
|
+
const boxes = []; const r = state.render; if (!r) return boxes;
|
|
174
|
+
for (const p of state.spec.plots) boxes.push(p.rect.slice());
|
|
175
|
+
for (const h of r.hotspots) {
|
|
176
|
+
if (h.id === excludeId || h.id.startsWith("plot:")) continue;
|
|
177
|
+
boxes.push([h.x / r.img_w, 1 - (h.top + h.h) / r.img_h, h.w / r.img_w, h.h / r.img_h]);
|
|
178
|
+
}
|
|
179
|
+
return boxes;
|
|
180
|
+
}
|
|
181
|
+
// Position-only snap (unlike snapRect, it never changes a box's size, and it lets
|
|
182
|
+
// labels sit slightly off-canvas). Mutates box[0]/box[1] and returns the guide lines.
|
|
183
|
+
function snapBoxPos(box, others, imgW, imgH) {
|
|
184
|
+
const thrX = SNAP_PX / imgW, thrY = SNAP_PX / imgH;
|
|
185
|
+
const tx = [0, 0.25, 0.5, 0.75, 1], ty = [0, 0.25, 0.5, 0.75, 1];
|
|
186
|
+
for (const o of others) { tx.push(o[0], o[0] + o[2] / 2, o[0] + o[2]); ty.push(o[1], o[1] + o[3] / 2, o[1] + o[3]); }
|
|
187
|
+
const g = [];
|
|
188
|
+
const xE = [[v => box[0] = v, box[0]], [v => box[0] = v - box[2] / 2, box[0] + box[2] / 2], [v => box[0] = v - box[2], box[0] + box[2]]];
|
|
189
|
+
for (const [set, val] of xE) { for (const t of tx) { if (Math.abs(val - t) < thrX) { set(t); g.push({ axis: "v", f: t }); break; } } }
|
|
190
|
+
const yE = [[v => box[1] = v, box[1]], [v => box[1] = v - box[3] / 2, box[1] + box[3] / 2], [v => box[1] = v - box[3], box[1] + box[3]]];
|
|
191
|
+
for (const [set, val] of yE) { for (const t of ty) { if (Math.abs(val - t) < thrY) { set(t); g.push({ axis: "h", f: t }); break; } } }
|
|
192
|
+
box[0] = clamp(box[0], -0.2, 1.2); box[1] = clamp(box[1], -0.2, 1.2);
|
|
193
|
+
return g;
|
|
194
|
+
}
|
|
161
195
|
|
|
162
196
|
/* ---------------- overlays ---------------- */
|
|
163
197
|
function buildOverlays() {
|
|
@@ -181,6 +215,7 @@ function buildOverlays() {
|
|
|
181
215
|
div.style.left = (h.x / r.img_w * 100) + "%"; div.style.top = (h.top / r.img_h * 100) + "%";
|
|
182
216
|
div.style.width = (h.w / r.img_w * 100) + "%"; div.style.height = (h.h / r.img_h * 100) + "%";
|
|
183
217
|
makeMovable(div, h.id, "mov");
|
|
218
|
+
if (isCanvasMovable(h.id) && rotTarget(h.id)) addRotHandle(div, h.id);
|
|
184
219
|
overlay.appendChild(div);
|
|
185
220
|
}
|
|
186
221
|
refreshSelection();
|
|
@@ -196,11 +231,78 @@ function isCanvasMovable(id) {
|
|
|
196
231
|
}
|
|
197
232
|
return false;
|
|
198
233
|
}
|
|
234
|
+
// The spec field an element's rotation lives in (text-family only; matplotlib
|
|
235
|
+
// cannot rotate a whole axes panel, so plots get no rotation).
|
|
236
|
+
function rotTarget(id) {
|
|
237
|
+
if (id.startsWith("figartist:")) { const fa = state.spec.figure_artists[+id.slice(10)]; if (fa && ["text", "suptitle"].includes(fa.type)) return { get: () => fa.rotation ?? 0, set: (d) => { fa.rotation = d; } }; return null; }
|
|
238
|
+
if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) return { get: () => p.title.rotation ?? 0, set: (d) => { p.title.rotation = d; } }; return null; }
|
|
239
|
+
if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[+j]; if (a && ["text", "annotation"].includes(a.type)) return { get: () => a.rotation ?? 0, set: (d) => { a.rotation = d; } }; return null; }
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// Wix/PowerPoint-style rotation handle: a circle above the element; drag around
|
|
243
|
+
// its center to rotate (Shift snaps to 15°). The overlay box is rotated live as
|
|
244
|
+
// a preview; the real rotation comes from the draft render.
|
|
245
|
+
function addRotHandle(movDiv, id) {
|
|
246
|
+
const h = document.createElement("div"); h.className = "rot-handle"; h.title = "Drag to rotate (Shift snaps to 15°)";
|
|
247
|
+
h.addEventListener("pointerdown", (e) => {
|
|
248
|
+
e.preventDefault(); e.stopPropagation();
|
|
249
|
+
h.setPointerCapture(e.pointerId);
|
|
250
|
+
const rect0 = movDiv.getBoundingClientRect();
|
|
251
|
+
const cx0 = rect0.left + rect0.width / 2, cy0 = rect0.top + rect0.height / 2;
|
|
252
|
+
const startAng = Math.atan2(e.clientY - cy0, e.clientX - cx0);
|
|
253
|
+
const tgt = rotTarget(id); const rot0 = tgt.get();
|
|
254
|
+
if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec);
|
|
255
|
+
// CSS rotate() is clockwise-positive (screen), matplotlib rotation is
|
|
256
|
+
// counterclockwise-positive, so the box preview is rotate(-spec).
|
|
257
|
+
movDiv.style.transform = `rotate(${-rot0}deg)`;
|
|
258
|
+
const onMove = (ev) => {
|
|
259
|
+
let d = (Math.atan2(ev.clientY - cy0, ev.clientX - cx0) - startAng) * 180 / Math.PI; // CW-positive on screen
|
|
260
|
+
if (ev.shiftKey) d = Math.round(d / 15) * 15;
|
|
261
|
+
let S = Math.round(rot0 - d); S = ((S % 360) + 360) % 360; if (S > 180) S -= 360; // spec (CCW+), normalized
|
|
262
|
+
tgt.set(S); movDiv.style.transform = `rotate(${-S}deg)`; scheduleDraft();
|
|
263
|
+
};
|
|
264
|
+
const onUp = (ev) => { try { h.releasePointerCapture(ev.pointerId); } catch (_) {} document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); movDiv.style.transform = ""; commitSnapshot(); commit(); };
|
|
265
|
+
document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp);
|
|
266
|
+
});
|
|
267
|
+
movDiv.appendChild(h);
|
|
268
|
+
}
|
|
199
269
|
|
|
200
270
|
function refreshSelection() {
|
|
201
271
|
overlay.querySelectorAll(".plotbox").forEach((d) => d.classList.toggle("selected", state.selection.has("plot:" + d.dataset.pid)));
|
|
202
272
|
overlay.querySelectorAll(".mov").forEach((d) => d.classList.toggle("selected", state.selection.has(d.dataset.id)));
|
|
203
273
|
document.querySelectorAll(".layer").forEach((l) => l.classList.toggle("active", l.dataset.id && state.selection.has(l.dataset.id)));
|
|
274
|
+
drawGroupBox();
|
|
275
|
+
}
|
|
276
|
+
// Dashed outline around a multi-selection so the group's extent is visible.
|
|
277
|
+
function drawGroupBox() {
|
|
278
|
+
let gb = document.getElementById("groupBox");
|
|
279
|
+
if (state.selection.size < 2) { if (gb) gb.remove(); return; }
|
|
280
|
+
const r = state.render; if (!r) return;
|
|
281
|
+
let l = 1, b = 1, rr = 0, tt = 0, any = false;
|
|
282
|
+
const absorb = (box) => { if (!box) return; any = true; l = Math.min(l, box[0]); b = Math.min(b, box[1]); rr = Math.max(rr, box[0] + box[2]); tt = Math.max(tt, box[1] + box[3]); };
|
|
283
|
+
for (const id of state.selection) {
|
|
284
|
+
if (id.startsWith("plot:")) { const p = state.spec.plots.find((x) => x.id === id.slice(5)); if (p) absorb(p.rect); }
|
|
285
|
+
else { const h = r.hotspots.find((x) => x.id === id); if (h) absorb([h.x / r.img_w, 1 - (h.top + h.h) / r.img_h, h.w / r.img_w, h.h / r.img_h]); }
|
|
286
|
+
}
|
|
287
|
+
if (!any) return;
|
|
288
|
+
if (!gb) { gb = document.createElement("div"); gb.id = "groupBox"; overlay.appendChild(gb); }
|
|
289
|
+
gb.style.left = (l * 100) + "%"; gb.style.top = ((1 - tt) * 100) + "%";
|
|
290
|
+
gb.style.width = ((rr - l) * 100) + "%"; gb.style.height = ((tt - b) * 100) + "%";
|
|
291
|
+
gb.style.display = "block";
|
|
292
|
+
}
|
|
293
|
+
// Rubber-band select: everything whose bbox intersects the drag rect (in figure
|
|
294
|
+
// fractions). Shift adds to the current selection.
|
|
295
|
+
function marqueeSelect(x0, y0, x1, y1, base) {
|
|
296
|
+
const a = toFig(x0, y0), b = toFig(x1, y1);
|
|
297
|
+
const L = Math.min(a.fx, b.fx), R = Math.max(a.fx, b.fx);
|
|
298
|
+
const T = 1 - Math.min(a.fyTop, b.fyTop), B = 1 - Math.max(a.fyTop, b.fyTop);
|
|
299
|
+
const hit = (box) => box[0] < R && box[0] + box[2] > L && box[1] < T && box[1] + box[3] > B;
|
|
300
|
+
const sel = new Set(base);
|
|
301
|
+
for (const p of state.spec.plots) if (hit(p.rect)) sel.add("plot:" + p.id);
|
|
302
|
+
const r = state.render;
|
|
303
|
+
if (r) for (const h of r.hotspots) { if (h.id.startsWith("plot:")) continue; if (hit([h.x / r.img_w, 1 - (h.top + h.h) / r.img_h, h.w / r.img_w, h.h / r.img_h])) sel.add(h.id); }
|
|
304
|
+
state.selection = sel; state.selected = sel.size ? [...sel].pop() : null;
|
|
305
|
+
refreshSelection(); buildInspector();
|
|
204
306
|
}
|
|
205
307
|
|
|
206
308
|
/* ---------------- move / resize / drag ---------------- */
|
|
@@ -212,7 +314,7 @@ function makeMovable(el, id, mode) {
|
|
|
212
314
|
const shift = e.shiftKey;
|
|
213
315
|
const handle = (e.target.dataset && e.target.dataset.h) || null;
|
|
214
316
|
const startF = toFig(e.clientX, e.clientY);
|
|
215
|
-
let moved = false, onMove, onUp, sx = 0, sy = 0;
|
|
317
|
+
let moved = false, onMove, onUp, sx = 0, sy = 0, lastDxF = 0, lastDyF = 0, isGroup = false, groupDivs = [el];
|
|
216
318
|
if (mode === "box") {
|
|
217
319
|
const p = state.spec.plots.find((x) => x.id === id.slice(5));
|
|
218
320
|
const rect0 = p.rect.slice();
|
|
@@ -224,6 +326,9 @@ function makeMovable(el, id, mode) {
|
|
|
224
326
|
onMove = (ev) => {
|
|
225
327
|
const F = toFig(ev.clientX, ev.clientY);
|
|
226
328
|
const dxF = F.fx - startF.fx, dyBF = startF.fyTop - F.fyTop;
|
|
329
|
+
if (!moved && Math.abs(dxF) * r.img_w * state.zoom < 3 && Math.abs(dyBF) * r.img_h * state.zoom < 3) return; // sub-3px = a click, not a drag
|
|
330
|
+
if (!moved) pushHist(); // capture pre-drag state so a move/resize is undoable
|
|
331
|
+
moved = true;
|
|
227
332
|
if (handle === null) {
|
|
228
333
|
const rect = rect0.slice(); rect[0] += dxF; rect[1] += dyBF;
|
|
229
334
|
const guides = snapRect(rect, others, r.img_w, r.img_h);
|
|
@@ -244,14 +349,31 @@ function makeMovable(el, id, mode) {
|
|
|
244
349
|
Object.assign(el.style, rectToCss(rect));
|
|
245
350
|
}
|
|
246
351
|
scheduleDraft();
|
|
247
|
-
moved = true;
|
|
248
352
|
};
|
|
249
353
|
} else {
|
|
250
354
|
sx = e.clientX; sy = e.clientY;
|
|
355
|
+
const r0 = state.render;
|
|
356
|
+
const imgWpx = r0 ? r0.img_w * state.zoom : 1, imgHpx = r0 ? r0.img_h * state.zoom : 1;
|
|
357
|
+
const hs0 = r0 && r0.hotspots.find((h) => h.id === id);
|
|
358
|
+
const startBox = hs0 ? [hs0.x / r0.img_w, 1 - (hs0.top + hs0.h) / r0.img_h, hs0.w / r0.img_w, hs0.h / r0.img_h] : null;
|
|
359
|
+
const others = startBox ? snapModel(id) : [];
|
|
360
|
+
isGroup = state.selection.size > 1 && state.selection.has(id);
|
|
361
|
+
groupDivs = isGroup ? [...overlay.querySelectorAll(".mov")].filter((d) => state.selection.has(d.dataset.id)) : [el];
|
|
251
362
|
onMove = (ev) => {
|
|
252
363
|
const dx = ev.clientX - sx, dy = ev.clientY - sy;
|
|
253
|
-
|
|
254
|
-
if (
|
|
364
|
+
if (!moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return; // sub-3px = a click, not a drag
|
|
365
|
+
if (!moved) pushHist(); // capture pre-drag state so a move is undoable
|
|
366
|
+
moved = true;
|
|
367
|
+
let dxF = dx / imgWpx, dyF = -dy / imgHpx;
|
|
368
|
+
if (startBox) {
|
|
369
|
+
const box = startBox.slice(); box[0] += dxF; box[1] += dyF;
|
|
370
|
+
drawGuides(snapBoxPos(box, others, r0.img_w, r0.img_h));
|
|
371
|
+
lastDxF = box[0] - startBox[0]; lastDyF = box[1] - startBox[1]; // the snapped delta
|
|
372
|
+
} else { lastDxF = dxF; lastDyF = dyF; }
|
|
373
|
+
// el sits inside the zoomed stageInner, so its local px are screen px / zoom;
|
|
374
|
+
// a group drag moves every selected label by the same (snapped) delta.
|
|
375
|
+
groupDivs.forEach((d) => { d.style.transform = `translate(${lastDxF * imgWpx / state.zoom}px, ${-lastDyF * imgHpx / state.zoom}px)`; });
|
|
376
|
+
scheduleDraft();
|
|
255
377
|
};
|
|
256
378
|
}
|
|
257
379
|
onUp = (ev) => {
|
|
@@ -259,9 +381,9 @@ function makeMovable(el, id, mode) {
|
|
|
259
381
|
document.removeEventListener("pointerup", onUp);
|
|
260
382
|
document.removeEventListener("pointercancel", onUp);
|
|
261
383
|
drawGuides([]);
|
|
262
|
-
|
|
384
|
+
groupDivs.forEach((d) => { d.style.transform = ""; });
|
|
263
385
|
if (!moved) { select(id, shift); return; }
|
|
264
|
-
if (mode !== "box")
|
|
386
|
+
if (mode !== "box") { const ids = isGroup ? [...state.selection] : [id]; for (const eid of ids) commitArtDrag(eid, lastDxF, lastDyF); }
|
|
265
387
|
commit();
|
|
266
388
|
};
|
|
267
389
|
document.addEventListener("pointermove", onMove);
|
|
@@ -270,11 +392,9 @@ function makeMovable(el, id, mode) {
|
|
|
270
392
|
});
|
|
271
393
|
}
|
|
272
394
|
|
|
273
|
-
function commitArtDrag(id,
|
|
274
|
-
// Apply the drag DELTA (in figure fractions) to the element's own
|
|
275
|
-
// element lands exactly where the user dropped it
|
|
276
|
-
const rect = figImg.getBoundingClientRect();
|
|
277
|
-
const dxF = dxPx / rect.width, dyF = -dyPx / rect.height;
|
|
395
|
+
function commitArtDrag(id, dxF, dyF) {
|
|
396
|
+
// Apply the drag DELTA (in figure fractions, already snapped) to the element's own
|
|
397
|
+
// coordinates, so the element lands exactly where the user dropped it.
|
|
278
398
|
const r = state.spec;
|
|
279
399
|
if (id.startsWith("title:")) {
|
|
280
400
|
const pid = id.slice(6); const p = r.plots.find((x) => x.id === pid);
|
|
@@ -320,6 +440,47 @@ async function commit() {
|
|
|
320
440
|
await doRender(false);
|
|
321
441
|
}
|
|
322
442
|
|
|
443
|
+
/* ---------------- clipboard (copy / paste / cut / duplicate) ----------------
|
|
444
|
+
Copies top-level elements: plots (with their artists/lines) and figure_artists.
|
|
445
|
+
Signal refs are name-based, so a same-figure paste is always valid. */
|
|
446
|
+
function copySelection() {
|
|
447
|
+
const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
|
|
448
|
+
if (!ids.length) return;
|
|
449
|
+
const plotIds = new Set(ids.filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
|
|
450
|
+
const figIdxs = ids.filter((i) => i.startsWith("figartist:")).map((i) => +i.slice(10));
|
|
451
|
+
const plots = state.spec.plots.filter((p) => plotIds.has(p.id)).map((p) => clone(p));
|
|
452
|
+
const figs = figIdxs.map((j) => clone(state.spec.figure_artists[j]));
|
|
453
|
+
if (!plots.length && !figs.length) return;
|
|
454
|
+
state.clipboard = { plots, figs };
|
|
455
|
+
toast("Copied " + (plots.length + figs.length) + " element" + (plots.length + figs.length > 1 ? "s" : ""));
|
|
456
|
+
}
|
|
457
|
+
function pasteClipboard() {
|
|
458
|
+
const cb = state.clipboard; if (!cb || (!cb.plots.length && !cb.figs.length)) return;
|
|
459
|
+
pushHist();
|
|
460
|
+
for (const p of cb.plots) { const c = clone(p); c.id = nextPlotId(); c.rect = [r4(clamp(c.rect[0] + 0.05, 0, 1)), r4(clamp(c.rect[1] + 0.05, 0, 1)), c.rect[2], c.rect[3]]; state.spec.plots.push(c); }
|
|
461
|
+
for (const f of cb.figs) { const c = clone(f); if (c.loc) c.loc = [r4(c.loc[0] + 0.05), r4(c.loc[1] + 0.05)]; else { c.x = r4((c.x || 0) + 0.05); c.y = r4((c.y || 0) + 0.05); } state.spec.figure_artists.push(c); }
|
|
462
|
+
select(null); commit();
|
|
463
|
+
}
|
|
464
|
+
function deleteSelection() {
|
|
465
|
+
if (!state.selected && !state.selection.size) return;
|
|
466
|
+
pushHist();
|
|
467
|
+
const ids = state.selection.size ? [...state.selection] : [state.selected];
|
|
468
|
+
const plotIds = new Set(ids.filter((id) => id.startsWith("plot:")).map((id) => id.slice(5)));
|
|
469
|
+
if (plotIds.size) state.spec.plots = state.spec.plots.filter((p) => !plotIds.has(p.id));
|
|
470
|
+
const artDel = new Map(), lineDel = new Map(), figDel = [];
|
|
471
|
+
for (const id of ids) {
|
|
472
|
+
if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); if (!artDel.has(pid)) artDel.set(pid, []); artDel.get(pid).push(+j); }
|
|
473
|
+
else if (id.startsWith("line:")) { const [, pid, j] = id.split(":"); if (!lineDel.has(pid)) lineDel.set(pid, []); lineDel.get(pid).push(+j); }
|
|
474
|
+
else if (id.startsWith("figartist:")) figDel.push(+id.slice(10));
|
|
475
|
+
else if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) p.title.visible = false; }
|
|
476
|
+
}
|
|
477
|
+
// splice high-to-low so deleting several items at once doesn't shift the other indices
|
|
478
|
+
for (const [pid, js] of artDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p) js.sort((a, b) => b - a).forEach((j) => p.artists.splice(j, 1)); }
|
|
479
|
+
for (const [pid, js] of lineDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p && p.extra_lines) js.sort((a, b) => b - a).forEach((j) => p.extra_lines.splice(j, 1)); }
|
|
480
|
+
figDel.sort((a, b) => b - a).forEach((j) => state.spec.figure_artists.splice(j, 1));
|
|
481
|
+
select(null); commit();
|
|
482
|
+
}
|
|
483
|
+
|
|
323
484
|
let nudgePushed = false, nudgeTimer = null;
|
|
324
485
|
function nudgeSelected(dx, dy) {
|
|
325
486
|
const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
|
|
@@ -336,7 +497,16 @@ function nudgeOne(id, dx, dy) {
|
|
|
336
497
|
if (kind === "plot") { const p = state.spec.plots.find((x) => x.id === pid); if (p) { p.rect[0] = clamp(r4(p.rect[0] + dx), 0, 1); p.rect[1] = clamp(r4(p.rect[1] + dy), 0, 1); } }
|
|
337
498
|
else if (kind === "title") { const p = state.spec.plots.find((x) => x.id === pid); const t = p && p.title; if (t) { t.x = r4(clamp(t.x + dx / p.rect[2], -0.6, 1.6)); t.y = r4(clamp(t.y + dy / p.rect[3], -0.6, 1.6)); t.visible = true; } }
|
|
338
499
|
else if (kind === "figartist") { const fa = state.spec.figure_artists[+pid]; if (fa) { if (fa.loc) { fa.loc[0] = r4(clamp(fa.loc[0] + dx, 0, 1.2)); fa.loc[1] = r4(clamp(fa.loc[1] + dy, -0.2, 1.2)); } else { fa.x = r4(clamp((fa.x || 0) + dx, -0.1, 1.1)); fa.y = r4(clamp((fa.y || 0) + dy, -0.1, 1.1)); } } }
|
|
339
|
-
else if (kind === "artist") { const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[j]; if (a) {
|
|
500
|
+
else if (kind === "artist") { const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[j]; if (a) {
|
|
501
|
+
if (a.type === "text" && a.axes_frac) { a.x = r4(clamp(a.x + dx / p.rect[2], -0.6, 1.6)); a.y = r4(clamp(a.y + dy / p.rect[3], -0.6, 1.6)); }
|
|
502
|
+
else if (a.type === "vline") a.x = r4(a.x + dx * 8);
|
|
503
|
+
else if (a.type === "hline") a.y = r4(a.y + dy * 8);
|
|
504
|
+
else if (a.type === "circle" || a.type === "ellipse") { a.x = r4(a.x + dx * 8); a.y = r4(a.y + dy * 8); }
|
|
505
|
+
else if (a.type === "box") { a.x0 = r4(clamp(a.x0 + dx, -0.2, 1.2)); a.y0 = r4(clamp(a.y0 + dy, -0.2, 1.2)); }
|
|
506
|
+
else if (a.type === "vspan") { a.x0 = r4(a.x0 + dx * 8); a.x1 = r4(a.x1 + dx * 8); }
|
|
507
|
+
else if (a.type === "annotation") { a.xytext = [r4(a.xytext[0] + dx), r4(a.xytext[1] + dy)]; }
|
|
508
|
+
else if (a.type === "arrow") { a.x0 = r4(a.x0 + dx * 8); a.y0 = r4(a.y0 + dy * 8); a.x1 = r4(a.x1 + dx * 8); a.y1 = r4(a.y1 + dy * 8); }
|
|
509
|
+
} }
|
|
340
510
|
}
|
|
341
511
|
|
|
342
512
|
/* ---------------- signal drag -> create plot ---------------- */
|
|
@@ -520,6 +690,8 @@ function addPlotAt(fx, fyB, signal) {
|
|
|
520
690
|
pushHist();
|
|
521
691
|
const w = 0.3, h = 0.3;
|
|
522
692
|
const rect = [r4(clamp(fx - w / 2, 0, 1 - w)), r4(clamp(fyB - h / 2, 0, 1 - h)), w, h];
|
|
693
|
+
const r = state.render || { img_w: 1000, img_h: 1000 };
|
|
694
|
+
snapRect(rect, state.spec.plots.map((p) => p.rect), r.img_w, r.img_h); // snap the new plot on drop
|
|
523
695
|
const p = plotForSignal(signal, rect);
|
|
524
696
|
state.spec.plots.push(p);
|
|
525
697
|
select("plot:" + p.id);
|
|
@@ -701,7 +873,13 @@ function buildFigureElementsSection() {
|
|
|
701
873
|
}
|
|
702
874
|
|
|
703
875
|
function layoutAll(op, param) {
|
|
704
|
-
|
|
876
|
+
// Act on the selected plots when a multi-selection is active, else all plots.
|
|
877
|
+
let plots = state.spec.plots;
|
|
878
|
+
if (state.selection.size > 1) {
|
|
879
|
+
const selIds = new Set([...state.selection].filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
|
|
880
|
+
if (selIds.size > 1) plots = plots.filter((p) => selIds.has(p.id));
|
|
881
|
+
}
|
|
882
|
+
if (!plots.length) return;
|
|
705
883
|
const L = state.spec.layout || { margin: 0.05, gap: 0.04 };
|
|
706
884
|
const rects = plots.map((p) => p.rect);
|
|
707
885
|
pushHist();
|
|
@@ -788,7 +966,7 @@ function plotTypeSelect(pid) {
|
|
|
788
966
|
const p = state.spec.plots[idx(pid)];
|
|
789
967
|
const w = document.createElement("div"); w.className = "ctl";
|
|
790
968
|
const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = "Plot type"; head.appendChild(lab); w.appendChild(head);
|
|
791
|
-
const sel = document.createElement("select"); sel.className = "ctl-sel";
|
|
969
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Plot type");
|
|
792
970
|
const groups = {};
|
|
793
971
|
for (const [k, v] of Object.entries(PLOT_TYPES)) (groups[v.group] = groups[v.group] || []).push([k, v.label]);
|
|
794
972
|
for (const gname of Object.keys(groups)) {
|
|
@@ -835,9 +1013,11 @@ function buildPlotInspector(body, pid) {
|
|
|
835
1013
|
body.appendChild(ctlHead("Axes"));
|
|
836
1014
|
body.appendChild(ticksText("X ticks (blank = auto)", ["plots", idx(pid), "axes", "xticks"], p.axes.xticks));
|
|
837
1015
|
body.appendChild(ticksText("Y ticks (blank = auto)", ["plots", idx(pid), "axes", "yticks"], p.axes.yticks));
|
|
838
|
-
body.appendChild(
|
|
1016
|
+
body.appendChild(labelsText("X tick labels (blank = auto)", ["plots", idx(pid), "axes", "xticklabels"], p.axes.xticklabels));
|
|
1017
|
+
body.appendChild(labelsText("Y tick labels (blank = auto)", ["plots", idx(pid), "axes", "yticklabels"], p.axes.yticklabels));
|
|
1018
|
+
body.appendChild(text("X label", ["plots", idx(pid), "axes", "xlabel"], p.axes.xlabel, true));
|
|
839
1019
|
body.appendChild(check("Show X label", ["plots", idx(pid), "axes", "xlabel_visible"], p.axes.xlabel_visible));
|
|
840
|
-
body.appendChild(text("Y label", ["plots", idx(pid), "axes", "ylabel"], p.axes.ylabel));
|
|
1020
|
+
body.appendChild(text("Y label", ["plots", idx(pid), "axes", "ylabel"], p.axes.ylabel, true));
|
|
841
1021
|
body.appendChild(check("Show Y label", ["plots", idx(pid), "axes", "ylabel_visible"], p.axes.ylabel_visible));
|
|
842
1022
|
body.appendChild(hint("Labels and titles support LaTeX mathtext, e.g. <code>$x_0$</code>."));
|
|
843
1023
|
body.appendChild(num2("X range (blank = auto)", ["plots", idx(pid), "axes", "xlim"], p.axes.xlim));
|
|
@@ -855,7 +1035,7 @@ function buildPlotInspector(body, pid) {
|
|
|
855
1035
|
body.appendChild(check("Show y tick labels", ["plots", idx(pid), "axes", "labelleft"], p.axes.labelleft));
|
|
856
1036
|
body.appendChild(ctlHead("Title"));
|
|
857
1037
|
body.appendChild(check("Show title", ["plots", idx(pid), "title", "visible"], p.title.visible));
|
|
858
|
-
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], p.title.text));
|
|
1038
|
+
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], p.title.text, true));
|
|
859
1039
|
body.appendChild(slider("Title size", ["plots", idx(pid), "title", "fontsize"], p.title.fontsize, 6, 24, 0.5));
|
|
860
1040
|
body.appendChild(selControl("Title weight", ["plots", idx(pid), "title", "weight"], p.title.weight, [["normal", "Normal"], ["bold", "Bold"]]));
|
|
861
1041
|
body.appendChild(ctlHead("Artists"));
|
|
@@ -869,10 +1049,11 @@ const idx = (pid) => state.spec.plots.findIndex((p) => p.id === pid);
|
|
|
869
1049
|
|
|
870
1050
|
function buildTitleInspector(body, pid) {
|
|
871
1051
|
const t = state.spec.plots.find((x) => x.id === pid).title;
|
|
872
|
-
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text));
|
|
1052
|
+
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text, true));
|
|
873
1053
|
body.appendChild(slider("X (of panel)", ["plots", idx(pid), "title", "x"], t.x, -0.6, 1.6, 0.02));
|
|
874
1054
|
body.appendChild(slider("Y (of panel)", ["plots", idx(pid), "title", "y"], t.y, -0.6, 1.6, 0.02));
|
|
875
1055
|
body.appendChild(slider("Size (pt)", ["plots", idx(pid), "title", "fontsize"], t.fontsize, 6, 24, 0.5));
|
|
1056
|
+
body.appendChild(slider("Rotation (deg)", ["plots", idx(pid), "title", "rotation"], t.rotation ?? 0, -180, 180, 1));
|
|
876
1057
|
body.appendChild(selControl("Weight", ["plots", idx(pid), "title", "weight"], t.weight, [["normal", "Normal"], ["bold", "Bold"]]));
|
|
877
1058
|
body.appendChild(color("Color", ["plots", idx(pid), "title", "color"], t.color));
|
|
878
1059
|
body.appendChild(hint("Drag the title on the canvas to reposition it freely."));
|
|
@@ -881,14 +1062,15 @@ function buildTitleInspector(body, pid) {
|
|
|
881
1062
|
function buildArtistInspector(body, pid, j) {
|
|
882
1063
|
const a = state.spec.plots.find((x) => x.id === pid).artists[j];
|
|
883
1064
|
const P = (k, v, mn, mx, st) => ["plots", idx(pid), "artists", j, k];
|
|
884
|
-
if (a.type === "vline") { body.appendChild(slider("X value", P("x", a.x), 0, 20, 0.5)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"], ["dashdot", "Dash dot"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 1, 0, 1, 0.05)); }
|
|
885
|
-
else if (a.type === "hline") { body.appendChild(slider("Y value", P("y", a.y), -1, 1, 0.05)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); }
|
|
886
|
-
else if (a.type === "text") { body.appendChild(text("Text", P("text"), a.text)); body.appendChild(slider("X", P("x"), a.x, -1, 2, 0.02)); body.appendChild(slider("Y", P("y"), a.y, -1, 2, 0.02)); body.appendChild(check("Use panel fraction", P("axes_frac"), !!a.axes_frac)); body.appendChild(slider("Size", P("fontsize"), a.fontsize ?? 10, 6, 24, 0.5)); body.appendChild(color("Color", P("color"), a.color || "#000")); }
|
|
1065
|
+
if (a.type === "vline") { body.appendChild(slider("X value", P("x", a.x), 0, 20, 0.5, null, true)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"], ["dashdot", "Dash dot"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 1, 0, 1, 0.05)); }
|
|
1066
|
+
else if (a.type === "hline") { body.appendChild(slider("Y value", P("y", a.y), -1, 1, 0.05, null, true)); body.appendChild(color("Color", P("color"), a.color)); body.appendChild(selControl("Style", P("ls"), a.ls || "-", [["-", "Solid"], ["dotted", "Dotted"], ["dashed", "Dashed"]])); body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1)); }
|
|
1067
|
+
else if (a.type === "text") { body.appendChild(text("Text", P("text"), a.text, true)); body.appendChild(slider("X", P("x"), a.x, -1, 2, 0.02)); body.appendChild(slider("Y", P("y"), a.y, -1, 2, 0.02)); body.appendChild(check("Use panel fraction", P("axes_frac"), !!a.axes_frac)); body.appendChild(slider("Size", P("fontsize"), a.fontsize ?? 10, 6, 24, 0.5)); body.appendChild(slider("Rotation (deg)", P("rotation"), a.rotation ?? 0, -180, 180, 1)); body.appendChild(color("Color", P("color"), a.color || "#000")); }
|
|
887
1068
|
else if (a.type === "annotation") {
|
|
888
|
-
body.appendChild(text("Text", P("text"), a.text));
|
|
1069
|
+
body.appendChild(text("Text", P("text"), a.text, true));
|
|
889
1070
|
body.appendChild(num2("Arrow points to (data x, y)", P("xy"), a.xy));
|
|
890
1071
|
body.appendChild(num2("Label at (panel x, y)", P("xytext"), a.xytext));
|
|
891
1072
|
body.appendChild(slider("Label size", P("fontsize"), a.fontsize ?? 10, 6, 20, 0.5));
|
|
1073
|
+
body.appendChild(slider("Rotation (deg)", P("rotation"), a.rotation ?? 0, -180, 180, 1));
|
|
892
1074
|
body.appendChild(slider("Arrow width", P("lw"), a.lw ?? 1.2, 0.3, 4, 0.1));
|
|
893
1075
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
894
1076
|
}
|
|
@@ -903,22 +1085,22 @@ function buildArtistInspector(body, pid, j) {
|
|
|
903
1085
|
body.appendChild(slider("Edge width", P("lw"), a.lw ?? 1, 0, 4, 0.1));
|
|
904
1086
|
}
|
|
905
1087
|
else if (a.type === "vspan") {
|
|
906
|
-
body.appendChild(slider("X start (data)", P("x0"), a.x0, 0, 20, 0.5));
|
|
907
|
-
body.appendChild(slider("X end (data)", P("x1"), a.x1, 0, 20, 0.5));
|
|
1088
|
+
body.appendChild(slider("X start (data)", P("x0"), a.x0, 0, 20, 0.5, null, true));
|
|
1089
|
+
body.appendChild(slider("X end (data)", P("x1"), a.x1, 0, 20, 0.5, null, true));
|
|
908
1090
|
body.appendChild(color("Color", P("color"), a.color || "#b03a2e"));
|
|
909
1091
|
body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 0.12, 0, 0.6, 0.01));
|
|
910
1092
|
}
|
|
911
1093
|
else if (a.type === "circle") {
|
|
912
|
-
body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2));
|
|
913
|
-
body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2));
|
|
1094
|
+
body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
|
|
1095
|
+
body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
|
|
914
1096
|
body.appendChild(slider("Radius", P("r"), a.r ?? 1, 0.05, 10, 0.1));
|
|
915
1097
|
body.appendChild(check("Filled", P("fill"), !!a.fill));
|
|
916
1098
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
917
1099
|
body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
|
|
918
1100
|
}
|
|
919
1101
|
else if (a.type === "ellipse") {
|
|
920
|
-
body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2));
|
|
921
|
-
body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2));
|
|
1102
|
+
body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
|
|
1103
|
+
body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
|
|
922
1104
|
body.appendChild(slider("Width", P("w"), a.w ?? 2, 0.1, 10, 0.1));
|
|
923
1105
|
body.appendChild(slider("Height", P("h"), a.h ?? 1, 0.1, 10, 0.1));
|
|
924
1106
|
body.appendChild(slider("Angle (deg)", P("angle"), a.angle ?? 0, -180, 180, 5));
|
|
@@ -930,13 +1112,29 @@ function buildArtistInspector(body, pid, j) {
|
|
|
930
1112
|
body.appendChild(check("Filled", P("fill"), !!a.fill));
|
|
931
1113
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
932
1114
|
body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
|
|
933
|
-
|
|
1115
|
+
const pts = (a.points && a.points.length) ? a.points.slice() : [[4, 0], [6, 0], [5, 1]];
|
|
1116
|
+
body.appendChild(ctlHead("Points (data coords)"));
|
|
1117
|
+
const plist = document.createElement("div"); plist.className = "art-list";
|
|
1118
|
+
pts.forEach((pt, vi) => {
|
|
1119
|
+
const r2 = document.createElement("div"); r2.className = "num-row";
|
|
1120
|
+
const nx = document.createElement("input"), ny = document.createElement("input"), sep = document.createElement("span");
|
|
1121
|
+
sep.className = "sep"; sep.textContent = ","; nx.type = ny.type = "text"; nx.value = pt[0]; ny.value = pt[1];
|
|
1122
|
+
const up = () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); a.points = pts.map((q, i) => (i === vi ? [parseFloat(nx.value) || 0, parseFloat(ny.value) || 0] : q)); scheduleDraft(); };
|
|
1123
|
+
nx.addEventListener("input", up); ny.addEventListener("input", up);
|
|
1124
|
+
nx.addEventListener("change", () => { commitSnapshot(); commit(); }); ny.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1125
|
+
const rmv = document.createElement("span"); rmv.textContent = "×"; rmv.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px";
|
|
1126
|
+
rmv.onclick = () => { pushHist(); pts.splice(vi, 1); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
|
|
1127
|
+
r2.append(nx, sep, ny, rmv); plist.appendChild(r2);
|
|
1128
|
+
});
|
|
1129
|
+
const addPt = document.createElement("button"); addPt.className = "pill"; addPt.textContent = "+ Point";
|
|
1130
|
+
addPt.onclick = () => { pushHist(); pts.push([5, 5]); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
|
|
1131
|
+
body.appendChild(plist); body.appendChild(addPt);
|
|
934
1132
|
}
|
|
935
1133
|
else if (a.type === "arrow") {
|
|
936
|
-
body.appendChild(slider("From X", P("x0"), a.x0, -10, 20, 0.2));
|
|
937
|
-
body.appendChild(slider("From Y", P("y0"), a.y0, -10, 10, 0.2));
|
|
938
|
-
body.appendChild(slider("To X", P("x1"), a.x1, -10, 20, 0.2));
|
|
939
|
-
body.appendChild(slider("To Y", P("y1"), a.y1, -10, 10, 0.2));
|
|
1134
|
+
body.appendChild(slider("From X", P("x0"), a.x0, -10, 20, 0.2, null, true));
|
|
1135
|
+
body.appendChild(slider("From Y", P("y0"), a.y0, -10, 10, 0.2, null, true));
|
|
1136
|
+
body.appendChild(slider("To X", P("x1"), a.x1, -10, 20, 0.2, null, true));
|
|
1137
|
+
body.appendChild(slider("To Y", P("y1"), a.y1, -10, 10, 0.2, null, true));
|
|
940
1138
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
941
1139
|
body.appendChild(slider("Width", P("lw"), a.lw ?? 1.5, 0.3, 4, 0.1));
|
|
942
1140
|
}
|
|
@@ -950,10 +1148,11 @@ function buildFigartistInspector(body, j) {
|
|
|
950
1148
|
const fa = state.spec.figure_artists[j];
|
|
951
1149
|
const P = (...k) => ["figure_artists", j, ...k];
|
|
952
1150
|
if (fa.type === "text") {
|
|
953
|
-
body.appendChild(text("Text", P("text"), fa.text));
|
|
1151
|
+
body.appendChild(text("Text", P("text"), fa.text, true));
|
|
954
1152
|
body.appendChild(slider("X", P("x"), fa.x, 0, 1, 0.01));
|
|
955
1153
|
body.appendChild(slider("Y", P("y"), fa.y, 0, 1, 0.01));
|
|
956
1154
|
body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 12, 6, 26, 0.5));
|
|
1155
|
+
body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
|
|
957
1156
|
body.appendChild(color("Color", P("color"), fa.color || "#000"));
|
|
958
1157
|
} else if (fa.type === "legend") {
|
|
959
1158
|
body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 10, 6, 20, 0.5));
|
|
@@ -967,25 +1166,29 @@ function buildFigartistInspector(body, j) {
|
|
|
967
1166
|
const tin = document.createElement("input"); tin.type = "text"; tin.value = lb; tin.style.flex = "2";
|
|
968
1167
|
tin.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); fa.labels[i] = tin.value; scheduleDraft(); });
|
|
969
1168
|
tin.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
970
|
-
const mk = document.createElement("select"); mk.className = "ctl-sel"; mk.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
|
|
1169
|
+
const mk = document.createElement("select"); mk.className = "ctl-sel"; mk.setAttribute("aria-label", "Entry marker"); mk.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
|
|
971
1170
|
for (const [v, l] of [["", "— line"], ["o", "o dot"], ["^", "^ tri"], ["s", "s sq"], ["D", "D dia"]]) { const o = document.createElement("option"); o.value = v; o.textContent = l; mk.appendChild(o); }
|
|
972
1171
|
mk.value = (fa.handles[i] && fa.handles[i].marker) || "";
|
|
973
1172
|
mk.addEventListener("change", () => { pushHist(); fa.handles = fa.handles || []; fa.handles[i] = fa.handles[i] || {}; fa.handles[i].marker = mk.value; fa.handles[i].ms = mk.value ? 3 : 0; commit(); });
|
|
974
|
-
const ls = document.createElement("select"); ls.className = "ctl-sel"; ls.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
|
|
1173
|
+
const ls = document.createElement("select"); ls.className = "ctl-sel"; ls.setAttribute("aria-label", "Entry line style"); ls.style.cssText = "width:auto;flex:0 0 auto;padding:4px 6px";
|
|
975
1174
|
for (const [v, l] of [["-", "solid"], ["dotted", "dotted"], ["dashed", "dashed"]]) { const o = document.createElement("option"); o.value = v; o.textContent = l; ls.appendChild(o); }
|
|
976
1175
|
ls.value = (fa.handles[i] && fa.handles[i].ls) || "-";
|
|
977
1176
|
ls.addEventListener("change", () => { pushHist(); fa.handles = fa.handles || []; fa.handles[i] = fa.handles[i] || {}; fa.handles[i].ls = ls.value; commit(); });
|
|
1177
|
+
const ci = document.createElement("input"); ci.type = "color"; ci.title = "Entry color"; const _hc = (fa.handles[i] && fa.handles[i].color) || ""; ci.value = /^#[0-9a-f]{6}$/i.test(_hc) ? _hc : "#b03a2e"; ci.style.cssText = "width:26px;height:22px;padding:1px;border:1px solid var(--line);border-radius:5px;background:var(--input-bg);flex:0 0 auto";
|
|
1178
|
+
ci.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); fa.handles[i] = fa.handles[i] || {}; fa.handles[i].color = ci.value; scheduleDraft(); });
|
|
1179
|
+
ci.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
978
1180
|
const rm = document.createElement("span"); rm.textContent = "×"; rm.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px;font-size:15px";
|
|
979
1181
|
rm.onclick = () => { pushHist(); fa.labels.splice(i, 1); (fa.handles || []).splice(i, 1); select("figartist:" + j); commit(); };
|
|
980
|
-
row.append(tin, mk, ls, rm); elist.appendChild(row);
|
|
1182
|
+
row.append(tin, ci, mk, ls, rm); elist.appendChild(row);
|
|
981
1183
|
});
|
|
982
1184
|
body.appendChild(elist);
|
|
983
1185
|
const addE = document.createElement("button"); addE.className = "pill"; addE.textContent = "+ Entry";
|
|
984
1186
|
addE.onclick = () => { pushHist(); fa.labels = fa.labels || []; fa.handles = fa.handles || []; fa.labels.push("entry"); fa.handles.push({ color: "#b03a2e", lw: 1.5 }); select("figartist:" + j); commit(); };
|
|
985
1187
|
body.appendChild(addE);
|
|
986
1188
|
} else if (fa.type === "suptitle") {
|
|
987
|
-
body.appendChild(text("Text", P("text"), fa.text));
|
|
1189
|
+
body.appendChild(text("Text", P("text"), fa.text, true));
|
|
988
1190
|
body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 14, 6, 26, 0.5));
|
|
1191
|
+
body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
|
|
989
1192
|
body.appendChild(color("Color", P("color"), fa.color || "#000"));
|
|
990
1193
|
}
|
|
991
1194
|
body.appendChild(ctlHead(""));
|
|
@@ -996,7 +1199,7 @@ function buildFigartistInspector(body, j) {
|
|
|
996
1199
|
|
|
997
1200
|
function errorbarSelect(p) {
|
|
998
1201
|
const w = document.createElement("div"); w.className = "ctl"; w.appendChild(ctlHead("Error bars (±)"));
|
|
999
|
-
const sel = document.createElement("select"); sel.className = "ctl-sel";
|
|
1202
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Error bars");
|
|
1000
1203
|
const none = document.createElement("option"); none.value = ""; none.textContent = "(none)"; sel.appendChild(none);
|
|
1001
1204
|
for (const s of state.signals) { const o = document.createElement("option"); o.value = s.ref; o.textContent = s.label; sel.appendChild(o); }
|
|
1002
1205
|
sel.value = p.data.errorbar ? p.data.errorbar.ref : "";
|
|
@@ -1060,7 +1263,7 @@ function buildArtistList(body, pid) {
|
|
|
1060
1263
|
/* ---------------- control builders ---------------- */
|
|
1061
1264
|
function ctlHead(t) { const h = document.createElement("div"); h.className = "section"; h.textContent = t; return h; }
|
|
1062
1265
|
function hint(t) { const h = document.createElement("div"); h.className = "hint"; h.innerHTML = t; return h; }
|
|
1063
|
-
function slider(label, path, val, min, max, step, afterChange) {
|
|
1266
|
+
function slider(label, path, val, min, max, step, afterChange, noClamp) {
|
|
1064
1267
|
const w = document.createElement("div"); w.className = "ctl";
|
|
1065
1268
|
const head = document.createElement("div"); head.className = "ctl-head";
|
|
1066
1269
|
const lab = document.createElement("label"); lab.textContent = label;
|
|
@@ -1071,7 +1274,7 @@ function slider(label, path, val, min, max, step, afterChange) {
|
|
|
1071
1274
|
const vv = document.createElement("input"); vv.type = "text"; vv.className = "val num-in";
|
|
1072
1275
|
vv.inputMode = "decimal"; vv.spellcheck = false; vv.setAttribute("aria-label", label + " value");
|
|
1073
1276
|
head.append(lab, vv); w.appendChild(head);
|
|
1074
|
-
const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step;
|
|
1277
|
+
const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step; inp.setAttribute("aria-label", label + " (slider)");
|
|
1075
1278
|
let cur = val;
|
|
1076
1279
|
const paint = () => { if (document.activeElement !== vv) vv.value = fmt(cur, step); inp.value = cur; };
|
|
1077
1280
|
paint();
|
|
@@ -1085,7 +1288,9 @@ function slider(label, path, val, min, max, step, afterChange) {
|
|
|
1085
1288
|
function commitVal() {
|
|
1086
1289
|
const v = parseFloat(vv.value);
|
|
1087
1290
|
if (isNaN(v)) { paint(); return; } // not a number: keep the last good value
|
|
1088
|
-
|
|
1291
|
+
// Data-coordinate fields (noClamp) take the typed value as authoritative; the
|
|
1292
|
+
// range only bounds coarse dragging. Everything else clamps to the slider range.
|
|
1293
|
+
const cv = noClamp ? v : Math.max(min, Math.min(max, v));
|
|
1089
1294
|
if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec);
|
|
1090
1295
|
cur = cv; setPath(state.spec, path, cv); inp.value = cv; vv.value = fmt(cv, step);
|
|
1091
1296
|
commitSnapshot(); if (afterChange) afterChange(); commit();
|
|
@@ -1097,38 +1302,67 @@ function slider(label, path, val, min, max, step, afterChange) {
|
|
|
1097
1302
|
});
|
|
1098
1303
|
w.appendChild(inp); return w;
|
|
1099
1304
|
}
|
|
1100
|
-
|
|
1305
|
+
// Esc in a half-edited text field reverts to the pre-edit snapshot (not a commit).
|
|
1306
|
+
function wireEscRevert(el) {
|
|
1307
|
+
el.addEventListener("keydown", (e) => {
|
|
1308
|
+
if (e.key === "Escape" && state.sliderSnapshot) {
|
|
1309
|
+
e.preventDefault(); e.stopPropagation();
|
|
1310
|
+
state.spec = state.sliderSnapshot; state.sliderSnapshot = null; updateHist(); commit();
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
function text(label, path, val, multiline) {
|
|
1101
1315
|
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1102
|
-
const inp = document.createElement(
|
|
1316
|
+
const inp = document.createElement(multiline ? "textarea" : "input");
|
|
1317
|
+
if (multiline) { inp.rows = 2; inp.className = "ctl-area"; } else { inp.type = "text"; }
|
|
1318
|
+
inp.value = val == null ? "" : val; inp.placeholder = "Text… (LaTeX like $x_0$ works)"; inp.spellcheck = false; inp.setAttribute("aria-label", label);
|
|
1103
1319
|
inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, inp.value); scheduleDraft(); });
|
|
1104
1320
|
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1321
|
+
wireEscRevert(inp);
|
|
1105
1322
|
w.appendChild(inp); return w;
|
|
1106
1323
|
}
|
|
1107
1324
|
function ticksText(label, path, val) {
|
|
1108
1325
|
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1109
|
-
const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : "";
|
|
1326
|
+
const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : ""; inp.placeholder = "e.g. 0, 5, 10";
|
|
1110
1327
|
const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n)) : "auto"; };
|
|
1111
1328
|
inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
|
|
1112
1329
|
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1330
|
+
wireEscRevert(inp);
|
|
1331
|
+
w.appendChild(inp); return w;
|
|
1332
|
+
}
|
|
1333
|
+
// Comma-separated STRING list (custom tick labels; LaTeX allowed, blank = none).
|
|
1334
|
+
function labelsText(label, path, val) {
|
|
1335
|
+
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1336
|
+
const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : ""; inp.placeholder = "e.g. a, b, c (LaTeX ok)"; inp.spellcheck = false;
|
|
1337
|
+
const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => s.trim()) : null; };
|
|
1338
|
+
inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
|
|
1339
|
+
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1340
|
+
wireEscRevert(inp);
|
|
1113
1341
|
w.appendChild(inp); return w;
|
|
1114
1342
|
}
|
|
1115
1343
|
function num2(label, path, val) {
|
|
1116
1344
|
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1117
1345
|
const row = document.createElement("div"); row.className = "num-row";
|
|
1118
|
-
const a = document.createElement("input"), b = document.createElement("input"), sep = document.createElement("span"); sep.className = "sep"; sep.textContent = "–"; a.type = b.type = "text";
|
|
1346
|
+
const a = document.createElement("input"), b = document.createElement("input"), sep = document.createElement("span"); sep.className = "sep"; sep.textContent = "–"; a.type = b.type = "text"; a.placeholder = "min"; b.placeholder = "max";
|
|
1119
1347
|
a.value = val ? val[0] : ""; b.value = val ? val[1] : "";
|
|
1120
1348
|
const parse = () => { const x = parseFloat(a.value), y = parseFloat(b.value); return (a.value && b.value && !isNaN(x) && !isNaN(y)) ? [x, y] : null; };
|
|
1121
1349
|
a.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); }); b.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
|
|
1122
1350
|
a.addEventListener("change", () => { commitSnapshot(); commit(); }); b.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1351
|
+
wireEscRevert(a); wireEscRevert(b);
|
|
1123
1352
|
row.append(a, sep, b); w.appendChild(row); return w;
|
|
1124
1353
|
}
|
|
1125
1354
|
function color(label, path, val) {
|
|
1126
1355
|
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1127
1356
|
const row = document.createElement("div"); row.className = "color-row";
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1357
|
+
const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || "");
|
|
1358
|
+
const inp = document.createElement("input"); inp.type = "color"; inp.value = isHex(val) ? val : "#000000"; inp.setAttribute("aria-label", label + " (color picker)");
|
|
1359
|
+
const hex = document.createElement("input"); hex.type = "text"; hex.className = "color-hex"; hex.value = val || "#000000"; hex.spellcheck = false; hex.placeholder = "#rrggbb or tab:red"; hex.setAttribute("aria-label", label + " (hex or name)");
|
|
1360
|
+
const apply = (v) => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, v); if (isHex(v)) inp.value = v; scheduleDraft(); };
|
|
1361
|
+
inp.addEventListener("input", () => { apply(inp.value); hex.value = inp.value; });
|
|
1131
1362
|
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1363
|
+
hex.addEventListener("input", () => { apply(hex.value); });
|
|
1364
|
+
hex.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1365
|
+
wireEscRevert(hex);
|
|
1132
1366
|
row.append(inp, hex); w.appendChild(row); return w;
|
|
1133
1367
|
}
|
|
1134
1368
|
function check(label, path, val) {
|
|
@@ -1145,20 +1379,20 @@ function checkInline(label, path, val) {
|
|
|
1145
1379
|
}
|
|
1146
1380
|
function selControl(label, path, val, options) {
|
|
1147
1381
|
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1148
|
-
const sel = document.createElement("select"); sel.className = "ctl-sel";
|
|
1382
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
|
|
1149
1383
|
for (const [v, l] of options) { const o = document.createElement("option"); o.value = v; o.textContent = l; if (v === val) o.selected = true; sel.appendChild(o); }
|
|
1150
1384
|
sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value); commit(); });
|
|
1151
1385
|
w.appendChild(sel); return w;
|
|
1152
1386
|
}
|
|
1153
1387
|
function sigSelect(label, path, val, allowNone) {
|
|
1154
1388
|
const w = document.createElement("div"); w.className = "ctl"; const head = document.createElement("div"); head.className = "ctl-head"; const lab = document.createElement("label"); lab.textContent = label; head.appendChild(lab); w.appendChild(head);
|
|
1155
|
-
const sel = document.createElement("select"); sel.className = "ctl-sel";
|
|
1389
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
|
|
1156
1390
|
if (allowNone) { const o = document.createElement("option"); o.value = ""; o.textContent = "(none)"; if (!val) o.selected = true; sel.appendChild(o); }
|
|
1157
1391
|
for (const s of state.signals) { const o = document.createElement("option"); o.value = s.ref; o.textContent = s.label + (s.ndim > 1 ? " (" + s.shape.join("×") + ")" : ""); if (s.ref === val) o.selected = true; sel.appendChild(o); }
|
|
1158
1392
|
sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value || null); commit(); });
|
|
1159
1393
|
w.appendChild(sel); return w;
|
|
1160
1394
|
}
|
|
1161
|
-
function fmt(v, step) { if (v == null) return "(default)";
|
|
1395
|
+
function fmt(v, step) { if (v == null) return "(default)"; const dp = step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step) + 1e-9)); return String(Number(v.toFixed(dp))); }
|
|
1162
1396
|
|
|
1163
1397
|
/* ---------------- zoom ---------------- */
|
|
1164
1398
|
function applyTransform() {
|
|
@@ -1195,7 +1429,7 @@ function fitZoom() {
|
|
|
1195
1429
|
async function openExport() {
|
|
1196
1430
|
const { code } = await get(`/api/canvases/${state.slug}/code`);
|
|
1197
1431
|
$("#codeBody").textContent = code; $("#codePath").textContent = (state.name || "canvas") + ".py";
|
|
1198
|
-
$("#exportModal")
|
|
1432
|
+
openModal($("#exportModal"));
|
|
1199
1433
|
}
|
|
1200
1434
|
|
|
1201
1435
|
/* ---------------- 3-D view finder (double-click a 3-D plot) ---------------- */
|
|
@@ -1223,7 +1457,7 @@ async function openView3D(pid) {
|
|
|
1223
1457
|
$("#v3e").value = v3d.elev; $("#v3a").value = v3d.azim; $("#v3d").value = v3d.dist;
|
|
1224
1458
|
$("#v3invR").checked = v3d.invertR; $("#v3invZ").checked = v3d.invertZ;
|
|
1225
1459
|
v3dVals();
|
|
1226
|
-
$("#view3dModal")
|
|
1460
|
+
openModal($("#view3dModal"));
|
|
1227
1461
|
v3dRender();
|
|
1228
1462
|
}
|
|
1229
1463
|
function v3dRender() {
|
|
@@ -1267,17 +1501,45 @@ function v3dWire() {
|
|
|
1267
1501
|
pushHist();
|
|
1268
1502
|
const p = state.spec.plots.find((x) => x.id === v3d.pid);
|
|
1269
1503
|
p.style.elev = v3d.elev; p.style.azim = v3d.azim; p.style.dist = v3d.dist;
|
|
1270
|
-
$("#view3dModal")
|
|
1504
|
+
closeModal($("#view3dModal"));
|
|
1271
1505
|
commit();
|
|
1272
1506
|
};
|
|
1273
|
-
const close = () => $("#view3dModal")
|
|
1507
|
+
const close = () => closeModal($("#view3dModal"));
|
|
1274
1508
|
$("#v3dCancel").onclick = close; $("#view3dClose").onclick = close;
|
|
1275
1509
|
$("#view3dModal").onclick = (e) => { if (e.target === $("#view3dModal")) close(); };
|
|
1276
1510
|
}
|
|
1277
1511
|
|
|
1278
1512
|
/* ---------------- toast ---------------- */
|
|
1279
1513
|
let toastTimer;
|
|
1280
|
-
function toast(msg, isErr) { const t = $("#toast"); t.textContent = msg; t.style.background = isErr ? "var(--danger)" : "
|
|
1514
|
+
function toast(msg, isErr) { const t = $("#toast"); t.textContent = msg; t.style.background = isErr ? "var(--danger)" : "var(--toast-bg)"; t.classList.remove("hidden"); requestAnimationFrame(() => t.classList.add("show")); clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.classList.remove("show"); setTimeout(() => t.classList.add("hidden"), 200); }, isErr ? 4000 : 2200); }
|
|
1515
|
+
|
|
1516
|
+
/* ---------------- modal focus management ----------------
|
|
1517
|
+
Move focus into the dialog on open, trap Tab within it, and restore focus to
|
|
1518
|
+
the trigger on close (a11y: aria-modal="true" without a trap lets Tab escape). */
|
|
1519
|
+
let modalLastFocus = null;
|
|
1520
|
+
function _focusables(el) { return [...el.querySelectorAll("button, input, select, textarea")].filter((n) => !n.disabled && n.offsetParent !== null); }
|
|
1521
|
+
function openModal(el) { modalLastFocus = document.activeElement; el.classList.remove("hidden"); el._onTrap = (e) => { if (e.key !== "Tab") return; const f = _focusables(el); if (!f.length) return; const first = f[0], last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; el.addEventListener("keydown", el._onTrap); const f = _focusables(el); if (f.length) f[0].focus(); }
|
|
1522
|
+
function closeModal(el) { el.classList.add("hidden"); if (el._onTrap) { el.removeEventListener("keydown", el._onTrap); el._onTrap = null; } if (modalLastFocus && modalLastFocus.focus) { try { modalLastFocus.focus(); } catch (_) {} } }
|
|
1523
|
+
// Styled confirm (replaces the native window.confirm) — resolves true/false.
|
|
1524
|
+
function confirmDialog(message, okLabel) {
|
|
1525
|
+
return new Promise((resolve) => {
|
|
1526
|
+
let box = document.getElementById("confirmModal");
|
|
1527
|
+
if (!box) {
|
|
1528
|
+
box = document.createElement("div"); box.id = "confirmModal"; box.className = "modal hidden"; box.setAttribute("role", "dialog"); box.setAttribute("aria-modal", "true");
|
|
1529
|
+
box.innerHTML = `<div class="modal-card"><h3>Confirm</h3><p class="confirm-msg"></p><div class="modal-actions modal-actions-right"><button class="textbtn confirm-cancel">Cancel</button><button class="primary confirm-ok"></button></div></div>`;
|
|
1530
|
+
document.body.appendChild(box);
|
|
1531
|
+
}
|
|
1532
|
+
box.querySelector(".confirm-msg").textContent = message;
|
|
1533
|
+
const ok = box.querySelector(".confirm-ok"), cancel = box.querySelector(".confirm-cancel");
|
|
1534
|
+
ok.textContent = okLabel || "Confirm";
|
|
1535
|
+
let settled = false;
|
|
1536
|
+
const done = (v) => { if (settled) return; settled = true; box.classList.add("hidden"); box.onclick = null; ok.onclick = cancel.onclick = null; resolve(v); };
|
|
1537
|
+
ok.onclick = () => done(true);
|
|
1538
|
+
cancel.onclick = () => done(false);
|
|
1539
|
+
box.onclick = (e) => { if (e.target === box) done(false); };
|
|
1540
|
+
openModal(box);
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1281
1543
|
|
|
1282
1544
|
/* ---------------- boot ---------------- */
|
|
1283
1545
|
async function boot() {
|
|
@@ -1289,6 +1551,7 @@ async function boot() {
|
|
|
1289
1551
|
doc = await get(`/api/canvases/${slug}`);
|
|
1290
1552
|
} catch (e) { return showLoadError(e); }
|
|
1291
1553
|
state.name = doc.name;
|
|
1554
|
+
document.title = "Fig Foundry — " + state.name;
|
|
1292
1555
|
state.spec = doc.spec;
|
|
1293
1556
|
state.dataset = doc.spec.dataset || null;
|
|
1294
1557
|
let sigRes;
|
|
@@ -1311,11 +1574,32 @@ function showLoadError(e) {
|
|
|
1311
1574
|
}
|
|
1312
1575
|
function wireChrome() {
|
|
1313
1576
|
v3dWire();
|
|
1577
|
+
// The key handler accepts ⌘ on macOS (metaKey); make the visible labels match.
|
|
1578
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
1579
|
+
const MOD = isMac ? "⌘" : "Ctrl";
|
|
1580
|
+
$("#undoBtn").title = "Undo (" + MOD + "+Z)";
|
|
1581
|
+
$("#redoBtn").title = "Redo (" + MOD + "Shift+Z)";
|
|
1314
1582
|
$("#themeBtn").onclick = cycleTheme;
|
|
1583
|
+
// Help / shortcuts modal
|
|
1584
|
+
const helpRows = [
|
|
1585
|
+
[MOD + "+Z", "Undo"], [MOD + "+Shift+Z", "Redo"],
|
|
1586
|
+
[MOD + "+A", "Select all"], [MOD + "+C / V / X", "Copy / paste / cut"], [MOD + "+D", "Duplicate"],
|
|
1587
|
+
["Delete / Backspace", "Delete selection"], ["Esc", "Deselect / close dialog"],
|
|
1588
|
+
["Arrow keys", "Nudge (Shift = bigger)"], ["Shift+click", "Add to / remove from selection"],
|
|
1589
|
+
["Drag empty space", "Marquee select"], ["Space+drag / middle-drag", "Pan"],
|
|
1590
|
+
["Scroll", "Zoom"], ["Double-click a 3-D plot", "Open the viewfinder"],
|
|
1591
|
+
];
|
|
1592
|
+
$("#helpList").innerHTML = helpRows.map(([k, d]) => `<span class="k">${k}</span><span class="d">${d}</span>`).join("");
|
|
1593
|
+
const helpModal = $("#helpModal");
|
|
1594
|
+
$("#helpBtn").onclick = () => openModal(helpModal);
|
|
1595
|
+
$("#helpClose").onclick = () => closeModal(helpModal);
|
|
1596
|
+
$("#helpOk").onclick = () => closeModal(helpModal);
|
|
1597
|
+
helpModal.onclick = (e) => { if (e.target === helpModal) closeModal(helpModal); };
|
|
1315
1598
|
$("#backBtn").onclick = () => { location.href = "/"; };
|
|
1316
1599
|
$("#figName").addEventListener("change", async () => {
|
|
1317
1600
|
state.name = $("#figName").value.trim() || "Untitled";
|
|
1318
1601
|
$("#figName").value = state.name;
|
|
1602
|
+
document.title = "Fig Foundry — " + state.name;
|
|
1319
1603
|
await api(`/api/canvases/${state.slug}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name }) });
|
|
1320
1604
|
toast("Renamed to " + state.name);
|
|
1321
1605
|
});
|
|
@@ -1325,7 +1609,7 @@ function wireChrome() {
|
|
|
1325
1609
|
location.href = "/canvas?slug=" + r.slug;
|
|
1326
1610
|
};
|
|
1327
1611
|
$("#deleteBtn").onclick = async () => {
|
|
1328
|
-
if (!
|
|
1612
|
+
if (!(await confirmDialog('Delete "' + state.name + '"? This cannot be undone.', "Delete"))) return;
|
|
1329
1613
|
await api(`/api/canvases/${state.slug}`, { method: "DELETE" });
|
|
1330
1614
|
location.href = "/";
|
|
1331
1615
|
};
|
|
@@ -1334,58 +1618,78 @@ function wireChrome() {
|
|
|
1334
1618
|
$("#addPlotBtn").onclick = () => { pushHist(); const sg = state.signals.filter((s) => s.ndim === 1); const x = sg.find((s) => s.name === "x") || sg[0]; const y = sg.find((s) => s !== x) || sg[0]; const p = _blankPlot(nextPlotId(), [0.35, 0.35, 0.3, 0.3], x ? x.ref : null, y ? y.ref : null); p.type = "line"; state.spec.plots.push(p); select("plot:" + p.id); commit(); };
|
|
1335
1619
|
$("#loadDataBtn").onclick = (e) => { e.stopPropagation(); loadDataMenu(e.clientX, e.clientY); };
|
|
1336
1620
|
$("#exportBtn").onclick = openExport;
|
|
1337
|
-
$("#modalClose").onclick = () => $("#exportModal")
|
|
1338
|
-
$("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) $("#exportModal")
|
|
1621
|
+
$("#modalClose").onclick = () => closeModal($("#exportModal"));
|
|
1622
|
+
$("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) closeModal($("#exportModal")); };
|
|
1339
1623
|
$("#copyCode").onclick = async () => { try { await navigator.clipboard.writeText($("#codeBody").textContent); toast("Code copied"); } catch (e) { toast("Copy failed", true); } };
|
|
1340
1624
|
$("#downloadCode").onclick = () => { location.href = `/api/canvases/${state.slug}/download`; toast("Downloading " + (state.name || "canvas") + ".py"); };
|
|
1341
1625
|
$("#dlPng").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=png`; };
|
|
1342
1626
|
$("#dlSvg").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=svg`; };
|
|
1343
1627
|
$("#dlPdf").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=pdf`; };
|
|
1344
|
-
let pan = null, spaceDown = false;
|
|
1628
|
+
let pan = null, spaceDown = false, marquee = null, marqueeEl = null;
|
|
1345
1629
|
stage.addEventListener("wheel", (e) => { if (!state.render) return; e.preventDefault(); setZoom(state.zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12), e.clientX, e.clientY); }, { passive: false });
|
|
1346
1630
|
stage.addEventListener("pointerdown", (e) => {
|
|
1347
1631
|
const empty = e.target === stage || e.target === stageInner || e.target === figImg || e.target === overlay;
|
|
1348
|
-
if (e.button === 1 || (e.button === 0 &&
|
|
1632
|
+
if (e.button === 1 || (e.button === 0 && spaceDown)) {
|
|
1349
1633
|
e.preventDefault();
|
|
1350
1634
|
stage.setPointerCapture(e.pointerId); // pointerup is delivered even if released outside the window
|
|
1351
|
-
pan = { x: e.clientX, y: e.clientY, px: state.pan.x, py: state.pan.y
|
|
1635
|
+
pan = { x: e.clientX, y: e.clientY, px: state.pan.x, py: state.pan.y };
|
|
1636
|
+
} else if (e.button === 0 && empty && !spaceDown) {
|
|
1637
|
+
e.preventDefault();
|
|
1638
|
+
stage.setPointerCapture(e.pointerId);
|
|
1639
|
+
marquee = { x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY, base: e.shiftKey ? new Set(state.selection) : new Set() };
|
|
1640
|
+
if (!marqueeEl) { marqueeEl = document.createElement("div"); marqueeEl.className = "marquee"; stage.appendChild(marqueeEl); }
|
|
1641
|
+
marqueeEl.style.display = "block";
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
window.addEventListener("pointermove", (e) => {
|
|
1645
|
+
if (pan) { state.pan.x = pan.px + (e.clientX - pan.x); state.pan.y = pan.py + (e.clientY - pan.y); applyTransform(); }
|
|
1646
|
+
if (marquee) {
|
|
1647
|
+
marquee.x1 = e.clientX; marquee.y1 = e.clientY;
|
|
1648
|
+
const sr = stage.getBoundingClientRect();
|
|
1649
|
+
marqueeEl.style.left = (Math.min(marquee.x0, marquee.x1) - sr.left) + "px";
|
|
1650
|
+
marqueeEl.style.top = (Math.min(marquee.y0, marquee.y1) - sr.top) + "px";
|
|
1651
|
+
marqueeEl.style.width = Math.abs(marquee.x1 - marquee.x0) + "px";
|
|
1652
|
+
marqueeEl.style.height = Math.abs(marquee.y1 - marquee.y0) + "px";
|
|
1352
1653
|
}
|
|
1353
1654
|
});
|
|
1354
|
-
window.addEventListener("
|
|
1355
|
-
|
|
1356
|
-
|
|
1655
|
+
window.addEventListener("pointerup", (e) => {
|
|
1656
|
+
if (pan) pan = null;
|
|
1657
|
+
if (marquee) {
|
|
1658
|
+
const small = Math.abs(e.clientX - marquee.x0) < 3 && Math.abs(e.clientY - marquee.y0) < 3;
|
|
1659
|
+
if (small) { if (!marquee.base.size) select(null); }
|
|
1660
|
+
else marqueeSelect(marquee.x0, marquee.y0, e.clientX, e.clientY, marquee.base);
|
|
1661
|
+
if (marqueeEl) marqueeEl.style.display = "none";
|
|
1662
|
+
marquee = null;
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
window.addEventListener("pointercancel", () => { pan = null; if (marqueeEl) marqueeEl.style.display = "none"; marquee = null; });
|
|
1357
1666
|
window.addEventListener("keydown", (e) => { if (e.code === "Space" && !/INPUT|SELECT|TEXTAREA/.test(e.target.tagName || "")) { spaceDown = true; stage.style.cursor = "grab"; } });
|
|
1358
1667
|
window.addEventListener("keyup", (e) => { if (e.code === "Space") { spaceDown = false; stage.style.cursor = ""; } });
|
|
1359
1668
|
document.addEventListener("keydown", (e) => {
|
|
1360
1669
|
const exportOpen = !$("#exportModal").classList.contains("hidden");
|
|
1361
1670
|
const v3dOpen = !$("#view3dModal").classList.contains("hidden");
|
|
1671
|
+
const helpOpen = !$("#helpModal").classList.contains("hidden");
|
|
1362
1672
|
// Escape closes a modal first (works even when a slider inside it has focus)
|
|
1363
|
-
if (e.key === "Escape" && (exportOpen || v3dOpen)) { if (exportOpen) $("#exportModal")
|
|
1673
|
+
if (e.key === "Escape" && (exportOpen || v3dOpen || helpOpen)) { if (exportOpen) closeModal($("#exportModal")); else if (v3dOpen) closeModal($("#view3dModal")); else closeModal($("#helpModal")); return; }
|
|
1364
1674
|
const tgt = e.target;
|
|
1365
|
-
|
|
1675
|
+
// Only text-like fields own their keys (typing, native Ctrl+C, Esc-revert). A focused
|
|
1676
|
+
// range/checkbox/color/select must still let modifier chords, Delete and Escape through
|
|
1677
|
+
// to the global handlers (previously every INPUT swallowed Ctrl+Z).
|
|
1678
|
+
const textLike = tgt.isContentEditable || tgt.tagName === "TEXTAREA" || (tgt.tagName === "INPUT" && /text|number|search|email|tel|url|password/.test(tgt.type));
|
|
1679
|
+
if (textLike) return;
|
|
1680
|
+
if ((tgt.tagName === "INPUT" || tgt.tagName === "SELECT") && !e.ctrlKey && !e.metaKey && !["Delete", "Backspace", "Escape"].includes(e.key)) return; // plain keys (arrows) stay native on a focused control
|
|
1366
1681
|
if (e.key === "Escape") { select(null); return; }
|
|
1367
1682
|
if (exportOpen || v3dOpen) return; // a modal is up: global shortcuts must not edit the figure behind it
|
|
1368
|
-
|
|
1369
|
-
|
|
1683
|
+
const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
|
|
1684
|
+
if (mod && k === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
|
1685
|
+
else if (mod && k === "y") { e.preventDefault(); redo(); }
|
|
1686
|
+
else if (mod && k === "a") { e.preventDefault(); state.selection = new Set([...state.spec.plots.map((p) => "plot:" + p.id), ...state.spec.figure_artists.map((_, j) => "figartist:" + j)]); state.selected = state.selection.size ? [...state.selection].pop() : null; refreshSelection(); buildInspector(); }
|
|
1687
|
+
else if (mod && k === "c") { e.preventDefault(); copySelection(); }
|
|
1688
|
+
else if (mod && k === "v") { e.preventDefault(); pasteClipboard(); }
|
|
1689
|
+
else if (mod && k === "x") { e.preventDefault(); copySelection(); deleteSelection(); }
|
|
1690
|
+
else if (mod && k === "d") { e.preventDefault(); copySelection(); pasteClipboard(); }
|
|
1370
1691
|
else if (e.key.startsWith("Arrow") && state.selected) { e.preventDefault(); const s = e.shiftKey ? 0.05 : 0.005; if (e.key === "ArrowLeft") nudgeSelected(-s, 0); else if (e.key === "ArrowRight") nudgeSelected(s, 0); else if (e.key === "ArrowUp") nudgeSelected(0, s); else if (e.key === "ArrowDown") nudgeSelected(0, -s); }
|
|
1371
|
-
else if ((e.key === "Delete" || e.key === "Backspace") && state.selected) {
|
|
1372
|
-
e.preventDefault(); pushHist();
|
|
1373
|
-
const ids = state.selection.size ? [...state.selection] : [state.selected];
|
|
1374
|
-
const plotIds = new Set(ids.filter((id) => id.startsWith("plot:")).map((id) => id.slice(5)));
|
|
1375
|
-
if (plotIds.size) state.spec.plots = state.spec.plots.filter((p) => !plotIds.has(p.id));
|
|
1376
|
-
const artDel = new Map(), lineDel = new Map(), figDel = [];
|
|
1377
|
-
for (const id of ids) {
|
|
1378
|
-
if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); if (!artDel.has(pid)) artDel.set(pid, []); artDel.get(pid).push(+j); }
|
|
1379
|
-
else if (id.startsWith("line:")) { const [, pid, j] = id.split(":"); if (!lineDel.has(pid)) lineDel.set(pid, []); lineDel.get(pid).push(+j); }
|
|
1380
|
-
else if (id.startsWith("figartist:")) figDel.push(+id.slice(10));
|
|
1381
|
-
else if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) p.title.visible = false; }
|
|
1382
|
-
}
|
|
1383
|
-
// splice high-to-low so deleting several items at once doesn't shift the other indices
|
|
1384
|
-
for (const [pid, js] of artDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p) js.sort((a, b) => b - a).forEach((j) => p.artists.splice(j, 1)); }
|
|
1385
|
-
for (const [pid, js] of lineDel) { const p = state.spec.plots.find((x) => x.id === pid); if (p && p.extra_lines) js.sort((a, b) => b - a).forEach((j) => p.extra_lines.splice(j, 1)); }
|
|
1386
|
-
figDel.sort((a, b) => b - a).forEach((j) => state.spec.figure_artists.splice(j, 1));
|
|
1387
|
-
select(null); commit();
|
|
1388
|
-
}
|
|
1692
|
+
else if ((e.key === "Delete" || e.key === "Backspace") && (state.selected || state.selection.size)) { e.preventDefault(); deleteSelection(); }
|
|
1389
1693
|
});
|
|
1390
1694
|
// settle (full-quality render + save) the instant an arrow key is released
|
|
1391
1695
|
document.addEventListener("keyup", (e) => {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
})();
|
|
17
17
|
</script>
|
|
18
18
|
<title>Fig Foundry</title>
|
|
19
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect x='2' y='2' width='60' height='60' rx='15' fill='%232f6690'/%3E%3Cpolyline points='16,45 27,31 35,39 49,20' fill='none' stroke='%23fff' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E">
|
|
19
20
|
<link rel="stylesheet" href="/static/style.css">
|
|
20
21
|
<link rel="stylesheet" href="/static/canvas.css">
|
|
21
22
|
</head>
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
artwork stays publication-clean no matter what the UI chrome looks like.
|
|
8
8
|
========================================================================== */
|
|
9
9
|
:root {
|
|
10
|
+
color-scheme: light;
|
|
10
11
|
/* surfaces */
|
|
11
12
|
--bg: #eef0f3;
|
|
12
13
|
--panel: #ffffff;
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
/* text */
|
|
18
19
|
--ink: #1c2026;
|
|
19
20
|
--muted: #58616d;
|
|
20
|
-
--faint: #
|
|
21
|
+
--faint: #5d6673;
|
|
21
22
|
/* lines */
|
|
22
23
|
--line: #e6e8ec;
|
|
23
24
|
--line-strong: #d3d8de;
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
[data-theme="dark"] {
|
|
54
|
+
color-scheme: dark;
|
|
53
55
|
--bg: #0f1115;
|
|
54
56
|
--panel: #171a21;
|
|
55
57
|
--panel-2: #1d212a;
|
|
@@ -285,6 +287,13 @@ input[type="text"] {
|
|
|
285
287
|
transition: border-color .12s, box-shadow .12s;
|
|
286
288
|
}
|
|
287
289
|
input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
|
290
|
+
textarea.ctl-area {
|
|
291
|
+
width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
|
|
292
|
+
font-size: 13px; line-height: 1.35; color: var(--ink); background: var(--input-bg); outline: none;
|
|
293
|
+
font-family: inherit; resize: vertical; min-height: 42px;
|
|
294
|
+
transition: border-color .12s, box-shadow .12s;
|
|
295
|
+
}
|
|
296
|
+
textarea.ctl-area:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
|
288
297
|
.color-row { display: flex; align-items: center; gap: 10px; }
|
|
289
298
|
input[type="color"] {
|
|
290
299
|
width: 38px; height: 30px; border: 1px solid var(--line); border-radius: 7px;
|
|
@@ -347,3 +356,10 @@ input[type="color"] {
|
|
|
347
356
|
::-webkit-scrollbar { width: 11px; height: 11px; }
|
|
348
357
|
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
|
|
349
358
|
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); border: 3px solid transparent; background-clip: content-box; }
|
|
359
|
+
|
|
360
|
+
/* responsive topbar: wrap + compact below ~1100px so the Export button never clips */
|
|
361
|
+
@media (max-width: 1100px) {
|
|
362
|
+
.topbar { flex-wrap: wrap; height: auto; padding: 8px 12px; row-gap: 8px; }
|
|
363
|
+
.fig-name { flex: 1 1 120px; max-width: 170px; }
|
|
364
|
+
#zoomLabel { display: none; }
|
|
365
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "figfoundry"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "WYSIWYG matplotlib figure composer: load data (.npz/.pkl), drag signals onto a canvas to build plots, and export clean reproducible code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|