majoplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- majoplot/__init__.py +0 -0
- majoplot/__main__.py +25 -0
- majoplot/app/__init__.py +0 -0
- majoplot/app/cli.py +259 -0
- majoplot/app/gui.py +6 -0
- majoplot/config.json +11 -0
- majoplot/domain/base.py +433 -0
- majoplot/domain/importers/PPMS_Resistivity.py +128 -0
- majoplot/domain/importers/VSM.py +109 -0
- majoplot/domain/importers/XRD.py +62 -0
- majoplot/domain/muti_axes_spec.py +172 -0
- majoplot/domain/scenarios/PPMS_Resistivity/RT.py +119 -0
- majoplot/domain/scenarios/VSM/MT.py +131 -0
- majoplot/domain/scenarios/VSM/MT_insert.py +135 -0
- majoplot/domain/scenarios/VSM/MT_reliability_analysis.py +145 -0
- majoplot/domain/scenarios/XRD/Compare.py +104 -0
- majoplot/domain/utils.py +87 -0
- majoplot/gui/__init__.py +0 -0
- majoplot/gui/main.py +529 -0
- majoplot/infra/plotters/matplot.py +337 -0
- majoplot/infra/plotters/origin.py +1006 -0
- majoplot/infra/plotters/origin_utils/originlab_type_library.py +403 -0
- majoplot-0.1.0.dist-info/METADATA +81 -0
- majoplot-0.1.0.dist-info/RECORD +27 -0
- majoplot-0.1.0.dist-info/WHEEL +4 -0
- majoplot-0.1.0.dist-info/entry_points.txt +2 -0
- majoplot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import WindowsPath, Path
|
|
4
|
+
from typing import Optional, Any, Union
|
|
5
|
+
_ColorLike = Union[str, tuple[int, int, int], list[int]]
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from win32com.client import Dispatch
|
|
9
|
+
|
|
10
|
+
from ...domain.base import *
|
|
11
|
+
from ...domain.muti_axes_spec import *
|
|
12
|
+
from .origin_utils.originlab_type_library import constants
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# --- Unit/scale mapping ---
|
|
16
|
+
# Origin uses its own point-like units for font size, line width, and symbol size in LabTalk.
|
|
17
|
+
# FONT_PT_SCALE is a rough conversion factor used throughout style setters.
|
|
18
|
+
# INSET_SCALE / MAIN_SCALE are intended to scale styles between main vs inset layers,
|
|
19
|
+
# but note that curve_scale is computed later and is NOT used inside _apply_curve_style() in this file.
|
|
20
|
+
FONT_PT_SCALE = 2.3
|
|
21
|
+
INSET_SCALE = 2.5 # inset linewidth/marker/title a bit smaller
|
|
22
|
+
MAIN_SCALE = 1.5 # inset linewidth/marker/title a bit smaller
|
|
23
|
+
# -------------------------
|
|
24
|
+
# COM Resourse
|
|
25
|
+
# -------------------------
|
|
26
|
+
## How to get help:
|
|
27
|
+
## Run:
|
|
28
|
+
## python -m win32com.client.makepy
|
|
29
|
+
## choose "OriginLab Type Library"
|
|
30
|
+
## Find APIs in generated py file. (maybe under C:\Users\user_name\AppData\Local\Temp\gen_py/)
|
|
31
|
+
|
|
32
|
+
## tested with OriginPro 2021
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -------------------------
|
|
36
|
+
# Origin COM bootstrap (COM = Component Object Model)
|
|
37
|
+
# -------------------------
|
|
38
|
+
# OriginCOM is a context manager that creates/controls an Origin instance.
|
|
39
|
+
# Typical usage:
|
|
40
|
+
# with OriginCOM(visible=True) as og:
|
|
41
|
+
# plot(project, name, og, proj_dir=..., overwrite=...)
|
|
42
|
+
# Behavior notes:
|
|
43
|
+
# - __enter__ calls BeginSession() and returns the Application COM object.
|
|
44
|
+
# - __exit__ tries to Exit() Origin (closing the application).
|
|
45
|
+
# - If you need Origin to remain open for interactive debugging, modify *caller* behavior,
|
|
46
|
+
# not this class; this annotated file must keep logic unchanged.
|
|
47
|
+
class OriginCOM:
|
|
48
|
+
"""
|
|
49
|
+
OriginCOM
|
|
50
|
+
create a ApplicationCMOSI instance of Origin. See https://www.originlab.com/doc/en/COM/Classes/ApplicationCOMSI.
|
|
51
|
+
"""
|
|
52
|
+
def __init__(self,visible:bool):
|
|
53
|
+
self.visible = visible
|
|
54
|
+
app = None
|
|
55
|
+
|
|
56
|
+
def __enter__(self):
|
|
57
|
+
# Acquire COM automation entry point: Origin.ApplicationCOMSI
|
|
58
|
+
# Most actions below happen through LabTalk commands (og.Execute).
|
|
59
|
+
# Dispatch() attaches to Origin via COM.
|
|
60
|
+
self.app = Dispatch("Origin.ApplicationCOMSI")
|
|
61
|
+
if self.visible:
|
|
62
|
+
self.app.Visible = constants.MAINWND_SHOW
|
|
63
|
+
else:
|
|
64
|
+
self.app.Visible = constants.MAINWND_HIDE
|
|
65
|
+
# Reduce UI noise / command window chatter
|
|
66
|
+
# doc -mc 1: suppress some UI/command-window chatter during batch automation.
|
|
67
|
+
self.app.Execute("doc -mc 1;")
|
|
68
|
+
# BeginSession(): recommended by Origin for COM automation batches.
|
|
69
|
+
self.app.BeginSession()
|
|
70
|
+
return self.app
|
|
71
|
+
|
|
72
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
73
|
+
try:
|
|
74
|
+
self.app.Execute("doc -mc 0;")
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
if self.app is not None:
|
|
79
|
+
self.app.Exit()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
self.app = None
|
|
83
|
+
|
|
84
|
+
# =========================
|
|
85
|
+
# LabTalk helpers
|
|
86
|
+
# =========================
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# -------------------------
|
|
90
|
+
# LabTalk command execution helper
|
|
91
|
+
# -------------------------
|
|
92
|
+
# This wrapper intentionally swallows failures after printing.
|
|
93
|
+
# Consequence: style commands can fail silently, leaving defaults in place,
|
|
94
|
+
# which may look like "random" styles. Consider temporarily making this stricter
|
|
95
|
+
# during debugging (but NOT in this annotated file).
|
|
96
|
+
def _try_exec(og: Any, lt: str) -> None:
|
|
97
|
+
"""
|
|
98
|
+
execute Labtalk commands
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
og.Execute(lt)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
print(f"LabTalk command '{lt}' failed: {e}")
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# ==========================
|
|
107
|
+
# OPJU Manager
|
|
108
|
+
# ==========================
|
|
109
|
+
|
|
110
|
+
# -------------------------
|
|
111
|
+
# Project open/create (.opju)
|
|
112
|
+
# -------------------------
|
|
113
|
+
# This function enforces suffix and ensures the directory exists.
|
|
114
|
+
# It does NOT create folders/pages inside the project; it only manages the project file itself.
|
|
115
|
+
def open_or_create_project(
|
|
116
|
+
og,
|
|
117
|
+
project_path: Union[str, Path],
|
|
118
|
+
*,
|
|
119
|
+
readonly: bool = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Open an existing Origin project, or create a new one and save to path.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
og : COM object
|
|
127
|
+
Origin.ApplicationCOMSI instance
|
|
128
|
+
project_path : str | Path
|
|
129
|
+
Full path to .opju or .opj
|
|
130
|
+
readonly : bool
|
|
131
|
+
Open project in read-only mode if exists
|
|
132
|
+
"""
|
|
133
|
+
path = Path(project_path).expanduser().resolve()
|
|
134
|
+
|
|
135
|
+
# ---- sanity checks ----
|
|
136
|
+
if path.suffix.lower() not in {".opju", ".opj"}:
|
|
137
|
+
raise ValueError("Origin project must end with .opju or .opj")
|
|
138
|
+
|
|
139
|
+
# Ensure parent directory exists (for create case)
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# If project already exists: load it.
|
|
143
|
+
if path.exists():
|
|
144
|
+
# ===== open existing project =====
|
|
145
|
+
# Origin COM exposes Open() directly
|
|
146
|
+
og.Load(str(path), int(bool(readonly)))
|
|
147
|
+
else:
|
|
148
|
+
# ===== create new project =====
|
|
149
|
+
try:
|
|
150
|
+
og.NewProject()
|
|
151
|
+
except Exception:
|
|
152
|
+
# fallback (older builds / edge cases)
|
|
153
|
+
og.Execute("doc -n;")
|
|
154
|
+
|
|
155
|
+
# Save immediately to bind project path
|
|
156
|
+
og.Save(str(path))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# -------------------------
|
|
162
|
+
# LabTalk string escaping
|
|
163
|
+
# -------------------------
|
|
164
|
+
# Origin LabTalk uses "..." string literals. Backslashes and quotes need escaping.
|
|
165
|
+
def _lt_quote(s: str) -> str:
|
|
166
|
+
"""Escape string for LabTalk "..." literal."""
|
|
167
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
168
|
+
|
|
169
|
+
def _lt_quote_keep_backslash(s: str) -> str:
|
|
170
|
+
# Only escape double quotes for LabTalk "...".
|
|
171
|
+
return s.replace('"', r'\"')
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Color helpers
|
|
176
|
+
# - _lt_color_expr outputs color(name) or color(r,g,b) for #RRGGBB
|
|
177
|
+
# - _lt_color_rgb always forces RGB form to avoid name-resolution surprises
|
|
178
|
+
def _lt_color_expr(color: str) -> str:
|
|
179
|
+
"""Return LabTalk color() expression supporting '#RRGGBB' and names."""
|
|
180
|
+
c = (color or "").strip()
|
|
181
|
+
if c.startswith("#") and len(c) in (7, 9):
|
|
182
|
+
r = int(c[1:3], 16)
|
|
183
|
+
g = int(c[3:5], 16)
|
|
184
|
+
b = int(c[5:7], 16)
|
|
185
|
+
return f"color({r},{g},{b})"
|
|
186
|
+
if not c:
|
|
187
|
+
c = "black"
|
|
188
|
+
return f"color({c})"
|
|
189
|
+
|
|
190
|
+
def _lt_color_rgb(color: _ColorLike, *, fallback=(250, 179, 209)) -> str:
|
|
191
|
+
"""
|
|
192
|
+
Convert a color literal to LabTalk-safe RGB expression: color(R,G,B).
|
|
193
|
+
|
|
194
|
+
Intended for Axis / Grid / Tick / Border colors in Origin.
|
|
195
|
+
Avoids silent fallback caused by color(name).
|
|
196
|
+
|
|
197
|
+
Supported inputs:
|
|
198
|
+
- color names: "grey", "gray", "black", "red", ...
|
|
199
|
+
- hex strings: "#RRGGBB", "#RGB"
|
|
200
|
+
- RGB tuple/list: (r, g, b)
|
|
201
|
+
- None -> fallback
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
str: 'color(R,G,B)'
|
|
205
|
+
"""
|
|
206
|
+
if color is None:
|
|
207
|
+
r, g, b = fallback
|
|
208
|
+
return f"color({r},{g},{b})"
|
|
209
|
+
|
|
210
|
+
# --- RGB tuple/list ---
|
|
211
|
+
if isinstance(color, (tuple, list)) and len(color) == 3:
|
|
212
|
+
r, g, b = (int(c) for c in color)
|
|
213
|
+
return f"color({r},{g},{b})"
|
|
214
|
+
|
|
215
|
+
if not isinstance(color, str):
|
|
216
|
+
r, g, b = fallback
|
|
217
|
+
return f"color({r},{g},{b})"
|
|
218
|
+
|
|
219
|
+
c = color.strip().lower()
|
|
220
|
+
|
|
221
|
+
# --- Hex formats ---
|
|
222
|
+
if c.startswith("#"):
|
|
223
|
+
h = c[1:]
|
|
224
|
+
if len(h) == 6:
|
|
225
|
+
r = int(h[0:2], 16)
|
|
226
|
+
g = int(h[2:4], 16)
|
|
227
|
+
b = int(h[4:6], 16)
|
|
228
|
+
return f"color({r},{g},{b})"
|
|
229
|
+
if len(h) == 3:
|
|
230
|
+
r = int(h[0] * 2, 16)
|
|
231
|
+
g = int(h[1] * 2, 16)
|
|
232
|
+
b = int(h[2] * 2, 16)
|
|
233
|
+
return f"color({r},{g},{b})"
|
|
234
|
+
|
|
235
|
+
# --- Named colors (minimal but sufficient for grids) ---
|
|
236
|
+
NAMED_RGB = {
|
|
237
|
+
"black": (0, 0, 0),
|
|
238
|
+
"white": (255, 255, 255),
|
|
239
|
+
"red": (255, 0, 0),
|
|
240
|
+
"green": (0, 128, 0),
|
|
241
|
+
"blue": (0, 0, 255),
|
|
242
|
+
"grey": (128, 128, 128),
|
|
243
|
+
"gray": (128, 128, 128),
|
|
244
|
+
"lightgrey": (200, 200, 200),
|
|
245
|
+
"lightgray": (200, 200, 200),
|
|
246
|
+
"darkgrey": (96, 96, 96),
|
|
247
|
+
"darkgray": (96, 96, 96),
|
|
248
|
+
"pinkie_pie": (250,179,209)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if c in NAMED_RGB:
|
|
252
|
+
r, g, b = NAMED_RGB[c]
|
|
253
|
+
return f"color({r},{g},{b})"
|
|
254
|
+
|
|
255
|
+
# --- Fallback ---
|
|
256
|
+
r, g, b = fallback
|
|
257
|
+
return f"color({r},{g},{b})"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Origin plot style code maps
|
|
261
|
+
# These integers are consumed by `set %C -d <code>` or similar LabTalk.
|
|
262
|
+
_LINESTYLE_CODE = {
|
|
263
|
+
"-": 0, # solid
|
|
264
|
+
"--": 2, # dash
|
|
265
|
+
":": 3, # dot
|
|
266
|
+
"-.": 4, # dash-dot
|
|
267
|
+
}
|
|
268
|
+
_GRID_LINESTYLE_CODE = {
|
|
269
|
+
"-": 1, # solid
|
|
270
|
+
"--": 2, # dash
|
|
271
|
+
":": 3, # dot
|
|
272
|
+
"-.": 4, # dash-dot
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_MARKER_CODE = {
|
|
276
|
+
"s": 1, # square
|
|
277
|
+
"o": 2, # circle
|
|
278
|
+
"^": 3, # up triangle
|
|
279
|
+
"v": 4, # down triangle
|
|
280
|
+
"D": 5, # diamond
|
|
281
|
+
"d": 5,
|
|
282
|
+
"+": 6, # plus
|
|
283
|
+
"x": 7, # x
|
|
284
|
+
"*": 8, # star
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# IMPORTANT: plotxy plot code selection
|
|
290
|
+
# In this file, _choose_plot_code ALWAYS returns 202 (line+symbol).
|
|
291
|
+
# That means even if a series should be "line only" or "scatter only",
|
|
292
|
+
# Origin still creates a line+symbol plot, and later style setters try to disable/alter pieces.
|
|
293
|
+
# This can contribute to confusing marker/line behavior.
|
|
294
|
+
def _choose_plot_code(linestyle: str, marker: str) -> int:
|
|
295
|
+
"""plotxy plot codes: 200=line, 201=scatter, 202=line+symbol"""
|
|
296
|
+
# has_line = bool(linestyle) and linestyle != "None"
|
|
297
|
+
# has_marker = bool(marker) and marker != "None"
|
|
298
|
+
# if has_line and has_marker:
|
|
299
|
+
# return 202
|
|
300
|
+
# if has_marker and not has_line:
|
|
301
|
+
# return 201
|
|
302
|
+
# return 200
|
|
303
|
+
return 202
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _graph_sanitize_name(name: str, fallback: str = "MajoGraph") -> str:
|
|
307
|
+
"""
|
|
308
|
+
Origin Graph Page Short Name Limit: <= 24 Characters
|
|
309
|
+
"""
|
|
310
|
+
if not name:
|
|
311
|
+
return fallback
|
|
312
|
+
out = []
|
|
313
|
+
for ch in name:
|
|
314
|
+
if ch.isalnum() or ch == "_":
|
|
315
|
+
out.append(ch)
|
|
316
|
+
else:
|
|
317
|
+
out.append("_")
|
|
318
|
+
s = "".join(out).strip("_")
|
|
319
|
+
if not s:
|
|
320
|
+
return fallback
|
|
321
|
+
if not s[0].isalpha():
|
|
322
|
+
s = "G_" + s
|
|
323
|
+
if len(s) > 24:
|
|
324
|
+
s = f"{s[:10]}__{s[-10:]}"
|
|
325
|
+
return s
|
|
326
|
+
|
|
327
|
+
def _workbook_sanitize_name(name: str, fallback: str = "MajoGraph") -> str:
|
|
328
|
+
"""
|
|
329
|
+
Origin WorkBook Short Name Limit: <= 13 Characters
|
|
330
|
+
"""
|
|
331
|
+
if not name:
|
|
332
|
+
return fallback
|
|
333
|
+
out = []
|
|
334
|
+
for ch in name:
|
|
335
|
+
if ch.isalnum():
|
|
336
|
+
out.append(ch)
|
|
337
|
+
s = "".join(out)
|
|
338
|
+
if not s:
|
|
339
|
+
return fallback
|
|
340
|
+
if not s[0].isalpha():
|
|
341
|
+
s = "W" + s
|
|
342
|
+
if len(s) > 13:
|
|
343
|
+
s = f"{s[:6]}{s[-6:]}"
|
|
344
|
+
return s
|
|
345
|
+
|
|
346
|
+
# =========================
|
|
347
|
+
# Figure-level style cycle
|
|
348
|
+
# =========================
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# -------------------------
|
|
352
|
+
# Figure-level style cycle (shared across ALL layers)
|
|
353
|
+
# -------------------------
|
|
354
|
+
# This generator advances a single index i each time next() is called.
|
|
355
|
+
# In plot(), a single iterator is created per Figure and is consumed for every curve
|
|
356
|
+
# across all layers. There is NO per-axes reset here, even for StackAxesSpec.
|
|
357
|
+
def iter_cycles(figspec: FigureSpec) -> Iterator[dict[str, Any]]:
|
|
358
|
+
"""Yield per-line styles. Each new line advances ALL cycles once."""
|
|
359
|
+
i = 0
|
|
360
|
+
while True:
|
|
361
|
+
yield {
|
|
362
|
+
"color": figspec.linecolor_cycle[i % len(figspec.linecolor_cycle)],
|
|
363
|
+
"linestyle": figspec.linestyle_cycle[i % len(figspec.linestyle_cycle)],
|
|
364
|
+
"marker": figspec.linemarker_cycle[i % len(figspec.linemarker_cycle)],
|
|
365
|
+
"alpha": figspec.alpa_cycle[i % len(figspec.alpa_cycle)],
|
|
366
|
+
}
|
|
367
|
+
i += 1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# =========================
|
|
371
|
+
# Spec application helpers
|
|
372
|
+
# =========================
|
|
373
|
+
|
|
374
|
+
# Log-scale heuristic
|
|
375
|
+
# If limits are positive AND span ratio exceeds threshold, enable log10 axis in Origin.
|
|
376
|
+
# Note: the code later writes layer.x.from/to and layer.y.from/to using numeric values;
|
|
377
|
+
# their interpretation depends on layer.x.type / layer.y.type (linear vs log).
|
|
378
|
+
def _should_set_log_scale(axes_obj: Axes) -> tuple[bool,bool]:
|
|
379
|
+
"""Enable log scale when limits are positive and span exceeds thresholds."""
|
|
380
|
+
xl = axes_obj.xlim[0]
|
|
381
|
+
xr = axes_obj.xlim[1]
|
|
382
|
+
yl = axes_obj.ylim[0]
|
|
383
|
+
yr = axes_obj.ylim[1]
|
|
384
|
+
x_span_min = axes_obj.spec.x_log_scale_min_span
|
|
385
|
+
y_span_min = axes_obj.spec.y_log_scale_min_span
|
|
386
|
+
|
|
387
|
+
should_x, should_y = False, False
|
|
388
|
+
if xl > 0 and xr > 0 and (xr / xl) >= x_span_min:
|
|
389
|
+
should_x = True
|
|
390
|
+
|
|
391
|
+
if yl > 0 and yr > 0 and (yr / yl) >= y_span_min:
|
|
392
|
+
should_y = True
|
|
393
|
+
return should_x, should_y
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _apply_full_frame_axes(og: Any) -> None:
|
|
397
|
+
"""Force full-frame axes (top/right show line & ticks) at UI/OPJU level."""
|
|
398
|
+
# Caller should have selected layer: layer -s N;
|
|
399
|
+
_try_exec(og, "layer.border=0;")
|
|
400
|
+
|
|
401
|
+
# Axis display: 0=None, 1=First, 2=Second, 3=Both
|
|
402
|
+
# For X: First=Bottom, Second=Top; For Y: First=Left, Second=Right
|
|
403
|
+
_try_exec(og, "axis -ps X A 3;") # show bottom+top axis line & ticks
|
|
404
|
+
_try_exec(og, "axis -ps Y A 3;") # show left+right axis line & ticks
|
|
405
|
+
|
|
406
|
+
# Tick label display (optional):
|
|
407
|
+
# Keep labels only on Bottom/Left (typical matplotlib look).
|
|
408
|
+
_try_exec(og, "axis -ps X L 1;")
|
|
409
|
+
_try_exec(og, "axis -ps Y L 1;")
|
|
410
|
+
|
|
411
|
+
# Keep secondary axes linked to primary if your version supports it
|
|
412
|
+
_try_exec(og, "layer.x2.link=1;")
|
|
413
|
+
_try_exec(og, "layer.y2.link=1;")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _ticks_mask(direction: str, include_minor: bool) -> int:
|
|
419
|
+
# ticks bitmask: major in=1, major out=2, minor in=4, minor out=8
|
|
420
|
+
d = (direction or "in").lower()
|
|
421
|
+
if d == "in":
|
|
422
|
+
return (1 | (4 if include_minor else 0))
|
|
423
|
+
return (2 | (8 if include_minor else 0))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# Tick application (best-effort)
|
|
428
|
+
# Many tick/grid properties in Origin depend on version; failed commands fall back silently via _try_exec.
|
|
429
|
+
def _apply_tick_spec(og: Any, major: Optional[TickSpec], minor: Optional[TickSpec], global_fontsize: float, scale: float) -> None:
|
|
430
|
+
"""Apply major/minor ticks (best-effort) to x/y axes."""
|
|
431
|
+
include_minor = minor is not None
|
|
432
|
+
|
|
433
|
+
# tick label fontsize (Origin uses layer.x.label.fsize etc)
|
|
434
|
+
_try_exec(og, f"layer.x.label.fsize={global_fontsize * FONT_PT_SCALE * scale};")
|
|
435
|
+
_try_exec(og, f"layer.y.label.fsize={global_fontsize * FONT_PT_SCALE * scale};")
|
|
436
|
+
_try_exec(og, f"layer.x2.label.fsize={global_fontsize * FONT_PT_SCALE *scale};")
|
|
437
|
+
_try_exec(og, f"layer.y2.label.fsize={global_fontsize * FONT_PT_SCALE * scale};")
|
|
438
|
+
|
|
439
|
+
# Direction
|
|
440
|
+
direction = major.direction if major is not None else ("in" if minor is None else minor.direction)
|
|
441
|
+
mask = _ticks_mask(direction, include_minor)
|
|
442
|
+
_try_exec(og, f"layer.x.ticks={mask}; layer.y.ticks={mask};")
|
|
443
|
+
_try_exec(og, f"layer.x2.ticks={mask}; layer.y2.ticks={mask};")
|
|
444
|
+
|
|
445
|
+
# Length/width
|
|
446
|
+
if major is not None:
|
|
447
|
+
_try_exec(og, f"layer.x.ticklength={major.length * scale}; layer.y.ticklength={major.length * scale};")
|
|
448
|
+
_try_exec(og, f"layer.x2.ticklength={major.length * scale}; layer.y2.ticklength={major.length * scale};")
|
|
449
|
+
_try_exec(og, f"layer.x.tickthickness={major.width}; layer.y.tickthickness={major.width};")
|
|
450
|
+
_try_exec(og, f"layer.x2.tickthickness={major.width}; layer.y2.tickthickness={major.width};")
|
|
451
|
+
|
|
452
|
+
# Which sides show ticks (best-effort)
|
|
453
|
+
if not major.top:
|
|
454
|
+
_try_exec(og, "layer.x2.show=0;")
|
|
455
|
+
if not major.left:
|
|
456
|
+
_try_exec(og, "layer.y.show=0;") # left Y axis; risky, but matches intent
|
|
457
|
+
|
|
458
|
+
if minor is not None:
|
|
459
|
+
_try_exec(og, f"layer.x.mticklength={minor.length * scale}; layer.y.mticklength={minor.length * scale};")
|
|
460
|
+
_try_exec(og, f"layer.x2.mticklength={minor.length * scale}; layer.y2.mticklength={minor.length * scale};")
|
|
461
|
+
_try_exec(og, f"layer.x.mtickthickness={minor.width}; layer.y.mtickthickness={minor.width};")
|
|
462
|
+
_try_exec(og, f"layer.x2.mtickthickness={minor.width}; layer.y2.mtickthickness={minor.width};")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# Grid application (best-effort)
|
|
467
|
+
# If these properties mismatch your Origin version, commands may fail and grids remain default/unchanged.
|
|
468
|
+
def _apply_grid_spec(og: Any, major: Optional[GridSpec], minor: Optional[GridSpec]) -> None:
|
|
469
|
+
"""Apply major/minor grids (best-effort)."""
|
|
470
|
+
major_on = major is not None
|
|
471
|
+
minor_on = minor is not None
|
|
472
|
+
|
|
473
|
+
# Show flags: 0 none, 1 major, 2 minor, 3 both
|
|
474
|
+
show = (1 if major_on else 0) | (2 if minor_on else 0)
|
|
475
|
+
|
|
476
|
+
# Turn on/off grid display (documented)
|
|
477
|
+
_try_exec(og, f"layer.x.grid.show={show}; layer.y.grid.show={show};")
|
|
478
|
+
|
|
479
|
+
# Some scripts / versions also use showGrids; setting both is harmless and increases robustness
|
|
480
|
+
_try_exec(og, f"layer.x.showGrids={show}; layer.y.showGrids={show};")
|
|
481
|
+
|
|
482
|
+
# Must use RGB here.
|
|
483
|
+
if major is not None:
|
|
484
|
+
lt = _GRID_LINESTYLE_CODE.get(major.linestyle, 1)
|
|
485
|
+
# Use documented properties (width in points)
|
|
486
|
+
_try_exec(og, f"layer.x.grid.majorType={lt}; layer.y.grid.majorType={lt};")
|
|
487
|
+
_try_exec(og, f"layer.x.grid.majorWidth={float(major.linewidth)}; layer.y.grid.majorWidth={float(major.linewidth)};")
|
|
488
|
+
_try_exec(og, f"layer.x.grid.majorColor={_lt_color_rgb(major.color)}; layer.y.grid.majorColor={_lt_color_rgb(major.color)};")
|
|
489
|
+
|
|
490
|
+
if minor is not None:
|
|
491
|
+
lt = _GRID_LINESTYLE_CODE.get(minor.linestyle, 1)
|
|
492
|
+
_try_exec(og, f"layer.x.grid.minorType={lt}; layer.y.grid.minorType={lt};")
|
|
493
|
+
_try_exec(og, f"layer.x.grid.minorWidth={float(minor.linewidth)}; layer.y.grid.minorWidth={float(minor.linewidth)};")
|
|
494
|
+
_try_exec(og, f"layer.x.grid.minorColor={_lt_color_rgb(minor.color)}; layer.y.grid.minorColor={_lt_color_rgb(minor.color)};")
|
|
495
|
+
|
|
496
|
+
def _apply_annotations(og: Any, ann: Optional[list[AnnotationSpec]], scale: float) -> None:
|
|
497
|
+
if not ann:
|
|
498
|
+
return
|
|
499
|
+
for a in ann:
|
|
500
|
+
text = _lt_quote(a.text)
|
|
501
|
+
x, y = a.xy
|
|
502
|
+
_try_exec(og, f'label -a {float(x)} {float(y)} "{text}";')
|
|
503
|
+
_try_exec(og, f"label.fsize={a.fontsize * FONT_PT_SCALE * scale};")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# -------------------------
|
|
508
|
+
# AxesSpec -> Origin layer properties
|
|
509
|
+
# -------------------------
|
|
510
|
+
# Workflow: select layer -> enforce full-frame -> set titles -> set scale types -> set limits -> ticks/grids -> annotations
|
|
511
|
+
# Important detail: this is applied AFTER curves are plotted (in plot()).
|
|
512
|
+
def _apply_axes_spec(
|
|
513
|
+
og: Any,
|
|
514
|
+
layer_idx: int,
|
|
515
|
+
axes_obj: Axes,
|
|
516
|
+
*,
|
|
517
|
+
ignore_grid: bool,
|
|
518
|
+
figspec: FigureSpec,
|
|
519
|
+
x_is_log: bool,
|
|
520
|
+
y_is_log: bool,
|
|
521
|
+
scale: float,
|
|
522
|
+
force_show_titles: bool = False
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Apply AxesSpec to selected layer."""
|
|
525
|
+
spec = axes_obj.spec
|
|
526
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
527
|
+
|
|
528
|
+
_apply_full_frame_axes(og)
|
|
529
|
+
|
|
530
|
+
# Titles
|
|
531
|
+
if force_show_titles:
|
|
532
|
+
# Titles: use label -n to ensure special objects (XB/YL) are created for every layer
|
|
533
|
+
_try_exec(og, f'label -n XB "{_lt_quote(spec.x_axis_title)}";')
|
|
534
|
+
_try_exec(og, f'label -n YL "{_lt_quote(spec.y_axis_title)}";')
|
|
535
|
+
|
|
536
|
+
# After creation, setting properties via XB/YL works reliably
|
|
537
|
+
_try_exec(og, f"XB.fsize={spec.axis_title_font_size * FONT_PT_SCALE * 2 * scale};")
|
|
538
|
+
_try_exec(og, f"YL.fsize={spec.axis_title_font_size * FONT_PT_SCALE * 2 * scale};")
|
|
539
|
+
_try_exec(og, "YL.rotate=90;") # rotate Y axis title to 90 degrees
|
|
540
|
+
|
|
541
|
+
# Scale type: 0=linear, 2=log10
|
|
542
|
+
_try_exec(og, f"layer.x.type={2 if x_is_log else 0};")
|
|
543
|
+
_try_exec(og, f"layer.y.type={2 if y_is_log else 0};")
|
|
544
|
+
|
|
545
|
+
# Limits
|
|
546
|
+
# Disable auto scaling before setting from/to.
|
|
547
|
+
_try_exec(og,f"layer.x.auto=0")
|
|
548
|
+
_try_exec(og,f"layer.x.rescale_margin=5")
|
|
549
|
+
_try_exec(og,f"layer.y.auto=0")
|
|
550
|
+
_try_exec(og,f"layer.y.rescale_margin=5")
|
|
551
|
+
xl, xr = axes_obj.xlim
|
|
552
|
+
yl, yr = axes_obj.ylim
|
|
553
|
+
# Add 5% padding based on data-driven xlim/ylim if explicit limits are missing.
|
|
554
|
+
xl_margin = xl - (xr - xl) * 0.05
|
|
555
|
+
xr_margin = xr + (xr - xl) * 0.05
|
|
556
|
+
yl_margin = yl - (yr - yl) * 0.05
|
|
557
|
+
yr_margin = yr + (yr - yl) * 0.05
|
|
558
|
+
if spec.x_left_lim is not None:
|
|
559
|
+
_try_exec(og, f"layer.x.from={float(spec.x_left_lim)};")
|
|
560
|
+
else:
|
|
561
|
+
_try_exec(og, f"layer.x.from={float(xl_margin)};")
|
|
562
|
+
if spec.x_right_lim is not None:
|
|
563
|
+
_try_exec(og, f"layer.x.to={float(spec.x_right_lim)};")
|
|
564
|
+
else:
|
|
565
|
+
_try_exec(og, f"layer.x.to={float(xr_margin)};")
|
|
566
|
+
|
|
567
|
+
if spec.y_left_lim is not None:
|
|
568
|
+
_try_exec(og, f"layer.y.from={float(spec.y_left_lim)};")
|
|
569
|
+
else:
|
|
570
|
+
_try_exec(og, f"layer.y.from={float(yl_margin)};")
|
|
571
|
+
if spec.y_right_lim is not None:
|
|
572
|
+
_try_exec(og, f"layer.y.to={float(spec.y_right_lim)};")
|
|
573
|
+
else:
|
|
574
|
+
_try_exec(og, f"layer.y.to={float(yr_margin)};")
|
|
575
|
+
|
|
576
|
+
# Ticks & grids
|
|
577
|
+
_apply_tick_spec(og, spec.major_tick, spec.minor_tick, figspec.global_fontsize, scale)
|
|
578
|
+
if not ignore_grid:
|
|
579
|
+
_apply_grid_spec(og, spec.major_grid, spec.minor_grid)
|
|
580
|
+
|
|
581
|
+
# Annotations
|
|
582
|
+
_apply_annotations(og, spec.annotation, scale)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _legend_anchor_xy(leg: LegendSpec) -> tuple[float, float]:
|
|
586
|
+
"""Use explicit anchor_x/y if user set them; else map loc to a sane default."""
|
|
587
|
+
# In your LegendSpec, anchor defaults to (1,1). We'll interpret as "relative top-right".
|
|
588
|
+
if leg.anchor_x != 1 or leg.anchor_y != 1:
|
|
589
|
+
return float(leg.anchor_x) * 100.0, float(leg.anchor_y) * 100.0
|
|
590
|
+
|
|
591
|
+
loc = (leg.loc or "upper right").lower().replace("_", " ")
|
|
592
|
+
table = {
|
|
593
|
+
"upper right": (90.0, 90.0),
|
|
594
|
+
"upper left": (10.0, 90.0),
|
|
595
|
+
"lower left": (10.0, 10.0),
|
|
596
|
+
"lower right": (90.0, 10.0),
|
|
597
|
+
"center": (50.0, 50.0),
|
|
598
|
+
}
|
|
599
|
+
return table.get(loc, (90.0, 90.0))
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _apply_legend_text(og: Any, layer_idx: int, legend_spec: LegendSpec, legend_text: str, scale: float) -> None:
|
|
603
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
604
|
+
|
|
605
|
+
# Ensure the legend object exists (template may not have one)
|
|
606
|
+
_try_exec(og, "legend;")
|
|
607
|
+
_try_exec(og, "legend -r;")
|
|
608
|
+
|
|
609
|
+
# Set legend text
|
|
610
|
+
_try_exec(og, f'string __py_lg$="{_lt_quote_keep_backslash(legend_text)}";')
|
|
611
|
+
_try_exec(og, "legend.text$=__py_lg$;")
|
|
612
|
+
|
|
613
|
+
# Font and frame
|
|
614
|
+
_try_exec(og, f"legend.fsize={legend_spec.fontsize * FONT_PT_SCALE * scale};")
|
|
615
|
+
_try_exec(og, f"legend.frame={1 if legend_spec.frameon else 0};")
|
|
616
|
+
|
|
617
|
+
# Place legend: interpret your (x,y) as percent of axis range -> convert to axis units
|
|
618
|
+
px, py = _legend_anchor_xy(legend_spec) # 0..100
|
|
619
|
+
fx = float(px) / 100.0
|
|
620
|
+
fy = float(py) / 100.0
|
|
621
|
+
_try_exec(
|
|
622
|
+
og,
|
|
623
|
+
f"double __x=layer.x.from+(layer.x.to-layer.x.from)*{fx:.8f};"
|
|
624
|
+
f"double __y=layer.y.from+(layer.y.to-layer.y.from)*{fy:.8f};"
|
|
625
|
+
"legend.x=__x; legend.y=__y;"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
_try_exec(og, "legend.update=1;")
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _set_layer_position_percent(og: Any, layer_idx: int, width: float, height: float, xoff: float, yoff: float) -> None:
|
|
633
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
634
|
+
_try_exec(og, f"layer {width:.4f} {height:.4f} {xoff:.4f} {yoff:.4f};")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _apply_multi_axes_layout(og: Any, multi_spec: MutiAxesSpec, n_layers: int) -> None:
|
|
638
|
+
if isinstance(multi_spec, InsertAxesSpec) and n_layers >= 2:
|
|
639
|
+
main_w, main_h, main_x, main_y = 78.0, 72.0, 15.0, 15.0
|
|
640
|
+
_set_layer_position_percent(og, 1, main_w, main_h, main_x, main_y)
|
|
641
|
+
|
|
642
|
+
insert_w = main_w * float(multi_spec.width)
|
|
643
|
+
insert_h = main_h * float(multi_spec.height)
|
|
644
|
+
insert_x = main_x + main_w * float(multi_spec.x)
|
|
645
|
+
insert_y = main_y + main_h * float(multi_spec.y)
|
|
646
|
+
insert_y = 100 - (insert_y + insert_h)
|
|
647
|
+
_set_layer_position_percent(og, 2, insert_w, insert_h, insert_x, insert_y)
|
|
648
|
+
|
|
649
|
+
_try_exec(og, "layer -s 2; layer.link=0;")
|
|
650
|
+
|
|
651
|
+
if isinstance(multi_spec, StackAxesSpec):
|
|
652
|
+
# Grid layout: nrows x ncols
|
|
653
|
+
nrows = int(multi_spec.nrows)
|
|
654
|
+
ncols = int(multi_spec.ncols)
|
|
655
|
+
|
|
656
|
+
# Safety: if mismatched, do best-effort with min(n_layers, nrows*ncols)
|
|
657
|
+
total = nrows * ncols
|
|
658
|
+
use_layers = min(n_layers, total)
|
|
659
|
+
|
|
660
|
+
# Layout parameters in percent (tweak if you want tighter spacing)
|
|
661
|
+
left = 15.0
|
|
662
|
+
right = 10.0
|
|
663
|
+
bottom = 12.0
|
|
664
|
+
top = 10.0
|
|
665
|
+
hgap = 6.0 # horizontal gap between cells
|
|
666
|
+
vgap = 6.0 # vertical gap between cells
|
|
667
|
+
|
|
668
|
+
avail_w = 100.0 - left - right - (ncols - 1) * hgap
|
|
669
|
+
avail_h = 100.0 - bottom - top - (nrows - 1) * vgap
|
|
670
|
+
cell_w = avail_w / ncols
|
|
671
|
+
cell_h = avail_h / nrows
|
|
672
|
+
|
|
673
|
+
for idx in range(1, use_layers + 1):
|
|
674
|
+
k = idx - 1
|
|
675
|
+
r = k // ncols
|
|
676
|
+
c = k % ncols
|
|
677
|
+
|
|
678
|
+
# Origin layer positioning is easier to reason in "bottom-left" coordinates.
|
|
679
|
+
# We want row 0 at the top, so map it to highest yoff.
|
|
680
|
+
xoff = left + c * (cell_w + hgap)
|
|
681
|
+
yoff = bottom + r * (cell_h + vgap)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
_set_layer_position_percent(og, idx, cell_w, cell_h, xoff, yoff)
|
|
685
|
+
|
|
686
|
+
# Optional: unlink axes among layers
|
|
687
|
+
for idx in range(1, use_layers + 1):
|
|
688
|
+
_try_exec(og, f"layer -s {idx}; layer.link=0;")
|
|
689
|
+
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# -------------------------
|
|
695
|
+
# Curve styling via LabTalk `set`
|
|
696
|
+
# -------------------------
|
|
697
|
+
# Styles are applied to plot_ref (default "%C" = current plot).
|
|
698
|
+
# If Origin does not update %C to the newly-created plot after plotxy,
|
|
699
|
+
# style commands may apply to the wrong curve ("off-by-one" or cross-layer).
|
|
700
|
+
# That can produce complex, nontrivial mismatches (not just a simple swap).
|
|
701
|
+
def _apply_curve_style(og, axes_spec, cyc, *, plot_ref="%C") -> None:
|
|
702
|
+
color = str(cyc.get("color", "black"))
|
|
703
|
+
linestyle = str(cyc.get("linestyle", "-"))
|
|
704
|
+
marker = str(cyc.get("marker", "o"))
|
|
705
|
+
alpha = float(cyc.get("alpha", 1.0))
|
|
706
|
+
|
|
707
|
+
lw = float(axes_spec.linewidth) * FONT_PT_SCALE
|
|
708
|
+
ms = float(axes_spec.marker_size) * FONT_PT_SCALE
|
|
709
|
+
|
|
710
|
+
# Always apply base color/alpha first
|
|
711
|
+
_try_exec(og, f"set {plot_ref} -c {_lt_color_expr(color)};")
|
|
712
|
+
_try_exec(og, f"set {plot_ref} -t {int(alpha * 100)};")
|
|
713
|
+
|
|
714
|
+
if linestyle == "|":
|
|
715
|
+
# --- Special case: "stick" style implemented via Drop Lines ---
|
|
716
|
+
# 1) No connecting line (Connect: No Line)
|
|
717
|
+
_try_exec(og, f"set {plot_ref} -l 0;") # 0 = scatter/no line :contentReference[oaicite:4]{index=4}
|
|
718
|
+
|
|
719
|
+
# 2) Hide symbols (we only want droplines)
|
|
720
|
+
_try_exec(og, f"set {plot_ref} -z 0;") # symbol size -> 0 (your code uses -z for size)
|
|
721
|
+
|
|
722
|
+
# 3) Enable vertical drop lines
|
|
723
|
+
_try_exec(og, f"set {plot_ref} -lv 1;") # show vertical drop lines :contentReference[oaicite:5]{index=5}
|
|
724
|
+
|
|
725
|
+
# 4) Vertical drop line style: dashed
|
|
726
|
+
_try_exec(og, f"set {plot_ref} -lvs 1;") # 1 = dash :contentReference[oaicite:6]{index=6}
|
|
727
|
+
|
|
728
|
+
# 5) Vertical drop line width
|
|
729
|
+
_try_exec(og, f"set {plot_ref} -lvw {int(lw)};")
|
|
730
|
+
|
|
731
|
+
# 6) Optional: try to set dropline color by palette index (only works for simple named colors)
|
|
732
|
+
# Origin docs say -lvc follows palette index (1 black, 2 red, 3 green, 4 blue, ...). :contentReference[oaicite:8]{index=8}
|
|
733
|
+
_COLOR_INDEX = {"black": 1, "red": 2, "green": 3, "blue": 4, "white": 0}
|
|
734
|
+
ci = _COLOR_INDEX.get(color.strip().lower()) if isinstance(color, str) else None
|
|
735
|
+
if ci is not None:
|
|
736
|
+
_try_exec(og, f"set {plot_ref} -lvc {ci};")
|
|
737
|
+
|
|
738
|
+
return # IMPORTANT: do not fall through to normal line/marker logic
|
|
739
|
+
|
|
740
|
+
# --- Normal path (your existing logic) ---
|
|
741
|
+
_try_exec(og, f"set {plot_ref} -wp {lw};")
|
|
742
|
+
_try_exec(og, f"set {plot_ref} -z {ms};")
|
|
743
|
+
|
|
744
|
+
ls_code = _LINESTYLE_CODE.get(linestyle)
|
|
745
|
+
if ls_code is not None:
|
|
746
|
+
_try_exec(og, f"set {plot_ref} -d {int(ls_code)};")
|
|
747
|
+
|
|
748
|
+
mk_code = _MARKER_CODE.get(marker)
|
|
749
|
+
if mk_code is not None:
|
|
750
|
+
_try_exec(og, f"set {plot_ref} -k {int(mk_code)};")
|
|
751
|
+
else:
|
|
752
|
+
_try_exec(og, f"set {plot_ref} -z 0;")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _save_project(og: Any, proj_name: str, opju_dir: str) -> None:
|
|
759
|
+
out_dir = WindowsPath(opju_dir)
|
|
760
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
761
|
+
opju_path = out_dir / f"{proj_name}.opju"
|
|
762
|
+
og.Save(str(opju_path.absolute()))
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# -------------------------
|
|
767
|
+
# Legend text templates
|
|
768
|
+
# -------------------------
|
|
769
|
+
# Origin legend uses escape sequences like: \l(1) meaning "sample of plot #1".
|
|
770
|
+
# If plot order is changed (e.g., `layer -r`), the mapping from index -> curve changes.
|
|
771
|
+
# This is a high-priority suspect when legend labels and line samples appear mismatched.
|
|
772
|
+
def _legend_text_single_layer(labels: list[str]) -> str:
|
|
773
|
+
return "\n".join([f"\\l({i}) {lab}" for i, lab in enumerate(labels, start=1)])
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _legend_text_all_layers(layer_labels: list[list[str]]) -> str:
|
|
777
|
+
lines: list[str] = []
|
|
778
|
+
for L, labels in enumerate(layer_labels, start=1):
|
|
779
|
+
for P, lab in enumerate(labels, start=1):
|
|
780
|
+
lines.append(f"\\l({L}.{P}) {lab}")
|
|
781
|
+
return "\n".join(lines)
|
|
782
|
+
|
|
783
|
+
def _apply_layer_background_from_figure(
|
|
784
|
+
og: Any,
|
|
785
|
+
facecolor,
|
|
786
|
+
*,
|
|
787
|
+
layer_idx: int | None = None,
|
|
788
|
+
) -> None:
|
|
789
|
+
"""
|
|
790
|
+
Apply FigureSpec.facecolor to layer background and FORCE it opaque.
|
|
791
|
+
This works for both main layer and inset layers.
|
|
792
|
+
"""
|
|
793
|
+
cexpr = _lt_color_rgb(facecolor, fallback=(255, 255, 255))
|
|
794
|
+
|
|
795
|
+
if layer_idx is None:
|
|
796
|
+
# Assume current layer is selected
|
|
797
|
+
_try_exec(og, "layer.fill=1;") # REQUIRED for inset
|
|
798
|
+
_try_exec(og, f"layer.color={cexpr};") # background color
|
|
799
|
+
_try_exec(og, "layer.transparency=0;") # 0 = opaque
|
|
800
|
+
else:
|
|
801
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
802
|
+
_try_exec(og, f"layer{layer_idx}.fill=1;")
|
|
803
|
+
_try_exec(og, f"layer{layer_idx}.color={cexpr};")
|
|
804
|
+
_try_exec(og, f"layer{layer_idx}.transparency=0;")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# -------------------------
|
|
808
|
+
# Core plotting
|
|
809
|
+
# -------------------------
|
|
810
|
+
|
|
811
|
+
# =====================================================================
|
|
812
|
+
# Main pipeline: domain Project -> Origin workbook(s) + graph page(s)
|
|
813
|
+
# =====================================================================
|
|
814
|
+
# High-level runtime structure per Figure:
|
|
815
|
+
# 1) Ensure project opened/created
|
|
816
|
+
# 2) Create a workbook named after figure_name
|
|
817
|
+
# - One worksheet per Axes (layer)
|
|
818
|
+
# - Each Data contributes two columns (X then Y)
|
|
819
|
+
# 3) Create a graph page (template "Origin"), fallback via plotxy if CreatePage fails
|
|
820
|
+
# 4) Ensure layer count == number of Axes
|
|
821
|
+
# 5) Plot curves for each layer, consuming a SINGLE figure-level style cycle
|
|
822
|
+
# - After each layer, run `layer -r` (reverse plot order within the layer)
|
|
823
|
+
# 6) Apply AxesSpec formatting (titles/limits/log/ticks/grids/annotations)
|
|
824
|
+
# 7) Build legend text and write legend.text$
|
|
825
|
+
# 8) Save project if requested
|
|
826
|
+
#
|
|
827
|
+
# Debugging suspects for style chaos (non-exhaustive):
|
|
828
|
+
# - _choose_plot_code always returns 202 (line+symbol)
|
|
829
|
+
# - %C reference may not point to the intended curve when styling
|
|
830
|
+
# - `layer -r` changes plot indices used by legend \l(...)
|
|
831
|
+
# - CreatePage fallback may create an extra initial plot before the main loop plots again
|
|
832
|
+
# - curve_scale computed but not used inside _apply_curve_style
|
|
833
|
+
def plot(proj: Project, proj_name: str, og: Any, /, proj_dir: Optional[str] = None, overwrite=False) -> None:
|
|
834
|
+
"""Plot FigureSpec into Origin and optionally save OPJU.
|
|
835
|
+
"""
|
|
836
|
+
if overwrite:
|
|
837
|
+
# Start fresh
|
|
838
|
+
try:
|
|
839
|
+
og.NewProject()
|
|
840
|
+
except Exception:
|
|
841
|
+
_try_exec(og, "doc -n;")
|
|
842
|
+
else:
|
|
843
|
+
open_or_create_project(og, Path(proj_dir) / f"{proj_name}.opju")
|
|
844
|
+
|
|
845
|
+
for folder_name, folder in proj.items():
|
|
846
|
+
for figure_name, figure in folder.items():
|
|
847
|
+
_try_exec(og, "pe_cd /")
|
|
848
|
+
_try_exec(og, f"pe_mkdir {folder_name} chk:=1 cd:=1")
|
|
849
|
+
|
|
850
|
+
axes_pool = figure.axes_pool
|
|
851
|
+
figspec: FigureSpec = figure.spec
|
|
852
|
+
multi_spec: MutiAxesSpec = figure.muti_axes_spec
|
|
853
|
+
|
|
854
|
+
if not axes_pool:
|
|
855
|
+
break
|
|
856
|
+
|
|
857
|
+
# ===== Workbook + data =====
|
|
858
|
+
# A New workbook
|
|
859
|
+
wb = og.WorksheetPages.Add("Origin")
|
|
860
|
+
wb.Name = _workbook_sanitize_name(figure_name)
|
|
861
|
+
og.Execute(f'page.longname$ = "{figure_name}";')
|
|
862
|
+
# create worksheets
|
|
863
|
+
wks_layer_names = [wb.Layers(0).Name]
|
|
864
|
+
for _ in range(len(axes_pool)-1):
|
|
865
|
+
wks_layer_names.append(wb.Layers.Add().Name)
|
|
866
|
+
wks_pool = [f"[{wb.Name}]{name}" for name in wks_layer_names]
|
|
867
|
+
# Put datas into worksheets, set axis titles(longnames) and legends(comments).
|
|
868
|
+
for wks_name,axes in zip(wks_pool, axes_pool):
|
|
869
|
+
datas = axes.data_pool
|
|
870
|
+
wks = og.FindWorksheet(wks_name)
|
|
871
|
+
for i,data in enumerate(datas):
|
|
872
|
+
og.PutWorksheet(wks_name,data.points_for_plot[:,0:2].tolist(),0,-1) # 0:first_row -1:append_col
|
|
873
|
+
id1 = i * 2
|
|
874
|
+
id2 = id1 + 1
|
|
875
|
+
wks.Columns(id1).Type = constants.COLTYPE_X
|
|
876
|
+
wks.Columns(id2).Type = constants.COLTYPE_Y
|
|
877
|
+
|
|
878
|
+
# --- Robust column long names: data.headers may be missing/short ---
|
|
879
|
+
headers = list(getattr(data, "headers", []) or [])
|
|
880
|
+
# Prefer user-provided headers; otherwise fallback to deterministic defaults.
|
|
881
|
+
x_name = headers[0] if len(headers) >= 1 and headers[0] else f"X{i+1}"
|
|
882
|
+
y_name = headers[1] if len(headers) >= 2 and headers[1] else f"Y{i+1}"
|
|
883
|
+
wks.Columns(id1).LongName = x_name
|
|
884
|
+
wks.Columns(id2).LongName = y_name
|
|
885
|
+
wks.Columns(id2).Comments = data.labels.brief_summary
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
# ===== Create graph page =====
|
|
890
|
+
graph_name = _graph_sanitize_name(figure_name)
|
|
891
|
+
try:
|
|
892
|
+
gr_page = str(og.CreatePage(constants.OPT_GRAPH, graph_name, "Origin"))
|
|
893
|
+
og.Execute(f'page.longname$ = "{figure_name}";')
|
|
894
|
+
except Exception:
|
|
895
|
+
# FALLBACK PATH: create graph via plotxy <new ...>.
|
|
896
|
+
# IMPORTANT: this may create the first plot immediately.
|
|
897
|
+
# Later, the main plotting loop may plot the same series again,
|
|
898
|
+
# which can duplicate curves and scramble plot indices/styles.
|
|
899
|
+
# fallback: create via plotxy new
|
|
900
|
+
first_sheet = wks_pool[0]
|
|
901
|
+
cyc0 = next(iter_cycles(figspec))
|
|
902
|
+
code0 = _choose_plot_code(str(cyc0["linestyle"]), str(cyc0["marker"]))
|
|
903
|
+
_try_exec(og, f'plotxy iy:=({first_sheet}!col(1),col(2)) plot:={code0} ogl:=<new name:={graph_name}>;')
|
|
904
|
+
try:
|
|
905
|
+
gr_page = str(og.GetLTStr("%H")).strip()
|
|
906
|
+
except Exception:
|
|
907
|
+
gr_page = graph_name
|
|
908
|
+
|
|
909
|
+
_try_exec(og, f'win -a "{_lt_quote(gr_page)}";')
|
|
910
|
+
|
|
911
|
+
# Ensure layer count
|
|
912
|
+
for _ in range(2, len(axes_pool) + 1):
|
|
913
|
+
_try_exec(og, "layer -n;")
|
|
914
|
+
|
|
915
|
+
# Muti Layout if needed
|
|
916
|
+
_apply_multi_axes_layout(og, multi_spec, len(axes_pool))
|
|
917
|
+
|
|
918
|
+
# ===== Plot curves with figure-level cycles =====
|
|
919
|
+
global_cycle_iter = iter_cycles(figspec)
|
|
920
|
+
|
|
921
|
+
for layer_idx, (axes, sheet_name) in enumerate(zip(axes_pool, wks_pool), start=1):
|
|
922
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
923
|
+
# for stack, each layer has its own cycle
|
|
924
|
+
if isinstance(multi_spec, StackAxesSpec):
|
|
925
|
+
cycle_iter = iter_cycles(figspec) # reset per layer
|
|
926
|
+
else:
|
|
927
|
+
cycle_iter = global_cycle_iter # share across figure
|
|
928
|
+
|
|
929
|
+
is_inset = isinstance(multi_spec, InsertAxesSpec) and layer_idx == 2
|
|
930
|
+
curve_scale = INSET_SCALE if is_inset else MAIN_SCALE
|
|
931
|
+
|
|
932
|
+
for j, data in enumerate(axes.data_pool):
|
|
933
|
+
cyc = next(cycle_iter)
|
|
934
|
+
code = _choose_plot_code(str(cyc["linestyle"]), str(cyc["marker"]))
|
|
935
|
+
|
|
936
|
+
xcol = j * 2 + 1
|
|
937
|
+
ycol = xcol + 1
|
|
938
|
+
|
|
939
|
+
_try_exec(
|
|
940
|
+
og,
|
|
941
|
+
f"plotxy iy:=({sheet_name}!col({xcol}),{sheet_name}!col({ycol})) "
|
|
942
|
+
f"plot:={code} ogl:={layer_idx} rescale:=0 legend:=0;"
|
|
943
|
+
)
|
|
944
|
+
_try_exec(og, f"layer -s {layer_idx}; layer -c;") # count plots in active layer -> count, %Z :contentReference[oaicite:2]{index=2}
|
|
945
|
+
_try_exec(og, "layer.plot = count;") # set active plot index to the last one :contentReference[oaicite:3]{index=3}
|
|
946
|
+
_apply_curve_style(og, axes.spec, cyc, plot_ref="%C")
|
|
947
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
948
|
+
|
|
949
|
+
# ===== Axes formatting =====
|
|
950
|
+
for layer_idx, axes in enumerate(axes_pool, start=1):
|
|
951
|
+
is_inset = isinstance(multi_spec, InsertAxesSpec) and layer_idx == 2
|
|
952
|
+
scale = INSET_SCALE if is_inset else 1.0
|
|
953
|
+
|
|
954
|
+
x_is_log, y_is_log = _should_set_log_scale(axes)
|
|
955
|
+
|
|
956
|
+
if isinstance(figure.muti_axes_spec, StackAxesSpec):
|
|
957
|
+
force_show_titles = True
|
|
958
|
+
else:
|
|
959
|
+
force_show_titles = False
|
|
960
|
+
|
|
961
|
+
_apply_axes_spec(
|
|
962
|
+
og,
|
|
963
|
+
layer_idx,
|
|
964
|
+
axes,
|
|
965
|
+
figspec=figspec,
|
|
966
|
+
ignore_grid=is_inset,
|
|
967
|
+
x_is_log=x_is_log,
|
|
968
|
+
y_is_log=y_is_log,
|
|
969
|
+
scale=scale,
|
|
970
|
+
force_show_titles=force_show_titles
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
# If no explicit limits, rescale
|
|
974
|
+
# NOTE: This condition uses OR across the four limits.
|
|
975
|
+
# If ANY one limit is None, Rescale runs and may override from/to values set earlier.
|
|
976
|
+
if axes.spec.x_left_lim is None or axes.spec.x_right_lim is None or axes.spec.y_left_lim is None or axes.spec.y_right_lim is None:
|
|
977
|
+
_try_exec(og, f"layer -s {layer_idx}; Rescale;")
|
|
978
|
+
|
|
979
|
+
if figspec.facecolor is not None:
|
|
980
|
+
_try_exec(og, f"layer -s {layer_idx};")
|
|
981
|
+
_apply_layer_background_from_figure(og, figspec.facecolor)
|
|
982
|
+
|
|
983
|
+
# ===== Legends =====
|
|
984
|
+
# Build labels
|
|
985
|
+
per_layer_labels: list[list[str]] = []
|
|
986
|
+
for axes in axes_pool:
|
|
987
|
+
per_layer_labels.append([d.labels.brief_summary for d in axes.data_pool])
|
|
988
|
+
|
|
989
|
+
if isinstance(multi_spec, InsertAxesSpec) and getattr(multi_spec, "legend_holder", "") == "last axes":
|
|
990
|
+
holder_idx = len(axes_pool)
|
|
991
|
+
legend_spec = axes_pool[-1].spec.legend or figspec.legend
|
|
992
|
+
if legend_spec is not None:
|
|
993
|
+
txt = _legend_text_all_layers(per_layer_labels)
|
|
994
|
+
_apply_legend_text(og, holder_idx, legend_spec, txt, scale=INSET_SCALE if holder_idx == 2 else MAIN_SCALE)
|
|
995
|
+
for i in range(1, holder_idx):
|
|
996
|
+
_try_exec(og, f"layer -s {i}; legend.text$=\"\";")
|
|
997
|
+
else:
|
|
998
|
+
for layer_idx, axes in enumerate(axes_pool, start=1):
|
|
999
|
+
legend_spec = axes.spec.legend or figspec.legend
|
|
1000
|
+
if legend_spec is None:
|
|
1001
|
+
continue
|
|
1002
|
+
txt = _legend_text_single_layer(per_layer_labels[layer_idx - 1])
|
|
1003
|
+
_apply_legend_text(og, layer_idx, legend_spec, txt, scale=MAIN_SCALE)
|
|
1004
|
+
|
|
1005
|
+
if proj_dir and proj_name:
|
|
1006
|
+
_save_project(og, proj_name, proj_dir)
|