figfoundry 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {figfoundry-0.2.1 → figfoundry-0.3.0}/PKG-INFO +1 -1
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/canvas.py +55 -18
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/static/canvas.css +12 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/static/canvas.html +10 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/static/canvas.js +391 -90
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/static/index.html +1 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/static/style.css +17 -1
- {figfoundry-0.2.1 → figfoundry-0.3.0}/pyproject.toml +1 -1
- {figfoundry-0.2.1 → figfoundry-0.3.0}/uv.lock +1 -1
- {figfoundry-0.2.1 → figfoundry-0.3.0}/.gitattributes +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/.gitignore +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/LICENSE +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/README.md +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/__init__.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/__main__.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/app.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/cli.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/figfoundry/data_io.py +0 -0
- {figfoundry-0.2.1 → figfoundry-0.3.0}/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.0
|
|
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,75 @@ 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
|
+
const onMove = (ev) => {
|
|
256
|
+
let d = (Math.atan2(ev.clientY - cy0, ev.clientX - cx0) - startAng) * 180 / Math.PI;
|
|
257
|
+
if (ev.shiftKey) d = Math.round(d / 15) * 15;
|
|
258
|
+
let r = Math.round(rot0 + d); r = ((r % 360) + 360) % 360; if (r > 180) r -= 360;
|
|
259
|
+
tgt.set(r); movDiv.style.transform = `rotate(${r}deg)`; scheduleDraft();
|
|
260
|
+
};
|
|
261
|
+
const onUp = (ev) => { try { h.releasePointerCapture(ev.pointerId); } catch (_) {} document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); movDiv.style.transform = ""; commitSnapshot(); commit(); };
|
|
262
|
+
document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp);
|
|
263
|
+
});
|
|
264
|
+
movDiv.appendChild(h);
|
|
265
|
+
}
|
|
199
266
|
|
|
200
267
|
function refreshSelection() {
|
|
201
268
|
overlay.querySelectorAll(".plotbox").forEach((d) => d.classList.toggle("selected", state.selection.has("plot:" + d.dataset.pid)));
|
|
202
269
|
overlay.querySelectorAll(".mov").forEach((d) => d.classList.toggle("selected", state.selection.has(d.dataset.id)));
|
|
203
270
|
document.querySelectorAll(".layer").forEach((l) => l.classList.toggle("active", l.dataset.id && state.selection.has(l.dataset.id)));
|
|
271
|
+
drawGroupBox();
|
|
272
|
+
}
|
|
273
|
+
// Dashed outline around a multi-selection so the group's extent is visible.
|
|
274
|
+
function drawGroupBox() {
|
|
275
|
+
let gb = document.getElementById("groupBox");
|
|
276
|
+
if (state.selection.size < 2) { if (gb) gb.remove(); return; }
|
|
277
|
+
const r = state.render; if (!r) return;
|
|
278
|
+
let l = 1, b = 1, rr = 0, tt = 0, any = false;
|
|
279
|
+
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]); };
|
|
280
|
+
for (const id of state.selection) {
|
|
281
|
+
if (id.startsWith("plot:")) { const p = state.spec.plots.find((x) => x.id === id.slice(5)); if (p) absorb(p.rect); }
|
|
282
|
+
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]); }
|
|
283
|
+
}
|
|
284
|
+
if (!any) return;
|
|
285
|
+
if (!gb) { gb = document.createElement("div"); gb.id = "groupBox"; overlay.appendChild(gb); }
|
|
286
|
+
gb.style.left = (l * 100) + "%"; gb.style.top = ((1 - tt) * 100) + "%";
|
|
287
|
+
gb.style.width = ((rr - l) * 100) + "%"; gb.style.height = ((tt - b) * 100) + "%";
|
|
288
|
+
gb.style.display = "block";
|
|
289
|
+
}
|
|
290
|
+
// Rubber-band select: everything whose bbox intersects the drag rect (in figure
|
|
291
|
+
// fractions). Shift adds to the current selection.
|
|
292
|
+
function marqueeSelect(x0, y0, x1, y1, base) {
|
|
293
|
+
const a = toFig(x0, y0), b = toFig(x1, y1);
|
|
294
|
+
const L = Math.min(a.fx, b.fx), R = Math.max(a.fx, b.fx);
|
|
295
|
+
const T = 1 - Math.min(a.fyTop, b.fyTop), B = 1 - Math.max(a.fyTop, b.fyTop);
|
|
296
|
+
const hit = (box) => box[0] < R && box[0] + box[2] > L && box[1] < T && box[1] + box[3] > B;
|
|
297
|
+
const sel = new Set(base);
|
|
298
|
+
for (const p of state.spec.plots) if (hit(p.rect)) sel.add("plot:" + p.id);
|
|
299
|
+
const r = state.render;
|
|
300
|
+
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); }
|
|
301
|
+
state.selection = sel; state.selected = sel.size ? [...sel].pop() : null;
|
|
302
|
+
refreshSelection(); buildInspector();
|
|
204
303
|
}
|
|
205
304
|
|
|
206
305
|
/* ---------------- move / resize / drag ---------------- */
|
|
@@ -212,7 +311,7 @@ function makeMovable(el, id, mode) {
|
|
|
212
311
|
const shift = e.shiftKey;
|
|
213
312
|
const handle = (e.target.dataset && e.target.dataset.h) || null;
|
|
214
313
|
const startF = toFig(e.clientX, e.clientY);
|
|
215
|
-
let moved = false, onMove, onUp, sx = 0, sy = 0;
|
|
314
|
+
let moved = false, onMove, onUp, sx = 0, sy = 0, lastDxF = 0, lastDyF = 0, isGroup = false, groupDivs = [el];
|
|
216
315
|
if (mode === "box") {
|
|
217
316
|
const p = state.spec.plots.find((x) => x.id === id.slice(5));
|
|
218
317
|
const rect0 = p.rect.slice();
|
|
@@ -224,6 +323,9 @@ function makeMovable(el, id, mode) {
|
|
|
224
323
|
onMove = (ev) => {
|
|
225
324
|
const F = toFig(ev.clientX, ev.clientY);
|
|
226
325
|
const dxF = F.fx - startF.fx, dyBF = startF.fyTop - F.fyTop;
|
|
326
|
+
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
|
|
327
|
+
if (!moved) pushHist(); // capture pre-drag state so a move/resize is undoable
|
|
328
|
+
moved = true;
|
|
227
329
|
if (handle === null) {
|
|
228
330
|
const rect = rect0.slice(); rect[0] += dxF; rect[1] += dyBF;
|
|
229
331
|
const guides = snapRect(rect, others, r.img_w, r.img_h);
|
|
@@ -244,14 +346,31 @@ function makeMovable(el, id, mode) {
|
|
|
244
346
|
Object.assign(el.style, rectToCss(rect));
|
|
245
347
|
}
|
|
246
348
|
scheduleDraft();
|
|
247
|
-
moved = true;
|
|
248
349
|
};
|
|
249
350
|
} else {
|
|
250
351
|
sx = e.clientX; sy = e.clientY;
|
|
352
|
+
const r0 = state.render;
|
|
353
|
+
const imgWpx = r0 ? r0.img_w * state.zoom : 1, imgHpx = r0 ? r0.img_h * state.zoom : 1;
|
|
354
|
+
const hs0 = r0 && r0.hotspots.find((h) => h.id === id);
|
|
355
|
+
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;
|
|
356
|
+
const others = startBox ? snapModel(id) : [];
|
|
357
|
+
isGroup = state.selection.size > 1 && state.selection.has(id);
|
|
358
|
+
groupDivs = isGroup ? [...overlay.querySelectorAll(".mov")].filter((d) => state.selection.has(d.dataset.id)) : [el];
|
|
251
359
|
onMove = (ev) => {
|
|
252
360
|
const dx = ev.clientX - sx, dy = ev.clientY - sy;
|
|
253
|
-
|
|
254
|
-
if (
|
|
361
|
+
if (!moved && Math.abs(dx) < 3 && Math.abs(dy) < 3) return; // sub-3px = a click, not a drag
|
|
362
|
+
if (!moved) pushHist(); // capture pre-drag state so a move is undoable
|
|
363
|
+
moved = true;
|
|
364
|
+
let dxF = dx / imgWpx, dyF = -dy / imgHpx;
|
|
365
|
+
if (startBox) {
|
|
366
|
+
const box = startBox.slice(); box[0] += dxF; box[1] += dyF;
|
|
367
|
+
drawGuides(snapBoxPos(box, others, r0.img_w, r0.img_h));
|
|
368
|
+
lastDxF = box[0] - startBox[0]; lastDyF = box[1] - startBox[1]; // the snapped delta
|
|
369
|
+
} else { lastDxF = dxF; lastDyF = dyF; }
|
|
370
|
+
// el sits inside the zoomed stageInner, so its local px are screen px / zoom;
|
|
371
|
+
// a group drag moves every selected label by the same (snapped) delta.
|
|
372
|
+
groupDivs.forEach((d) => { d.style.transform = `translate(${lastDxF * imgWpx / state.zoom}px, ${-lastDyF * imgHpx / state.zoom}px)`; });
|
|
373
|
+
scheduleDraft();
|
|
255
374
|
};
|
|
256
375
|
}
|
|
257
376
|
onUp = (ev) => {
|
|
@@ -259,9 +378,9 @@ function makeMovable(el, id, mode) {
|
|
|
259
378
|
document.removeEventListener("pointerup", onUp);
|
|
260
379
|
document.removeEventListener("pointercancel", onUp);
|
|
261
380
|
drawGuides([]);
|
|
262
|
-
|
|
381
|
+
groupDivs.forEach((d) => { d.style.transform = ""; });
|
|
263
382
|
if (!moved) { select(id, shift); return; }
|
|
264
|
-
if (mode !== "box")
|
|
383
|
+
if (mode !== "box") { const ids = isGroup ? [...state.selection] : [id]; for (const eid of ids) commitArtDrag(eid, lastDxF, lastDyF); }
|
|
265
384
|
commit();
|
|
266
385
|
};
|
|
267
386
|
document.addEventListener("pointermove", onMove);
|
|
@@ -270,11 +389,9 @@ function makeMovable(el, id, mode) {
|
|
|
270
389
|
});
|
|
271
390
|
}
|
|
272
391
|
|
|
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;
|
|
392
|
+
function commitArtDrag(id, dxF, dyF) {
|
|
393
|
+
// Apply the drag DELTA (in figure fractions, already snapped) to the element's own
|
|
394
|
+
// coordinates, so the element lands exactly where the user dropped it.
|
|
278
395
|
const r = state.spec;
|
|
279
396
|
if (id.startsWith("title:")) {
|
|
280
397
|
const pid = id.slice(6); const p = r.plots.find((x) => x.id === pid);
|
|
@@ -320,6 +437,47 @@ async function commit() {
|
|
|
320
437
|
await doRender(false);
|
|
321
438
|
}
|
|
322
439
|
|
|
440
|
+
/* ---------------- clipboard (copy / paste / cut / duplicate) ----------------
|
|
441
|
+
Copies top-level elements: plots (with their artists/lines) and figure_artists.
|
|
442
|
+
Signal refs are name-based, so a same-figure paste is always valid. */
|
|
443
|
+
function copySelection() {
|
|
444
|
+
const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
|
|
445
|
+
if (!ids.length) return;
|
|
446
|
+
const plotIds = new Set(ids.filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
|
|
447
|
+
const figIdxs = ids.filter((i) => i.startsWith("figartist:")).map((i) => +i.slice(10));
|
|
448
|
+
const plots = state.spec.plots.filter((p) => plotIds.has(p.id)).map((p) => clone(p));
|
|
449
|
+
const figs = figIdxs.map((j) => clone(state.spec.figure_artists[j]));
|
|
450
|
+
if (!plots.length && !figs.length) return;
|
|
451
|
+
state.clipboard = { plots, figs };
|
|
452
|
+
toast("Copied " + (plots.length + figs.length) + " element" + (plots.length + figs.length > 1 ? "s" : ""));
|
|
453
|
+
}
|
|
454
|
+
function pasteClipboard() {
|
|
455
|
+
const cb = state.clipboard; if (!cb || (!cb.plots.length && !cb.figs.length)) return;
|
|
456
|
+
pushHist();
|
|
457
|
+
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); }
|
|
458
|
+
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); }
|
|
459
|
+
select(null); commit();
|
|
460
|
+
}
|
|
461
|
+
function deleteSelection() {
|
|
462
|
+
if (!state.selected && !state.selection.size) return;
|
|
463
|
+
pushHist();
|
|
464
|
+
const ids = state.selection.size ? [...state.selection] : [state.selected];
|
|
465
|
+
const plotIds = new Set(ids.filter((id) => id.startsWith("plot:")).map((id) => id.slice(5)));
|
|
466
|
+
if (plotIds.size) state.spec.plots = state.spec.plots.filter((p) => !plotIds.has(p.id));
|
|
467
|
+
const artDel = new Map(), lineDel = new Map(), figDel = [];
|
|
468
|
+
for (const id of ids) {
|
|
469
|
+
if (id.startsWith("artist:")) { const [, pid, j] = id.split(":"); if (!artDel.has(pid)) artDel.set(pid, []); artDel.get(pid).push(+j); }
|
|
470
|
+
else if (id.startsWith("line:")) { const [, pid, j] = id.split(":"); if (!lineDel.has(pid)) lineDel.set(pid, []); lineDel.get(pid).push(+j); }
|
|
471
|
+
else if (id.startsWith("figartist:")) figDel.push(+id.slice(10));
|
|
472
|
+
else if (id.startsWith("title:")) { const p = state.spec.plots.find((x) => x.id === id.slice(6)); if (p) p.title.visible = false; }
|
|
473
|
+
}
|
|
474
|
+
// splice high-to-low so deleting several items at once doesn't shift the other indices
|
|
475
|
+
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)); }
|
|
476
|
+
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)); }
|
|
477
|
+
figDel.sort((a, b) => b - a).forEach((j) => state.spec.figure_artists.splice(j, 1));
|
|
478
|
+
select(null); commit();
|
|
479
|
+
}
|
|
480
|
+
|
|
323
481
|
let nudgePushed = false, nudgeTimer = null;
|
|
324
482
|
function nudgeSelected(dx, dy) {
|
|
325
483
|
const ids = state.selection.size ? [...state.selection] : (state.selected ? [state.selected] : []);
|
|
@@ -336,7 +494,16 @@ function nudgeOne(id, dx, dy) {
|
|
|
336
494
|
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
495
|
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
496
|
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) {
|
|
497
|
+
else if (kind === "artist") { const p = state.spec.plots.find((x) => x.id === pid); const a = p && p.artists[j]; if (a) {
|
|
498
|
+
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)); }
|
|
499
|
+
else if (a.type === "vline") a.x = r4(a.x + dx * 8);
|
|
500
|
+
else if (a.type === "hline") a.y = r4(a.y + dy * 8);
|
|
501
|
+
else if (a.type === "circle" || a.type === "ellipse") { a.x = r4(a.x + dx * 8); a.y = r4(a.y + dy * 8); }
|
|
502
|
+
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)); }
|
|
503
|
+
else if (a.type === "vspan") { a.x0 = r4(a.x0 + dx * 8); a.x1 = r4(a.x1 + dx * 8); }
|
|
504
|
+
else if (a.type === "annotation") { a.xytext = [r4(a.xytext[0] + dx), r4(a.xytext[1] + dy)]; }
|
|
505
|
+
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); }
|
|
506
|
+
} }
|
|
340
507
|
}
|
|
341
508
|
|
|
342
509
|
/* ---------------- signal drag -> create plot ---------------- */
|
|
@@ -520,6 +687,8 @@ function addPlotAt(fx, fyB, signal) {
|
|
|
520
687
|
pushHist();
|
|
521
688
|
const w = 0.3, h = 0.3;
|
|
522
689
|
const rect = [r4(clamp(fx - w / 2, 0, 1 - w)), r4(clamp(fyB - h / 2, 0, 1 - h)), w, h];
|
|
690
|
+
const r = state.render || { img_w: 1000, img_h: 1000 };
|
|
691
|
+
snapRect(rect, state.spec.plots.map((p) => p.rect), r.img_w, r.img_h); // snap the new plot on drop
|
|
523
692
|
const p = plotForSignal(signal, rect);
|
|
524
693
|
state.spec.plots.push(p);
|
|
525
694
|
select("plot:" + p.id);
|
|
@@ -701,7 +870,13 @@ function buildFigureElementsSection() {
|
|
|
701
870
|
}
|
|
702
871
|
|
|
703
872
|
function layoutAll(op, param) {
|
|
704
|
-
|
|
873
|
+
// Act on the selected plots when a multi-selection is active, else all plots.
|
|
874
|
+
let plots = state.spec.plots;
|
|
875
|
+
if (state.selection.size > 1) {
|
|
876
|
+
const selIds = new Set([...state.selection].filter((i) => i.startsWith("plot:")).map((i) => i.slice(5)));
|
|
877
|
+
if (selIds.size > 1) plots = plots.filter((p) => selIds.has(p.id));
|
|
878
|
+
}
|
|
879
|
+
if (!plots.length) return;
|
|
705
880
|
const L = state.spec.layout || { margin: 0.05, gap: 0.04 };
|
|
706
881
|
const rects = plots.map((p) => p.rect);
|
|
707
882
|
pushHist();
|
|
@@ -788,7 +963,7 @@ function plotTypeSelect(pid) {
|
|
|
788
963
|
const p = state.spec.plots[idx(pid)];
|
|
789
964
|
const w = document.createElement("div"); w.className = "ctl";
|
|
790
965
|
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";
|
|
966
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Plot type");
|
|
792
967
|
const groups = {};
|
|
793
968
|
for (const [k, v] of Object.entries(PLOT_TYPES)) (groups[v.group] = groups[v.group] || []).push([k, v.label]);
|
|
794
969
|
for (const gname of Object.keys(groups)) {
|
|
@@ -835,9 +1010,11 @@ function buildPlotInspector(body, pid) {
|
|
|
835
1010
|
body.appendChild(ctlHead("Axes"));
|
|
836
1011
|
body.appendChild(ticksText("X ticks (blank = auto)", ["plots", idx(pid), "axes", "xticks"], p.axes.xticks));
|
|
837
1012
|
body.appendChild(ticksText("Y ticks (blank = auto)", ["plots", idx(pid), "axes", "yticks"], p.axes.yticks));
|
|
838
|
-
body.appendChild(
|
|
1013
|
+
body.appendChild(labelsText("X tick labels (blank = auto)", ["plots", idx(pid), "axes", "xticklabels"], p.axes.xticklabels));
|
|
1014
|
+
body.appendChild(labelsText("Y tick labels (blank = auto)", ["plots", idx(pid), "axes", "yticklabels"], p.axes.yticklabels));
|
|
1015
|
+
body.appendChild(text("X label", ["plots", idx(pid), "axes", "xlabel"], p.axes.xlabel, true));
|
|
839
1016
|
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));
|
|
1017
|
+
body.appendChild(text("Y label", ["plots", idx(pid), "axes", "ylabel"], p.axes.ylabel, true));
|
|
841
1018
|
body.appendChild(check("Show Y label", ["plots", idx(pid), "axes", "ylabel_visible"], p.axes.ylabel_visible));
|
|
842
1019
|
body.appendChild(hint("Labels and titles support LaTeX mathtext, e.g. <code>$x_0$</code>."));
|
|
843
1020
|
body.appendChild(num2("X range (blank = auto)", ["plots", idx(pid), "axes", "xlim"], p.axes.xlim));
|
|
@@ -855,7 +1032,7 @@ function buildPlotInspector(body, pid) {
|
|
|
855
1032
|
body.appendChild(check("Show y tick labels", ["plots", idx(pid), "axes", "labelleft"], p.axes.labelleft));
|
|
856
1033
|
body.appendChild(ctlHead("Title"));
|
|
857
1034
|
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));
|
|
1035
|
+
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], p.title.text, true));
|
|
859
1036
|
body.appendChild(slider("Title size", ["plots", idx(pid), "title", "fontsize"], p.title.fontsize, 6, 24, 0.5));
|
|
860
1037
|
body.appendChild(selControl("Title weight", ["plots", idx(pid), "title", "weight"], p.title.weight, [["normal", "Normal"], ["bold", "Bold"]]));
|
|
861
1038
|
body.appendChild(ctlHead("Artists"));
|
|
@@ -869,10 +1046,11 @@ const idx = (pid) => state.spec.plots.findIndex((p) => p.id === pid);
|
|
|
869
1046
|
|
|
870
1047
|
function buildTitleInspector(body, pid) {
|
|
871
1048
|
const t = state.spec.plots.find((x) => x.id === pid).title;
|
|
872
|
-
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text));
|
|
1049
|
+
body.appendChild(text("Title text", ["plots", idx(pid), "title", "text"], t.text, true));
|
|
873
1050
|
body.appendChild(slider("X (of panel)", ["plots", idx(pid), "title", "x"], t.x, -0.6, 1.6, 0.02));
|
|
874
1051
|
body.appendChild(slider("Y (of panel)", ["plots", idx(pid), "title", "y"], t.y, -0.6, 1.6, 0.02));
|
|
875
1052
|
body.appendChild(slider("Size (pt)", ["plots", idx(pid), "title", "fontsize"], t.fontsize, 6, 24, 0.5));
|
|
1053
|
+
body.appendChild(slider("Rotation (deg)", ["plots", idx(pid), "title", "rotation"], t.rotation ?? 0, -180, 180, 1));
|
|
876
1054
|
body.appendChild(selControl("Weight", ["plots", idx(pid), "title", "weight"], t.weight, [["normal", "Normal"], ["bold", "Bold"]]));
|
|
877
1055
|
body.appendChild(color("Color", ["plots", idx(pid), "title", "color"], t.color));
|
|
878
1056
|
body.appendChild(hint("Drag the title on the canvas to reposition it freely."));
|
|
@@ -881,14 +1059,15 @@ function buildTitleInspector(body, pid) {
|
|
|
881
1059
|
function buildArtistInspector(body, pid, j) {
|
|
882
1060
|
const a = state.spec.plots.find((x) => x.id === pid).artists[j];
|
|
883
1061
|
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")); }
|
|
1062
|
+
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)); }
|
|
1063
|
+
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)); }
|
|
1064
|
+
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
1065
|
else if (a.type === "annotation") {
|
|
888
|
-
body.appendChild(text("Text", P("text"), a.text));
|
|
1066
|
+
body.appendChild(text("Text", P("text"), a.text, true));
|
|
889
1067
|
body.appendChild(num2("Arrow points to (data x, y)", P("xy"), a.xy));
|
|
890
1068
|
body.appendChild(num2("Label at (panel x, y)", P("xytext"), a.xytext));
|
|
891
1069
|
body.appendChild(slider("Label size", P("fontsize"), a.fontsize ?? 10, 6, 20, 0.5));
|
|
1070
|
+
body.appendChild(slider("Rotation (deg)", P("rotation"), a.rotation ?? 0, -180, 180, 1));
|
|
892
1071
|
body.appendChild(slider("Arrow width", P("lw"), a.lw ?? 1.2, 0.3, 4, 0.1));
|
|
893
1072
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
894
1073
|
}
|
|
@@ -903,22 +1082,22 @@ function buildArtistInspector(body, pid, j) {
|
|
|
903
1082
|
body.appendChild(slider("Edge width", P("lw"), a.lw ?? 1, 0, 4, 0.1));
|
|
904
1083
|
}
|
|
905
1084
|
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));
|
|
1085
|
+
body.appendChild(slider("X start (data)", P("x0"), a.x0, 0, 20, 0.5, null, true));
|
|
1086
|
+
body.appendChild(slider("X end (data)", P("x1"), a.x1, 0, 20, 0.5, null, true));
|
|
908
1087
|
body.appendChild(color("Color", P("color"), a.color || "#b03a2e"));
|
|
909
1088
|
body.appendChild(slider("Opacity", P("alpha"), a.alpha ?? 0.12, 0, 0.6, 0.01));
|
|
910
1089
|
}
|
|
911
1090
|
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));
|
|
1091
|
+
body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
|
|
1092
|
+
body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
|
|
914
1093
|
body.appendChild(slider("Radius", P("r"), a.r ?? 1, 0.05, 10, 0.1));
|
|
915
1094
|
body.appendChild(check("Filled", P("fill"), !!a.fill));
|
|
916
1095
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
917
1096
|
body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
|
|
918
1097
|
}
|
|
919
1098
|
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));
|
|
1099
|
+
body.appendChild(slider("Center X (data)", P("x"), a.x, -10, 20, 0.2, null, true));
|
|
1100
|
+
body.appendChild(slider("Center Y (data)", P("y"), a.y, -10, 10, 0.2, null, true));
|
|
922
1101
|
body.appendChild(slider("Width", P("w"), a.w ?? 2, 0.1, 10, 0.1));
|
|
923
1102
|
body.appendChild(slider("Height", P("h"), a.h ?? 1, 0.1, 10, 0.1));
|
|
924
1103
|
body.appendChild(slider("Angle (deg)", P("angle"), a.angle ?? 0, -180, 180, 5));
|
|
@@ -930,13 +1109,29 @@ function buildArtistInspector(body, pid, j) {
|
|
|
930
1109
|
body.appendChild(check("Filled", P("fill"), !!a.fill));
|
|
931
1110
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
932
1111
|
body.appendChild(slider("Width", P("lw"), a.lw ?? 1, 0.2, 4, 0.1));
|
|
933
|
-
|
|
1112
|
+
const pts = (a.points && a.points.length) ? a.points.slice() : [[4, 0], [6, 0], [5, 1]];
|
|
1113
|
+
body.appendChild(ctlHead("Points (data coords)"));
|
|
1114
|
+
const plist = document.createElement("div"); plist.className = "art-list";
|
|
1115
|
+
pts.forEach((pt, vi) => {
|
|
1116
|
+
const r2 = document.createElement("div"); r2.className = "num-row";
|
|
1117
|
+
const nx = document.createElement("input"), ny = document.createElement("input"), sep = document.createElement("span");
|
|
1118
|
+
sep.className = "sep"; sep.textContent = ","; nx.type = ny.type = "text"; nx.value = pt[0]; ny.value = pt[1];
|
|
1119
|
+
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(); };
|
|
1120
|
+
nx.addEventListener("input", up); ny.addEventListener("input", up);
|
|
1121
|
+
nx.addEventListener("change", () => { commitSnapshot(); commit(); }); ny.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1122
|
+
const rmv = document.createElement("span"); rmv.textContent = "×"; rmv.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px";
|
|
1123
|
+
rmv.onclick = () => { pushHist(); pts.splice(vi, 1); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
|
|
1124
|
+
r2.append(nx, sep, ny, rmv); plist.appendChild(r2);
|
|
1125
|
+
});
|
|
1126
|
+
const addPt = document.createElement("button"); addPt.className = "pill"; addPt.textContent = "+ Point";
|
|
1127
|
+
addPt.onclick = () => { pushHist(); pts.push([5, 5]); a.points = pts.slice(); select("artist:" + pid + ":" + j); commit(); };
|
|
1128
|
+
body.appendChild(plist); body.appendChild(addPt);
|
|
934
1129
|
}
|
|
935
1130
|
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));
|
|
1131
|
+
body.appendChild(slider("From X", P("x0"), a.x0, -10, 20, 0.2, null, true));
|
|
1132
|
+
body.appendChild(slider("From Y", P("y0"), a.y0, -10, 10, 0.2, null, true));
|
|
1133
|
+
body.appendChild(slider("To X", P("x1"), a.x1, -10, 20, 0.2, null, true));
|
|
1134
|
+
body.appendChild(slider("To Y", P("y1"), a.y1, -10, 10, 0.2, null, true));
|
|
940
1135
|
body.appendChild(color("Color", P("color"), a.color || "#000"));
|
|
941
1136
|
body.appendChild(slider("Width", P("lw"), a.lw ?? 1.5, 0.3, 4, 0.1));
|
|
942
1137
|
}
|
|
@@ -950,10 +1145,11 @@ function buildFigartistInspector(body, j) {
|
|
|
950
1145
|
const fa = state.spec.figure_artists[j];
|
|
951
1146
|
const P = (...k) => ["figure_artists", j, ...k];
|
|
952
1147
|
if (fa.type === "text") {
|
|
953
|
-
body.appendChild(text("Text", P("text"), fa.text));
|
|
1148
|
+
body.appendChild(text("Text", P("text"), fa.text, true));
|
|
954
1149
|
body.appendChild(slider("X", P("x"), fa.x, 0, 1, 0.01));
|
|
955
1150
|
body.appendChild(slider("Y", P("y"), fa.y, 0, 1, 0.01));
|
|
956
1151
|
body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 12, 6, 26, 0.5));
|
|
1152
|
+
body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
|
|
957
1153
|
body.appendChild(color("Color", P("color"), fa.color || "#000"));
|
|
958
1154
|
} else if (fa.type === "legend") {
|
|
959
1155
|
body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 10, 6, 20, 0.5));
|
|
@@ -967,25 +1163,29 @@ function buildFigartistInspector(body, j) {
|
|
|
967
1163
|
const tin = document.createElement("input"); tin.type = "text"; tin.value = lb; tin.style.flex = "2";
|
|
968
1164
|
tin.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); fa.labels[i] = tin.value; scheduleDraft(); });
|
|
969
1165
|
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";
|
|
1166
|
+
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
1167
|
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
1168
|
mk.value = (fa.handles[i] && fa.handles[i].marker) || "";
|
|
973
1169
|
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";
|
|
1170
|
+
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
1171
|
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
1172
|
ls.value = (fa.handles[i] && fa.handles[i].ls) || "-";
|
|
977
1173
|
ls.addEventListener("change", () => { pushHist(); fa.handles = fa.handles || []; fa.handles[i] = fa.handles[i] || {}; fa.handles[i].ls = ls.value; commit(); });
|
|
1174
|
+
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";
|
|
1175
|
+
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(); });
|
|
1176
|
+
ci.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
978
1177
|
const rm = document.createElement("span"); rm.textContent = "×"; rm.style.cssText = "cursor:pointer;color:var(--faint);padding:0 6px;font-size:15px";
|
|
979
1178
|
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);
|
|
1179
|
+
row.append(tin, ci, mk, ls, rm); elist.appendChild(row);
|
|
981
1180
|
});
|
|
982
1181
|
body.appendChild(elist);
|
|
983
1182
|
const addE = document.createElement("button"); addE.className = "pill"; addE.textContent = "+ Entry";
|
|
984
1183
|
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
1184
|
body.appendChild(addE);
|
|
986
1185
|
} else if (fa.type === "suptitle") {
|
|
987
|
-
body.appendChild(text("Text", P("text"), fa.text));
|
|
1186
|
+
body.appendChild(text("Text", P("text"), fa.text, true));
|
|
988
1187
|
body.appendChild(slider("Size (pt)", P("fontsize"), fa.fontsize ?? 14, 6, 26, 0.5));
|
|
1188
|
+
body.appendChild(slider("Rotation (deg)", P("rotation"), fa.rotation ?? 0, -180, 180, 1));
|
|
989
1189
|
body.appendChild(color("Color", P("color"), fa.color || "#000"));
|
|
990
1190
|
}
|
|
991
1191
|
body.appendChild(ctlHead(""));
|
|
@@ -996,7 +1196,7 @@ function buildFigartistInspector(body, j) {
|
|
|
996
1196
|
|
|
997
1197
|
function errorbarSelect(p) {
|
|
998
1198
|
const w = document.createElement("div"); w.className = "ctl"; w.appendChild(ctlHead("Error bars (±)"));
|
|
999
|
-
const sel = document.createElement("select"); sel.className = "ctl-sel";
|
|
1199
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", "Error bars");
|
|
1000
1200
|
const none = document.createElement("option"); none.value = ""; none.textContent = "(none)"; sel.appendChild(none);
|
|
1001
1201
|
for (const s of state.signals) { const o = document.createElement("option"); o.value = s.ref; o.textContent = s.label; sel.appendChild(o); }
|
|
1002
1202
|
sel.value = p.data.errorbar ? p.data.errorbar.ref : "";
|
|
@@ -1060,7 +1260,7 @@ function buildArtistList(body, pid) {
|
|
|
1060
1260
|
/* ---------------- control builders ---------------- */
|
|
1061
1261
|
function ctlHead(t) { const h = document.createElement("div"); h.className = "section"; h.textContent = t; return h; }
|
|
1062
1262
|
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) {
|
|
1263
|
+
function slider(label, path, val, min, max, step, afterChange, noClamp) {
|
|
1064
1264
|
const w = document.createElement("div"); w.className = "ctl";
|
|
1065
1265
|
const head = document.createElement("div"); head.className = "ctl-head";
|
|
1066
1266
|
const lab = document.createElement("label"); lab.textContent = label;
|
|
@@ -1071,7 +1271,7 @@ function slider(label, path, val, min, max, step, afterChange) {
|
|
|
1071
1271
|
const vv = document.createElement("input"); vv.type = "text"; vv.className = "val num-in";
|
|
1072
1272
|
vv.inputMode = "decimal"; vv.spellcheck = false; vv.setAttribute("aria-label", label + " value");
|
|
1073
1273
|
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;
|
|
1274
|
+
const inp = document.createElement("input"); inp.type = "range"; inp.min = min; inp.max = max; inp.step = step; inp.setAttribute("aria-label", label + " (slider)");
|
|
1075
1275
|
let cur = val;
|
|
1076
1276
|
const paint = () => { if (document.activeElement !== vv) vv.value = fmt(cur, step); inp.value = cur; };
|
|
1077
1277
|
paint();
|
|
@@ -1085,7 +1285,9 @@ function slider(label, path, val, min, max, step, afterChange) {
|
|
|
1085
1285
|
function commitVal() {
|
|
1086
1286
|
const v = parseFloat(vv.value);
|
|
1087
1287
|
if (isNaN(v)) { paint(); return; } // not a number: keep the last good value
|
|
1088
|
-
|
|
1288
|
+
// Data-coordinate fields (noClamp) take the typed value as authoritative; the
|
|
1289
|
+
// range only bounds coarse dragging. Everything else clamps to the slider range.
|
|
1290
|
+
const cv = noClamp ? v : Math.max(min, Math.min(max, v));
|
|
1089
1291
|
if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec);
|
|
1090
1292
|
cur = cv; setPath(state.spec, path, cv); inp.value = cv; vv.value = fmt(cv, step);
|
|
1091
1293
|
commitSnapshot(); if (afterChange) afterChange(); commit();
|
|
@@ -1097,38 +1299,67 @@ function slider(label, path, val, min, max, step, afterChange) {
|
|
|
1097
1299
|
});
|
|
1098
1300
|
w.appendChild(inp); return w;
|
|
1099
1301
|
}
|
|
1100
|
-
|
|
1302
|
+
// Esc in a half-edited text field reverts to the pre-edit snapshot (not a commit).
|
|
1303
|
+
function wireEscRevert(el) {
|
|
1304
|
+
el.addEventListener("keydown", (e) => {
|
|
1305
|
+
if (e.key === "Escape" && state.sliderSnapshot) {
|
|
1306
|
+
e.preventDefault(); e.stopPropagation();
|
|
1307
|
+
state.spec = state.sliderSnapshot; state.sliderSnapshot = null; updateHist(); commit();
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
function text(label, path, val, multiline) {
|
|
1101
1312
|
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(
|
|
1313
|
+
const inp = document.createElement(multiline ? "textarea" : "input");
|
|
1314
|
+
if (multiline) { inp.rows = 2; inp.className = "ctl-area"; } else { inp.type = "text"; }
|
|
1315
|
+
inp.value = val == null ? "" : val; inp.placeholder = "Text… (LaTeX like $x_0$ works)"; inp.spellcheck = false; inp.setAttribute("aria-label", label);
|
|
1103
1316
|
inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, inp.value); scheduleDraft(); });
|
|
1104
1317
|
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1318
|
+
wireEscRevert(inp);
|
|
1105
1319
|
w.appendChild(inp); return w;
|
|
1106
1320
|
}
|
|
1107
1321
|
function ticksText(label, path, val) {
|
|
1108
1322
|
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(", ") : "";
|
|
1323
|
+
const inp = document.createElement("input"); inp.type = "text"; inp.value = Array.isArray(val) ? val.join(", ") : ""; inp.placeholder = "e.g. 0, 5, 10";
|
|
1110
1324
|
const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => parseFloat(s.trim())).filter((n) => !isNaN(n)) : "auto"; };
|
|
1111
1325
|
inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
|
|
1112
1326
|
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1327
|
+
wireEscRevert(inp);
|
|
1328
|
+
w.appendChild(inp); return w;
|
|
1329
|
+
}
|
|
1330
|
+
// Comma-separated STRING list (custom tick labels; LaTeX allowed, blank = none).
|
|
1331
|
+
function labelsText(label, path, val) {
|
|
1332
|
+
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);
|
|
1333
|
+
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;
|
|
1334
|
+
const parse = () => { const t = inp.value.trim(); return t ? t.split(",").map((s) => s.trim()) : null; };
|
|
1335
|
+
inp.addEventListener("input", () => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, parse()); scheduleDraft(); });
|
|
1336
|
+
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1337
|
+
wireEscRevert(inp);
|
|
1113
1338
|
w.appendChild(inp); return w;
|
|
1114
1339
|
}
|
|
1115
1340
|
function num2(label, path, val) {
|
|
1116
1341
|
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
1342
|
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";
|
|
1343
|
+
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
1344
|
a.value = val ? val[0] : ""; b.value = val ? val[1] : "";
|
|
1120
1345
|
const parse = () => { const x = parseFloat(a.value), y = parseFloat(b.value); return (a.value && b.value && !isNaN(x) && !isNaN(y)) ? [x, y] : null; };
|
|
1121
1346
|
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
1347
|
a.addEventListener("change", () => { commitSnapshot(); commit(); }); b.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1348
|
+
wireEscRevert(a); wireEscRevert(b);
|
|
1123
1349
|
row.append(a, sep, b); w.appendChild(row); return w;
|
|
1124
1350
|
}
|
|
1125
1351
|
function color(label, path, val) {
|
|
1126
1352
|
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
1353
|
const row = document.createElement("div"); row.className = "color-row";
|
|
1128
|
-
const
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1354
|
+
const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || "");
|
|
1355
|
+
const inp = document.createElement("input"); inp.type = "color"; inp.value = isHex(val) ? val : "#000000"; inp.setAttribute("aria-label", label + " (color picker)");
|
|
1356
|
+
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)");
|
|
1357
|
+
const apply = (v) => { if (state.sliderSnapshot === null) state.sliderSnapshot = clone(state.spec); setPath(state.spec, path, v); if (isHex(v)) inp.value = v; scheduleDraft(); };
|
|
1358
|
+
inp.addEventListener("input", () => { apply(inp.value); hex.value = inp.value; });
|
|
1131
1359
|
inp.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1360
|
+
hex.addEventListener("input", () => { apply(hex.value); });
|
|
1361
|
+
hex.addEventListener("change", () => { commitSnapshot(); commit(); });
|
|
1362
|
+
wireEscRevert(hex);
|
|
1132
1363
|
row.append(inp, hex); w.appendChild(row); return w;
|
|
1133
1364
|
}
|
|
1134
1365
|
function check(label, path, val) {
|
|
@@ -1145,20 +1376,20 @@ function checkInline(label, path, val) {
|
|
|
1145
1376
|
}
|
|
1146
1377
|
function selControl(label, path, val, options) {
|
|
1147
1378
|
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";
|
|
1379
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
|
|
1149
1380
|
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
1381
|
sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value); commit(); });
|
|
1151
1382
|
w.appendChild(sel); return w;
|
|
1152
1383
|
}
|
|
1153
1384
|
function sigSelect(label, path, val, allowNone) {
|
|
1154
1385
|
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";
|
|
1386
|
+
const sel = document.createElement("select"); sel.className = "ctl-sel"; sel.setAttribute("aria-label", label);
|
|
1156
1387
|
if (allowNone) { const o = document.createElement("option"); o.value = ""; o.textContent = "(none)"; if (!val) o.selected = true; sel.appendChild(o); }
|
|
1157
1388
|
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
1389
|
sel.addEventListener("change", () => { pushHist(); setPath(state.spec, path, sel.value || null); commit(); });
|
|
1159
1390
|
w.appendChild(sel); return w;
|
|
1160
1391
|
}
|
|
1161
|
-
function fmt(v, step) { if (v == null) return "(default)";
|
|
1392
|
+
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
1393
|
|
|
1163
1394
|
/* ---------------- zoom ---------------- */
|
|
1164
1395
|
function applyTransform() {
|
|
@@ -1195,7 +1426,7 @@ function fitZoom() {
|
|
|
1195
1426
|
async function openExport() {
|
|
1196
1427
|
const { code } = await get(`/api/canvases/${state.slug}/code`);
|
|
1197
1428
|
$("#codeBody").textContent = code; $("#codePath").textContent = (state.name || "canvas") + ".py";
|
|
1198
|
-
$("#exportModal")
|
|
1429
|
+
openModal($("#exportModal"));
|
|
1199
1430
|
}
|
|
1200
1431
|
|
|
1201
1432
|
/* ---------------- 3-D view finder (double-click a 3-D plot) ---------------- */
|
|
@@ -1223,7 +1454,7 @@ async function openView3D(pid) {
|
|
|
1223
1454
|
$("#v3e").value = v3d.elev; $("#v3a").value = v3d.azim; $("#v3d").value = v3d.dist;
|
|
1224
1455
|
$("#v3invR").checked = v3d.invertR; $("#v3invZ").checked = v3d.invertZ;
|
|
1225
1456
|
v3dVals();
|
|
1226
|
-
$("#view3dModal")
|
|
1457
|
+
openModal($("#view3dModal"));
|
|
1227
1458
|
v3dRender();
|
|
1228
1459
|
}
|
|
1229
1460
|
function v3dRender() {
|
|
@@ -1267,17 +1498,45 @@ function v3dWire() {
|
|
|
1267
1498
|
pushHist();
|
|
1268
1499
|
const p = state.spec.plots.find((x) => x.id === v3d.pid);
|
|
1269
1500
|
p.style.elev = v3d.elev; p.style.azim = v3d.azim; p.style.dist = v3d.dist;
|
|
1270
|
-
$("#view3dModal")
|
|
1501
|
+
closeModal($("#view3dModal"));
|
|
1271
1502
|
commit();
|
|
1272
1503
|
};
|
|
1273
|
-
const close = () => $("#view3dModal")
|
|
1504
|
+
const close = () => closeModal($("#view3dModal"));
|
|
1274
1505
|
$("#v3dCancel").onclick = close; $("#view3dClose").onclick = close;
|
|
1275
1506
|
$("#view3dModal").onclick = (e) => { if (e.target === $("#view3dModal")) close(); };
|
|
1276
1507
|
}
|
|
1277
1508
|
|
|
1278
1509
|
/* ---------------- toast ---------------- */
|
|
1279
1510
|
let toastTimer;
|
|
1280
|
-
function toast(msg, isErr) { const t = $("#toast"); t.textContent = msg; t.style.background = isErr ? "var(--danger)" : "
|
|
1511
|
+
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); }
|
|
1512
|
+
|
|
1513
|
+
/* ---------------- modal focus management ----------------
|
|
1514
|
+
Move focus into the dialog on open, trap Tab within it, and restore focus to
|
|
1515
|
+
the trigger on close (a11y: aria-modal="true" without a trap lets Tab escape). */
|
|
1516
|
+
let modalLastFocus = null;
|
|
1517
|
+
function _focusables(el) { return [...el.querySelectorAll("button, input, select, textarea")].filter((n) => !n.disabled && n.offsetParent !== null); }
|
|
1518
|
+
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(); }
|
|
1519
|
+
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 (_) {} } }
|
|
1520
|
+
// Styled confirm (replaces the native window.confirm) — resolves true/false.
|
|
1521
|
+
function confirmDialog(message, okLabel) {
|
|
1522
|
+
return new Promise((resolve) => {
|
|
1523
|
+
let box = document.getElementById("confirmModal");
|
|
1524
|
+
if (!box) {
|
|
1525
|
+
box = document.createElement("div"); box.id = "confirmModal"; box.className = "modal hidden"; box.setAttribute("role", "dialog"); box.setAttribute("aria-modal", "true");
|
|
1526
|
+
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>`;
|
|
1527
|
+
document.body.appendChild(box);
|
|
1528
|
+
}
|
|
1529
|
+
box.querySelector(".confirm-msg").textContent = message;
|
|
1530
|
+
const ok = box.querySelector(".confirm-ok"), cancel = box.querySelector(".confirm-cancel");
|
|
1531
|
+
ok.textContent = okLabel || "Confirm";
|
|
1532
|
+
let settled = false;
|
|
1533
|
+
const done = (v) => { if (settled) return; settled = true; box.classList.add("hidden"); box.onclick = null; ok.onclick = cancel.onclick = null; resolve(v); };
|
|
1534
|
+
ok.onclick = () => done(true);
|
|
1535
|
+
cancel.onclick = () => done(false);
|
|
1536
|
+
box.onclick = (e) => { if (e.target === box) done(false); };
|
|
1537
|
+
openModal(box);
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1281
1540
|
|
|
1282
1541
|
/* ---------------- boot ---------------- */
|
|
1283
1542
|
async function boot() {
|
|
@@ -1289,6 +1548,7 @@ async function boot() {
|
|
|
1289
1548
|
doc = await get(`/api/canvases/${slug}`);
|
|
1290
1549
|
} catch (e) { return showLoadError(e); }
|
|
1291
1550
|
state.name = doc.name;
|
|
1551
|
+
document.title = "Fig Foundry — " + state.name;
|
|
1292
1552
|
state.spec = doc.spec;
|
|
1293
1553
|
state.dataset = doc.spec.dataset || null;
|
|
1294
1554
|
let sigRes;
|
|
@@ -1311,11 +1571,32 @@ function showLoadError(e) {
|
|
|
1311
1571
|
}
|
|
1312
1572
|
function wireChrome() {
|
|
1313
1573
|
v3dWire();
|
|
1574
|
+
// The key handler accepts ⌘ on macOS (metaKey); make the visible labels match.
|
|
1575
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
1576
|
+
const MOD = isMac ? "⌘" : "Ctrl";
|
|
1577
|
+
$("#undoBtn").title = "Undo (" + MOD + "+Z)";
|
|
1578
|
+
$("#redoBtn").title = "Redo (" + MOD + "Shift+Z)";
|
|
1314
1579
|
$("#themeBtn").onclick = cycleTheme;
|
|
1580
|
+
// Help / shortcuts modal
|
|
1581
|
+
const helpRows = [
|
|
1582
|
+
[MOD + "+Z", "Undo"], [MOD + "+Shift+Z", "Redo"],
|
|
1583
|
+
[MOD + "+A", "Select all"], [MOD + "+C / V / X", "Copy / paste / cut"], [MOD + "+D", "Duplicate"],
|
|
1584
|
+
["Delete / Backspace", "Delete selection"], ["Esc", "Deselect / close dialog"],
|
|
1585
|
+
["Arrow keys", "Nudge (Shift = bigger)"], ["Shift+click", "Add to / remove from selection"],
|
|
1586
|
+
["Drag empty space", "Marquee select"], ["Space+drag / middle-drag", "Pan"],
|
|
1587
|
+
["Scroll", "Zoom"], ["Double-click a 3-D plot", "Open the viewfinder"],
|
|
1588
|
+
];
|
|
1589
|
+
$("#helpList").innerHTML = helpRows.map(([k, d]) => `<span class="k">${k}</span><span class="d">${d}</span>`).join("");
|
|
1590
|
+
const helpModal = $("#helpModal");
|
|
1591
|
+
$("#helpBtn").onclick = () => openModal(helpModal);
|
|
1592
|
+
$("#helpClose").onclick = () => closeModal(helpModal);
|
|
1593
|
+
$("#helpOk").onclick = () => closeModal(helpModal);
|
|
1594
|
+
helpModal.onclick = (e) => { if (e.target === helpModal) closeModal(helpModal); };
|
|
1315
1595
|
$("#backBtn").onclick = () => { location.href = "/"; };
|
|
1316
1596
|
$("#figName").addEventListener("change", async () => {
|
|
1317
1597
|
state.name = $("#figName").value.trim() || "Untitled";
|
|
1318
1598
|
$("#figName").value = state.name;
|
|
1599
|
+
document.title = "Fig Foundry — " + state.name;
|
|
1319
1600
|
await api(`/api/canvases/${state.slug}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.name }) });
|
|
1320
1601
|
toast("Renamed to " + state.name);
|
|
1321
1602
|
});
|
|
@@ -1325,7 +1606,7 @@ function wireChrome() {
|
|
|
1325
1606
|
location.href = "/canvas?slug=" + r.slug;
|
|
1326
1607
|
};
|
|
1327
1608
|
$("#deleteBtn").onclick = async () => {
|
|
1328
|
-
if (!
|
|
1609
|
+
if (!(await confirmDialog('Delete "' + state.name + '"? This cannot be undone.', "Delete"))) return;
|
|
1329
1610
|
await api(`/api/canvases/${state.slug}`, { method: "DELETE" });
|
|
1330
1611
|
location.href = "/";
|
|
1331
1612
|
};
|
|
@@ -1334,58 +1615,78 @@ function wireChrome() {
|
|
|
1334
1615
|
$("#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
1616
|
$("#loadDataBtn").onclick = (e) => { e.stopPropagation(); loadDataMenu(e.clientX, e.clientY); };
|
|
1336
1617
|
$("#exportBtn").onclick = openExport;
|
|
1337
|
-
$("#modalClose").onclick = () => $("#exportModal")
|
|
1338
|
-
$("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) $("#exportModal")
|
|
1618
|
+
$("#modalClose").onclick = () => closeModal($("#exportModal"));
|
|
1619
|
+
$("#exportModal").onclick = (e) => { if (e.target === $("#exportModal")) closeModal($("#exportModal")); };
|
|
1339
1620
|
$("#copyCode").onclick = async () => { try { await navigator.clipboard.writeText($("#codeBody").textContent); toast("Code copied"); } catch (e) { toast("Copy failed", true); } };
|
|
1340
1621
|
$("#downloadCode").onclick = () => { location.href = `/api/canvases/${state.slug}/download`; toast("Downloading " + (state.name || "canvas") + ".py"); };
|
|
1341
1622
|
$("#dlPng").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=png`; };
|
|
1342
1623
|
$("#dlSvg").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=svg`; };
|
|
1343
1624
|
$("#dlPdf").onclick = () => { location.href = `/api/canvases/${state.slug}/figure?format=pdf`; };
|
|
1344
|
-
let pan = null, spaceDown = false;
|
|
1625
|
+
let pan = null, spaceDown = false, marquee = null, marqueeEl = null;
|
|
1345
1626
|
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
1627
|
stage.addEventListener("pointerdown", (e) => {
|
|
1347
1628
|
const empty = e.target === stage || e.target === stageInner || e.target === figImg || e.target === overlay;
|
|
1348
|
-
if (e.button === 1 || (e.button === 0 &&
|
|
1629
|
+
if (e.button === 1 || (e.button === 0 && spaceDown)) {
|
|
1349
1630
|
e.preventDefault();
|
|
1350
1631
|
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
|
|
1632
|
+
pan = { x: e.clientX, y: e.clientY, px: state.pan.x, py: state.pan.y };
|
|
1633
|
+
} else if (e.button === 0 && empty && !spaceDown) {
|
|
1634
|
+
e.preventDefault();
|
|
1635
|
+
stage.setPointerCapture(e.pointerId);
|
|
1636
|
+
marquee = { x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY, base: e.shiftKey ? new Set(state.selection) : new Set() };
|
|
1637
|
+
if (!marqueeEl) { marqueeEl = document.createElement("div"); marqueeEl.className = "marquee"; stage.appendChild(marqueeEl); }
|
|
1638
|
+
marqueeEl.style.display = "block";
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
window.addEventListener("pointermove", (e) => {
|
|
1642
|
+
if (pan) { state.pan.x = pan.px + (e.clientX - pan.x); state.pan.y = pan.py + (e.clientY - pan.y); applyTransform(); }
|
|
1643
|
+
if (marquee) {
|
|
1644
|
+
marquee.x1 = e.clientX; marquee.y1 = e.clientY;
|
|
1645
|
+
const sr = stage.getBoundingClientRect();
|
|
1646
|
+
marqueeEl.style.left = (Math.min(marquee.x0, marquee.x1) - sr.left) + "px";
|
|
1647
|
+
marqueeEl.style.top = (Math.min(marquee.y0, marquee.y1) - sr.top) + "px";
|
|
1648
|
+
marqueeEl.style.width = Math.abs(marquee.x1 - marquee.x0) + "px";
|
|
1649
|
+
marqueeEl.style.height = Math.abs(marquee.y1 - marquee.y0) + "px";
|
|
1352
1650
|
}
|
|
1353
1651
|
});
|
|
1354
|
-
window.addEventListener("
|
|
1355
|
-
|
|
1356
|
-
|
|
1652
|
+
window.addEventListener("pointerup", (e) => {
|
|
1653
|
+
if (pan) pan = null;
|
|
1654
|
+
if (marquee) {
|
|
1655
|
+
const small = Math.abs(e.clientX - marquee.x0) < 3 && Math.abs(e.clientY - marquee.y0) < 3;
|
|
1656
|
+
if (small) { if (!marquee.base.size) select(null); }
|
|
1657
|
+
else marqueeSelect(marquee.x0, marquee.y0, e.clientX, e.clientY, marquee.base);
|
|
1658
|
+
if (marqueeEl) marqueeEl.style.display = "none";
|
|
1659
|
+
marquee = null;
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
window.addEventListener("pointercancel", () => { pan = null; if (marqueeEl) marqueeEl.style.display = "none"; marquee = null; });
|
|
1357
1663
|
window.addEventListener("keydown", (e) => { if (e.code === "Space" && !/INPUT|SELECT|TEXTAREA/.test(e.target.tagName || "")) { spaceDown = true; stage.style.cursor = "grab"; } });
|
|
1358
1664
|
window.addEventListener("keyup", (e) => { if (e.code === "Space") { spaceDown = false; stage.style.cursor = ""; } });
|
|
1359
1665
|
document.addEventListener("keydown", (e) => {
|
|
1360
1666
|
const exportOpen = !$("#exportModal").classList.contains("hidden");
|
|
1361
1667
|
const v3dOpen = !$("#view3dModal").classList.contains("hidden");
|
|
1668
|
+
const helpOpen = !$("#helpModal").classList.contains("hidden");
|
|
1362
1669
|
// Escape closes a modal first (works even when a slider inside it has focus)
|
|
1363
|
-
if (e.key === "Escape" && (exportOpen || v3dOpen)) { if (exportOpen) $("#exportModal")
|
|
1670
|
+
if (e.key === "Escape" && (exportOpen || v3dOpen || helpOpen)) { if (exportOpen) closeModal($("#exportModal")); else if (v3dOpen) closeModal($("#view3dModal")); else closeModal($("#helpModal")); return; }
|
|
1364
1671
|
const tgt = e.target;
|
|
1365
|
-
|
|
1672
|
+
// Only text-like fields own their keys (typing, native Ctrl+C, Esc-revert). A focused
|
|
1673
|
+
// range/checkbox/color/select must still let modifier chords, Delete and Escape through
|
|
1674
|
+
// to the global handlers (previously every INPUT swallowed Ctrl+Z).
|
|
1675
|
+
const textLike = tgt.isContentEditable || tgt.tagName === "TEXTAREA" || (tgt.tagName === "INPUT" && /text|number|search|email|tel|url|password/.test(tgt.type));
|
|
1676
|
+
if (textLike) return;
|
|
1677
|
+
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
1678
|
if (e.key === "Escape") { select(null); return; }
|
|
1367
1679
|
if (exportOpen || v3dOpen) return; // a modal is up: global shortcuts must not edit the figure behind it
|
|
1368
|
-
|
|
1369
|
-
|
|
1680
|
+
const mod = e.ctrlKey || e.metaKey, k = e.key.toLowerCase();
|
|
1681
|
+
if (mod && k === "z") { e.preventDefault(); e.shiftKey ? redo() : undo(); }
|
|
1682
|
+
else if (mod && k === "y") { e.preventDefault(); redo(); }
|
|
1683
|
+
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(); }
|
|
1684
|
+
else if (mod && k === "c") { e.preventDefault(); copySelection(); }
|
|
1685
|
+
else if (mod && k === "v") { e.preventDefault(); pasteClipboard(); }
|
|
1686
|
+
else if (mod && k === "x") { e.preventDefault(); copySelection(); deleteSelection(); }
|
|
1687
|
+
else if (mod && k === "d") { e.preventDefault(); copySelection(); pasteClipboard(); }
|
|
1370
1688
|
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
|
-
}
|
|
1689
|
+
else if ((e.key === "Delete" || e.key === "Backspace") && (state.selected || state.selection.size)) { e.preventDefault(); deleteSelection(); }
|
|
1389
1690
|
});
|
|
1390
1691
|
// settle (full-quality render + save) the instant an arrow key is released
|
|
1391
1692
|
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.0"
|
|
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
|