From b20ba927975484345619e65432afcfb16a52517a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 01:30:12 +0000 Subject: [PATCH 1/4] =?UTF-8?q?v3.9.0:=20Diagram=20Style=20Editor=EF=BC=88?= =?UTF-8?q?=E9=A1=8F=E8=89=B2/=E5=AD=97=E5=9E=8B/=E8=BB=B8/=E6=A8=99?= =?UTF-8?q?=E7=B1=A4=E4=BA=92=E5=8B=95=E5=BC=8F=E7=B7=A8=E8=BC=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能:讓使用者針對 AgeCalc diagram(DFN/DFI/DFW/DFA/DFC/DFD/DFR)的 顏色、字型、線寬、軸刻度、legend、各圖 title/x/y 標籤做互動式編輯, 整合進現有 DiagramPlot refresh 迴路,非獨立子程式。 - Step 1 中央 style dict:_get_style() 改「base ← preset ← overrides」三層合併; 新增 publication / presentation preset;散落的 matplotlib hardcode (legend/字級/線寬/tick/grid/atm marker/軸標籤)一律改讀 dict。 pyADR/classic 合併結果與舊 dict 逐鍵一致,字級 override 為 None 時 以 _fs() 略過 kwarg,既有輸出不變。 - GROUP_COLORS 集中到 Utilities 單一真相 + 去重,新增色盲安全的 GROUP_COLORS_CVD;resolution: editor > caller > preset。 - Step 2 UI/DiagramStyleEditor.py:左分頁控件 + 右側大圖置中預覽 + preset 下拉(改參數自動切 custom) + Reset/Apply/OK,non-modal 單例。 - 雙入口共用:NTNU DiagramPlots_SH sidebar 與 AutoPipeline AgeCalcPage 各加 ⚙,寫同一份 set_style_overrides,Apply 走既有 SH_apply_axes / _refresh_diagrams 重畫;兩 app Style 下拉加 Publication/Presentation。 驗證:四檔 ast.parse 通過;style 系統單元測試(向後相容 + overrides merge/clear + text override + group resolution)通過;dialog offscreen 截圖確認分頁/preset/target 預覽/overrides 合併正常。NO.65 端對端數值 比對待有樣品資料環境再跑,再發 Release tag。 DFR 微調:改用共用 _apply_frame(),pyADR 下 axes facecolor 由白改 transparent(與其他 spectra 一致),中心值/誤差不變。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DhiyS8YrAJ2MeSGoo5Pyt --- .work/.app_info.txt | 4 +- AutoPipeline.py | 65 ++++- CHANGELOG.md | 30 +++ NTNU_DataReduction.py | 69 +++++- README.md | 7 +- UI/DiagramStyleEditor.py | 519 +++++++++++++++++++++++++++++++++++++++ Utilities.py | 394 ++++++++++++++++++----------- 7 files changed, 931 insertions(+), 157 deletions(-) create mode 100644 UI/DiagramStyleEditor.py diff --git a/.work/.app_info.txt b/.work/.app_info.txt index b287301..93d7f9c 100644 --- a/.work/.app_info.txt +++ b/.work/.app_info.txt @@ -1,8 +1,8 @@ Version: -3.8.93 +3.9.0 Last updated date: -12/06/2026 +02/07/2026 Developer: Chi-Hsiu Pang An-Jun (Andrew) Liu diff --git a/AutoPipeline.py b/AutoPipeline.py index 50319be..ef1621a 100644 --- a/AutoPipeline.py +++ b/AutoPipeline.py @@ -4852,10 +4852,18 @@ def stat_cell(label, w_width=None): opts2_hl = QtWidgets.QHBoxLayout() opts2_hl.addWidget(QtWidgets.QLabel('Style:')) self._plot_style_combo = QtWidgets.QComboBox() - self._plot_style_combo.addItems(['pyADR', 'Classic (PDF)']) + self._plot_style_combo.addItems(['pyADR', 'Classic (PDF)', + 'Publication', 'Presentation']) self._plot_style_combo.setToolTip( - 'pyADR: colored fills | Classic (PDF): black & white') + 'pyADR: colored fills | Classic (PDF): black & white | ' + 'Publication: muted journal style | Presentation: large fonts') opts2_hl.addWidget(self._plot_style_combo) + self._styleEditBtn = QtWidgets.QPushButton('⚙') + self._styleEditBtn.setFixedSize(24, 22) + self._styleEditBtn.setToolTip('Diagram Style Editor:編輯顏色/字型/軸/標籤') + self._styleEditBtn.setCursor(QtCore.Qt.PointingHandCursor) + self._styleEditBtn.clicked.connect(self._open_style_editor) + opts2_hl.addWidget(self._styleEditBtn) opts2_hl.addSpacing(10) self._plot_cb_logy = QtWidgets.QCheckBox('Log Y') self._plot_cb_logy.setToolTip('Log-scale Y axis (Ca/K, Cl/K, Degassing).') @@ -5194,12 +5202,59 @@ def _sync_axis_controls_from_actual(self): ed[3].setPlaceholderText(f'auto ({self._fmt_axis(ay[k][1], k)})') def _plot_style(self): - """'pyADR' or 'classic' from the Style combo (default pyADR).""" + """Map the Style combo to a Utilities preset name (default pyADR).""" try: - return ('classic' if 'Classic' in self._plot_style_combo.currentText() - else 'pyADR') + txt = self._plot_style_combo.currentText() except Exception: return 'pyADR' + if 'Classic' in txt: + return 'classic' + if 'Publication' in txt: + return 'publication' + if 'Presentation' in txt: + return 'presentation' + return 'pyADR' + + def _current_diagram_target(self): + """回傳目前顯示中的 diagram code(給 style editor 預覽跟隨)。""" + try: + name = self._tabs.tabText(self._tabs.currentIndex()) + except Exception: + return 'DFW' + _map = {'Age Spectrum': 'DFW', 'Inverse': 'DFI', 'Inverse Isochron': 'DFI', + 'Normal': 'DFN', 'Normal Isochron': 'DFN', 'Ca/K': 'DFA', + 'Cl/K': 'DFC', 'Degassing': 'DFD', '⁴⁰Ar(r)%': 'DFR'} + for k, v in _map.items(): + if k in name: + return v + return 'DFW' + + def _open_style_editor(self): + """開啟 DiagramStyleEditor(non-modal 單例)。""" + try: + from UI.DiagramStyleEditor import DiagramStyleEditor + except Exception as e: + QtWidgets.QMessageBox.warning(self, 'Style Editor', + f'無法載入樣式編輯器:\n{e}') + return + + def _apply(overrides, preset): + Utilities.set_style_overrides(overrides) + self._style_overrides = overrides # 供 session 持久化 + self._refresh_diagrams() + + dlg = getattr(self, '_style_editor', None) + if dlg is None: + # diagram PNGs 由 Utilities 寫到 repo/.work + work = os.path.join( + os.path.dirname(os.path.abspath(__file__)), '.work') + dlg = DiagramStyleEditor( + self, host_get_style=self._plot_style, on_apply=_apply, + work_dir=work, current_target=self._current_diagram_target()) + self._style_editor = dlg + dlg.show() + dlg.raise_() + dlg.activateWindow() def populate(self, steps, datum_csv, work_dir, consts=None): self._steps = steps diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c0ccd..5bcf3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ GitHub Releases(tag)最新仍為 **v3.8.54(Latest,彙整 v3.8.9 → v3.8 --- +## V3.9.0(2026-07-02)— Diagram Style Editor:可視化編輯圖表樣式/軸/標籤 + +新功能。讓使用者針對 AgeCalc diagram(DFN/DFI/DFW/DFA/DFC/DFD/DFR)的顏色、字型、線寬、軸刻度、legend、以及各圖的 title/x/y 標籤做互動式編輯,不必改 code。整合進現有 DiagramPlot 的 refresh 迴路,**不是**獨立子程式。 + +### 設計 +- **中央 style dict(Step 1)**:`Utilities._get_style()` 從只有 pyADR/classic 兩檔擴充為「base 預設 ← preset ← 使用者 overrides」三層合併。新增 `publication`(IsoplotR 風 muted 色 + tick 朝內 + 四邊框 + minor ticks)與 `presentation`(同配色、字級/線寬放大)兩個 preset。散落在各 plot function 的 hardcode(legend loc/fontsize/framealpha、label/tick/annot 字級、迴歸/連接/WMA/邊框線寬、tick 方向、grid、大氣 marker 色、軸標籤文字)一律改讀 dict。 +- **向後相容**:pyADR / classic 兩 preset 的合併結果與舊 dict 逐鍵一致;字級類 override 為 None 時以 `_fs()` 完全略過該 kwarg,matplotlib 預設不變,既有輸出保持不動(NO.65 driven 驗證前提)。 +- **GROUP_COLORS 集中 + 去重**:原本在 `NTNU_DataReduction.py` 與 `Utilities.py` 各定義一份,改由 `Utilities.GROUP_COLORS` 單一真相;新增 `GROUP_COLORS_CVD`(8 色、色盲安全,publication/presentation 用)。resolution 順序:editor override > caller 傳入 > preset 預設(`_resolve_group_colors`)。 +- **DiagramStyleEditor(Step 2)**:新檔 `UI/DiagramStyleEditor.py`。左側分頁(顏色 / 字型 / 線條&Marker / Legend / 軸 / 文字標籤)+ 頂部 preset 下拉(改任一參數自動切 custom)+ 右側大圖置中預覽(讀 `.work/.png`,auto-scale,圖為主體)+ Reset/Cancel/Apply/OK。non-modal 單例。 +- **雙入口共用**:NTNU_DataReduction DiagramPlots_SH sidebar 與 AutoPipeline AgeCalcPage Plot Controls 各加一顆 ⚙,開同一個 dialog、寫同一份 overrides(`Utilities.set_style_overrides`),Apply → 既有 `SH_apply_axes` / `_refresh_diagrams` 重畫。兩 app 的 Style 下拉同步加入 Publication / Presentation 選項。 + +### 影響 +- 預設行為不變:未開 editor、preset 維持 pyADR 時,所有圖輸出與 v3.8.93 相同。 +- **DFR(radiogenic yield)微調**:改用共用的 `_apply_frame()`,pyADR 下 axes facecolor 由預設白改為 transparent(與 DFW/DFA/DFC 一致),classic 下多了 minor ticks。純外觀一致化,中心值與誤差不變。 + +### 驗證 +- 四檔 `ast.parse` 通過。 +- style 系統單元測試:pyADR/classic 合併結果與舊 dict 逐鍵一致;overrides merge/clear、per-target text override、group color resolution 皆正確。 +- DiagramStyleEditor 以 `QT_QPA_PLATFORM=offscreen` 實際開啟並截圖:分頁/preset 切換/target 預覽/collect_overrides→`_get_style` 合併(group[0]=#2a78d6、tick_dir=in、ticks_top_right=True)全數正常。 +- **待辦**:NO.65 muscovite 端對端數值比對(確認 pyADR preset 下輸出 bit-identical)留待有樣品資料的環境跑,再發 Release tag。 + +### 檔案改動 +- `Utilities.py`:`_get_style` 三層合併 + `_STYLE_PRESETS`/`_STYLE_BASE`/`_STYLE_OVERRIDES`、`set_style_overrides`/`get_style_overrides`/`available_styles`、helper `_txt`/`_fs`/`_legend_kw`/`_resolve_group_colors`/`_apply_frame`;各 plot function 套用。 +- `UI/DiagramStyleEditor.py`:新檔(dialog)。 +- `NTNU_DataReduction.py`:styleCombo 加 Publication/Presentation、`_get_plot_style` 對應、sidebar ⚙ 按鈕 + `_open_style_editor`/`_current_sh_target`。 +- `AutoPipeline.py`:Plot Controls Style 加選項 + ⚙ 按鈕、`_plot_style` 對應、`_open_style_editor`/`_current_diagram_target`。 +- `.work/.app_info.txt`:3.8.93 → 3.9.0。 + +--- + ## V3.8.93(2026-06-22)— 修 AutoPipeline 按 Help 跳出開機 splash 畫面 使用者回報:在 AutoPipeline 按 Help → Formulas,會冒出開機 splash(pyADR logo / NTNU Ar/Ar Lab)蓋在 Help dialog 上。 diff --git a/NTNU_DataReduction.py b/NTNU_DataReduction.py index 94eb0f3..e7ba764 100644 --- a/NTNU_DataReduction.py +++ b/NTNU_DataReduction.py @@ -1110,11 +1110,17 @@ def _mk_sep(): # Style selector # styleCombo created here; placed on left sidebar by _place_btn3D timer self.styleCombo = QtWidgets.QComboBox(self.centralwidget) - self.styleCombo.addItems(["pyADR", "Classic (PDF)"]) - self.styleCombo.setToolTip("pyADR: colored fills | Classic (PDF): black & white") + self.styleCombo.addItems(["pyADR", "Classic (PDF)", "Publication", "Presentation"]) + self.styleCombo.setToolTip("pyADR: colored fills | Classic (PDF): black & white | " + "Publication: muted, journal ticks | Presentation: large fonts") self.styleCombo.hide() # will be repositioned in _place_btn3D self._styleLbl = QtWidgets.QLabel("Style:", self.centralwidget) self._styleLbl.hide() + # ⚙ Diagram Style Editor(v3.9.0);positioned in _place_btn3D + self.styleEditBtn = QtWidgets.QPushButton("⚙", self.centralwidget) + self.styleEditBtn.setToolTip("Diagram Style Editor:編輯顏色/字型/軸/標籤") + self.styleEditBtn.setCursor(QtCore.Qt.PointingHandCursor) + self.styleEditBtn.hide() # Legend # Legend (own row, full width) -- per user request, replaces DISPLAY header @@ -1387,8 +1393,12 @@ def _place_btn3D(): style_y = ag.y() + (bh + gap) * 6 self._styleLbl.setGeometry(bx, style_y, lbl_w, bh) self._styleLbl.show() - self.styleCombo.setGeometry(bx + lbl_w + 2, style_y, bw - lbl_w - 2, bh) + gear_w = 26 + self.styleCombo.setGeometry(bx + lbl_w + 2, style_y, + bw - lbl_w - 2 - gear_w - 2, bh) self.styleCombo.show() + self.styleEditBtn.setGeometry(bx + bw - gear_w, style_y, gear_w, bh) + self.styleEditBtn.show() QtCore.QTimer.singleShot(0, _place_btn3D) # FIX#9: Group selector row inside ctrlBox (top, above active diagram label) @@ -2060,9 +2070,57 @@ def _get_current_xy_data(self): def _get_plot_style(self): - """Return 'pyADR' or 'classic' based on UI selector.""" + """Map the UI style selector to a Utilities preset name.""" txt = self.DiagramPlots_SHPage.styleCombo.currentText() - return 'classic' if 'Classic' in txt else 'pyADR' + if 'Classic' in txt: + return 'classic' + if 'Publication' in txt: + return 'publication' + if 'Presentation' in txt: + return 'presentation' + return 'pyADR' + + def _current_sh_target(self): + """Best-effort current SH diagram code for style-editor preview.""" + try: + txt = self.DiagramPlots_SHPage.box.currentText().lower() + except Exception: + return 'DFW' + if 'inverse' in txt: + return 'DFI' + if 'normal' in txt or 'isochron' in txt: + return 'DFN' + if 'ca/k' in txt: + return 'DFA' + if 'cl/k' in txt: + return 'DFC' + return 'DFW' + + def _open_style_editor(self): + """Open the shared DiagramStyleEditor (non-modal singleton).""" + try: + from UI.DiagramStyleEditor import DiagramStyleEditor + except Exception as e: + QtWidgets.QMessageBox.warning(self, 'Style Editor', + '無法載入樣式編輯器:\n{}'.format(e)) + return + + def _apply(overrides, preset): + Utilities.set_style_overrides(overrides) + self._diagram_style_overrides = overrides + self.SH_apply_axes() + + dlg = getattr(self, '_style_editor', None) + if dlg is None: + work = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '.work') + dlg = DiagramStyleEditor( + self, host_get_style=self._get_plot_style, on_apply=_apply, + work_dir=work, current_target=self._current_sh_target()) + self._style_editor = dlg + dlg.show() + dlg.raise_() + dlg.activateWindow() def _dfs_load_panel_into_spinboxes(self): """DFS only: load the selected panel's saved (xlim, ylim) into the @@ -3023,6 +3081,7 @@ def _h(): self.DiagramPlots_SHPage.showGroupFitsCheckbox.stateChanged.connect(self.SH_apply_axes) self.DiagramPlots_SHPage.showOverallFitCheckbox.stateChanged.connect(self.SH_apply_axes) self.DiagramPlots_SHPage.styleCombo.currentIndexChanged.connect(self.SH_apply_axes) + self.DiagramPlots_SHPage.styleEditBtn.clicked.connect(self._open_style_editor) self.DiagramPlots_SHPage.logYCheckbox.stateChanged.connect(self.SH_apply_axes) self.DiagramPlots_SHPage.showGroupSpanCheckbox.stateChanged.connect(self.SH_apply_axes) self.DiagramPlots_SHPage.showAllCompCheckbox.stateChanged.connect(self.SH_apply_axes) diff --git a/README.md b/README.md index e0c09ad..6bfd77b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ ![logo](.work/logo.png) -# pyADR — NTNU modified fork (v3.8.93) +# pyADR — NTNU modified fork (v3.9.0) 40Ar/39Ar data reduction tool with GUI. Modified fork of [pyADR](https://github.com/AndrewLiu0725/pyADR) (original by **An-Jun (Andrew) Liu**), now maintained by **PANG Chi-Hsiu (Academia Sinica)**. -This fork adds: a full batch-automation pipeline (**Argon Pipeline**: Calculate T₀ → MassRatio → AgeCalc + Datum), modern isochron math (York 2004 default, Vermeesch 2018/2024), editable J / parameters with on-the-fly recompute, ³⁶Ar-blank sensitivity tools (age spectrum + inverse isochron), 2σ reporting with uncertainty budgets, a bilingual (中 / EN) in-app Help & formulas reference, Excel native chart export, step-heating diagram types (DFD/DFS/DFM + grouped 3D plane fit), performance optimization, and auto-update notification. +This fork adds: a full batch-automation pipeline (**Argon Pipeline**: Calculate T₀ → MassRatio → AgeCalc + Datum), modern isochron math (York 2004 default, Vermeesch 2018/2024), editable J / parameters with on-the-fly recompute, ³⁶Ar-blank sensitivity tools (age spectrum + inverse isochron), 2σ reporting with uncertainty budgets, a **Diagram Style Editor** (interactive colours / fonts / axes / per-diagram labels with publication & presentation presets), a bilingual (中 / EN) in-app Help & formulas reference, Excel native chart export, step-heating diagram types (DFD/DFS/DFM + grouped 3D plane fit), performance optimization, and auto-update notification. Original README → `README_origin.md` | Full changelog → `CHANGELOG.md` @@ -126,8 +126,9 @@ python NTNU_DataReduction.py ## Changelog 摘要 -### v3.8.9 – v3.8.93 (2026-05 → 2026-06) — 摘要 +### v3.9.0 – v3.8.9 (2026-07 → 2026-05) — 摘要 +> **v3.9.0**:新增 **Diagram Style Editor**。AgeCalc 各圖(age spectrum / inverse & normal isochron / Ca/K / Cl/K / degassing / ⁴⁰Ar(r)%)的顏色、字型、線寬、軸刻度、legend、以及每張圖的 title/x/y 標籤都能互動式編輯,不必改 code。DiagramPlots_SH 與 AgeCalcPage 各有一顆 ⚙ 開同一個 dialog(左控件 + 右大圖預覽),Style 下拉新增 **Publication**(期刊 muted 風)與 **Presentation**(大字投影片風)preset。內部把散落的 matplotlib hardcode 收斂成單一 style dict、GROUP_COLORS 集中去重;pyADR/classic preset 輸出保持不變。 > **v3.8.89–93**:in-app Help 改**中英雙語**(左下角 CN/EN 切換)+ 補 isochron 兩種回歸方法(OLS / York)物理意義 + 新增 σ(T₀) 分頁;DiagramPlot SH isochron 預設也改 **York**;修「在 AutoPipeline 按 Help 會跳出開機 splash」。 > **v3.8.88**:修 Plot Controls 改軸範圍按 Apply 後,切到 diagram 分頁圖才刷新(分頁顯示時自動重新縮放 PNG)。 > **v3.8.87**:³⁶Ar-blank 敏感度對話框加「Inverse Isochron」檢視(拉 ³⁶ blank 看 trapped ⁴⁰/³⁶、age、各溫階共線性怎麼動)。 diff --git a/UI/DiagramStyleEditor.py b/UI/DiagramStyleEditor.py new file mode 100644 index 0000000..2a9881d --- /dev/null +++ b/UI/DiagramStyleEditor.py @@ -0,0 +1,519 @@ +"""DiagramStyleEditor — 診斷圖樣式編輯 QDialog(v3.9.0) + +pyADR 的 AgeCalc diagram(DFN/DFI/DFW/DFA/DFC/DFD/DFR)樣式編輯器。 +NTNU_DataReduction 的 DiagramPlots_SH sidebar 與 AutoPipeline AgeCalcPage +的 Plot Controls 各掛一顆 ⚙,開啟同一個 dialog、寫同一份中央 style dict +(Utilities.set_style_overrides),兩端不會樣式漂移。 + +設計: +- 圖表為主體,右側大圖置中(QLabel 顯示 .work/.png,auto-scale)。 +- 控件收在左側 QTabWidget(顏色 / 字型 / 線條&Marker / Legend / 軸 / 文字標籤)。 +- 頂部 preset 下拉,改任一參數 → 自動切為 custom。 +- non-modal 單例;host 提供 on_apply callback,Apply/OK 時呼叫以重畫。 + +本檔只負責 UI 與把設定收集成 overrides dict;實際重畫走 host 既有的 +_refresh_diagrams / SH_apply_axes,不另建繪圖路徑。 +""" +import os + +from PyQt5 import QtCore, QtGui, QtWidgets + +import Utilities + + +# 對應 Utilities plot function 的 target 代碼與人類可讀名 +TARGETS = [ + ('DFW', 'Age Spectrum'), + ('DFI', 'Inverse Isochron'), + ('DFN', 'Normal Isochron'), + ('DFA', 'Ca/K'), + ('DFC', 'Cl/K'), + ('DFD', 'Degassing'), + ('DFR', '⁴⁰Ar(r)%'), +] + +# 單一序列色的 key → 顯示名 +COLOR_KEYS = [ + ('age', 'Age spectrum / isochron 主色'), + ('cak', 'Ca/K'), + ('clk', 'Cl/K'), + ('atm', '大氣參考'), + ('atm_marker', '大氣 marker'), + ('iso_dot', 'Isochron 資料點'), + ('edge', '邊框'), + ('mean_color', 'WMA / mean 線'), +] + +LEGEND_LOCS = ['best', 'upper left', 'upper right', 'lower left', + 'lower right', 'upper center', 'lower center', 'center'] + + +class _ColorButton(QtWidgets.QPushButton): + """色塊按鈕,點擊開 QColorDialog。changed(str hex) 訊號。""" + changed = QtCore.pyqtSignal(str) + + def __init__(self, color='#000000', parent=None): + super().__init__(parent) + self.setFixedSize(46, 20) + self._color = color + self._apply() + self.clicked.connect(self._pick) + + def _apply(self): + self.setStyleSheet( + f'background:{self._color};border:1px solid #888;border-radius:3px;') + self.setToolTip(self._color) + + def color(self): + return self._color + + def setColor(self, c): + if c and c != self._color: + self._color = c + self._apply() + + def _pick(self): + c = QtGui.QColor(self._color) + chosen = QtWidgets.QColorDialog.getColor( + c, self, 'Pick colour', + QtWidgets.QColorDialog.ShowAlphaChannel) + if chosen.isValid(): + self._color = chosen.name() + self._apply() + self.changed.emit(self._color) + + +class DiagramStyleEditor(QtWidgets.QDialog): + """診斷圖樣式編輯器。 + + host_get_style : callable → 目前 preset 名('pyADR'/'classic'/...) + on_apply : callable(overrides:dict, preset:str)。host 在此把 + overrides 套進 Utilities.set_style_overrides 並重畫。 + work_dir : .work 目錄,用來讀預覽 PNG。 + current_target : 開啟時預設顯示的 target code(跟隨主視窗)。 + """ + + def __init__(self, parent=None, host_get_style=None, on_apply=None, + work_dir=None, current_target='DFW'): + super().__init__(parent) + self.setWindowTitle('Diagram Style Editor') + self.setModal(False) + self._host_get_style = host_get_style or (lambda: 'pyADR') + self._on_apply = on_apply + self._work_dir = work_dir or os.path.join( + os.path.dirname(os.path.dirname(__file__)), '.work') + self._preview_target = current_target if any( + t[0] == current_target for t in TARGETS) else 'DFW' + self._loading = False # 抑制載入時的 dirty 標記 + self._color_btns = {} + self._text_edits = {} # (target, field) → QLineEdit + + self.resize(1060, 640) + self._build_ui() + _preset = self._host_get_style() + _pi = self.presetCombo.findText(_preset) + if _pi >= 0: + self.presetCombo.blockSignals(True) + self.presetCombo.setCurrentIndex(_pi) + self.presetCombo.blockSignals(False) + self._load_from_style(_preset) + self._refresh_preview() + + # ------------------------------------------------------------------ UI -- + def _build_ui(self): + root = QtWidgets.QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + body = QtWidgets.QHBoxLayout() + body.setContentsMargins(10, 10, 10, 6) + body.setSpacing(10) + root.addLayout(body, 1) + + # ── 左側控件欄 ────────────────────────────────────────────── + left = QtWidgets.QVBoxLayout() + left.setSpacing(6) + body.addLayout(left, 0) + + preset_row = QtWidgets.QHBoxLayout() + preset_row.addWidget(QtWidgets.QLabel('Preset')) + self.presetCombo = QtWidgets.QComboBox() + self._preset_names = Utilities.available_styles() + ['custom'] + self.presetCombo.addItems(self._preset_names) + self.presetCombo.currentIndexChanged.connect(self._on_preset_changed) + preset_row.addWidget(self.presetCombo, 1) + left.addLayout(preset_row) + + self.tabs = QtWidgets.QTabWidget() + self.tabs.setFixedWidth(310) + self.tabs.addTab(self._tab_colors(), '顏色') + self.tabs.addTab(self._tab_fonts(), '字型') + self.tabs.addTab(self._tab_lines(), '線條/Marker') + self.tabs.addTab(self._tab_legend(), 'Legend') + self.tabs.addTab(self._tab_axes(), '軸') + self.tabs.addTab(self._tab_text(), '文字標籤') + left.addWidget(self.tabs, 1) + + # ── 右側預覽(主體)───────────────────────────────────────── + right = QtWidgets.QVBoxLayout() + right.setSpacing(6) + body.addLayout(right, 1) + + prev_bar = QtWidgets.QHBoxLayout() + prev_bar.addWidget(QtWidgets.QLabel('即時預覽')) + prev_bar.addStretch(1) + prev_bar.addWidget(QtWidgets.QLabel('target')) + self.targetCombo = QtWidgets.QComboBox() + for code, name in TARGETS: + self.targetCombo.addItem(f'{name} ({code})', code) + _idx = self.targetCombo.findData(self._preview_target) + if _idx >= 0: + self.targetCombo.setCurrentIndex(_idx) + self.targetCombo.currentIndexChanged.connect(self._on_target_changed) + prev_bar.addWidget(self.targetCombo) + right.addLayout(prev_bar) + + self.previewLabel = QtWidgets.QLabel('(套用後產生預覽)') + self.previewLabel.setAlignment(QtCore.Qt.AlignCenter) + self.previewLabel.setMinimumSize(520, 420) + self.previewLabel.setStyleSheet( + 'background:#fcfcfb;border:1px solid #ddd;border-radius:4px;') + right.addWidget(self.previewLabel, 1) + + self.hintLabel = QtWidgets.QLabel( + 'Apply 後以現行 pipeline 重算重畫。圖為 dialog 主體,置中放大。') + self.hintLabel.setStyleSheet('color:#898781;font-size:11px;') + right.addWidget(self.hintLabel) + + # ── 底部按鈕 ──────────────────────────────────────────────── + foot = QtWidgets.QHBoxLayout() + foot.setContentsMargins(10, 4, 10, 8) + self.resetBtn = QtWidgets.QPushButton('Reset to preset') + self.resetBtn.clicked.connect(self._on_reset) + foot.addWidget(self.resetBtn) + foot.addStretch(1) + cancelBtn = QtWidgets.QPushButton('Cancel') + cancelBtn.clicked.connect(self.reject) + applyBtn = QtWidgets.QPushButton('Apply') + applyBtn.clicked.connect(self._apply) + okBtn = QtWidgets.QPushButton('OK') + okBtn.setDefault(True) + okBtn.clicked.connect(self._ok) + for b in (cancelBtn, applyBtn, okBtn): + foot.addWidget(b) + root.addLayout(foot, 0) + + def _scroll(self, inner): + sa = QtWidgets.QScrollArea() + sa.setWidgetResizable(True) + sa.setFrameShape(QtWidgets.QFrame.NoFrame) + sa.setWidget(inner) + return sa + + def _tab_colors(self): + w = QtWidgets.QWidget() + form = QtWidgets.QFormLayout(w) + form.setLabelAlignment(QtCore.Qt.AlignRight) + for key, label in COLOR_KEYS: + btn = _ColorButton() + btn.changed.connect(self._mark_dirty) + self._color_btns[key] = btn + form.addRow(label, btn) + form.addRow(QtWidgets.QLabel('
')) + form.addRow(QtWidgets.QLabel('分組色 GROUP_COLORS')) + self.groupList = QtWidgets.QListWidget() + self.groupList.setFixedHeight(150) + self.groupList.itemDoubleClicked.connect(self._edit_group_color) + form.addRow(self.groupList) + gbtns = QtWidgets.QHBoxLayout() + addb = QtWidgets.QPushButton('+') + addb.clicked.connect(self._add_group_color) + rmb = QtWidgets.QPushButton('−') + rmb.clicked.connect(self._remove_group_color) + gbtns.addWidget(addb) + gbtns.addWidget(rmb) + gbtns.addStretch(1) + gw = QtWidgets.QWidget() + gw.setLayout(gbtns) + form.addRow(gw) + form.addRow(QtWidgets.QLabel( + '順序=色盲安全機制,' + '雙擊改色')) + return self._scroll(w) + + def _spin(self, lo, hi, val, dec=0, step=1.0): + s = (QtWidgets.QDoubleSpinBox() if dec else QtWidgets.QSpinBox()) + s.setRange(lo, hi) + if dec: + s.setDecimals(dec) + s.setSingleStep(step) + s.setValue(val) + s.setSpecialValueText('auto') # 最小值顯示 auto = 用預設 + return s + + def _tab_fonts(self): + w = QtWidgets.QWidget() + form = QtWidgets.QFormLayout(w) + # 0 = auto(沿用 matplotlib 預設) + self.fAxis = self._spin(0, 40, 0) + self.fTick = self._spin(0, 40, 0) + self.fTitle = self._spin(0, 40, 0) + self.fAnnot = self._spin(0, 40, 8) + self.fGroup = self._spin(0, 40, 7) + for s in (self.fAxis, self.fTick, self.fTitle, self.fAnnot, self.fGroup): + s.valueChanged.connect(self._mark_dirty) + form.addRow('軸標題 pt', self.fAxis) + form.addRow('刻度 pt', self.fTick) + form.addRow('圖標題 pt', self.fTitle) + form.addRow('註記 pt', self.fAnnot) + form.addRow('分組註記 pt', self.fGroup) + form.addRow(QtWidgets.QLabel( + '0 = auto,' + '字型 family 固定 Arial(見 CLAUDE.md §5)')) + return self._scroll(w) + + def _tab_lines(self): + w = QtWidgets.QWidget() + form = QtWidgets.QFormLayout(w) + self.lwFit = self._spin(0.1, 6.0, 2.0, dec=1, step=0.1) + self.lwLink = self._spin(0.1, 6.0, 0.5, dec=1, step=0.1) + self.lwWma = self._spin(0.1, 6.0, 1.5, dec=1, step=0.1) + self.lwSpine = self._spin(0.1, 6.0, 1.0, dec=1, step=0.1) + self.lwBar = self._spin(0.1, 6.0, 0.5, dec=1, step=0.1) + for s in (self.lwFit, self.lwLink, self.lwWma, self.lwSpine, self.lwBar): + s.valueChanged.connect(self._mark_dirty) + form.addRow('迴歸線寬', self.lwFit) + form.addRow('連接線寬', self.lwLink) + form.addRow('WMA 線寬', self.lwWma) + form.addRow('邊框線寬', self.lwSpine) + form.addRow('step box 邊寬', self.lwBar) + return self._scroll(w) + + def _tab_legend(self): + w = QtWidgets.QWidget() + form = QtWidgets.QFormLayout(w) + self.legLoc = QtWidgets.QComboBox() + self.legLoc.addItems(LEGEND_LOCS) + self.legLoc.currentIndexChanged.connect(self._mark_dirty) + self.legFs = self._spin(4, 30, 8) + self.legFs.valueChanged.connect(self._mark_dirty) + self.legFa = self._spin(0.0, 1.0, 0.85, dec=2, step=0.05) + self.legFa.setSpecialValueText('') + self.legFa.valueChanged.connect(self._mark_dirty) + form.addRow('位置 loc', self.legLoc) + form.addRow('字級', self.legFs) + form.addRow('框透明度', self.legFa) + return self._scroll(w) + + def _tab_axes(self): + w = QtWidgets.QWidget() + form = QtWidgets.QFormLayout(w) + self.tickDir = QtWidgets.QComboBox() + self.tickDir.addItems(['out', 'in', 'inout']) + self.tickDir.currentIndexChanged.connect(self._mark_dirty) + self.cbTopRight = QtWidgets.QCheckBox('top + right ticks') + self.cbMinor = QtWidgets.QCheckBox('minor ticks') + self.cbGrid = QtWidgets.QCheckBox('grid') + for cb in (self.cbTopRight, self.cbMinor, self.cbGrid): + cb.toggled.connect(self._mark_dirty) + form.addRow('刻度方向', self.tickDir) + form.addRow(self.cbTopRight) + form.addRow(self.cbMinor) + form.addRow(self.cbGrid) + return self._scroll(w) + + def _tab_text(self): + w = QtWidgets.QWidget() + v = QtWidgets.QVBoxLayout(w) + v.addWidget(QtWidgets.QLabel('每個 diagram 的 title / x / y 標籤覆寫,' + '留空=用預設')) + v.addWidget(QtWidgets.QLabel( + '下標請用 mathtext,' + r'例如 $T_0$、$^{40}$Ar(見 CLAUDE.md §5)')) + for code, name in TARGETS: + box = QtWidgets.QGroupBox(f'{name} ({code})') + gf = QtWidgets.QFormLayout(box) + for field, lbl in [('title', 'title'), ('xlabel', 'x'), + ('ylabel', 'y')]: + ed = QtWidgets.QLineEdit() + ed.setPlaceholderText('(預設)') + ed.textEdited.connect(self._mark_dirty) + self._text_edits[(code, field)] = ed + gf.addRow(lbl, ed) + v.addWidget(box) + v.addStretch(1) + return self._scroll(w) + + # -------------------------------------------------------------- state -- + def _mark_dirty(self, *args): + if self._loading: + return + # 切到 custom(不觸發 reload) + idx = self.presetCombo.findText('custom') + if idx >= 0 and self.presetCombo.currentIndex() != idx: + self.presetCombo.blockSignals(True) + self.presetCombo.setCurrentIndex(idx) + self.presetCombo.blockSignals(False) + + def _load_from_style(self, preset): + """把某 preset 的合併結果灌進所有控件。""" + self._loading = True + try: + Utilities.set_style_overrides(None) # 讀乾淨的 preset 值 + st = Utilities._get_style(preset) + finally: + pass + for key, _ in COLOR_KEYS: + val = st.get(key) + if isinstance(val, str) and val.startswith('#'): + self._color_btns[key].setColor(val) + elif isinstance(val, str): + # 具名色(white/teal/red…)轉 hex 以利 picker + qc = QtGui.QColor(val) + self._color_btns[key].setColor(qc.name() if qc.isValid() + else '#000000') + self._set_group_list(st.get('group_colors', + Utilities.GROUP_COLORS)) + self.fAxis.setValue(st.get('font_axis') or 0) + self.fTick.setValue(st.get('font_tick') or 0) + self.fTitle.setValue(st.get('font_title') or 0) + self.fAnnot.setValue(st.get('font_annot') or 0) + self.fGroup.setValue(st.get('font_group') or 0) + self.lwFit.setValue(st.get('lw_fit', 2.0)) + self.lwLink.setValue(st.get('lw_link', 0.5)) + self.lwWma.setValue(st.get('lw_wma', 1.5)) + self.lwSpine.setValue(st.get('lw_spine', 1.0)) + self.lwBar.setValue(st.get('lw', 0.5)) + self.legLoc.setCurrentText(st.get('legend_loc', 'upper left')) + self.legFs.setValue(st.get('legend_fontsize', 8)) + self.legFa.setValue(st.get('legend_framealpha', 0.85)) + self.tickDir.setCurrentText(st.get('tick_dir', 'out')) + self.cbTopRight.setChecked(bool(st.get('ticks_top_right'))) + self.cbMinor.setChecked(bool(st.get('minor'))) + self.cbGrid.setChecked(bool(st.get('grid'))) + for (code, field), ed in self._text_edits.items(): + ed.setText(st.get('text', {}).get(code, {}).get(field, '')) + self._loading = False + + def _set_group_list(self, colors): + self.groupList.clear() + for c in colors: + it = QtWidgets.QListWidgetItem(c) + it.setBackground(QtGui.QColor(c)) + it.setForeground(QtGui.QColor('#ffffff' + if QtGui.QColor(c).lightness() < 128 else '#000000')) + self.groupList.addItem(it) + + def _group_colors(self): + return [self.groupList.item(i).text() + for i in range(self.groupList.count())] + + def _edit_group_color(self, item): + c = QtWidgets.QColorDialog.getColor(QtGui.QColor(item.text()), self) + if c.isValid(): + item.setText(c.name()) + item.setBackground(c) + self._mark_dirty() + + def _add_group_color(self): + c = QtWidgets.QColorDialog.getColor(QtGui.QColor('#888888'), self) + if c.isValid(): + it = QtWidgets.QListWidgetItem(c.name()) + it.setBackground(c) + self.groupList.addItem(it) + self._mark_dirty() + + def _remove_group_color(self): + row = self.groupList.currentRow() + if row >= 0: + self.groupList.takeItem(row) + self._mark_dirty() + + def _on_preset_changed(self, _idx): + name = self.presetCombo.currentText() + if name == 'custom': + return # 停在使用者當前設定 + self._load_from_style(name) + self._apply() + + def _on_reset(self): + name = self.presetCombo.currentText() + base = 'pyADR' if name == 'custom' else name + self.presetCombo.blockSignals(True) + self.presetCombo.setCurrentText(base) + self.presetCombo.blockSignals(False) + self._load_from_style(base) + self._apply() + + def _on_target_changed(self, _idx): + self._preview_target = self.targetCombo.currentData() + self._refresh_preview() + + # -------------------------------------------------------------- output -- + def _collect_overrides(self): + """把控件狀態收集成 Utilities overrides dict。""" + ov = {} + for key, _ in COLOR_KEYS: + ov[key] = self._color_btns[key].color() + ov['group_colors'] = self._group_colors() + ov['font_axis'] = self.fAxis.value() or None + ov['font_tick'] = self.fTick.value() or None + ov['font_title'] = self.fTitle.value() or None + ov['font_annot'] = self.fAnnot.value() or None + ov['font_group'] = self.fGroup.value() or None + ov['lw_fit'] = self.lwFit.value() + ov['lw_link'] = self.lwLink.value() + ov['lw_wma'] = self.lwWma.value() + ov['lw_spine'] = self.lwSpine.value() + ov['lw'] = self.lwBar.value() + ov['legend_loc'] = self.legLoc.currentText() + ov['legend_fontsize'] = self.legFs.value() + ov['legend_framealpha'] = self.legFa.value() + ov['tick_dir'] = self.tickDir.currentText() + ov['ticks_top_right'] = self.cbTopRight.isChecked() + ov['minor'] = self.cbMinor.isChecked() + ov['grid'] = self.cbGrid.isChecked() + text = {} + for (code, field), ed in self._text_edits.items(): + val = ed.text().strip() + if val: + text.setdefault(code, {})[field] = val + if text: + ov['text'] = text + return ov + + def current_preset(self): + return self.presetCombo.currentText() + + def _apply(self): + overrides = self._collect_overrides() + preset = self.current_preset() + if self._on_apply: + try: + self._on_apply(overrides, preset) + except Exception as e: # host 重畫失敗不該炸掉 dialog + self.hintLabel.setText(f'重畫失敗: {e}') + self.hintLabel.setStyleSheet('color:#c0392b;font-size:11px;') + return + self._refresh_preview() + + def _ok(self): + self._apply() + self.accept() + + def _refresh_preview(self): + path = os.path.join(self._work_dir, f'{self._preview_target}.png') + if os.path.exists(path): + pm = QtGui.QPixmap(path) + if not pm.isNull(): + self.previewLabel.setPixmap(pm.scaled( + self.previewLabel.size(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation)) + return + self.previewLabel.setText( + f'{self._preview_target}.png 尚未產生\n(先在主視窗畫一次,或按 Apply)') + + def resizeEvent(self, ev): + super().resizeEvent(ev) + self._refresh_preview() diff --git a/Utilities.py b/Utilities.py index 7cac992..6f4e4e3 100644 --- a/Utilities.py +++ b/Utilities.py @@ -785,23 +785,14 @@ def apply_controls(ax, target=None): else: ax.set_ylim(ymin, ymax) - if legend_name is not None: - title_str = str(legend_name).strip() - if title_str: - ax.set_title(title_str) + _ist = _get_style(style) + _title_default = str(legend_name).strip() if legend_name is not None else '' + _title = _txt(_ist, target, 'title', _title_default) + if _title: + ax.set_title(_title, **_fs(_ist.get('font_title'))) # Classic or pyADR frame / ticks - _ist = _get_style(style) - if _ist.get('classic'): - ax.set_facecolor('white') - ax.tick_params(which='both', direction='out', - top=True, right=True, bottom=True, left=True) - ax.minorticks_on() - for _sp in ax.spines.values(): - _sp.set_visible(True); _sp.set_linewidth(1.0); _sp.set_color('black') - else: - ax.set_facecolor('none') - ax.tick_params(which='both', direction='out', top=False, right=False) + _apply_frame(ax, _ist) def _iso_savefig(fig_obj, outpath): """Save isochron figure with correct facecolor.""" @@ -809,6 +800,16 @@ def _iso_savefig(fig_obj, outpath): fig_obj.savefig(outpath, dpi=300, facecolor='white' if _ist.get('classic') else 'none') + def _iso_labels(ax, target, xdefault, ydefault): + """Axis labels with per-target text override + font size from style.""" + _ist = _get_style(style) + ax.set_xlabel(_txt(_ist, target, 'xlabel', xdefault), + **_fs(_ist.get('font_axis'))) + ax.set_ylabel(_txt(_ist, target, 'ylabel', ydefault), + **_fs(_ist.get('font_axis'))) + + group_colors = _resolve_group_colors(group_colors, _get_style(style)) + # ========================================================= # NORMAL ISOCHRON: X = 39Ar(m)/36Ar(m), Y = 40Ar(m)/36Ar(m) # Where (m) = measured = sum of all components @@ -983,8 +984,7 @@ def _iso_savefig(fig_obj, outpath): # Check if we have enough data points if len(x) < 2: - ax_n.set_xlabel('$^{39}$Ar/$^{36}$Ar') - ax_n.set_ylabel('$^{40}$Ar/$^{36}$Ar') + _iso_labels(ax_n, 'DFN', '$^{39}$Ar/$^{36}$Ar', '$^{40}$Ar/$^{36}$Ar') apply_controls(ax_n, target="DFN") lim_DFN = (ax_n.get_xlim(), ax_n.get_ylim()) fig_n.savefig(os.path.join(outdir, "DFN.png"), dpi=300, facecolor=("white" if _get_style(style).get("classic") else "none")) @@ -1003,8 +1003,7 @@ def _iso_savefig(fig_obj, outpath): # fit (post-outlier-removal, with selected method) is the single line # drawn — avoids two overlapping lines on the diagram. except: - ax_n.set_xlabel('$^{39}$Ar/$^{36}$Ar') - ax_n.set_ylabel('$^{40}$Ar/$^{36}$Ar') + _iso_labels(ax_n, 'DFN', '$^{39}$Ar/$^{36}$Ar', '$^{40}$Ar/$^{36}$Ar') apply_controls(ax_n, target="DFN") lim_DFN = (ax_n.get_xlim(), ax_n.get_ylim()) fig_n.savefig(os.path.join(outdir, "DFN.png"), dpi=300, facecolor=("white" if _get_style(style).get("classic") else "none")) @@ -1015,8 +1014,7 @@ def _iso_savefig(fig_obj, outpath): return result, {"DFN": lim_DFN, "DFI": lim_DFI, "DFN_pts": [], "DFI_pts": []} return result - ax_n.set_xlabel('$^{39}$Ar/$^{36}$Ar') - ax_n.set_ylabel('$^{40}$Ar/$^{36}$Ar') + _iso_labels(ax_n, 'DFN', '$^{39}$Ar/$^{36}$Ar', '$^{40}$Ar/$^{36}$Ar') # FIX#1: store all valid points for data-based axis range (before outlier deletion) x_all_pts = x.copy() @@ -1112,7 +1110,7 @@ def _iso_savefig(fig_obj, outpath): _age_str_1 = f"\nT={_T_1:.2f} Ma" ax_n.annotate( f"G{_gn}(1pt) N={_N_g}{_age_str_1}\n⁴⁰Ar/³⁶Ar={_atm_y_dfn:.0f}(fixed)", - xy=(_x0d, _y0d), fontsize=7, color=_gc, fontweight='bold', + xy=(_x0d, _y0d), fontsize=_get_style(style).get('font_group', 7), color=_gc, fontweight='bold', ha='center', va='bottom', bbox=dict(boxstyle='round,pad=0.2', fc='white', alpha=0.7, ec=_gc)) continue @@ -1120,7 +1118,8 @@ def _iso_savefig(fig_obj, outpath): _gopt, _ = curve_fit(linear, _gxa, _gya) _x_ext = np.array([0.0, float(np.max(_gxa)) * 1.1]) ax_n.plot(_x_ext, linear(_x_ext, *_gopt), linestyle='-', - color=_gc, linewidth=2.0, zorder=5, label=f"Group {_gn}") + color=_gc, linewidth=_get_style(style).get('lw_fit', 2.0), + zorder=5, label=f"Group {_gn}") _g_ic = linear(0.0, *_gopt) _age_str = "" if (np.isfinite(_J_dfn) and np.isfinite(_Lam_dfn) @@ -1141,7 +1140,7 @@ def _iso_savefig(fig_obj, outpath): ax_n.annotate( f"G{_gn} N={_N_g}{_mswd_str}{_age_str}\n⁴⁰Ar/³⁶Ar={_g_ic:.0f}", xy=(0.98, _ann_y_n), xycoords='axes fraction', - fontsize=7, color=_gc, fontweight='bold', + fontsize=_get_style(style).get('font_group', 7), color=_gc, fontweight='bold', ha='right', va='top', bbox=dict(boxstyle='round,pad=0.3', fc='gold', alpha=0.85, ec=_gc)) except Exception: @@ -1149,12 +1148,13 @@ def _iso_savefig(fig_obj, outpath): # FIX#2: Legend inside axes frame (no bbox_to_anchor) if show_legend: - ax_n.legend(loc='upper left', fontsize=8, framealpha=0.85) + ax_n.legend(**_legend_kw(_get_style(style))) # v3.8.1 FIX: atmospheric value marker = red X (was red circle) # Show atmospheric value marker — top layer, no clipping if show_atm: - ax_n.plot(0, atm_value, marker='x', markersize=11, color='red', + ax_n.plot(0, atm_value, marker='x', markersize=11, + color=_get_style(style).get('atm_marker', 'red'), linestyle='None', zorder=100, markeredgewidth=2.5, clip_on=False) @@ -1319,8 +1319,7 @@ def _iso_savefig(fig_obj, outpath): mask_inv = mask_inv[valid_inv] if len(x_inv) < 2: - ax_iv.set_xlabel('$^{39}$Ar/$^{40}$Ar') - ax_iv.set_ylabel('$^{36}$Ar/$^{40}$Ar') + _iso_labels(ax_iv, 'DFI', '$^{39}$Ar/$^{40}$Ar', '$^{36}$Ar/$^{40}$Ar') apply_controls(ax_iv, target="DFI") lim_DFI = (ax_iv.get_xlim(), ax_iv.get_ylim()) fig_iv.savefig(os.path.join(outdir, "DFI.png"), dpi=300, facecolor=("white" if _get_style(style).get("classic") else "none")) @@ -1339,8 +1338,7 @@ def _iso_savefig(fig_obj, outpath): # below (with chosen isochron_method, post-outlier-removal) is the only # line shown — avoids visual clutter from two near-overlapping lines. except: - ax_iv.set_xlabel('$^{39}$Ar/$^{40}$Ar') - ax_iv.set_ylabel('$^{36}$Ar/$^{40}$Ar') + _iso_labels(ax_iv, 'DFI', '$^{39}$Ar/$^{40}$Ar', '$^{36}$Ar/$^{40}$Ar') apply_controls(ax_iv, target="DFI") lim_DFI = (ax_iv.get_xlim(), ax_iv.get_ylim()) fig_iv.savefig(os.path.join(outdir, "DFI.png"), dpi=300, facecolor=("white" if _get_style(style).get("classic") else "none")) @@ -1351,8 +1349,7 @@ def _iso_savefig(fig_obj, outpath): return result, {"DFN": lim_DFN, "DFI": lim_DFI, "DFN_pts": [], "DFI_pts": []} return result - ax_iv.set_xlabel('$^{39}$Ar/$^{40}$Ar') - ax_iv.set_ylabel('$^{36}$Ar/$^{40}$Ar') + _iso_labels(ax_iv, 'DFI', '$^{39}$Ar/$^{40}$Ar', '$^{36}$Ar/$^{40}$Ar') # FIX#1: store all valid points for axis range (before outlier deletion) x_inv_all_pts = x_inv.copy() @@ -1464,7 +1461,7 @@ def _iso_savefig(fig_obj, outpath): _age_str_i1 = f"\nT={_T_i1:.2f} Ma" ax_iv.annotate( f"G{_gn}(1pt) N={_N_gi}{_age_str_i1}\n⁴⁰/³⁶={_atm40_36:.0f}(fixed)", - xy=(_x0i, _y0i), fontsize=7, color=_gc, fontweight='bold', + xy=(_x0i, _y0i), fontsize=_get_style(style).get('font_group', 7), color=_gc, fontweight='bold', ha='center', va='bottom', bbox=dict(boxstyle='round,pad=0.2', fc='white', alpha=0.7, ec=_gc)) continue @@ -1472,7 +1469,8 @@ def _iso_savefig(fig_obj, outpath): _gopt_i, _ = curve_fit(linear, _gxa, _gya) _x_ext = np.array([0.0, float(np.max(_gxa)) * 1.1]) ax_iv.plot(_x_ext, linear(_x_ext, *_gopt_i), linestyle='-', - color=_gc, linewidth=2.0, zorder=5, label=f"Group {_gn}") + color=_gc, linewidth=_get_style(style).get('lw_fit', 2.0), + zorder=5, label=f"Group {_gn}") _g_ic_inv = linear(0.0, *_gopt_i) _inv_sl = _gopt_i[0] # Age from X-intercept: 39/40 at y=0 → 40*/39 = -slope/ic @@ -1507,7 +1505,7 @@ def _iso_savefig(fig_obj, outpath): ax_iv.annotate( f"G{_gn} N={_N_gi}{_mswd_str_i}{_age_str_i}\n⁴⁰/³⁶={_atm_inv_str}", xy=(0.98, _ann_y), xycoords='axes fraction', - fontsize=7, color=_gc, fontweight='bold', + fontsize=_get_style(style).get('font_group', 7), color=_gc, fontweight='bold', ha='right', va='top', bbox=dict(boxstyle='round,pad=0.3', fc='gold', alpha=0.85, ec=_gc)) except Exception: @@ -1515,13 +1513,14 @@ def _iso_savefig(fig_obj, outpath): # FIX#2: Legend inside axes frame if show_legend: - ax_iv.legend(loc='upper left', fontsize=8, framealpha=0.85) + ax_iv.legend(**_legend_kw(_get_style(style))) # v3.8.1 FIX: atmospheric value marker = red X (was red circle) # Show atmospheric value marker (inverse) — top layer, no clipping if show_atm: inverse_atm = 1.0 / atm_value - ax_iv.plot(0, inverse_atm, marker='x', markersize=11, color='red', + ax_iv.plot(0, inverse_atm, marker='x', markersize=11, + color=_get_style(style).get('atm_marker', 'red'), linestyle='None', zorder=100, markeredgewidth=2.5, clip_on=False) @@ -1685,8 +1684,7 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non # Applied per panel below, overriding the single xlim/ylim/target_plot. if step_groups is None: step_groups = {} - if group_colors is None: - group_colors = ['#FF8C00','#1E90FF','#2ECC40','#FF4136','#B10DC9'] + group_colors = _resolve_group_colors(group_colors, _get_style(style)) import numpy as np import matplotlib.pyplot as plt import matplotlib.patches as patches @@ -1784,8 +1782,8 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non y_next = y_age[i + 1] # Draw line connecting steps (thinner) - w.plot([x_current_right, x_next_left], [y_current, y_next], - color='black', linewidth=0.5, linestyle='-') + w.plot([x_current_right, x_next_left], [y_current, y_next], + color='black', linewidth=st.get('lw_link', 0.5), linestyle='-') # If masked (excluded), mark with red X and remove from statistics if i < len(mask) and mask[i] == 0: @@ -1796,19 +1794,7 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non # 設定顯示範圍與標籤 plt.xlim(0, 100) - # Inward ticks on all 4 sides (NO.62 style) - # ── Classic frame: outward ticks all 4 sides, closed box ───────────── - if st.get('classic'): - w.set_facecolor('white') - w.tick_params(which='both', direction='out', - top=True, right=True, bottom=True, left=True) - w.minorticks_on() - for _sp in w.spines.values(): - _sp.set_visible(True); _sp.set_linewidth(1.0); _sp.set_color('black') - else: - w.set_facecolor('none') # transparent → Qt widget bg shows through - w.tick_params(which='both', direction='out', - top=False, right=False) + _apply_frame(w, st) # ── WMA overlay per group ────────────────────────────────────────────── if step_groups: @@ -1839,10 +1825,12 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non ar39_pct = float(sum(stepw[i] for i in gi)) w.fill_between([x0, x1], [wma-wma_err]*2, [wma+wma_err]*2, color=gc, alpha=0.25, zorder=3) - w.plot([x0, x1], [wma, wma], color=gc, linewidth=1.5, zorder=4) + w.plot([x0, x1], [wma, wma], color=gc, + linewidth=st.get('lw_wma', 1.5), zorder=4) w.text((x0+x1)/2, wma+wma_err*1.1, f'WMA={wma:.1f}±{wma_err:.1f} Ma\nMSWD={mswd:.2f} n={len(gi)}\n³⁹Ar={ar39_pct:.1f}%', - ha='center', va='bottom', fontsize=7, color=gc, zorder=5) + ha='center', va='bottom', fontsize=st.get('font_group', 7), + color=gc, zorder=5) # Calculate Y-axis range using age ± std (robust to NaN) if n > 0: @@ -1860,10 +1848,13 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non _xl, _yl = panel_limits['DFW'] if _xl is not None: w.set_xlim(_xl[0], _xl[1]) if _yl is not None: w.set_ylim(_yl[0], _yl[1]) - w.set_xlabel('Cumulative $^{39}$Ar Released(%)') - w.set_ylabel('Age (Ma)') - if legend_name: - plt.title(legend_name) + w.set_xlabel(_txt(st, 'DFW', 'xlabel', 'Cumulative $^{39}$Ar Released(%)'), + **_fs(st.get('font_axis'))) + w.set_ylabel(_txt(st, 'DFW', 'ylabel', 'Age (Ma)'), + **_fs(st.get('font_axis'))) + _t_dfw = _txt(st, 'DFW', 'title', legend_name if legend_name else '') + if _t_dfw: + w.set_title(_t_dfw, **_fs(st.get('font_title'))) # ── Group span indicator for DFW ──────────────────────────────────── if show_group_span and step_groups: from collections import defaultdict @@ -1885,7 +1876,8 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non _gc = group_colors[_gn - 1] if group_colors and _gn-1 < len(group_colors) else 'black' # arrow <-> w.annotate('', xy=(_xe, _y_span_base), xytext=(_xs, _y_span_base), - arrowprops=dict(arrowstyle='<->', color=_gc, lw=1.5)) + arrowprops=dict(arrowstyle='<->', color=_gc, + lw=st.get('lw_wma', 1.5))) # WMA & MSWD _g_ages = np.array([y_age[_si] for _si in _gsteps_sorted]) _g_errs = np.array([max(y_err[_si], 1e-6) for _si in _gsteps_sorted]) @@ -1900,7 +1892,8 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non f"MSWD={_mswd:.2f} ³⁹Ar={_cum_ar:.1f}%") _dy_txt = (_ylim_now[1] - _ylim_now[0]) * 0.04 w.text(_xmid, _y_span_base + _dy_txt, _lbl, - ha='center', va='bottom', fontsize=7, color=_gc) + ha='center', va='bottom', fontsize=st.get('font_group', 7), + color=_gc) # Axes bbox: use tight_layout + get_position (stable, DPI-independent, no bbox_inches='tight' shift) try: w.figure.tight_layout() @@ -2019,26 +2012,18 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non _xl, _yl = panel_limits['DFA'] if _xl is not None: ax_a.set_xlim(_xl[0], _xl[1]) if _yl is not None: ax_a.set_ylim(_yl[0], _yl[1]) - # ── Classic frame: outward ticks all 4 sides, closed box ───────────── - if st.get('classic'): - ax_a.set_facecolor('white') - ax_a.tick_params(which='both', direction='out', - top=True, right=True, bottom=True, left=True) - ax_a.minorticks_on() - for _sp in ax_a.spines.values(): - _sp.set_visible(True); _sp.set_linewidth(1.0); _sp.set_color('black') - else: - ax_a.set_facecolor('none') # transparent → Qt widget bg shows through - ax_a.tick_params(which='both', direction='out', - top=False, right=False) - ax_a.set_xlabel('Cumulative $^{39}$Ar Released(%)') - ax_a.set_ylabel('Ca/K ratio') + _apply_frame(ax_a, st) + ax_a.set_xlabel(_txt(st, 'DFA', 'xlabel', 'Cumulative $^{39}$Ar Released(%)'), + **_fs(st.get('font_axis'))) + ax_a.set_ylabel(_txt(st, 'DFA', 'ylabel', 'Ca/K ratio'), + **_fs(st.get('font_axis'))) ax_a.text(0.01, 0.99, r'Ca/K $=\ \frac{^{37}\!\mathrm{Ar}_{Ca}}{^{39}\!\mathrm{Ar}_K}\ \times\ 0.52$', transform=ax_a.transAxes, ha='left', va='top', - fontsize=8, color='#555555', style='italic') - if legend_name: - plt.title(legend_name) + fontsize=st.get('font_annot', 8), color='#555555', style='italic') + _t_dfa = _txt(st, 'DFA', 'title', legend_name if legend_name else '') + if _t_dfa: + ax_a.set_title(_t_dfa, **_fs(st.get('font_title'))) try: ax_a.figure.tight_layout() except Exception: @@ -2134,26 +2119,18 @@ def getSHStatistics(file, mask, constants, xlim=None, ylim=None, legend_name=Non _xl, _yl = panel_limits['DFC'] if _xl is not None: ax_cl.set_xlim(_xl[0], _xl[1]) if _yl is not None: ax_cl.set_ylim(_yl[0], _yl[1]) - # ── Classic frame: outward ticks all 4 sides, closed box ───────────── - if st.get('classic'): - ax_cl.set_facecolor('white') - ax_cl.tick_params(which='both', direction='out', - top=True, right=True, bottom=True, left=True) - ax_cl.minorticks_on() - for _sp in ax_cl.spines.values(): - _sp.set_visible(True); _sp.set_linewidth(1.0); _sp.set_color('black') - else: - ax_cl.set_facecolor('none') # transparent → Qt widget bg shows through - ax_cl.tick_params(which='both', direction='out', - top=False, right=False) - ax_cl.set_xlabel('Cumulative $^{39}$Ar Released(%)') - ax_cl.set_ylabel('Cl/K ratio') + _apply_frame(ax_cl, st) + ax_cl.set_xlabel(_txt(st, 'DFC', 'xlabel', 'Cumulative $^{39}$Ar Released(%)'), + **_fs(st.get('font_axis'))) + ax_cl.set_ylabel(_txt(st, 'DFC', 'ylabel', 'Cl/K ratio'), + **_fs(st.get('font_axis'))) ax_cl.text(0.01, 0.99, r'Cl/K $=\ \frac{^{38}\!\mathrm{Ar}_{Cl}}{^{39}\!\mathrm{Ar}_K}\ \times\ 0.22$', transform=ax_cl.transAxes, ha='left', va='top', - fontsize=8, color='#555555', style='italic') - if legend_name: - plt.title(legend_name) + fontsize=st.get('font_annot', 8), color='#555555', style='italic') + _t_dfc = _txt(st, 'DFC', 'title', legend_name if legend_name else '') + if _t_dfc: + ax_cl.set_title(_t_dfc, **_fs(st.get('font_title'))) try: ax_cl.figure.tight_layout() except Exception: @@ -3128,26 +3105,166 @@ def calcAge(measurement_filename, J, J_std, J_int, constants): # ============================================================ +# ── Diagram style system (v3.9.0) ──────────────────────────────────────── +# 中央 style dict:所有 AgeCalc diagram(DFN/DFI/DFW/DFA/DFC/DFD/DFR)的 +# 顏色/字型/線寬/legend/軸樣式集中於此。DiagramStyleEditor 透過 +# set_style_overrides() 疊加使用者自訂;plot functions 一律經 _get_style() +# 取值,不再各自 hardcode。 + +# 分組色。順序即色盲安全機制(相鄰對 CVD ΔE 最佳化),不要隨意重排。 +GROUP_COLORS = ['#FF8C00', '#1E90FF', '#2ECC40', '#FF4136', '#B10DC9'] +GROUP_COLORS_CVD = ['#2a78d6', '#1baf7a', '#eda100', '#008300', + '#4a3aa7', '#e34948', '#e87ba4', '#eb6834'] + +# 共通預設 = 現行 hardcode 值;font_* 為 None 表示沿用 matplotlib 預設, +# tick 的 ticks_top_right/minor 為 None 表示沿用 classic/pyADR 分支現行行為。 +_STYLE_BASE = dict( + group_colors=GROUP_COLORS, + atm_marker='red', + font_axis=None, font_tick=None, font_title=None, + font_annot=8, font_group=7, + legend_loc='upper left', legend_fontsize=8, legend_framealpha=0.85, + lw_fit=2.0, lw_link=0.5, lw_wma=1.5, lw_spine=1.0, + tick_dir='out', ticks_top_right=None, minor=None, + text={}, # per-target 文字覆寫:{'DFW': {'title':…,'xlabel':…,'ylabel':…}} +) + +_STYLE_PRESETS = { + 'pyADR': dict( + age='#4040a0', cak='#6a0dad', clk='teal', + atm='#b05000', iso_dot='#1a5a8a', + edge='black', lw=0.5, alpha=1.0, + mean_color='navy', mean_ls='--', + grid=True, bg=None, + classic=False, + ), + 'classic': dict( + age='white', cak='white', clk='white', + atm='white', iso_dot='black', + edge='black', lw=0.8, alpha=1.0, + mean_color='black', mean_ls='-', + grid=False, bg='white', + classic=True, + ), + # 期刊風:muted 色 + tick 朝內 + 四邊框 + minor ticks(IsoplotR 慣例) + 'publication': dict( + age='#4C72B0', cak='#55A868', clk='#8172B3', + atm='#b05000', iso_dot='#1a5a8a', + edge='black', lw=0.8, alpha=1.0, + mean_color='#202020', mean_ls='-', + grid=False, bg=None, + classic=False, + group_colors=GROUP_COLORS_CVD, + tick_dir='in', ticks_top_right=True, minor=True, + ), + # 投影片風:publication 配色,字級/線寬放大 + 'presentation': dict( + age='#4C72B0', cak='#55A868', clk='#8172B3', + atm='#b05000', iso_dot='#1a5a8a', + edge='black', lw=1.2, alpha=1.0, + mean_color='#202020', mean_ls='-', + grid=False, bg=None, + classic=False, + group_colors=GROUP_COLORS_CVD, + tick_dir='in', ticks_top_right=True, minor=True, + font_axis=13, font_tick=11, font_title=14, + font_annot=11, font_group=9, + legend_fontsize=10, + lw_fit=2.5, lw_link=0.8, lw_wma=2.0, + ), +} + +# DiagramStyleEditor 寫入的使用者覆寫(session 內有效,由 host 負責持久化) +_STYLE_OVERRIDES = {} + + +def available_styles(): + return list(_STYLE_PRESETS.keys()) + + +def set_style_overrides(overrides): + """Replace user style overrides (dict, same keys as _get_style output). + + 'text' key merges per-target: {'DFW': {'ylabel': 'Age (Ma)'}, ...}. + Pass {} / None to clear.""" + global _STYLE_OVERRIDES + _STYLE_OVERRIDES = dict(overrides) if overrides else {} + + +def get_style_overrides(): + return dict(_STYLE_OVERRIDES) + + def _get_style(style='pyADR'): - """Return color/appearance dict for the given plot style.""" - if style == 'classic': - return dict( - age='white', cak='white', clk='white', - atm='white', iso_dot='black', - edge='black', lw=0.8, alpha=1.0, - mean_color='black', mean_ls='-', - grid=False, bg='white', - classic=True, - ) - else: # pyADR (default) - return dict( - age='#4040a0', cak='#6a0dad', clk='teal', - atm='#b05000', iso_dot='#1a5a8a', - edge='black', lw=0.5, alpha=1.0, - mean_color='navy', mean_ls='--', - grid=True, bg=None, - classic=False, - ) + """Return the merged style dict: base defaults ← preset ← user overrides.""" + st = dict(_STYLE_BASE) + st.update(_STYLE_PRESETS.get(style, _STYLE_PRESETS['pyADR'])) + if _STYLE_OVERRIDES: + ov = dict(_STYLE_OVERRIDES) + txt = ov.pop('text', None) + ov.pop('preset', None) + st.update(ov) + if txt: + merged = {k: dict(v) for k, v in st.get('text', {}).items()} + for tgt, fields in txt.items(): + merged.setdefault(tgt, {}).update( + {k: v for k, v in fields.items() if v}) + st['text'] = merged + return st + + +def _txt(st, target, field, default): + """Per-target text override lookup (title/xlabel/ylabel).""" + try: + v = st.get('text', {}).get(target, {}).get(field) + except AttributeError: + v = None + return v if v else default + + +def _fs(v, key='fontsize'): + """Kwargs helper: omit the kwarg entirely when value is None so the + matplotlib default stays bit-identical to the pre-v3.9 output.""" + return {} if v is None else {key: v} + + +def _legend_kw(st, **extra): + kw = dict(loc=st.get('legend_loc', 'upper left'), + fontsize=st.get('legend_fontsize', 8), + framealpha=st.get('legend_framealpha', 0.85)) + kw.update(extra) + return kw + + +def _resolve_group_colors(passed, st): + """Editor override > caller-passed list > preset default.""" + if _STYLE_OVERRIDES.get('group_colors'): + return list(_STYLE_OVERRIDES['group_colors']) + if passed: + return list(passed) + return list(st.get('group_colors', GROUP_COLORS)) + + +def _apply_frame(ax, st): + """Shared classic/pyADR frame & tick styling (was copy-pasted per plot).""" + _tk = _fs(st.get('font_tick'), 'labelsize') + if st.get('classic'): + ax.set_facecolor('white') + ax.tick_params(which='both', direction=st.get('tick_dir', 'out'), + top=True, right=True, bottom=True, left=True, **_tk) + if st.get('minor') is not False: + ax.minorticks_on() + for _sp in ax.spines.values(): + _sp.set_visible(True) + _sp.set_linewidth(st.get('lw_spine', 1.0)) + _sp.set_color('black') + else: + ax.set_facecolor('none') # transparent → Qt widget bg shows through + _tr = bool(st.get('ticks_top_right')) + ax.tick_params(which='both', direction=st.get('tick_dir', 'out'), + top=_tr, right=_tr, **_tk) + if st.get('minor'): + ax.minorticks_on() def _read_sh_rows(file): """Return (header_col_dict, data_rows, stepw, y_age, y_age_err, mask_col).""" @@ -3223,6 +3340,7 @@ def getRadiogenicPlot(file, mask, constants, outdir = os.path.join(os.path.dirname(__file__), ".work") os.makedirs(outdir, exist_ok=True) _st = _get_style(style) + group_colors = _resolve_group_colors(group_colors, _st) col, rows, stepw, y_age, y_err, n = _read_sh_rows(file) atm_idx = col.get('40Ar(r)(%)', 19) @@ -3249,17 +3367,15 @@ def getRadiogenicPlot(file, mask, constants, ax.set_xlim(xlim[0], xlim[1]) if ylim is not None: ax.set_ylim(ylim[0], ylim[1]) - ax.set_xlabel('Cumulative $^{39}$Ar Released(%)') - ax.set_ylabel('%$^{40}$Ar*') - if legend_name: - ax.set_title(str(legend_name)) - if _st.get('classic'): - ax.set_facecolor('white') - ax.tick_params(which='both', direction='out', top=True, right=True, - bottom=True, left=True) - for _sp in ax.spines.values(): - _sp.set_visible(True); _sp.set_linewidth(1.0); _sp.set_color('black') - elif _st.get('grid'): + ax.set_xlabel(_txt(_st, 'DFR', 'xlabel', 'Cumulative $^{39}$Ar Released(%)'), + **_fs(_st.get('font_axis'))) + ax.set_ylabel(_txt(_st, 'DFR', 'ylabel', '%$^{40}$Ar*'), + **_fs(_st.get('font_axis'))) + _t_dfr = _txt(_st, 'DFR', 'title', str(legend_name) if legend_name else '') + if _t_dfr: + ax.set_title(_t_dfr, **_fs(_st.get('font_title'))) + _apply_frame(ax, _st) + if not _st.get('classic') and _st.get('grid'): ax.grid(True, alpha=0.25) actual_xlim = tuple(float(v) for v in ax.get_xlim()) @@ -3546,27 +3662,21 @@ def _sum_with_err(*names): if ylim is not None: ax.set_ylim(ylim[0], ylim[1]) - ax.set_xlabel("Temperature (°C)") - ax.set_ylabel("Ar amount (V)") - # Style frame st = _get_style(style) - if st.get('classic'): - ax.set_facecolor('white') - ax.tick_params(which='both', direction='out', - top=True, right=True, bottom=True, left=True) - ax.minorticks_on() - for sp in ax.spines.values(): - sp.set_visible(True); sp.set_linewidth(1.0); sp.set_color('black') - else: - ax.set_facecolor('none') - ax.tick_params(which='both', direction='out', top=False, right=False) + ax.set_xlabel(_txt(st, 'DFD', 'xlabel', "Temperature (°C)"), + **_fs(st.get('font_axis'))) + ax.set_ylabel(_txt(st, 'DFD', 'ylabel', "Ar amount (V)"), + **_fs(st.get('font_axis'))) + _apply_frame(ax, st) if show_legend: - ax.legend(loc='best', fontsize=8, ncol=2 if show_all_components else 1, frameon=False) + ax.legend(loc='best', fontsize=st.get('legend_fontsize', 8), + ncol=2 if show_all_components else 1, frameon=False) - if legend_name: - ax.set_title(legend_name) + _t_dfd = _txt(st, 'DFD', 'title', legend_name if legend_name else '') + if _t_dfd: + ax.set_title(_t_dfd, **_fs(st.get('font_title'))) try: fig.tight_layout() From aaee6526321f783d6a639c026e3a862dda9c3d27 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 03:24:44 +0000 Subject: [PATCH 2/4] =?UTF-8?q?v3.9.0:=20=E9=98=B2=E8=AD=B7=20Style=20Edit?= =?UTF-8?q?or=20=E9=96=8B=E5=95=9F=E4=BE=8B=E5=A4=96=E5=B0=8E=E8=87=B4=20a?= =?UTF-8?q?pp=20=E9=97=9C=E9=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 ⚙ 開 DiagramStyleEditor 時若發生例外(Qt slot 未捕捉例外會 abort 整個 app),改為顯示錯誤對話框而非直接關閉。最常見原因:執行資料夾的 Utilities.py 未同步到新版(缺 set_style_overrides)。 - NTNU_DataReduction._open_style_editor / AutoPipeline._open_style_editor: 整段包 try/except,失敗顯示 traceback。 - 新增 hasattr(Utilities, 'set_style_overrides') 前置檢查,舊版直接跳 「請同步 Utilities.py」提示。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013DhiyS8YrAJ2MeSGoo5Pyt --- AutoPipeline.py | 55 ++++++++++++++++++++++++++----------------- NTNU_DataReduction.py | 50 +++++++++++++++++++++++---------------- 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/AutoPipeline.py b/AutoPipeline.py index ef1621a..708494c 100644 --- a/AutoPipeline.py +++ b/AutoPipeline.py @@ -5230,31 +5230,42 @@ def _current_diagram_target(self): return 'DFW' def _open_style_editor(self): - """開啟 DiagramStyleEditor(non-modal 單例)。""" + """開啟 DiagramStyleEditor(non-modal 單例)。 + + 整段包 try:Qt slot 未捕捉例外會讓整個 app abort,失敗時改用 + 對話框顯示 traceback 而非直接關閉。 + """ try: + if not hasattr(Utilities, 'set_style_overrides'): + QtWidgets.QMessageBox.warning( + self, 'Style Editor', + 'Utilities.py 版本過舊(缺 set_style_overrides)。\n' + '請把 DEV 的 Utilities.py 同步到執行資料夾後重開。') + return from UI.DiagramStyleEditor import DiagramStyleEditor - except Exception as e: - QtWidgets.QMessageBox.warning(self, 'Style Editor', - f'無法載入樣式編輯器:\n{e}') - return - def _apply(overrides, preset): - Utilities.set_style_overrides(overrides) - self._style_overrides = overrides # 供 session 持久化 - self._refresh_diagrams() - - dlg = getattr(self, '_style_editor', None) - if dlg is None: - # diagram PNGs 由 Utilities 寫到 repo/.work - work = os.path.join( - os.path.dirname(os.path.abspath(__file__)), '.work') - dlg = DiagramStyleEditor( - self, host_get_style=self._plot_style, on_apply=_apply, - work_dir=work, current_target=self._current_diagram_target()) - self._style_editor = dlg - dlg.show() - dlg.raise_() - dlg.activateWindow() + def _apply(overrides, preset): + Utilities.set_style_overrides(overrides) + self._style_overrides = overrides # 供 session 持久化 + self._refresh_diagrams() + + dlg = getattr(self, '_style_editor', None) + if dlg is None: + # diagram PNGs 由 Utilities 寫到 repo/.work + work = os.path.join( + os.path.dirname(os.path.abspath(__file__)), '.work') + dlg = DiagramStyleEditor( + self, host_get_style=self._plot_style, on_apply=_apply, + work_dir=work, + current_target=self._current_diagram_target()) + self._style_editor = dlg + dlg.show() + dlg.raise_() + dlg.activateWindow() + except Exception: + import traceback + QtWidgets.QMessageBox.critical( + self, 'Style Editor 開啟失敗', traceback.format_exc()) def populate(self, steps, datum_csv, work_dir, consts=None): self._steps = steps diff --git a/NTNU_DataReduction.py b/NTNU_DataReduction.py index e7ba764..6df6079 100644 --- a/NTNU_DataReduction.py +++ b/NTNU_DataReduction.py @@ -2097,30 +2097,40 @@ def _current_sh_target(self): return 'DFW' def _open_style_editor(self): - """Open the shared DiagramStyleEditor (non-modal singleton).""" + """Open the shared DiagramStyleEditor (non-modal singleton). + + Whole body is guarded: an unhandled exception in a Qt slot aborts the + app, so on failure we show the traceback in a dialog instead of dying. + """ try: + if not hasattr(Utilities, 'set_style_overrides'): + QtWidgets.QMessageBox.warning( + self, 'Style Editor', + 'Utilities.py 版本過舊(缺 set_style_overrides)。\n' + '請把 DEV 的 Utilities.py 同步到執行資料夾後重開。') + return from UI.DiagramStyleEditor import DiagramStyleEditor - except Exception as e: - QtWidgets.QMessageBox.warning(self, 'Style Editor', - '無法載入樣式編輯器:\n{}'.format(e)) - return - def _apply(overrides, preset): - Utilities.set_style_overrides(overrides) - self._diagram_style_overrides = overrides - self.SH_apply_axes() + def _apply(overrides, preset): + Utilities.set_style_overrides(overrides) + self._diagram_style_overrides = overrides + self.SH_apply_axes() - dlg = getattr(self, '_style_editor', None) - if dlg is None: - work = os.path.join(os.path.dirname(os.path.abspath(__file__)), - '.work') - dlg = DiagramStyleEditor( - self, host_get_style=self._get_plot_style, on_apply=_apply, - work_dir=work, current_target=self._current_sh_target()) - self._style_editor = dlg - dlg.show() - dlg.raise_() - dlg.activateWindow() + dlg = getattr(self, '_style_editor', None) + if dlg is None: + work = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '.work') + dlg = DiagramStyleEditor( + self, host_get_style=self._get_plot_style, on_apply=_apply, + work_dir=work, current_target=self._current_sh_target()) + self._style_editor = dlg + dlg.show() + dlg.raise_() + dlg.activateWindow() + except Exception: + import traceback + QtWidgets.QMessageBox.critical( + self, 'Style Editor 開啟失敗', traceback.format_exc()) def _dfs_load_panel_into_spinboxes(self): """DFS only: load the selected panel's saved (xlim, ylim) into the From 9bfd2d252f5b2681cc457a9733c3dc1c0e077f1b Mon Sep 17 00:00:00 2001 From: FormosaRes <57651749+FormosaRes@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:07:35 +0800 Subject: [PATCH 3/4] =?UTF-8?q?v3.9.1:=20=E4=BF=AE=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=AA=A2=E6=9F=A5=E8=AA=A4=E5=A0=B1=EF=BC=88=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=AF=94=20GitHub=20=E6=96=B0=E6=99=82=E4=BB=8D=E8=B7=B3?= =?UTF-8?q?=E3=80=8C=E6=9C=89=E6=9B=B4=E6=96=B0=E3=80=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _bg_check_update / checkVersion 原本用 current == latest,只比相等不比 大小,抓 main 的 .app_info.txt 一不相同就報「有更新」。本地版本領先 GitHub main(開發機/pre-Release)或 raw.githubusercontent CDN 快取造成 短暫不一致時就會誤報要求更新到舊版(回報:Update Available v3.8.93 / You're on v3.9.0)。 新增 App._ver_newer(remote, local) 做數字序 tuple 比較(去 v 前綴、 補零對齊、3.10.0 > 3.9.0 不被字串序騙),兩處改成只有遠端嚴格較新 才提示。不動科學輸出。 Co-Authored-By: Claude Opus 4.8 --- .work/.app_info.txt | 2 +- CHANGELOG.md | 26 ++++++++++++++++++++++++++ NTNU_DataReduction.py | 35 +++++++++++++++++++++++++++++------ README.md | 2 +- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/.work/.app_info.txt b/.work/.app_info.txt index 93d7f9c..eedd4bd 100644 --- a/.work/.app_info.txt +++ b/.work/.app_info.txt @@ -1,5 +1,5 @@ Version: -3.9.0 +3.9.1 Last updated date: 02/07/2026 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcf3de..6aab352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ GitHub Releases(tag)最新仍為 **v3.8.54(Latest,彙整 v3.8.9 → v3.8 --- +## V3.9.1(2026-07-02)— 修更新檢查誤報(本地比 GitHub 新時仍跳「有更新」) + +使用者回報:啟動 toast 顯示「pyADR Update Available: v3.8.93 / You're on v3.9.0」——本地明明比較新,卻被通知去更新到舊版。 + +### 根因 +`_bg_check_update` 與 `checkVersion` 的版本比較用 `current == latest`,只判斷「相不相等」,不看大小。抓 `main` 的 `.app_info.txt` 版本與本地一不相同就報「有更新」。依現行工作流(main 版本號會領先 GitHub Release、開發機本地也常領先,raw.githubusercontent 又有約 5 分鐘 CDN 快取),本地版本領先或短暫不一致時就會誤報。 + +### 修法(`NTNU_DataReduction.py`) +- 新增 `App._ver_newer(remote, local)` 靜態方法:去 `v` 前綴、`[.\-_]` 切段、非數字段落當 0、補零對齊後做**數字序 tuple 比較**(`3.10.0 > 3.9.0`,不會被字串序誤判)。 +- `_bg_check_update`:改成 `if not _ver_newer(latest, current): return`——只有遠端**嚴格較新**才跳 toast;本地領先或相等一律靜默。 +- `checkVersion`(手動 Menu → Check Update):同樣改用 `_ver_newer`,本地領先或相等都回「No updates available」。 + +### 影響 +- 只有 UI 更新提示邏輯,**不動任何科學輸出**,NO.65 驗證不受影響。 +- 本地/開發版領先 GitHub main 時不再被誤報要求「更新到舊版」。 + +### 驗證 +- `ast.parse` 通過。 +- `_ver_newer` 單元測試 8 例全過(含回報情境 `3.8.93` vs `3.9.0` → False、`v` 前綴、長度不一 `3.9` vs `3.9.0`、`3.10.0` vs `3.9.0` 數字序)。 + +### 檔案改動 +- `NTNU_DataReduction.py`:`_ver_newer` 新增;`_bg_check_update` / `checkVersion` 比較邏輯。 +- `.work/.app_info.txt`:3.9.0 → 3.9.1 + +--- + ## V3.9.0(2026-07-02)— Diagram Style Editor:可視化編輯圖表樣式/軸/標籤 新功能。讓使用者針對 AgeCalc diagram(DFN/DFI/DFW/DFA/DFC/DFD/DFR)的顏色、字型、線寬、軸刻度、legend、以及各圖的 title/x/y 標籤做互動式編輯,不必改 code。整合進現有 DiagramPlot 的 refresh 迴路,**不是**獨立子程式。 diff --git a/NTNU_DataReduction.py b/NTNU_DataReduction.py index 6df6079..52af67c 100644 --- a/NTNU_DataReduction.py +++ b/NTNU_DataReduction.py @@ -3193,6 +3193,29 @@ def TableAdjust(self, table): def systemInfo(self): self.Popup(1, "System Info", "".join(self.app_info)) + @staticmethod + def _ver_newer(remote, local): + """True iff remote version string is strictly newer than local. + + Handles 'v' prefix and dotted parts; non-numeric segments -> 0. Ordered + compare (not ==), so a local build ahead of GitHub main (normal for the + developer / pre-Release) is NOT flagged as an update.""" + import re + + def key(v): + v = (v or '').strip().lstrip('vV') + out = [] + for p in re.split(r'[.\-_]', v): + m = re.match(r'\d+', p) + out.append(int(m.group()) if m else 0) + return tuple(out) or (0,) + + r, l = key(remote), key(local) + n = max(len(r), len(l)) + r += (0,) * (n - len(r)) + l += (0,) * (n - len(l)) + return r > l + # Background update check on startup (silent, no popup if up-to-date) def _bg_check_update(self): """Run on startup in a thread. Show Windows toast if new version exists.""" @@ -3205,9 +3228,9 @@ def _bg_check_update(self): return latest_version = page.text.split('\n')[1].rstrip() current_version = self.app_info[1].rstrip() - if current_version == latest_version: - return # already up-to-date, silent - # New version available — show Windows toast notification + if not self._ver_newer(latest_version, current_version): + return # up-to-date or local build is ahead — stay silent + # Remote is strictly newer — show Windows toast notification try: from winotify import Notification, audio logo_path = os.path.abspath(self.work_dir + '.work/logo.png') @@ -3238,11 +3261,11 @@ def checkVersion(self): latest_version = page.text.split('\n')[1].rstrip() current_version = self.app_info[1].rstrip() version_msg = "Installed Version: {}\nLatest Version: {}\n".format(current_version, latest_version) - if current_version == latest_version: - self.Popup(1, "No updates available at this time", version_msg) - else: + if self._ver_newer(latest_version, current_version): git_repo_url = "https://github.com/FormosaRes/pyADR.git" self.Popup(1, "There are updates available at this time", version_msg+"Please go to {} to update to the latest version!\n".format(git_repo_url)) + else: + self.Popup(1, "No updates available at this time", version_msg) else: self.Popup(2, "HTTP request failed!", "HTTP status {}".format(page.status_code)) except: diff --git a/README.md b/README.md index 6bfd77b..a4ab652 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ![logo](.work/logo.png) -# pyADR — NTNU modified fork (v3.9.0) +# pyADR — NTNU modified fork (v3.9.1) 40Ar/39Ar data reduction tool with GUI. Modified fork of [pyADR](https://github.com/AndrewLiu0725/pyADR) (original by **An-Jun (Andrew) Liu**), now maintained by **PANG Chi-Hsiu (Academia Sinica)**. From d0f754179421ee9a8d22364949c998a580023b10 Mon Sep 17 00:00:00 2001 From: FormosaRes <57651749+FormosaRes@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:17:05 +0800 Subject: [PATCH 4/4] =?UTF-8?q?v3.9.2:=20=E4=BF=AE=20DiagramPlot=20SH=20?= =?UTF-8?q?=E6=8C=89=20=E2=9A=99=20=E4=B8=80=E6=8C=89=E5=B0=B1=E6=95=B4?= =?UTF-8?q?=E5=80=8B=E7=A8=8B=E5=BC=8F=E9=97=9C=E9=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App 是純控制器類別(非 QWidget),_open_style_editor 卻用 DiagramStyleEditor(self, ...) 把 App 當 parent 傳給 QDialog.__init__ → TypeError: QDialog argument 1 has unexpected type 'App'。守衛裡的 QMessageBox.critical(self, ...) 又把 App 當 parent 再拋一次 TypeError → 未捕捉 → PyQt5 abort 整個進程。所以「一按就關、連錯誤框都沒有」。 修法:dialog 與 warning/critical 三處 parent self → self.widget (App 的頂層 QStackedWidget,是 QWidget)。AutoPipeline 端在 AgeCalcPage(QWidget) 內 self 本身是 widget,不受影響。純 UI 修正, 不動科學輸出。 Co-Authored-By: Claude Opus 4.8 --- .work/.app_info.txt | 2 +- CHANGELOG.md | 27 +++++++++++++++++++++++++++ NTNU_DataReduction.py | 11 ++++++++--- README.md | 2 +- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.work/.app_info.txt b/.work/.app_info.txt index eedd4bd..b66ebdf 100644 --- a/.work/.app_info.txt +++ b/.work/.app_info.txt @@ -1,5 +1,5 @@ Version: -3.9.1 +3.9.2 Last updated date: 02/07/2026 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aab352..ca28451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ GitHub Releases(tag)最新仍為 **v3.8.54(Latest,彙整 v3.8.9 → v3.8 --- +## V3.9.2(2026-07-02)— 修 DiagramPlot SH 按 ⚙ 一按就整個程式關閉(crash) + +使用者回報:DiagramPlot SH 按 ⚙ 開 Style Editor,視窗還沒出來整個 app 就直接關閉,且沒有任何錯誤視窗(v3.9.0 aaee652 加的 try/except 守衛沒擋住)。 + +### 根因(faulthandler / 真實 traceback) +`NTNU_DataReduction.App` 是純控制器類別,**不是** QWidget(QApplication 與各頁面是它的屬性)。`_open_style_editor` 卻用 `DiagramStyleEditor(self, ...)` 把 `App` 當 parent 傳給 `QDialog.__init__` → +`TypeError: QDialog argument 1 has unexpected type 'App'`。 +接著守衛的 `QtWidgets.QMessageBox.critical(self, ...)` **又**把 `App` 當 parent → 再拋一次 TypeError → 未捕捉 → PyQt5 直接 abort 整個進程。這就是「一按就關、連錯誤框都沒有」的原因。AutoPipeline 端在 `AgeCalcPage(QWidget)` 內,`self` 本身是 widget,不受影響。 + +### 修法(`NTNU_DataReduction.py` `_open_style_editor`) +- dialog parent 由 `self` 改為 `self.widget`(App 的頂層 `QStackedWidget`,是 QWidget)。 +- 守衛內 warning / critical 兩個 QMessageBox 的 parent 一併由 `self` 改 `self.widget`,避免例外處理本身又炸。 + +### 影響 +- 純 UI/parent 修正,**不動科學輸出**,NO.65 驗證不受影響。 +- DiagramPlot SH 按 ⚙ 正常開 Style Editor,不再閃退。 + +### 驗證 +- `ast.parse` 通過。 +- 以 QStackedWidget 當 parent 實測建構 DiagramStyleEditor 成功;以非 widget 當 parent 精準重現原始 `TypeError`(證明就是此因)。 + +### 檔案改動 +- `NTNU_DataReduction.py`:`_open_style_editor` 三處 parent `self` → `self.widget`。 +- `.work/.app_info.txt`:3.9.1 → 3.9.2 + +--- + ## V3.9.1(2026-07-02)— 修更新檢查誤報(本地比 GitHub 新時仍跳「有更新」) 使用者回報:啟動 toast 顯示「pyADR Update Available: v3.8.93 / You're on v3.9.0」——本地明明比較新,卻被通知去更新到舊版。 diff --git a/NTNU_DataReduction.py b/NTNU_DataReduction.py index 52af67c..1f8a348 100644 --- a/NTNU_DataReduction.py +++ b/NTNU_DataReduction.py @@ -2105,7 +2105,7 @@ def _open_style_editor(self): try: if not hasattr(Utilities, 'set_style_overrides'): QtWidgets.QMessageBox.warning( - self, 'Style Editor', + self.widget, 'Style Editor', 'Utilities.py 版本過舊(缺 set_style_overrides)。\n' '請把 DEV 的 Utilities.py 同步到執行資料夾後重開。') return @@ -2120,8 +2120,13 @@ def _apply(overrides, preset): if dlg is None: work = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.work') + # parent must be a QWidget; App is a plain controller, not a + # widget, so use self.widget (the top-level QStackedWidget). + # Passing App raised TypeError in QDialog.__init__ and aborted + # the app on gear click (v3.9.2). dlg = DiagramStyleEditor( - self, host_get_style=self._get_plot_style, on_apply=_apply, + self.widget, + host_get_style=self._get_plot_style, on_apply=_apply, work_dir=work, current_target=self._current_sh_target()) self._style_editor = dlg dlg.show() @@ -2130,7 +2135,7 @@ def _apply(overrides, preset): except Exception: import traceback QtWidgets.QMessageBox.critical( - self, 'Style Editor 開啟失敗', traceback.format_exc()) + self.widget, 'Style Editor 開啟失敗', traceback.format_exc()) def _dfs_load_panel_into_spinboxes(self): """DFS only: load the selected panel's saved (xlim, ylim) into the diff --git a/README.md b/README.md index a4ab652..25d6909 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ![logo](.work/logo.png) -# pyADR — NTNU modified fork (v3.9.1) +# pyADR — NTNU modified fork (v3.9.2) 40Ar/39Ar data reduction tool with GUI. Modified fork of [pyADR](https://github.com/AndrewLiu0725/pyADR) (original by **An-Jun (Andrew) Liu**), now maintained by **PANG Chi-Hsiu (Academia Sinica)**.