wellerlab 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.
Files changed (53) hide show
  1. wellerlab/__init__.py +17 -0
  2. wellerlab/metabo/__init__.py +3 -0
  3. wellerlab/metabo/metabo_core.py +282 -0
  4. wellerlab/metabo/widgets/__init__.py +17 -0
  5. wellerlab/metabo/widgets/_dialogs.py +37 -0
  6. wellerlab/metabo/widgets/_plot.py +194 -0
  7. wellerlab/metabo/widgets/icons/FeatureFilter.svg +6 -0
  8. wellerlab/metabo/widgets/icons/FeatureImport.svg +6 -0
  9. wellerlab/metabo/widgets/icons/Heatmap.svg +6 -0
  10. wellerlab/metabo/widgets/icons/Preprocess.svg +6 -0
  11. wellerlab/metabo/widgets/icons/UnivariateStats.svg +6 -0
  12. wellerlab/metabo/widgets/icons/Volcano.svg +6 -0
  13. wellerlab/metabo/widgets/owfeaturefilter.py +137 -0
  14. wellerlab/metabo/widgets/owfeatureimport.py +118 -0
  15. wellerlab/metabo/widgets/owheatmap.py +267 -0
  16. wellerlab/metabo/widgets/owpreprocess.py +164 -0
  17. wellerlab/metabo/widgets/owunivariate.py +185 -0
  18. wellerlab/metabo/widgets/owvolcano.py +512 -0
  19. wellerlab/nmr/__init__.py +14 -0
  20. wellerlab/nmr/widgets/__init__.py +17 -0
  21. wellerlab/nmr/widgets/icons/NMRBaseline.svg +6 -0
  22. wellerlab/nmr/widgets/icons/NMRBinning.svg +6 -0
  23. wellerlab/nmr/widgets/icons/NMRExclude.svg +6 -0
  24. wellerlab/nmr/widgets/icons/NMRFilter.svg +6 -0
  25. wellerlab/nmr/widgets/icons/NMRNormalize.svg +6 -0
  26. wellerlab/nmr/widgets/icons/NMRReference.svg +6 -0
  27. wellerlab/nmr/widgets/nmr_utils.py +74 -0
  28. wellerlab/nmr/widgets/ownmrbaseline.py +149 -0
  29. wellerlab/nmr/widgets/ownmrbinning.py +124 -0
  30. wellerlab/nmr/widgets/ownmrexclude.py +145 -0
  31. wellerlab/nmr/widgets/ownmrfilter.py +126 -0
  32. wellerlab/nmr/widgets/ownmrnormalize.py +134 -0
  33. wellerlab/nmr/widgets/ownmrreference.py +145 -0
  34. wellerlab/pca/__init__.py +16 -0
  35. wellerlab/pca/pca_analysis.py +246 -0
  36. wellerlab/pca/widgets/__init__.py +7 -0
  37. wellerlab/pca/widgets/icons/PCAPro.svg +6 -0
  38. wellerlab/pca/widgets/owpcawell.py +563 -0
  39. wellerlab/plsda/__init__.py +29 -0
  40. wellerlab/plsda/opls_core.py +377 -0
  41. wellerlab/plsda/oplsda_learner.py +209 -0
  42. wellerlab/plsda/plsda_learner.py +120 -0
  43. wellerlab/plsda/widgets/__init__.py +8 -0
  44. wellerlab/plsda/widgets/icons/OPLSDA.svg +6 -0
  45. wellerlab/plsda/widgets/icons/PLSDA.svg +6 -0
  46. wellerlab/plsda/widgets/owoplsda.py +506 -0
  47. wellerlab/plsda/widgets/owplsda.py +192 -0
  48. wellerlab/widgets/__init__.py +17 -0
  49. wellerlab-0.1.0.dist-info/METADATA +83 -0
  50. wellerlab-0.1.0.dist-info/RECORD +53 -0
  51. wellerlab-0.1.0.dist-info/WHEEL +5 -0
  52. wellerlab-0.1.0.dist-info/entry_points.txt +2 -0
  53. wellerlab-0.1.0.dist-info/top_level.txt +1 -0
wellerlab/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ WellerLab — the complete Orange3 tool suite of the Weller lab.
3
+
4
+ One distribution, one Orange category ("Weller Lab"):
5
+
6
+ wellerlab.metabo MetaboAnalyst-style feature-table statistics
7
+ (import, preprocess, filter, univariate, heatmap, volcano)
8
+ wellerlab.plsda PLS-DA and OPLS-DA (S-plot, VIP, Q2, permutation)
9
+ wellerlab.pca PCA Pro
10
+ wellerlab.nmr NMR preprocessing (baseline, binning, exclusion,
11
+ filter, normalization, reference)
12
+
13
+ The numerical cores (``metabo.metabo_core``, ``plsda.opls_core``) are free of
14
+ Qt/Orange and can be imported and tested headlessly.
15
+ """
16
+
17
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ """wellerlab.metabo — MetaboAnalyst-style add-on for Orange3."""
2
+
3
+ __version__ = "0.4.4"
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ MetaboAnalyst-style univariate statistics core for GC-MS / GC-IMS
5
+ feature tables (Compound Discoverer exports).
6
+
7
+ This module is the analytics heart of the wellerlab.metabo add-on. It is
8
+ deliberately free of any Qt / Orange dependency so it can be unit-tested
9
+ headlessly with plain numpy/scipy. The widgets in `orangemetabo/widgets/`
10
+ are thin GUI wrappers around the functions here.
11
+
12
+ Pipeline (matches the validated ground truth `cv_anova_alle_97_features.csv`,
13
+ 97/97 features on F, p and FDR_BH):
14
+
15
+ load -> sum-normalise -> log2 -> autoscale (z-score per feature)
16
+ -> univariate statistics (one-way ANOVA / Welch t / Kruskal-Wallis)
17
+ -> Benjamini-Hochberg FDR
18
+
19
+ All functions are pure: they take arrays / lists and return DataFrames or
20
+ arrays. No file I/O except the optional `load_feature_table` reader.
21
+ """
22
+ import csv
23
+ import numpy as np
24
+ import pandas as pd
25
+ from scipy import stats
26
+
27
+
28
+ # --------------------------------------------------------------------------
29
+ # Feature-table reader (Compound Discoverer 2-header semicolon CSV)
30
+ # --------------------------------------------------------------------------
31
+
32
+ def load_feature_table(path):
33
+ """Read a Compound Discoverer feature-table CSV.
34
+
35
+ Expected layout (semicolon-separated, UTF-8-BOM):
36
+ row 0: "" , "Area: <sample>.raw", ...
37
+ row 1: "" , "<group>", ...
38
+ row 2+: "<feature name>", values...
39
+
40
+ Returns (feature_names: list[str], samples: list[str],
41
+ groups: list[str], X: np.ndarray[features x samples]).
42
+ """
43
+ with open(path, encoding="utf-8-sig", newline="") as f:
44
+ rows = list(csv.reader(f, delimiter=";"))
45
+ samples = [h.replace("Area: ", "").replace(".raw", "").strip()
46
+ for h in rows[0][1:]]
47
+ groups = [g.strip() for g in rows[1][1:]]
48
+ feat = [r[0].strip() for r in rows[2:]]
49
+ X = np.array(
50
+ [[float(v) for v in r[1:1 + len(samples)]] for r in rows[2:]],
51
+ dtype=float,
52
+ )
53
+ return feat, samples, groups, X
54
+
55
+
56
+ # --------------------------------------------------------------------------
57
+ # Preprocessing
58
+ # --------------------------------------------------------------------------
59
+
60
+ def normalize_sum(X):
61
+ """Total-area (sum) normalisation: rescale each sample column so that
62
+ every column has the mean of the column totals."""
63
+ col_sums = X.sum(axis=0)
64
+ return X / col_sums * col_sums.mean()
65
+
66
+
67
+ def log2_transform(X):
68
+ """log2 with a guard against non-positive values (clip to tiny eps)."""
69
+ eps = np.finfo(float).tiny
70
+ return np.log2(np.clip(X, eps, None))
71
+
72
+
73
+ def impute_low(X, threshold=0.05, method="knn", k=3):
74
+ """Impute below-threshold values (fraction of column min or a hard cap).
75
+
76
+ Methods:
77
+ 'constant' : replace with the column minimum (or 0).
78
+ 'min' : replace with the column minimum.
79
+ 'knn' : replace with the mean of the k nearest samples in the
80
+ Euclidean distance of the non-imputed rows.
81
+ Returns (X_imputed, imputed_mask).
82
+ """
83
+ X = np.asarray(X, dtype=float).copy()
84
+ col_min = X.min(axis=0)
85
+ mask = X < (col_min * (1 + threshold))
86
+ if not mask.any():
87
+ return X, mask
88
+ if method in ("constant", "min"):
89
+ rows, cols = np.nonzero(mask)
90
+ X[rows, cols] = col_min[cols]
91
+ return X, mask
92
+ # knn: distance between samples (columns) over all feature rows;
93
+ # impute each flagged value with the mean of its k nearest samples.
94
+ n_rows, n_cols = X.shape
95
+ d = np.sqrt(((X[:, :, None] - X[:, None, :]) ** 2).sum(axis=0)) # c x c
96
+ np.fill_diagonal(d, np.inf)
97
+ order = d.argsort(axis=1)
98
+ for c in range(n_cols):
99
+ bad = np.nonzero(mask[:, c])[0]
100
+ if bad.size == 0:
101
+ continue
102
+ for r in bad:
103
+ # prefer samples that are not flagged on this same feature
104
+ nb = order[c, :max(k, 1)]
105
+ ok = [j for j in nb if not (mask[r, j] and j != c)]
106
+ if not ok:
107
+ X[r, c] = col_min[c]
108
+ else:
109
+ X[r, c] = X[r, ok].mean()
110
+ return X, mask
111
+
112
+
113
+ def scale_rows(X, method="autoscale"):
114
+ """Per-feature (row) scaling.
115
+
116
+ 'autoscale' : z-score (mean 0, std 1, ddof=1)
117
+ 'pareto' : (x - mean) / sqrt(std)
118
+ 'none' : unchanged
119
+ Constant rows are returned as zeros (division guard).
120
+ """
121
+ X = np.asarray(X, dtype=float)
122
+ mean = X.mean(axis=1, keepdims=True)
123
+ if method == "none":
124
+ return X
125
+ std = X.std(axis=1, keepdims=True, ddof=1)
126
+ denom = np.where(std > 0, np.sqrt(std) if method == "pareto" else std, 1.0)
127
+ return (X - mean) / denom
128
+
129
+
130
+ # --------------------------------------------------------------------------
131
+ # Multiple-testing correction
132
+ # --------------------------------------------------------------------------
133
+
134
+ def bh_fdr(p):
135
+ """Benjamini-Hochberg FDR for an array of p-values."""
136
+ p = np.asarray(p, dtype=float)
137
+ m = len(p)
138
+ if m == 0:
139
+ return p
140
+ order = p.argsort()
141
+ ranked = p[order]
142
+ q = np.minimum.accumulate((ranked * m / np.arange(1, m + 1))[::-1])[::-1]
143
+ qf = np.empty(m)
144
+ qf[order] = np.clip(q, 0, 1)
145
+ return qf
146
+
147
+
148
+ # --------------------------------------------------------------------------
149
+ # Univariate statistics
150
+ # --------------------------------------------------------------------------
151
+
152
+ def _groups_arrays(X, groups, levels):
153
+ """Yield the per-group column slices for every feature row."""
154
+ gi = {lv: np.array(groups) == lv for lv in levels}
155
+ return [X[:, idx] for idx in gi.values()]
156
+
157
+
158
+ def univariate(X, groups, method="anova", base=None, treats=None,
159
+ feature_names=None):
160
+ """Run the selected univariate test for every feature row.
161
+
162
+ method:
163
+ 'anova' : one-way ANOVA across all levels (>= 2 levels, >= 2 per level).
164
+ 'welch' : Welch two-sample t-test, `treats` combined vs `base`.
165
+ 'kruskal': one-way Kruskal-Wallis across all levels.
166
+
167
+ feature_names: optional list of row labels (same length as X rows);
168
+ when given, the `Feature` column holds these names, else row indices.
169
+
170
+ Returns (DataFrame, levels). The DataFrame has columns
171
+ Feature, stat, p, (log2FC for welch), FDR_BH,
172
+ sorted by ascending p.
173
+ """
174
+ X = np.asarray(X, dtype=float)
175
+ groups = list(groups)
176
+ levels = list(dict.fromkeys(groups))
177
+ n = X.shape[0]
178
+ names = list(feature_names) if feature_names is not None else list(range(n))
179
+ res = []
180
+ if method in ("anova", "kruskal"):
181
+ for i in range(n):
182
+ cols = [X[i, np.array(groups) == lv] for lv in levels]
183
+ if any(c.size < 2 for c in cols):
184
+ stat, p = np.nan, np.nan
185
+ elif method == "anova":
186
+ stat, p = stats.f_oneway(*cols)
187
+ else:
188
+ stat, p = stats.kruskal(*cols)
189
+ res.append(dict(Feature=names[i], stat=stat, p=p))
190
+ elif method == "welch":
191
+ if base not in levels or not treats:
192
+ raise ValueError("Welch test needs `base` and non-empty `treats`.")
193
+ b = np.array(groups) == base
194
+ a = np.isin(np.array(groups), treats)
195
+ for i in range(n):
196
+ if X[i, a].size < 2 or X[i, b].size < 2:
197
+ stat, p = np.nan, np.nan
198
+ fc = np.nan
199
+ else:
200
+ stat, p = stats.ttest_ind(X[i, a], X[i, b], equal_var=False)
201
+ fc = X[i, a].mean() - X[i, b].mean()
202
+ res.append(dict(Feature=names[i], stat=stat, p=p, log2FC=fc))
203
+ else:
204
+ raise ValueError(f"unknown method {method!r}")
205
+
206
+ df = pd.DataFrame(res)
207
+ df["p"] = pd.Series(df["p"]).astype(float)
208
+ df = df.sort_values("p", na_position="last").reset_index(drop=True)
209
+ df["FDR_BH"] = bh_fdr(df["p"].values)
210
+ return df, levels
211
+
212
+
213
+ def add_group_means(df, X, groups, levels, feature_names=None):
214
+ """Append per-group mean columns (on the supplied, usually scaled, X).
215
+
216
+ `df` must contain a `Feature` column. When `feature_names` is given it
217
+ maps feature names to their row index in X; otherwise `Feature` values
218
+ are assumed to already be row indices.
219
+ """
220
+ X = np.asarray(X, dtype=float)
221
+ groups = np.asarray(groups)
222
+ out = df.copy()
223
+ if feature_names is not None:
224
+ name_to_row = {n: i for i, n in enumerate(feature_names)}
225
+ else:
226
+ name_to_row = None
227
+ for lv in levels:
228
+ vals = []
229
+ for f in out["Feature"]:
230
+ i = name_to_row.get(f) if name_to_row is not None else f
231
+ vals.append(
232
+ X[i, groups == lv].mean() if (groups == lv).any() else np.nan)
233
+ out[f"mean_{lv}"] = vals
234
+ return out
235
+
236
+
237
+ def format_p(v):
238
+ """Human-friendly p-value string (scientific below 1e-4)."""
239
+ if v is None or (isinstance(v, float) and np.isnan(v)):
240
+ return "NA"
241
+ if v < 1e-4:
242
+ return f"{v:.2e}"
243
+ return f"{v:.4f}"
244
+
245
+
246
+ # --------------------------------------------------------------------------
247
+ # Volcano (contrast derived from per-group means)
248
+ # --------------------------------------------------------------------------
249
+
250
+ def volcano_table(df, group_a, group_b, fdr_col="FDR_BH", alpha=0.05, fc=1.0):
251
+ """Build a volcano table for the contrast `group_a` vs `group_b`.
252
+
253
+ Operates on a univariate results frame (as produced by `univariate` +
254
+ `add_group_means`): it needs a `Feature` column, the two `mean_<group>`
255
+ columns and an FDR column. Group means live in the (log2) space the data
256
+ was supplied in, so the fold change is their difference:
257
+
258
+ log2FC = mean_a - mean_b (positive => higher in group_a)
259
+
260
+ Returns a DataFrame with columns
261
+ Feature, log2FC, FDR_BH, neglog10FDR, direction,
262
+ where direction is "up" (FDR < alpha and log2FC >= fc),
263
+ "down"(FDR < alpha and log2FC <= -fc),
264
+ "ns" otherwise.
265
+ """
266
+ ca, cb = f"mean_{group_a}", f"mean_{group_b}"
267
+ for c in ("Feature", ca, cb, fdr_col):
268
+ if c not in df.columns:
269
+ raise ValueError(f"volcano needs column {c!r}")
270
+ lfc = df[ca].to_numpy(float) - df[cb].to_numpy(float)
271
+ fdr = df[fdr_col].to_numpy(float)
272
+ out = pd.DataFrame({
273
+ "Feature": df["Feature"].to_numpy(),
274
+ "log2FC": lfc,
275
+ "FDR_BH": fdr,
276
+ "neglog10FDR": -np.log10(np.clip(fdr, np.finfo(float).tiny, None)),
277
+ })
278
+ sig = fdr < alpha
279
+ out["direction"] = np.where(
280
+ sig & (lfc >= fc), "up",
281
+ np.where(sig & (lfc <= -fc), "down", "ns"))
282
+ return out
@@ -0,0 +1,17 @@
1
+ """Widget definitions for wellerlab.metabo."""
2
+
3
+ from .owfeatureimport import OWFeatureImport # noqa: F401
4
+ from .owpreprocess import OWMetaboPreprocess # noqa: F401
5
+ from .owfeaturefilter import OWFeatureFilter # noqa: F401
6
+ from .owunivariate import OWUnivariateStats # noqa: F401
7
+ from .owheatmap import OWMetaboHeatmap # noqa: F401
8
+ from .owvolcano import OWVolcano # noqa: F401
9
+
10
+ __all__ = [
11
+ "OWFeatureImport",
12
+ "OWMetaboPreprocess",
13
+ "OWFeatureFilter",
14
+ "OWUnivariateStats",
15
+ "OWMetaboHeatmap",
16
+ "OWVolcano",
17
+ ]
@@ -0,0 +1,37 @@
1
+ """Small Qt dialog helpers shared by the Metabo widgets.
2
+
3
+ Orange's ``Orange.widgets.utils.filedialogs`` API is not stable across
4
+ releases: the ``OpenFileDialog`` / ``SaveFileDialog`` classes used by older
5
+ widgets no longer exist in current Orange (they were replaced by the
6
+ ``open_filename_dialog`` / ``open_filename_dialog_save`` functions). To stay
7
+ working on every Orange version the widgets call plain ``QFileDialog``
8
+ directly through these two helpers — one idiom, no version-specific imports.
9
+ """
10
+
11
+
12
+ def open_feature_table(parent, start_dir=""):
13
+ """Ask for a feature-table CSV/TXT. Returns a path or None (cancelled)."""
14
+ from AnyQt.QtWidgets import QFileDialog
15
+ path, _ = QFileDialog.getOpenFileName(
16
+ parent, "Open feature table", start_dir or "",
17
+ "Feature table (*.csv *.txt *.tsv);;All files (*)")
18
+ return path or None
19
+
20
+
21
+ def save_figure(parent, fig, kind="PNG"):
22
+ """Ask for a path and write figure `fig` as PNG (dpi 300) or SVG.
23
+
24
+ `kind` is 'PNG' or 'SVG' (case-insensitive). Returns the path written,
25
+ or None if the user cancelled.
26
+ """
27
+ from AnyQt.QtWidgets import QFileDialog
28
+ kind = kind.upper()
29
+ ext = ".png" if kind == "PNG" else ".svg"
30
+ path, _ = QFileDialog.getSaveFileName(
31
+ parent, f"Save figure ({kind})", "", f"{kind} (*{ext});;All files (*)")
32
+ if not path:
33
+ return None
34
+ if not path.lower().endswith(ext):
35
+ path += ext
36
+ fig.savefig(path, dpi=300 if kind == "PNG" else None, facecolor="white")
37
+ return path
@@ -0,0 +1,194 @@
1
+ """Shared matplotlib-canvas helpers for the Metabo widgets.
2
+
3
+ Orange's own plots are pyqtgraph based and get pan/zoom/reset for free; the
4
+ Metabo heatmap and volcano are matplotlib canvases, so the interaction is
5
+ implemented here explicitly — and, importantly, it survives redraws (a plain
6
+ matplotlib canvas autoscales on every draw, which silently discards the user's
7
+ zoom).
8
+
9
+ Interaction provided by `PlotCanvas`:
10
+
11
+ * wheel over the plot zooms around the cursor,
12
+ * dragging with the left button pans,
13
+ * a left click (no drag) calls `on_click(axes, xdata, ydata)` — used by the
14
+ volcano for point selection, with a pixel tolerance so edge points work,
15
+ * `reset_view()` restores the automatic view; `after_draw()` keeps the
16
+ user's zoom across redraws.
17
+
18
+ `attach_toolbar()` adds matplotlib's navigation toolbar (save / home / zoom
19
+ rect / pan) and `draggable()` makes a legend movable.
20
+ """
21
+
22
+ import numpy as np
23
+ from typing import Callable, Optional
24
+ from AnyQt.QtCore import Qt
25
+ from matplotlib.backends.backend_qtagg import (
26
+ FigureCanvasQTAgg, NavigationToolbar2QT)
27
+ from matplotlib.figure import Figure
28
+
29
+ CLICK_TOLERANCE = 12 # px radius for click selection
30
+ DRAG_THRESHOLD = 3 # px before a press counts as a pan drag
31
+
32
+
33
+ class PlotCanvas(FigureCanvasQTAgg):
34
+ def __init__(self, figsize=(8, 5), dpi=100):
35
+ self.fig = Figure(figsize=figsize, dpi=dpi)
36
+ super().__init__(self.fig)
37
+ self.setFocusPolicy(Qt.StrongFocus)
38
+ self.main_axes = None # the axes the view is kept for
39
+ self.home = None # (xlim, ylim) of the automatic view
40
+ self.view = None # the user's current view (None = home)
41
+ self.on_click: Optional[Callable] = None # callback(axes, xdata, ydata)
42
+ self.toolbar = None
43
+ self._press = None
44
+
45
+ # ------------------------------------------------------------------ view
46
+ def after_draw(self, ax, new_data=False):
47
+ """Call after every redraw. Keeps the user's zoom unless data changed."""
48
+ axis = ax or (self.fig.axes[0] if self.fig.axes else None)
49
+ if axis is None:
50
+ return
51
+ self.main_axes = axis
52
+ if new_data or self.home is None:
53
+ self.home = (axis.get_xlim(), axis.get_ylim())
54
+ self.view = None
55
+ if self.view is not None:
56
+ axis.set_xlim(*self.view[0])
57
+ axis.set_ylim(*self.view[1])
58
+
59
+ def reset_view(self):
60
+ """Back to the automatic (home) view; also clears any toolbar mode."""
61
+ if self.toolbar is not None:
62
+ self.toolbar.mode = ""
63
+ try:
64
+ self.toolbar._update_buttons_checked()
65
+ except Exception:
66
+ pass
67
+ self.view = None
68
+ if self.home is not None and self.main_axes is not None:
69
+ self.main_axes.set_xlim(*self.home[0])
70
+ self.main_axes.set_ylim(*self.home[1])
71
+ self.draw_idle()
72
+
73
+ def _remember(self, ax):
74
+ if ax is self.main_axes:
75
+ self.view = (ax.get_xlim(), ax.get_ylim())
76
+
77
+ # --------------------------------------------------------------- helpers
78
+ def _axes_at(self, x, y):
79
+ for ax in reversed(self.fig.axes):
80
+ if ax.bbox.contains(x, y):
81
+ return ax
82
+ return None
83
+
84
+ @staticmethod
85
+ def _steps(event):
86
+ if not event.pixelDelta().isNull():
87
+ return event.pixelDelta().y()
88
+ return event.angleDelta().y() / 120.0
89
+
90
+ # ------------------------------------------------------------ wheel zoom
91
+ def zoom_at(self, ax, x, y, steps):
92
+ """Zoom `ax` around the display point (x, y) by `steps` wheel notches."""
93
+ cx, cy = ax.transData.inverted().transform((x, y))
94
+ k = 0.8 ** steps # wheel up (steps>0) zooms in
95
+ xl, yl = ax.get_xlim(), ax.get_ylim()
96
+ ax.set_xlim(cx + (xl[0] - cx) * k, cx + (xl[1] - cx) * k)
97
+ ax.set_ylim(cy + (yl[0] - cy) * k, cy + (yl[1] - cy) * k)
98
+ self._remember(ax)
99
+
100
+ def wheelEvent(self, event):
101
+ x, y = self.mouseEventCoords(event)
102
+ ax = self._axes_at(x, y)
103
+ steps = self._steps(event)
104
+ if ax is None or not steps:
105
+ super().wheelEvent(event)
106
+ return
107
+ self.zoom_at(ax, x, y, steps)
108
+ self.draw_idle()
109
+ event.accept() # don't let a parent scroll
110
+
111
+ # ------------------------------------------------- pan drag / click sel
112
+ def mousePressEvent(self, event):
113
+ if event.button() == Qt.LeftButton:
114
+ x, y = self.mouseEventCoords(event)
115
+ ax = self._axes_at(x, y)
116
+ if ax is not None:
117
+ self._press = (x, y, ax, ax.get_xlim(), ax.get_ylim(), False)
118
+ super().mousePressEvent(event)
119
+
120
+ def pan_by(self, ax, x0, y0, x1, y1, xl0, yl0):
121
+ """Pan `ax` so the display point (x0, y0) follows the cursor to (x1, y1)."""
122
+ inv = ax.transData.inverted()
123
+ dx = inv.transform((x0, y0))[0] - inv.transform((x1, y1))[0]
124
+ dy = inv.transform((x0, y0))[1] - inv.transform((x1, y1))[1]
125
+ ax.set_xlim(xl0[0] + dx, xl0[1] + dx)
126
+ ax.set_ylim(yl0[0] + dy, yl0[1] + dy)
127
+ self._remember(ax)
128
+
129
+ def mouseMoveEvent(self, event):
130
+ if self._press is not None and (event.buttons() & Qt.LeftButton):
131
+ x0, y0, ax, xl0, yl0, moved = self._press
132
+ x, y = self.mouseEventCoords(event)
133
+ if moved or abs(x - x0) + abs(y - y0) > DRAG_THRESHOLD:
134
+ self.pan_by(ax, x0, y0, x, y, xl0, yl0)
135
+ self._press = (x0, y0, ax, xl0, yl0, True)
136
+ self.draw_idle()
137
+ super().mouseMoveEvent(event)
138
+
139
+ def mouseReleaseEvent(self, event):
140
+ press, self._press = self._press, None
141
+ if (event.button() == Qt.LeftButton and press is not None
142
+ and not press[5] and self.on_click is not None):
143
+ ax = press[2]
144
+ x, y = self.mouseEventCoords(event)
145
+ self._remember(ax)
146
+ self.on_click(ax, *ax.transData.inverted().transform((x, y)))
147
+ super().mouseReleaseEvent(event)
148
+
149
+ # ------------------------------------------------------- nearest feature
150
+ def nearest_point(self, ax, xdata, ydata, offsets):
151
+ """Index of the scatter point nearest to (xdata, ydata), or None when
152
+ nothing is within CLICK_TOLERANCE pixels (so edge points are clickable)."""
153
+ if offsets is None or len(offsets) == 0:
154
+ return None
155
+ px, py = ax.transData.transform((xdata, ydata))
156
+ disp = ax.transData.transform(np.asarray(offsets, dtype=float))
157
+ d = np.hypot(disp[:, 0] - px, disp[:, 1] - py)
158
+ i = int(np.argmin(d))
159
+ return i if d[i] <= CLICK_TOLERANCE else None
160
+
161
+
162
+ def attach_toolbar(box, canvas, parent):
163
+ """Add a slim matplotlib toolbar to `box`: Home (= reset view) and Save.
164
+
165
+ The pan/zoom/back/forward/subplots actions are removed: panning and zooming
166
+ are done directly on the canvas (`PlotCanvas`), and matplotlib's modal
167
+ tools would swallow the left button, so point selection became impossible
168
+ until the user figured out how to leave the mode again.
169
+ """
170
+ toolbar = NavigationToolbar2QT(canvas, parent)
171
+ acts = getattr(toolbar, "_actions", {})
172
+ for key in ("back", "forward", "pan", "zoom",
173
+ "configure_subplots", "edit_parameters"):
174
+ action = acts.get(key)
175
+ if action is not None:
176
+ toolbar.removeAction(action)
177
+ home = acts.get("home")
178
+ if home is not None:
179
+ home.setToolTip("Reset view")
180
+ try:
181
+ home.triggered.disconnect()
182
+ except (TypeError, RuntimeError):
183
+ pass
184
+ home.triggered.connect(lambda *_: canvas.reset_view())
185
+ box.layout().addWidget(toolbar)
186
+ canvas.toolbar = toolbar
187
+ return toolbar
188
+
189
+
190
+ def draggable(legend):
191
+ """Make a matplotlib legend movable by dragging."""
192
+ if legend is not None:
193
+ legend.set_draggable(True)
194
+ return legend
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#1F4E79"/>
3
+ <g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
4
+ <text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
5
+ fill-opacity="0.85" text-anchor="middle">FIL</text>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#1F4E79"/>
3
+ <g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
4
+ <text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
5
+ fill-opacity="0.85" text-anchor="middle">IMP</text>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#1F4E79"/>
3
+ <g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
4
+ <text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
5
+ fill-opacity="0.85" text-anchor="middle">HM</text>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#1F4E79"/>
3
+ <g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
4
+ <text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
5
+ fill-opacity="0.85" text-anchor="middle">PRE</text>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#1F4E79"/>
3
+ <g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
4
+ <text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
5
+ fill-opacity="0.85" text-anchor="middle">UNI</text>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#1F4E79"/>
3
+ <g><polyline points="7,32 13,32 15,20 18,32 24,32 26,14 29,32 34,32 36,24 39,32 41,32" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><line x1="7" y1="36" x2="41" y2="36" stroke="#fff" stroke-width="1.5" opacity="0.7"/></g>
4
+ <text x="24" y="45" font-family="Helvetica,Arial,sans-serif" font-size="7" fill="#fff"
5
+ fill-opacity="0.85" text-anchor="middle">VOL</text>
6
+ </svg>