like-sci-figures 0.0.0-stage → 1.0.0
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.
- package/LICENSE +56 -0
- package/README.md +93 -2
- package/cli.js +79 -0
- package/examples/area.json +9 -0
- package/examples/bar.json +15 -0
- package/examples/bland_altman.json +128 -0
- package/examples/bode.json +8 -0
- package/examples/box.json +8 -0
- package/examples/bubble.json +7 -0
- package/examples/contour.json +10 -0
- package/examples/corr_matrix.json +99 -0
- package/examples/dendrogram.json +5 -0
- package/examples/density.json +8 -0
- package/examples/dot_plot.json +8 -0
- package/examples/ecdf.json +377 -0
- package/examples/errorbar.json +7 -0
- package/examples/forest.json +12 -0
- package/examples/heatmap.json +10 -0
- package/examples/hexbin.json +6009 -0
- package/examples/histogram.json +6 -0
- package/examples/kaplan_meier.json +6 -0
- package/examples/line.json +11 -0
- package/examples/manhattan.json +7 -0
- package/examples/panels.json +28 -0
- package/examples/pca.json +12 -0
- package/examples/periodogram.json +7 -0
- package/examples/pie.json +4 -0
- package/examples/radar.json +7 -0
- package/examples/ridge.json +11 -0
- package/examples/roc.json +6 -0
- package/examples/sankey.json +9 -0
- package/examples/scatter3d.json +6 -0
- package/examples/slope.json +6 -0
- package/examples/stacked_bar.json +12 -0
- package/examples/surface.json +11 -0
- package/examples/taylor.json +9 -0
- package/examples/ternary.json +18 -0
- package/examples/venn.json +12 -0
- package/examples/violin.json +9 -0
- package/examples/volcano.json +7 -0
- package/examples/waterfall.json +5 -0
- package/examples/windrose.json +407 -0
- package/package.json +33 -4
- package/requirements.txt +9 -0
- package/scripts/add_chart.py +201 -0
- package/scripts/build_gallery.py +360 -0
- package/scripts/chart_plugin.py +153 -0
- package/scripts/data_loader.py +260 -0
- package/scripts/fig_3d.py +103 -0
- package/scripts/fig_area.py +57 -0
- package/scripts/fig_bar.py +117 -0
- package/scripts/fig_biomed.py +289 -0
- package/scripts/fig_box.py +60 -0
- package/scripts/fig_bubble.py +65 -0
- package/scripts/fig_core.py +195 -0
- package/scripts/fig_datasci.py +276 -0
- package/scripts/fig_distribution.py +59 -0
- package/scripts/fig_engineering.py +224 -0
- package/scripts/fig_environ.py +239 -0
- package/scripts/fig_errorbar.py +49 -0
- package/scripts/fig_forest.py +54 -0
- package/scripts/fig_heatmap.py +51 -0
- package/scripts/fig_line.py +39 -0
- package/scripts/fig_pca.py +101 -0
- package/scripts/fig_pie.py +52 -0
- package/scripts/fig_radar.py +32 -0
- package/scripts/fig_sankey.py +196 -0
- package/scripts/fig_scatter.py +44 -0
- package/scripts/fig_subplots.py +124 -0
- package/scripts/fig_taylor.py +80 -0
- package/scripts/fig_venn.py +80 -0
- package/scripts/fig_windrose.py +82 -0
- package/scripts/font_detector.py +85 -0
- package/scripts/gen_figure.py +583 -0
- package/scripts/generate_samples.py +322 -0
- package/scripts/palette_picker.py +244 -0
- package/scripts/palette_pref.py +179 -0
- package/scripts/palette_preview.py +334 -0
- package/scripts/palettes.py +461 -0
- package/scripts/registry.py +138 -0
- package/scripts/render_utils.py +233 -0
- package/scripts/showcase.py +179 -0
- package/scripts/style_manager.py +267 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Environmental statistics suite: corr_matrix, hexbin, ecdf, bland_altman.
|
|
3
|
+
|
|
4
|
+
These four chart types are the standard "environmental statistics toolkit"
|
|
5
|
+
used in air/water/soil quality analysis, model-vs-observation evaluation,
|
|
6
|
+
and measurement-method agreement studies:
|
|
7
|
+
|
|
8
|
+
corr_matrix Pearson correlation matrix heatmap (variables x variables)
|
|
9
|
+
hexbin Hexagonal binned density scatter (dense point clouds)
|
|
10
|
+
ecdf Empirical cumulative distribution function (multi-group)
|
|
11
|
+
bland_altman Bland-Altman agreement plot (two methods, bias + 1.96SD LoA)
|
|
12
|
+
|
|
13
|
+
Each is implemented as a ChartPlugin subclass for automatic palette
|
|
14
|
+
integration and registry consistency.
|
|
15
|
+
"""
|
|
16
|
+
import numpy as np
|
|
17
|
+
from chart_plugin import ChartPlugin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_cjk_font(font_path):
|
|
21
|
+
if not font_path:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
from matplotlib import font_manager as fm
|
|
25
|
+
return fm.FontProperties(fname=font_path)
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CorrMatrixPlot(ChartPlugin):
|
|
31
|
+
"""Pearson correlation matrix heatmap.
|
|
32
|
+
|
|
33
|
+
Inputs (either form):
|
|
34
|
+
matrix: 2D array, rows = samples, columns = variables
|
|
35
|
+
series: dict {variable_name: [sample values, ...]}
|
|
36
|
+
Optionally `labels` names the variables (must match the variable count).
|
|
37
|
+
Rendering is symmetric in [-1, 1] with a diverging colormap (default
|
|
38
|
+
RdBu_r), so positive correlations are warm/red and negative cool/blue.
|
|
39
|
+
"""
|
|
40
|
+
name = "corr_matrix"
|
|
41
|
+
zh = "相关矩阵"
|
|
42
|
+
kind = "relational"
|
|
43
|
+
required_keys = ()
|
|
44
|
+
optional_keys = ("matrix", "series", "labels", "method")
|
|
45
|
+
data_format = "both"
|
|
46
|
+
|
|
47
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
48
|
+
labels = data.get("labels") or data.get("row_labels") or []
|
|
49
|
+
|
|
50
|
+
if "matrix" in data and data["matrix"] is not None:
|
|
51
|
+
mat = np.asarray(data["matrix"], dtype=float)
|
|
52
|
+
# rows = samples, cols = variables -> correlate over variables
|
|
53
|
+
if mat.ndim != 2 or mat.shape[1] < 2:
|
|
54
|
+
raise ValueError("corr_matrix: matrix needs >= 2 variable columns")
|
|
55
|
+
n_vars = mat.shape[1]
|
|
56
|
+
if not labels:
|
|
57
|
+
labels = ["V%d" % (i + 1) for i in range(n_vars)]
|
|
58
|
+
corr = np.corrcoef(mat, rowvar=False)
|
|
59
|
+
elif data.get("series"):
|
|
60
|
+
series = data["series"]
|
|
61
|
+
keys = list(series.keys())
|
|
62
|
+
arr = np.array([np.asarray(v, dtype=float) for v in series.values()])
|
|
63
|
+
if arr.ndim != 2 or arr.shape[0] < 2:
|
|
64
|
+
raise ValueError("corr_matrix: series needs >= 2 variables")
|
|
65
|
+
n_vars = arr.shape[0]
|
|
66
|
+
labels = labels or keys
|
|
67
|
+
# rows = variables, cols = samples -> correlate over samples
|
|
68
|
+
corr = np.corrcoef(arr, rowvar=True)
|
|
69
|
+
else:
|
|
70
|
+
raise ValueError("corr_matrix: provide 'matrix' or 'series'")
|
|
71
|
+
|
|
72
|
+
if len(labels) != n_vars:
|
|
73
|
+
labels = ["V%d" % (i + 1) for i in range(n_vars)]
|
|
74
|
+
|
|
75
|
+
cmap = kwargs.get("cmap") or "RdBu_r"
|
|
76
|
+
im = ax.imshow(corr, cmap=cmap, vmin=-1.0, vmax=1.0, aspect="auto")
|
|
77
|
+
|
|
78
|
+
# Annotate each cell; text colour contrasts against the diverging cmap.
|
|
79
|
+
for i in range(n_vars):
|
|
80
|
+
for j in range(n_vars):
|
|
81
|
+
v = corr[i, j]
|
|
82
|
+
color = "white" if abs(v) > 0.55 else "black"
|
|
83
|
+
ax.text(j, i, "%.2f" % v, ha="center", va="center",
|
|
84
|
+
fontsize=8, fontweight="bold", color=color)
|
|
85
|
+
|
|
86
|
+
from fig_core import set_ticklabels
|
|
87
|
+
ax.set_xticks(range(n_vars))
|
|
88
|
+
ax.set_yticks(range(n_vars))
|
|
89
|
+
set_ticklabels(ax, labels, "x", cjk_fp, rotation=45)
|
|
90
|
+
set_ticklabels(ax, labels, "y", cjk_fp)
|
|
91
|
+
|
|
92
|
+
ax.set_xlabel("Variable")
|
|
93
|
+
ax.set_ylabel("Variable")
|
|
94
|
+
return im # for colorbar
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class HexbinPlot(ChartPlugin):
|
|
98
|
+
"""Hexagonal binned density scatter for dense point clouds.
|
|
99
|
+
|
|
100
|
+
Useful when a plain scatter is overplotted (tens of thousands of points):
|
|
101
|
+
the bin colour encodes local point density. A 1:1 dashed line and an
|
|
102
|
+
r / RMSE annotation make this a natural model-vs-observation plot.
|
|
103
|
+
"""
|
|
104
|
+
name = "hexbin"
|
|
105
|
+
zh = "六边形分箱密度图"
|
|
106
|
+
kind = "relational"
|
|
107
|
+
required_keys = ("x", "y")
|
|
108
|
+
optional_keys = ("gridsize", "bins", "line", "stats", "xlabel", "ylabel")
|
|
109
|
+
data_format = "both"
|
|
110
|
+
|
|
111
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
112
|
+
x = np.asarray(data["x"], dtype=float).ravel()
|
|
113
|
+
y = np.asarray(data["y"], dtype=float).ravel()
|
|
114
|
+
if len(x) < 2 or len(y) < 2 or len(x) != len(y):
|
|
115
|
+
raise ValueError("hexbin: x and y must be same-length arrays (>=2 points)")
|
|
116
|
+
|
|
117
|
+
gridsize = int(data.get("gridsize", 25))
|
|
118
|
+
cmap = kwargs.get("cmap") or "viridis"
|
|
119
|
+
hb = ax.hexbin(x, y, gridsize=gridsize, cmap=cmap, mincnt=1,
|
|
120
|
+
linewidths=0.2, edgecolors="face")
|
|
121
|
+
|
|
122
|
+
# 1:1 reference line (model == observed)
|
|
123
|
+
if data.get("line", True):
|
|
124
|
+
lo = min(x.min(), y.min())
|
|
125
|
+
hi = max(x.max(), y.max())
|
|
126
|
+
ax.plot([lo, hi], [lo, hi], "r--", lw=1.2, alpha=0.8, label="1:1")
|
|
127
|
+
|
|
128
|
+
# Goodness-of-fit annotation
|
|
129
|
+
if data.get("stats", True):
|
|
130
|
+
r = float(np.corrcoef(x, y)[0, 1])
|
|
131
|
+
rmse = float(np.sqrt(np.mean((y - x) ** 2)))
|
|
132
|
+
fp = _load_cjk_font(cjk_fp)
|
|
133
|
+
ax.text(0.03, 0.97, "r = %.3f\nRMSE = %.3f" % (r, rmse),
|
|
134
|
+
transform=ax.transAxes, va="top", ha="left", fontsize=8,
|
|
135
|
+
fontproperties=fp,
|
|
136
|
+
bbox=dict(boxstyle="round,pad=0.3", fc="white",
|
|
137
|
+
ec="0.6", alpha=0.85))
|
|
138
|
+
|
|
139
|
+
ax.set_xlabel(data.get("xlabel", "X (measured)"))
|
|
140
|
+
ax.set_ylabel(data.get("ylabel", "Y (predicted)"))
|
|
141
|
+
return hb # for colorbar
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ECDFPlot(ChartPlugin):
|
|
145
|
+
"""Empirical cumulative distribution function (multi-group).
|
|
146
|
+
|
|
147
|
+
Inputs (either form):
|
|
148
|
+
series: dict {group_name: [values, ...]} -> one step curve per group
|
|
149
|
+
x: single array of values -> one step curve
|
|
150
|
+
Step curves ('steps-post') let you compare distributions without binning,
|
|
151
|
+
which is the standard tool for model validation / quantile comparisons.
|
|
152
|
+
"""
|
|
153
|
+
name = "ecdf"
|
|
154
|
+
zh = "经验累积分布"
|
|
155
|
+
kind = "statistics"
|
|
156
|
+
required_keys = ()
|
|
157
|
+
optional_keys = ("series", "x", "labels", "xlabel", "ylabel")
|
|
158
|
+
data_format = "both"
|
|
159
|
+
|
|
160
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
161
|
+
series = data.get("series")
|
|
162
|
+
if series:
|
|
163
|
+
keys = list(series.keys())
|
|
164
|
+
labels = data.get("labels") or keys
|
|
165
|
+
for i, key in enumerate(keys):
|
|
166
|
+
vals = np.sort(np.asarray(series[key], dtype=float).ravel())
|
|
167
|
+
if len(vals) == 0:
|
|
168
|
+
continue
|
|
169
|
+
y = np.arange(1, len(vals) + 1) / len(vals)
|
|
170
|
+
color = colors[i % len(colors)] if colors else None
|
|
171
|
+
lbl = labels[i % len(labels)] if labels else str(key)
|
|
172
|
+
ax.plot(vals, y, drawstyle="steps-post", lw=1.6,
|
|
173
|
+
color=color, label=str(lbl))
|
|
174
|
+
elif data.get("x") is not None:
|
|
175
|
+
vals = np.sort(np.asarray(data["x"], dtype=float).ravel())
|
|
176
|
+
if len(vals) == 0:
|
|
177
|
+
raise ValueError("ecdf: x is empty")
|
|
178
|
+
y = np.arange(1, len(vals) + 1) / len(vals)
|
|
179
|
+
ax.plot(vals, y, drawstyle="steps-post", lw=1.6,
|
|
180
|
+
color=colors[0] if colors else None)
|
|
181
|
+
else:
|
|
182
|
+
raise ValueError("ecdf: provide 'series' or 'x'")
|
|
183
|
+
|
|
184
|
+
ax.set_xlabel(data.get("xlabel", "Value"))
|
|
185
|
+
ax.set_ylabel(data.get("ylabel", "Cumulative probability"))
|
|
186
|
+
ax.set_ylim(0, 1.05)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class BlandAltmanPlot(ChartPlugin):
|
|
191
|
+
"""Bland-Altman agreement plot between two measurement methods.
|
|
192
|
+
|
|
193
|
+
Inputs (either form):
|
|
194
|
+
x / y: two same-length arrays of paired measurements
|
|
195
|
+
method1/method2: alternative key names for the two methods
|
|
196
|
+
Plots the mean of the pair vs. their difference, with the mean bias
|
|
197
|
+
(solid line) and the 95% limits of agreement bias +/- 1.96 SD (dashed).
|
|
198
|
+
"""
|
|
199
|
+
name = "bland_altman"
|
|
200
|
+
zh = "Bland-Altman图"
|
|
201
|
+
kind = "statistics"
|
|
202
|
+
required_keys = ()
|
|
203
|
+
optional_keys = ("x", "y", "method1", "method2", "xlabel", "ylabel")
|
|
204
|
+
data_format = "both"
|
|
205
|
+
|
|
206
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
207
|
+
if data.get("x") is not None and data.get("y") is not None:
|
|
208
|
+
m1 = np.asarray(data["x"], dtype=float).ravel()
|
|
209
|
+
m2 = np.asarray(data["y"], dtype=float).ravel()
|
|
210
|
+
elif data.get("method1") is not None and data.get("method2") is not None:
|
|
211
|
+
m1 = np.asarray(data["method1"], dtype=float).ravel()
|
|
212
|
+
m2 = np.asarray(data["method2"], dtype=float).ravel()
|
|
213
|
+
else:
|
|
214
|
+
raise ValueError("bland_altman: provide 'x'+'y' or 'method1'+'method2'")
|
|
215
|
+
if len(m1) < 3 or len(m1) != len(m2):
|
|
216
|
+
raise ValueError("bland_altman: need >=3 same-length paired measurements")
|
|
217
|
+
|
|
218
|
+
mean = (m1 + m2) / 2.0
|
|
219
|
+
diff = m1 - m2
|
|
220
|
+
bias = float(np.mean(diff))
|
|
221
|
+
sd = float(np.std(diff, ddof=1))
|
|
222
|
+
loa_hi = bias + 1.96 * sd
|
|
223
|
+
loa_lo = bias - 1.96 * sd
|
|
224
|
+
|
|
225
|
+
color = colors[0] if colors else None
|
|
226
|
+
ax.scatter(mean, diff, s=40, color=color, edgecolors="black",
|
|
227
|
+
linewidths=0.4, alpha=0.85)
|
|
228
|
+
|
|
229
|
+
ax.axhline(bias, color="#C0392B", lw=1.5, ls="-",
|
|
230
|
+
label="Mean bias %.2f" % bias)
|
|
231
|
+
ax.axhline(loa_hi, color="#2C3E50", lw=1.0, ls="--",
|
|
232
|
+
label="LoA +1.96SD %.2f" % loa_hi)
|
|
233
|
+
ax.axhline(loa_lo, color="#2C3E50", lw=1.0, ls="--",
|
|
234
|
+
label="LoA -1.96SD %.2f" % loa_lo)
|
|
235
|
+
ax.axhline(0, color="gray", lw=0.8, ls=":")
|
|
236
|
+
|
|
237
|
+
ax.set_xlabel(data.get("xlabel", "Mean of two methods"))
|
|
238
|
+
ax.set_ylabel(data.get("ylabel", "Difference (method1 - method2)"))
|
|
239
|
+
return None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Error-bar scatter generator (x-y points with optional xerr / yerr)."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_errorbar(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate x-y error-bar plot.
|
|
8
|
+
|
|
9
|
+
Data schema (JSON):
|
|
10
|
+
{
|
|
11
|
+
"x": [numbers],
|
|
12
|
+
"y": [numbers],
|
|
13
|
+
"xerr": [numbers] (optional),
|
|
14
|
+
"yerr": [numbers] (optional),
|
|
15
|
+
"groups":[labels] (optional)
|
|
16
|
+
}
|
|
17
|
+
At least one of xerr / yerr should be present; otherwise it falls back to a
|
|
18
|
+
plain scatter.
|
|
19
|
+
"""
|
|
20
|
+
x = np.array(data.get("x", []), dtype=float)
|
|
21
|
+
y = np.array(data.get("y", []), dtype=float)
|
|
22
|
+
xerr = data.get("xerr")
|
|
23
|
+
yerr = data.get("yerr")
|
|
24
|
+
groups = data.get("groups")
|
|
25
|
+
fmt = kwargs.get("fmt", 'o')
|
|
26
|
+
|
|
27
|
+
xerr_a = np.array(xerr, dtype=float) if xerr is not None else None
|
|
28
|
+
yerr_a = np.array(yerr, dtype=float) if yerr is not None else None
|
|
29
|
+
|
|
30
|
+
if groups is None:
|
|
31
|
+
ax.errorbar(x, y, xerr=xerr_a, yerr=yerr_a, fmt=fmt, color=colors[0],
|
|
32
|
+
ecolor='gray', capsize=3, elinewidth=1, markersize=6, zorder=3)
|
|
33
|
+
else:
|
|
34
|
+
seen, uniq = {}, []
|
|
35
|
+
for g in groups:
|
|
36
|
+
if g not in seen:
|
|
37
|
+
seen[g] = True
|
|
38
|
+
uniq.append(g)
|
|
39
|
+
for i, g in enumerate(uniq):
|
|
40
|
+
mask = np.array([j for j in range(len(groups)) if groups[j] == g])
|
|
41
|
+
xe = xerr_a[mask] if xerr_a is not None else None
|
|
42
|
+
ye = yerr_a[mask] if yerr_a is not None else None
|
|
43
|
+
c = colors[i % len(colors)]
|
|
44
|
+
ax.errorbar(x[mask], y[mask], xerr=xe, yerr=ye, fmt=fmt, color=c,
|
|
45
|
+
ecolor=c, capsize=3, elinewidth=1, markersize=6,
|
|
46
|
+
label=str(g), zorder=3)
|
|
47
|
+
|
|
48
|
+
ax.xaxis.grid(True, alpha=0.3, linestyle='--')
|
|
49
|
+
return None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Forest plot generator for meta-analysis."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_forest(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate forest plot (meta-analysis style)."""
|
|
8
|
+
labels = data.get("labels", data.get("studies", []))
|
|
9
|
+
estimates = data.get("estimates", [])
|
|
10
|
+
ci_low = data.get("ci_low", [])
|
|
11
|
+
ci_high = data.get("ci_high", [])
|
|
12
|
+
overall = data.get("overall", None)
|
|
13
|
+
ref_line = data.get("ref_line", 0)
|
|
14
|
+
|
|
15
|
+
y_pos = list(range(len(labels)))
|
|
16
|
+
|
|
17
|
+
# Draw CI lines and point estimates
|
|
18
|
+
for i, (est, lo, hi) in enumerate(zip(estimates, ci_low, ci_high)):
|
|
19
|
+
ax.plot([lo, hi], [i, i], '-', color=colors[0], linewidth=1.5, zorder=2)
|
|
20
|
+
ax.scatter(est, i, c=colors[0], s=80, zorder=3, edgecolors='black', linewidth=0.5)
|
|
21
|
+
|
|
22
|
+
# Reference line
|
|
23
|
+
ax.axvline(x=ref_line, color='gray', linestyle='--', linewidth=1, alpha=0.6)
|
|
24
|
+
|
|
25
|
+
# Overall diamond
|
|
26
|
+
if overall:
|
|
27
|
+
oy = len(labels)
|
|
28
|
+
est_o, lo_o, hi_o = overall["estimate"], overall["ci_low"], overall["ci_high"]
|
|
29
|
+
diamond_x = [lo_o, est_o, hi_o, est_o, lo_o]
|
|
30
|
+
diamond_y = [oy, oy - 0.2, oy, oy + 0.2, oy]
|
|
31
|
+
ax.fill(diamond_x, diamond_y, color=colors[1] if len(colors) > 1 else colors[0],
|
|
32
|
+
alpha=0.7, zorder=4)
|
|
33
|
+
|
|
34
|
+
# Set y-axis labels (study names)
|
|
35
|
+
ax.set_yticks(list(y_pos) + ([len(labels)] if overall else []))
|
|
36
|
+
all_labels = list(labels) + (["Overall"] if overall else [])
|
|
37
|
+
from fig_core import set_ticklabels
|
|
38
|
+
set_ticklabels(ax, all_labels, 'y', cjk_fp)
|
|
39
|
+
ax.invert_yaxis()
|
|
40
|
+
|
|
41
|
+
# Place value annotations in a dedicated column to the right of the CI lines.
|
|
42
|
+
# We expand the x-axis so the column stays inside the axes area and is never
|
|
43
|
+
# clipped by the figure edge or tight_layout.
|
|
44
|
+
x_min, x_max = ax.get_xlim()
|
|
45
|
+
x_range = x_max - x_min
|
|
46
|
+
text_x = x_max + x_range * 0.02
|
|
47
|
+
for i, (est, lo, hi) in enumerate(zip(estimates, ci_low, ci_high)):
|
|
48
|
+
ax.text(text_x, i, f'{est:.2f} [{lo:.2f}, {hi:.2f}]',
|
|
49
|
+
va='center', fontsize=8, clip_on=False)
|
|
50
|
+
if overall:
|
|
51
|
+
ax.text(text_x, oy, f'Overall: {est_o:.2f} [{lo_o:.2f}, {hi_o:.2f}]',
|
|
52
|
+
va='center', fontsize=9, fontweight='bold', clip_on=False)
|
|
53
|
+
# Reserve ~40% of the x-range as an annotation gutter.
|
|
54
|
+
ax.set_xlim(x_min, x_max + x_range * 0.45)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Heatmap generator with annotations."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def gen_heatmap(data, ax, colors, cjk_fp=None, **kwargs):
|
|
8
|
+
"""Generate annotated heatmap."""
|
|
9
|
+
matrix = data.get("matrix")
|
|
10
|
+
if matrix is None:
|
|
11
|
+
series = data.get("series", {})
|
|
12
|
+
if series:
|
|
13
|
+
matrix = np.array(list(series.values()))
|
|
14
|
+
else:
|
|
15
|
+
matrix = np.array([])
|
|
16
|
+
|
|
17
|
+
matrix = np.array(matrix, dtype=float)
|
|
18
|
+
row_labels = data.get("row_labels", data.get("labels", []))
|
|
19
|
+
col_labels = data.get("col_labels", [])
|
|
20
|
+
if not col_labels and "series" in data:
|
|
21
|
+
col_labels = data.get("labels", list(data["series"].keys()))
|
|
22
|
+
|
|
23
|
+
cmap = kwargs.get("cmap", "RdBu_r")
|
|
24
|
+
vmin = kwargs.get("vmin", None)
|
|
25
|
+
vmax = kwargs.get("vmax", None)
|
|
26
|
+
annot_fmt = kwargs.get("annot_format", "{:+.1f}")
|
|
27
|
+
center = kwargs.get("center", None)
|
|
28
|
+
|
|
29
|
+
if vmin is None or vmax is None:
|
|
30
|
+
abs_max = max(abs(matrix.min()), abs(matrix.max()))
|
|
31
|
+
vmin = vmin if vmin is not None else -abs_max
|
|
32
|
+
vmax = vmax if vmax is not None else abs_max
|
|
33
|
+
|
|
34
|
+
im = ax.imshow(matrix, cmap=cmap, vmin=vmin, vmax=vmax, aspect='auto')
|
|
35
|
+
|
|
36
|
+
# Text annotations
|
|
37
|
+
for i in range(matrix.shape[0]):
|
|
38
|
+
for j in range(matrix.shape[1]):
|
|
39
|
+
v = matrix[i, j]
|
|
40
|
+
clim = abs(vmin) + abs(vmax) if vmin is not None and vmax is not None else 2
|
|
41
|
+
color = 'white' if abs(v) > clim / 2 * 0.7 else 'black'
|
|
42
|
+
ax.text(j, i, annot_fmt.format(v), ha='center', va='center',
|
|
43
|
+
fontsize=9, fontweight='bold', color=color)
|
|
44
|
+
|
|
45
|
+
ax.set_xticks(range(len(col_labels)))
|
|
46
|
+
ax.set_yticks(range(len(row_labels)))
|
|
47
|
+
from fig_core import set_ticklabels
|
|
48
|
+
set_ticklabels(ax, col_labels, 'x', cjk_fp)
|
|
49
|
+
set_ticklabels(ax, row_labels, 'y', cjk_fp)
|
|
50
|
+
|
|
51
|
+
return im # for colorbar
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Line chart generator with error bands and markers."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_line(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate line chart with optional error bands."""
|
|
8
|
+
labels = data.get("labels", [])
|
|
9
|
+
series = data.get("series", {})
|
|
10
|
+
error_data = data.get("errors", {})
|
|
11
|
+
markers = ['o', 's', 'D', '^', 'v', 'p', '*', 'h', '+', 'x']
|
|
12
|
+
|
|
13
|
+
# Determine x: use labels if numeric, otherwise use index
|
|
14
|
+
numeric_labels = all(isinstance(l, (int, float)) or (isinstance(l, str) and l.replace('.', '', 1).replace('-', '', 1).isdigit())
|
|
15
|
+
for l in labels)
|
|
16
|
+
if numeric_labels and labels:
|
|
17
|
+
x = np.array([float(l) for l in labels])
|
|
18
|
+
else:
|
|
19
|
+
x = np.arange(len(labels)) if labels else np.arange(max(len(v) for v in series.values()))
|
|
20
|
+
|
|
21
|
+
for i, (name, values) in enumerate(series.items()):
|
|
22
|
+
c = colors[i % len(colors)]
|
|
23
|
+
mk = markers[i % len(markers)]
|
|
24
|
+
lw = kwargs.get("linewidth", 2)
|
|
25
|
+
ax.plot(x[:len(values)], values, c=c, marker=mk, markersize=6,
|
|
26
|
+
linewidth=lw, label=name, zorder=3)
|
|
27
|
+
|
|
28
|
+
# Error band
|
|
29
|
+
if name in error_data and error_data[name]:
|
|
30
|
+
errs = error_data[name]
|
|
31
|
+
if isinstance(errs, list) and len(errs) == len(values):
|
|
32
|
+
lower = [v - e for v, e in zip(values, errs)]
|
|
33
|
+
upper = [v + e for v, e in zip(values, errs)]
|
|
34
|
+
ax.fill_between(x[:len(values)], lower, upper, color=c, alpha=0.15, zorder=2)
|
|
35
|
+
|
|
36
|
+
if not numeric_labels and labels:
|
|
37
|
+
ax.set_xticks(x)
|
|
38
|
+
from fig_core import set_ticklabels
|
|
39
|
+
set_ticklabels(ax, labels, 'x', cjk_fp)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PCA biplot generator (sample scores + variable loadings), numpy-only."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_pca(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate a PCA biplot.
|
|
8
|
+
|
|
9
|
+
Data schema (JSON):
|
|
10
|
+
{
|
|
11
|
+
"variables": ["V1", "V2", ...], # column names
|
|
12
|
+
"samples": [[...], [...], ...], # row-wise observations
|
|
13
|
+
"groups": [labels] (optional), # color coding of samples
|
|
14
|
+
"sample_labels": [text] (optional) # per-sample annotation
|
|
15
|
+
}
|
|
16
|
+
Columns are z-scored; SVD yields scores (samples) and loadings (variables).
|
|
17
|
+
PC1/PC2 variance explained is shown on the axes.
|
|
18
|
+
"""
|
|
19
|
+
variables = data.get("variables", [])
|
|
20
|
+
samples = np.array(data.get("samples", []), dtype=float)
|
|
21
|
+
groups = data.get("groups")
|
|
22
|
+
sample_labels = data.get("sample_labels")
|
|
23
|
+
n_comp = int(kwargs.get("components", 2))
|
|
24
|
+
|
|
25
|
+
if samples.ndim != 2 or samples.shape[1] < 2:
|
|
26
|
+
return None
|
|
27
|
+
n, p = samples.shape
|
|
28
|
+
if not variables:
|
|
29
|
+
variables = [f"V{i+1}" for i in range(p)]
|
|
30
|
+
|
|
31
|
+
mean = samples.mean(axis=0)
|
|
32
|
+
std = samples.std(axis=0, ddof=0)
|
|
33
|
+
std[std == 0] = 1.0
|
|
34
|
+
Z = (samples - mean) / std
|
|
35
|
+
|
|
36
|
+
# SVD of standardized data
|
|
37
|
+
U, S, Vt = np.linalg.svd(Z, full_matrices=False)
|
|
38
|
+
scores = Z @ Vt.T # n x p, columns = PC1, PC2, ...
|
|
39
|
+
loadings = Vt.T * S # p x p, columns = component loadings
|
|
40
|
+
explained = (S ** 2) / (S ** 2).sum()
|
|
41
|
+
|
|
42
|
+
pc1 = scores[:, 0]
|
|
43
|
+
pc2 = scores[:, 1]
|
|
44
|
+
max_score = max(np.abs(scores[:, :2]).max(), 1e-9)
|
|
45
|
+
max_loading = np.abs(loadings[:, :2]).max() if p > 0 else 1e-9
|
|
46
|
+
scale = 0.8 * max_score / max_loading if max_loading > 0 else 1.0
|
|
47
|
+
|
|
48
|
+
if groups is None:
|
|
49
|
+
ax.scatter(pc1, pc2, s=55, color=colors[0], edgecolors='white', alpha=0.85, zorder=3)
|
|
50
|
+
else:
|
|
51
|
+
seen, uniq = {}, []
|
|
52
|
+
for g in groups:
|
|
53
|
+
if g not in seen:
|
|
54
|
+
seen[g] = True
|
|
55
|
+
uniq.append(g)
|
|
56
|
+
for i, g in enumerate(uniq):
|
|
57
|
+
mask = np.array([j for j in range(len(groups)) if groups[j] == g])
|
|
58
|
+
ax.scatter(pc1[mask], pc2[mask], s=55, color=colors[i % len(colors)],
|
|
59
|
+
edgecolors='white', alpha=0.85, label=str(g), zorder=3)
|
|
60
|
+
|
|
61
|
+
texts = []
|
|
62
|
+
if sample_labels is not None:
|
|
63
|
+
for xi, yi, lb in zip(pc1, pc2, sample_labels):
|
|
64
|
+
t = ax.annotate(str(lb), (xi, yi), textcoords='offset points', xytext=(3, 3),
|
|
65
|
+
fontsize=6, color='gray')
|
|
66
|
+
texts.append(t)
|
|
67
|
+
|
|
68
|
+
# variable loadings as arrows
|
|
69
|
+
for j, var in enumerate(variables):
|
|
70
|
+
lx = loadings[j, 0] * scale
|
|
71
|
+
ly = loadings[j, 1] * scale
|
|
72
|
+
ax.arrow(0, 0, lx, ly, color='#333333', width=0.008, head_width=0.05,
|
|
73
|
+
length_includes_head=True, zorder=2)
|
|
74
|
+
# anchor label just beyond the arrow tip, pushed a little further outward
|
|
75
|
+
norm = float(np.hypot(lx, ly))
|
|
76
|
+
if norm > 0:
|
|
77
|
+
ox, oy = lx * (1 + 0.08 / norm), ly * (1 + 0.08 / norm)
|
|
78
|
+
else:
|
|
79
|
+
ox, oy = lx, ly
|
|
80
|
+
ha = 'left' if lx >= 0 else 'right'
|
|
81
|
+
t = ax.text(ox, oy, str(var), fontsize=9, color='#333333',
|
|
82
|
+
ha=ha, va='center', zorder=4)
|
|
83
|
+
texts.append(t)
|
|
84
|
+
|
|
85
|
+
from fig_core import separate_labels
|
|
86
|
+
separate_labels(ax, texts)
|
|
87
|
+
|
|
88
|
+
# Add an extra margin so pushed labels are not clipped by the axes edge.
|
|
89
|
+
xlim = ax.get_xlim()
|
|
90
|
+
ylim = ax.get_ylim()
|
|
91
|
+
xpad = (xlim[1] - xlim[0]) * 0.08
|
|
92
|
+
ypad = (ylim[1] - ylim[0]) * 0.08
|
|
93
|
+
ax.set_xlim(xlim[0] - xpad, xlim[1] + xpad)
|
|
94
|
+
ax.set_ylim(ylim[0] - ypad, ylim[1] + ypad)
|
|
95
|
+
|
|
96
|
+
ax.axhline(0, color='gray', linewidth=0.6, alpha=0.5, zorder=1)
|
|
97
|
+
ax.axvline(0, color='gray', linewidth=0.6, alpha=0.5, zorder=1)
|
|
98
|
+
ax.set_xlabel(f"PC1 ({explained[0]*100:.1f}%)")
|
|
99
|
+
ax.set_ylabel(f"PC2 ({explained[1]*100:.1f}%)")
|
|
100
|
+
ax.xaxis.grid(True, alpha=0.3, linestyle='--')
|
|
101
|
+
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pie chart and donut chart generator."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_pie(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate pie chart or donut chart."""
|
|
8
|
+
labels = data.get("labels", [])
|
|
9
|
+
values = data.get("values", [])
|
|
10
|
+
|
|
11
|
+
if not values and data.get("series"):
|
|
12
|
+
first_key = list(data["series"].keys())[0]
|
|
13
|
+
values = data["series"][first_key]
|
|
14
|
+
|
|
15
|
+
if not values:
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
donut = kwargs.get("donut", False)
|
|
19
|
+
wedgeprops = {"width": 0.4, "edgecolor": "white", "linewidth": 1} if donut else \
|
|
20
|
+
{"edgecolor": "white", "linewidth": 1}
|
|
21
|
+
|
|
22
|
+
show_pct = kwargs.get("show_pct", True)
|
|
23
|
+
show_labels = kwargs.get("show_labels", True)
|
|
24
|
+
pctdistance = 0.8 if donut else 0.6
|
|
25
|
+
textprops = {"fontsize": 9}
|
|
26
|
+
|
|
27
|
+
if cjk_fp:
|
|
28
|
+
textprops["fontproperties"] = cjk_fp
|
|
29
|
+
|
|
30
|
+
autopct = '%1.1f%%' if show_pct else None
|
|
31
|
+
if autopct:
|
|
32
|
+
wedges, texts, autotexts = ax.pie(
|
|
33
|
+
values, labels=labels if show_labels else None,
|
|
34
|
+
autopct=autopct, startangle=90,
|
|
35
|
+
colors=colors[:len(values)],
|
|
36
|
+
wedgeprops=wedgeprops,
|
|
37
|
+
pctdistance=pctdistance,
|
|
38
|
+
textprops=textprops
|
|
39
|
+
)
|
|
40
|
+
for t in autotexts:
|
|
41
|
+
t.set_fontsize(8)
|
|
42
|
+
else:
|
|
43
|
+
wedges, texts = ax.pie(
|
|
44
|
+
values, labels=labels if show_labels else None,
|
|
45
|
+
startangle=90,
|
|
46
|
+
colors=colors[:len(values)],
|
|
47
|
+
wedgeprops=wedgeprops,
|
|
48
|
+
textprops=textprops
|
|
49
|
+
)[:2]
|
|
50
|
+
|
|
51
|
+
# Equal aspect ratio
|
|
52
|
+
ax.set_aspect('equal')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Radar chart (spider/radar plot) generator."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_radar(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate radar/spider chart."""
|
|
8
|
+
labels = data.get("labels", [])
|
|
9
|
+
series = data.get("series", {})
|
|
10
|
+
|
|
11
|
+
if not labels or not series:
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
n_vars = len(labels)
|
|
15
|
+
angles = np.linspace(0, 2 * np.pi, n_vars, endpoint=False).tolist()
|
|
16
|
+
angles += angles[:1] # close the loop
|
|
17
|
+
|
|
18
|
+
# Convert to polar
|
|
19
|
+
ax.set_theta_offset(np.pi / 2)
|
|
20
|
+
ax.set_theta_direction(-1)
|
|
21
|
+
|
|
22
|
+
for i, (name, values) in enumerate(series.items()):
|
|
23
|
+
vals = list(values) + [values[0]] # close the loop
|
|
24
|
+
c = colors[i % len(colors)]
|
|
25
|
+
ax.fill(angles, vals, alpha=0.15, color=c)
|
|
26
|
+
ax.plot(angles, vals, 'o-', linewidth=1.5, color=c, label=name, markersize=4)
|
|
27
|
+
ax.fill(angles, vals, alpha=0.05, color=c)
|
|
28
|
+
|
|
29
|
+
ax.set_xticks(angles[:-1])
|
|
30
|
+
from fig_core import set_ticklabels
|
|
31
|
+
set_ticklabels(ax, labels, 'x', cjk_fp)
|
|
32
|
+
ax.set_ylim(bottom=0)
|