like-sci-figures 0.0.0-stage → 1.0.1
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 +196 -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,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Biomedical chart types: volcano, manhattan, kaplan_meier, roc.
|
|
3
|
+
|
|
4
|
+
These chart types are commonly used in genomics, GWAS, clinical trials, and
|
|
5
|
+
machine learning diagnostics. Each is implemented as a ChartPlugin subclass
|
|
6
|
+
so they auto-register with the palette and example systems.
|
|
7
|
+
"""
|
|
8
|
+
import numpy as np
|
|
9
|
+
from chart_plugin import ChartPlugin
|
|
10
|
+
from fig_core import set_axis_labels, set_title
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_cjk_font(font_path):
|
|
14
|
+
"""Load a CJK font and return the font properties, or None."""
|
|
15
|
+
if not font_path:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
from matplotlib import font_manager as fm
|
|
19
|
+
fp = fm.FontProperties(fname=font_path)
|
|
20
|
+
return fp
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VolcanoPlot(ChartPlugin):
|
|
26
|
+
"""Volcano plot for differential expression analysis.
|
|
27
|
+
|
|
28
|
+
X-axis: log2(fold change)
|
|
29
|
+
Y-axis: -log10(p-value)
|
|
30
|
+
Points colored by significance threshold.
|
|
31
|
+
"""
|
|
32
|
+
name = "volcano"
|
|
33
|
+
zh = "火山图"
|
|
34
|
+
kind = "statistics"
|
|
35
|
+
required_keys = ("log2fc", "pvalue")
|
|
36
|
+
optional_keys = ("labels", "genes", "threshold_fc", "threshold_p")
|
|
37
|
+
data_format = "json"
|
|
38
|
+
|
|
39
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
40
|
+
from matplotlib.lines import Line2D
|
|
41
|
+
|
|
42
|
+
x = np.asarray(data["log2fc"], dtype=float)
|
|
43
|
+
y = np.asarray(data["pvalue"], dtype=float)
|
|
44
|
+
labels = data.get("labels") or data.get("genes") or []
|
|
45
|
+
fc_thresh = float(data.get("threshold_fc", 1.0))
|
|
46
|
+
p_thresh = float(data.get("threshold_p", 0.05))
|
|
47
|
+
# Convert p-value to -log10 scale.
|
|
48
|
+
y_neglog = -np.log10(np.clip(y, 1e-300, 1.0))
|
|
49
|
+
|
|
50
|
+
# Significance thresholds.
|
|
51
|
+
up_mask = (x > fc_thresh) & (y < p_thresh)
|
|
52
|
+
down_mask = (x < -fc_thresh) & (y < p_thresh)
|
|
53
|
+
ns_mask = ~up_mask & ~down_mask
|
|
54
|
+
|
|
55
|
+
# Plot non-significant points first (background).
|
|
56
|
+
ax.scatter(x[ns_mask], y_neglog[ns_mask], s=20, alpha=0.6,
|
|
57
|
+
color=colors[3] if len(colors) > 3 else "#999999",
|
|
58
|
+
label="Non-significant")
|
|
59
|
+
# Plot significant points on top.
|
|
60
|
+
ax.scatter(x[up_mask], y_neglog[up_mask], s=40,
|
|
61
|
+
color=colors[1] if len(colors) > 1 else "#D62728",
|
|
62
|
+
edgecolors="black", linewidths=0.5, label="Up-regulated")
|
|
63
|
+
ax.scatter(x[down_mask], y_neglog[down_mask], s=40,
|
|
64
|
+
color=colors[0] if len(colors) > 0 else "#1F77B4",
|
|
65
|
+
edgecolors="black", linewidths=0.5, label="Down-regulated")
|
|
66
|
+
|
|
67
|
+
# Threshold lines.
|
|
68
|
+
p_line = -np.log10(p_thresh)
|
|
69
|
+
ax.axhline(p_line, color="grey", linestyle="--", linewidth=0.8, alpha=0.6)
|
|
70
|
+
ax.axvline(fc_thresh, color="grey", linestyle="--", linewidth=0.8, alpha=0.6)
|
|
71
|
+
ax.axvline(-fc_thresh, color="grey", linestyle="--", linewidth=0.8, alpha=0.6)
|
|
72
|
+
|
|
73
|
+
# Label top points.
|
|
74
|
+
if labels and len(labels) > 0:
|
|
75
|
+
top_n = 10
|
|
76
|
+
top_idx = np.argsort(y_neglog)[-top_n:]
|
|
77
|
+
fp = _load_cjk_font(cjk_fp)
|
|
78
|
+
for idx in top_idx:
|
|
79
|
+
ax.annotate(
|
|
80
|
+
str(labels[idx]),
|
|
81
|
+
(x[idx], y_neglog[idx]),
|
|
82
|
+
textcoords="offset points", xytext=(5, 5),
|
|
83
|
+
fontsize=7, fontproperties=fp,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
ax.set_xlabel("log2(Fold Change)")
|
|
87
|
+
ax.set_ylabel("-log10(p-value)")
|
|
88
|
+
# Legend is placed outside the axes by finalize_and_save.
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ManhattanPlot(ChartPlugin):
|
|
93
|
+
"""Manhattan plot for GWAS (Genome-Wide Association Study).
|
|
94
|
+
|
|
95
|
+
X-axis: genomic position (chr:position)
|
|
96
|
+
Y-axis: -log10(p-value)
|
|
97
|
+
Alternating chromosome coloring.
|
|
98
|
+
"""
|
|
99
|
+
name = "manhattan"
|
|
100
|
+
zh = "曼哈顿图"
|
|
101
|
+
kind = "specialized"
|
|
102
|
+
required_keys = ("chr", "pos", "pvalue")
|
|
103
|
+
optional_keys = ("labels", "snp", "threshold")
|
|
104
|
+
data_format = "json"
|
|
105
|
+
|
|
106
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
107
|
+
chr_arr = np.asarray(data["chr"])
|
|
108
|
+
pos_arr = np.asarray(data["pos"], dtype=float)
|
|
109
|
+
p_arr = np.asarray(data["pvalue"], dtype=float)
|
|
110
|
+
labels = data.get("labels") or data.get("snp") or []
|
|
111
|
+
thresh_p = float(data.get("threshold", 5e-8))
|
|
112
|
+
|
|
113
|
+
y_neglog = -np.log10(np.clip(p_arr, 1e-300, 1.0))
|
|
114
|
+
|
|
115
|
+
# Create cumulative x-axis: chr blocks with gaps.
|
|
116
|
+
chr_unique = np.unique(chr_arr)
|
|
117
|
+
chr_block_width = 1.0
|
|
118
|
+
gap = 0.1
|
|
119
|
+
x = np.zeros(len(chr_arr))
|
|
120
|
+
chr_start = {}
|
|
121
|
+
for i, chr_name in enumerate(chr_unique):
|
|
122
|
+
chr_start[chr_name] = i * (chr_block_width + gap)
|
|
123
|
+
mask = chr_arr == chr_name
|
|
124
|
+
pos_in_chr = pos_arr[mask]
|
|
125
|
+
pos_range = pos_in_chr.max() - pos_in_chr.min()
|
|
126
|
+
pos_scaled = (pos_in_chr - pos_in_chr.min()) / (pos_range + 1) * chr_block_width
|
|
127
|
+
x[mask] = chr_start[chr_name] + pos_scaled
|
|
128
|
+
|
|
129
|
+
# Color by chromosome (alternating).
|
|
130
|
+
for i, chr_name in enumerate(chr_unique):
|
|
131
|
+
mask = chr_arr == chr_name
|
|
132
|
+
color = colors[i % len(colors)] if colors else "#1F77B4"
|
|
133
|
+
ax.scatter(x[mask], y_neglog[mask], s=8, color=color,
|
|
134
|
+
edgecolors="none", alpha=0.8)
|
|
135
|
+
|
|
136
|
+
# Threshold line.
|
|
137
|
+
thresh_line = -np.log10(thresh_p)
|
|
138
|
+
ax.axhline(thresh_line, color="red", linestyle="--", linewidth=1.0)
|
|
139
|
+
|
|
140
|
+
# Chromosome labels.
|
|
141
|
+
fp = _load_cjk_font(cjk_fp)
|
|
142
|
+
for i, chr_name in enumerate(chr_unique):
|
|
143
|
+
ax.text(chr_start[chr_name] + chr_block_width / 2, 0,
|
|
144
|
+
str(chr_name), ha="center", va="bottom", fontsize=7,
|
|
145
|
+
fontproperties=fp)
|
|
146
|
+
|
|
147
|
+
# Label top SNPs.
|
|
148
|
+
if labels and len(labels) > 0:
|
|
149
|
+
top_n = 15
|
|
150
|
+
top_idx = np.argsort(y_neglog)[-top_n:]
|
|
151
|
+
for idx in top_idx:
|
|
152
|
+
ax.annotate(
|
|
153
|
+
str(labels[idx]),
|
|
154
|
+
(x[idx], y_neglog[idx]),
|
|
155
|
+
textcoords="offset points", xytext=(3, 3),
|
|
156
|
+
fontsize=6, rotation=45, fontproperties=fp,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
ax.set_xlabel("Genomic Position")
|
|
160
|
+
ax.set_ylabel("-log10(p-value)")
|
|
161
|
+
ax.set_yticks(ax.get_yticks())
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class KaplanMeierCurve(ChartPlugin):
|
|
166
|
+
"""Kaplan-Meier survival curve.
|
|
167
|
+
|
|
168
|
+
X-axis: time
|
|
169
|
+
Y-axis: survival probability
|
|
170
|
+
Step function with confidence bands.
|
|
171
|
+
"""
|
|
172
|
+
name = "kaplan_meier"
|
|
173
|
+
zh = "KM生存曲线"
|
|
174
|
+
kind = "statistics"
|
|
175
|
+
required_keys = ("time", "event")
|
|
176
|
+
optional_keys = ("labels", "group", "group_labels", "ci")
|
|
177
|
+
data_format = "json"
|
|
178
|
+
|
|
179
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
180
|
+
# If grouped, plot each group separately.
|
|
181
|
+
if "group" in data:
|
|
182
|
+
groups = data["group"]
|
|
183
|
+
group_labels = data.get("group_labels", [str(g) for g in sorted(set(groups))])
|
|
184
|
+
unique_groups = sorted(set(groups))
|
|
185
|
+
for i, grp in enumerate(unique_groups):
|
|
186
|
+
mask = np.array(groups) == grp
|
|
187
|
+
sub_data = {
|
|
188
|
+
"time": np.asarray(data["time"])[mask],
|
|
189
|
+
"event": np.asarray(data["event"])[mask],
|
|
190
|
+
}
|
|
191
|
+
self._plot_km(ax, sub_data, colors, i, group_labels[i % len(group_labels)])
|
|
192
|
+
else:
|
|
193
|
+
self._plot_km(ax, data, colors, 0, "Survival")
|
|
194
|
+
|
|
195
|
+
ax.set_xlabel("Time")
|
|
196
|
+
ax.set_ylabel("Survival Probability")
|
|
197
|
+
ax.set_ylim(0, 1.05)
|
|
198
|
+
# Legend is placed outside the axes by finalize_and_save.
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _plot_km(self, ax, data, colors, color_idx, label):
|
|
202
|
+
"""Compute and plot a single KM curve."""
|
|
203
|
+
time = np.asarray(data["time"], dtype=float)
|
|
204
|
+
event = np.asarray(data["event"], dtype=float)
|
|
205
|
+
|
|
206
|
+
# Sort by time.
|
|
207
|
+
sort_idx = np.argsort(time)
|
|
208
|
+
time_sorted = time[sort_idx]
|
|
209
|
+
event_sorted = event[sort_idx]
|
|
210
|
+
|
|
211
|
+
# Compute KM estimate.
|
|
212
|
+
n_at_risk = len(time_sorted)
|
|
213
|
+
survival = np.ones(n_at_risk)
|
|
214
|
+
ci_lower = np.ones(n_at_risk)
|
|
215
|
+
ci_upper = np.ones(n_at_risk)
|
|
216
|
+
|
|
217
|
+
for i in range(n_at_risk):
|
|
218
|
+
if i > 0:
|
|
219
|
+
at_risk = n_at_risk - i
|
|
220
|
+
events = event_sorted[i]
|
|
221
|
+
if at_risk > 0:
|
|
222
|
+
survival[i] = survival[i - 1] * (1 - events / at_risk)
|
|
223
|
+
else:
|
|
224
|
+
survival[i] = survival[i - 1]
|
|
225
|
+
|
|
226
|
+
# Step plot.
|
|
227
|
+
color = colors[color_idx % len(colors)] if colors else "#1F77B4"
|
|
228
|
+
ax.step(time_sorted, survival, where="post", color=color, linewidth=2, label=label)
|
|
229
|
+
|
|
230
|
+
# Confidence interval (approximate).
|
|
231
|
+
if n_at_risk > 5:
|
|
232
|
+
se = np.sqrt(survival * (1 - survival) / np.maximum(n_at_risk - np.arange(n_at_risk), 1))
|
|
233
|
+
ci = 1.96 * se
|
|
234
|
+
ax.fill_between(time_sorted, np.maximum(survival - ci, 0),
|
|
235
|
+
np.minimum(survival + ci, 1),
|
|
236
|
+
color=color, alpha=0.15)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ROCCurve(ChartPlugin):
|
|
241
|
+
"""ROC (Receiver Operating Characteristic) curve.
|
|
242
|
+
|
|
243
|
+
X-axis: False Positive Rate (FPR)
|
|
244
|
+
Y-axis: True Positive Rate (TPR)
|
|
245
|
+
Multiple curves for different classifiers, with AUC annotation.
|
|
246
|
+
"""
|
|
247
|
+
name = "roc"
|
|
248
|
+
zh = "ROC曲线"
|
|
249
|
+
kind = "statistics"
|
|
250
|
+
required_keys = ("fpr", "tpr")
|
|
251
|
+
optional_keys = ("labels", "auc", "y_score", "y_true")
|
|
252
|
+
data_format = "json"
|
|
253
|
+
|
|
254
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
255
|
+
# Handle single or multiple ROC curves.
|
|
256
|
+
fpr_data = data["fpr"]
|
|
257
|
+
tpr_data = data["tpr"]
|
|
258
|
+
labels = data.get("labels") or []
|
|
259
|
+
auc_data = data.get("auc") or []
|
|
260
|
+
|
|
261
|
+
# If fpr/tpr are dicts (multi-curve), iterate.
|
|
262
|
+
if isinstance(fpr_data, dict):
|
|
263
|
+
for i, (name, fpr_list) in enumerate(fpr_data.items()):
|
|
264
|
+
tpr_list = tpr_data.get(name, [])
|
|
265
|
+
auc_val = auc_data.get(name, None) if isinstance(auc_data, dict) else None
|
|
266
|
+
self._plot_roc(ax, fpr_list, tpr_list, colors, i, name, auc_val)
|
|
267
|
+
else:
|
|
268
|
+
# Single curve.
|
|
269
|
+
fpr_list = fpr_data
|
|
270
|
+
tpr_list = tpr_data
|
|
271
|
+
label = labels[0] if labels else "Classifier"
|
|
272
|
+
auc_val = auc_data[0] if isinstance(auc_data, list) and len(auc_data) > 0 else None
|
|
273
|
+
self._plot_roc(ax, fpr_list, tpr_list, colors, 0, label, auc_val)
|
|
274
|
+
|
|
275
|
+
ax.plot([0, 1], [0, 1], "k--", linewidth=0.8, alpha=0.6, label="Random")
|
|
276
|
+
ax.set_xlabel("False Positive Rate (FPR)")
|
|
277
|
+
ax.set_ylabel("True Positive Rate (TPR)")
|
|
278
|
+
ax.set_xlim(-0.01, 1.01)
|
|
279
|
+
ax.set_ylim(-0.01, 1.01)
|
|
280
|
+
# Legend is placed outside the axes by finalize_and_save.
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def _plot_roc(self, ax, fpr, tpr, colors, color_idx, label, auc_val):
|
|
284
|
+
"""Plot a single ROC curve."""
|
|
285
|
+
color = colors[color_idx % len(colors)] if colors else "#1F77B4"
|
|
286
|
+
auc_text = f" AUC={auc_val:.3f}" if auc_val else ""
|
|
287
|
+
ax.plot(fpr, tpr, color=color, linewidth=2,
|
|
288
|
+
label=f"{label}{auc_text}")
|
|
289
|
+
return None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Box plot and violin plot generator."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_box(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate box plot with jitter points."""
|
|
8
|
+
labels = data.get("labels", [])
|
|
9
|
+
series = data.get("series", {})
|
|
10
|
+
|
|
11
|
+
positions = list(range(len(series)))
|
|
12
|
+
bp = ax.boxplot(list(series.values()), positions=positions, widths=0.5,
|
|
13
|
+
patch_artist=True, showfliers=True, flierprops={'marker': 'o', 'alpha': 0.5})
|
|
14
|
+
|
|
15
|
+
for i, (patch, name) in enumerate(zip(bp['boxes'], series.keys())):
|
|
16
|
+
patch.set_facecolor(colors[i % len(colors)])
|
|
17
|
+
patch.set_alpha(0.6)
|
|
18
|
+
|
|
19
|
+
# Jitter points
|
|
20
|
+
rng = np.random.default_rng(42)
|
|
21
|
+
for i, (name, values) in enumerate(series.items()):
|
|
22
|
+
vals = np.array(values, dtype=float)
|
|
23
|
+
jitter_x = positions[i] + rng.normal(0, 0.06, len(vals))
|
|
24
|
+
ax.scatter(jitter_x, vals, s=18, c=colors[i % len(colors)], alpha=0.5,
|
|
25
|
+
zorder=3, edgecolors='none')
|
|
26
|
+
|
|
27
|
+
ax.set_xticks(positions)
|
|
28
|
+
box_labels = labels if labels and len(labels) == len(series) else list(series.keys())
|
|
29
|
+
from fig_core import set_ticklabels
|
|
30
|
+
set_ticklabels(ax, box_labels, 'x', cjk_fp)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gen_violin(data, ax, colors, cjk_fp=None, **kwargs):
|
|
34
|
+
"""Generate violin plot with inner box."""
|
|
35
|
+
labels = data.get("labels", [])
|
|
36
|
+
series = data.get("series", {})
|
|
37
|
+
|
|
38
|
+
positions = list(range(len(series)))
|
|
39
|
+
values_list = list(series.values())
|
|
40
|
+
|
|
41
|
+
vp = ax.violinplot(values_list, positions=positions, widths=0.6,
|
|
42
|
+
showmeans=kwargs.get("show_means", True),
|
|
43
|
+
showmedians=kwargs.get("show_medians", True),
|
|
44
|
+
showextrema=kwargs.get("show_extrema", True))
|
|
45
|
+
|
|
46
|
+
for i, body in enumerate(vp['bodies']):
|
|
47
|
+
body.set_facecolor(colors[i % len(colors)])
|
|
48
|
+
body.set_alpha(0.6)
|
|
49
|
+
body.set_edgecolor(colors[i % len(colors)])
|
|
50
|
+
body.set_linewidth(1)
|
|
51
|
+
|
|
52
|
+
for part in ['cmeans', 'cmedians', 'cmins', 'cmaxes', 'cbars']:
|
|
53
|
+
if part in vp:
|
|
54
|
+
vp[part].set_color('#333333')
|
|
55
|
+
vp[part].set_linewidth(1)
|
|
56
|
+
|
|
57
|
+
ax.set_xticks(positions)
|
|
58
|
+
violin_labels = labels if labels and len(labels) == len(series) else list(series.keys())
|
|
59
|
+
from fig_core import set_ticklabels
|
|
60
|
+
set_ticklabels(ax, violin_labels, 'x', cjk_fp)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bubble chart generator: scatter with size-encoding of a third variable."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_bubble(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate bubble chart.
|
|
8
|
+
|
|
9
|
+
Data schema (JSON):
|
|
10
|
+
{
|
|
11
|
+
"x": [numbers],
|
|
12
|
+
"y": [numbers],
|
|
13
|
+
"sizes": [numbers], # REQUIRED — drives point area
|
|
14
|
+
"groups": [labels] (optional), # color coding
|
|
15
|
+
"labels": [text] (optional) # per-point text annotation
|
|
16
|
+
}
|
|
17
|
+
Point area is scaled linearly between a min/max pixel range so the largest
|
|
18
|
+
value gets the biggest bubble.
|
|
19
|
+
"""
|
|
20
|
+
x = np.array(data.get("x", []), dtype=float)
|
|
21
|
+
y = np.array(data.get("y", []), dtype=float)
|
|
22
|
+
sizes = data.get("sizes")
|
|
23
|
+
groups = data.get("groups")
|
|
24
|
+
point_labels = data.get("labels")
|
|
25
|
+
|
|
26
|
+
if sizes is None:
|
|
27
|
+
sizes = np.ones_like(x) * 80
|
|
28
|
+
sizes = np.array(sizes, dtype=float)
|
|
29
|
+
|
|
30
|
+
# Map value -> display area (sqrt for perceptual linearity handled by s=area)
|
|
31
|
+
smin, smax = 30, 700
|
|
32
|
+
if sizes.size and (sizes.max() - sizes.min()) > 0:
|
|
33
|
+
s_norm = (sizes - sizes.min()) / (sizes.max() - sizes.min()) * (smax - smin) + smin
|
|
34
|
+
else:
|
|
35
|
+
s_norm = np.full_like(sizes, (smin + smax) / 2, dtype=float)
|
|
36
|
+
|
|
37
|
+
if groups is None:
|
|
38
|
+
ax.scatter(x, y, s=s_norm, color=colors[0], alpha=0.55,
|
|
39
|
+
edgecolors='white', linewidth=0.6, zorder=3)
|
|
40
|
+
else:
|
|
41
|
+
seen, uniq = {}, []
|
|
42
|
+
for g in groups:
|
|
43
|
+
if g not in seen:
|
|
44
|
+
seen[g] = True
|
|
45
|
+
uniq.append(g)
|
|
46
|
+
for i, g in enumerate(uniq):
|
|
47
|
+
mask = np.array([j for j in range(len(groups)) if groups[j] == g])
|
|
48
|
+
ax.scatter(x[mask], y[mask], s=s_norm[mask], color=colors[i % len(colors)],
|
|
49
|
+
alpha=0.55, edgecolors='white', linewidth=0.6, label=str(g), zorder=3)
|
|
50
|
+
|
|
51
|
+
if point_labels is not None:
|
|
52
|
+
xmid = float(np.mean(x)) if x.size else 0.0
|
|
53
|
+
ymid = float(np.mean(y)) if y.size else 0.0
|
|
54
|
+
for xi, yi, lb in zip(x, y, point_labels):
|
|
55
|
+
# point labels inward on whichever side is closest to the edge
|
|
56
|
+
dx = 4 if xi <= xmid else -4
|
|
57
|
+
dy = 4 if yi <= ymid else -4
|
|
58
|
+
ax.annotate(str(lb), (xi, yi), textcoords='offset points', xytext=(dx, dy),
|
|
59
|
+
ha='left' if dx > 0 else 'right',
|
|
60
|
+
va='bottom' if dy > 0 else 'top',
|
|
61
|
+
fontsize=7, color='gray')
|
|
62
|
+
|
|
63
|
+
ax.margins(0.12) # headroom so large edge bubbles are not clipped
|
|
64
|
+
ax.xaxis.grid(True, alpha=0.3, linestyle='--')
|
|
65
|
+
return None
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Core engine: unified figure creation, style application, and saving."""
|
|
3
|
+
import os, sys
|
|
4
|
+
import matplotlib
|
|
5
|
+
matplotlib.use('Agg')
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import matplotlib.font_manager as fm
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
11
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
12
|
+
|
|
13
|
+
from style_manager import setup_style, list_styles
|
|
14
|
+
from font_detector import detect_cjk_font
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def has_cjk(text):
|
|
18
|
+
"""Check if text contains CJK characters."""
|
|
19
|
+
if not text:
|
|
20
|
+
return False
|
|
21
|
+
return any('\u4e00' <= ch <= '\u9fff' or '\u3400' <= ch <= '\u4dbf' for ch in str(text))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_cjk_font(font_path=None):
|
|
25
|
+
"""Load CJK font. Returns (FontProperties, font_name) or (None, None)."""
|
|
26
|
+
if font_path:
|
|
27
|
+
if os.path.exists(font_path):
|
|
28
|
+
fm.fontManager.addfont(font_path)
|
|
29
|
+
fp = fm.FontProperties(fname=font_path)
|
|
30
|
+
return fp, fp.get_name()
|
|
31
|
+
else:
|
|
32
|
+
detected = detect_cjk_font()
|
|
33
|
+
if detected and os.path.exists(detected):
|
|
34
|
+
fm.fontManager.addfont(detected)
|
|
35
|
+
fp = fm.FontProperties(fname=detected)
|
|
36
|
+
return fp, fp.get_name()
|
|
37
|
+
return None, None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scan_cjk(obj):
|
|
41
|
+
"""Recursively scan any JSON-like structure for CJK text (dict keys included).
|
|
42
|
+
|
|
43
|
+
Covers every display field generically — labels, series names, titles,
|
|
44
|
+
nodes, variables, model names, subplot titles, etc. Number-only containers
|
|
45
|
+
short-circuit quickly.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(obj, str):
|
|
48
|
+
return has_cjk(obj)
|
|
49
|
+
if isinstance(obj, dict):
|
|
50
|
+
return any(_scan_cjk(k) or _scan_cjk(v) for k, v in obj.items())
|
|
51
|
+
if isinstance(obj, (list, tuple)):
|
|
52
|
+
return any(_scan_cjk(v) for v in obj)
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def needs_cjk(data, title="", xlabel="", ylabel=""):
|
|
57
|
+
"""Auto-detect if any display text contains CJK characters."""
|
|
58
|
+
for t in [title, xlabel, ylabel]:
|
|
59
|
+
if t and has_cjk(t):
|
|
60
|
+
return True
|
|
61
|
+
return _scan_cjk(data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def apply_base_style(ax, grid=True):
|
|
65
|
+
"""Apply common styling to axes."""
|
|
66
|
+
ax.spines["top"].set_visible(False)
|
|
67
|
+
ax.spines["right"].set_visible(False)
|
|
68
|
+
if grid:
|
|
69
|
+
ax.yaxis.grid(True, alpha=0.3, linestyle='--')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_axis_labels(ax, xlabel, ylabel, cjk_fp=None):
|
|
73
|
+
"""Set axis labels with optional CJK font."""
|
|
74
|
+
if xlabel:
|
|
75
|
+
ax.set_xlabel(xlabel, fontproperties=cjk_fp if cjk_fp and has_cjk(xlabel) else None)
|
|
76
|
+
if ylabel:
|
|
77
|
+
ax.set_ylabel(ylabel, fontproperties=cjk_fp if cjk_fp and has_cjk(ylabel) else None)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def set_title(ax, title, cjk_fp=None):
|
|
81
|
+
"""Set figure title with optional CJK font."""
|
|
82
|
+
if title:
|
|
83
|
+
title_text = title.replace('\\n', '\n')
|
|
84
|
+
ax.set_title(title_text, fontweight='bold', pad=12,
|
|
85
|
+
fontproperties=cjk_fp if cjk_fp and has_cjk(title_text) else None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set_ticklabels(ax, labels, axis='x', cjk_fp=None, rotation=0):
|
|
89
|
+
"""Set tick labels with optional CJK font and rotation."""
|
|
90
|
+
use_cjk = cjk_fp and any(has_cjk(str(l)) for l in labels)
|
|
91
|
+
if axis == 'x':
|
|
92
|
+
ax.set_xticklabels(labels, fontproperties=cjk_fp if use_cjk else None, rotation=rotation)
|
|
93
|
+
else:
|
|
94
|
+
ax.set_yticklabels(labels, fontproperties=cjk_fp if use_cjk else None)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def separate_labels(ax, texts, max_iter=80, clamp=None, gap=0.55):
|
|
98
|
+
"""Best-effort 2D de-overlap of text labels using display-space bounding boxes.
|
|
99
|
+
|
|
100
|
+
Overlapping labels are pushed apart along the line joining their centres.
|
|
101
|
+
``clamp`` optionally bounds the data coordinates, e.g.
|
|
102
|
+
``[(0.02, 3.12), (0.0, None)]``. Silently no-ops when the backend exposes no
|
|
103
|
+
renderer (so it is always safe to call). Only works for ``ax.text`` artists
|
|
104
|
+
whose position is expressed in data coordinates.
|
|
105
|
+
"""
|
|
106
|
+
texts = [t for t in texts if t is not None]
|
|
107
|
+
if len(texts) < 2:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
fig = ax.figure
|
|
111
|
+
fig.canvas.draw()
|
|
112
|
+
renderer = fig.canvas.get_renderer()
|
|
113
|
+
except Exception:
|
|
114
|
+
return
|
|
115
|
+
inv = ax.transData.inverted()
|
|
116
|
+
for _ in range(max_iter):
|
|
117
|
+
boxes = [t.get_window_extent(renderer) for t in texts]
|
|
118
|
+
moved = False
|
|
119
|
+
for a in range(len(texts)):
|
|
120
|
+
for b in range(a + 1, len(texts)):
|
|
121
|
+
ba, bb = boxes[a], boxes[b]
|
|
122
|
+
if not ba.overlaps(bb):
|
|
123
|
+
continue
|
|
124
|
+
acx, acy = (ba.x0 + ba.x1) * 0.5, (ba.y0 + ba.y1) * 0.5
|
|
125
|
+
bcx, bcy = (bb.x0 + bb.x1) * 0.5, (bb.y0 + bb.y1) * 0.5
|
|
126
|
+
dx, dy = bcx - acx, bcy - acy
|
|
127
|
+
dist = (dx * dx + dy * dy) ** 0.5
|
|
128
|
+
if dist < 1e-6:
|
|
129
|
+
dx, dy, dist = 0.0, 1.0, 1.0
|
|
130
|
+
ux, uy = dx / dist, dy / dist
|
|
131
|
+
step = gap * min(ba.height, bb.height)
|
|
132
|
+
for t, sgn in ((texts[a], -1.0), (texts[b], 1.0)):
|
|
133
|
+
x_, y_ = t.get_position()
|
|
134
|
+
disp = ax.transData.transform((x_, y_))
|
|
135
|
+
nx, ny = inv.transform((disp[0] + sgn * ux * step,
|
|
136
|
+
disp[1] + sgn * uy * step))
|
|
137
|
+
if clamp is not None:
|
|
138
|
+
(lo0, hi0), (lo1, hi1) = clamp
|
|
139
|
+
nx = min(max(nx, lo0), hi0) if hi0 is not None else max(nx, lo0)
|
|
140
|
+
ny = min(max(ny, lo1), hi1) if hi1 is not None else max(ny, lo1)
|
|
141
|
+
t.set_position((nx, ny))
|
|
142
|
+
moved = True
|
|
143
|
+
if not moved:
|
|
144
|
+
break
|
|
145
|
+
try:
|
|
146
|
+
fig.canvas.draw()
|
|
147
|
+
except Exception:
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def finalize_and_save(fig, ax, out_path, legend=True, colorbar_artist=None, cbar_label=None,
|
|
152
|
+
cjk_fp=None, tight=True):
|
|
153
|
+
"""Finalize figure: legend, colorbar, layout, save.
|
|
154
|
+
|
|
155
|
+
Legend policy: place outside the axes on the right by default. This avoids
|
|
156
|
+
the classic "legend overlaps data" problem that ``loc='best'`` creates for
|
|
157
|
+
dense or multi-series charts. ``bbox_inches='tight'`` at save time ensures
|
|
158
|
+
the legend is never clipped, even if the requested figure width is small.
|
|
159
|
+
"""
|
|
160
|
+
# Respect a legend the generator already placed (e.g. windrose speed bins)
|
|
161
|
+
if legend and ax.get_legend() is None and ax.get_legend_handles_labels()[1]:
|
|
162
|
+
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1.0), borderaxespad=0,
|
|
163
|
+
framealpha=0.9, prop=cjk_fp if cjk_fp else None)
|
|
164
|
+
|
|
165
|
+
if colorbar_artist is not None and hasattr(colorbar_artist, 'get_cmap'):
|
|
166
|
+
cbar = fig.colorbar(colorbar_artist, ax=ax, shrink=0.8)
|
|
167
|
+
if cbar_label:
|
|
168
|
+
cbar.set_label(cbar_label)
|
|
169
|
+
|
|
170
|
+
if tight:
|
|
171
|
+
try:
|
|
172
|
+
# If the figure has a twin axes (e.g. histogram + KDE density), reserve
|
|
173
|
+
# extra right margin so the secondary y-label is not clipped.
|
|
174
|
+
has_twinx = len(fig.axes) > 1
|
|
175
|
+
if has_twinx:
|
|
176
|
+
fig.tight_layout(rect=[0, 0, 0.88, 1])
|
|
177
|
+
else:
|
|
178
|
+
fig.tight_layout()
|
|
179
|
+
except Exception:
|
|
180
|
+
# scienceplots may produce very large bboxes in edge cases (e.g. forest),
|
|
181
|
+
# or MemoryError if elements fall outside axes bounds
|
|
182
|
+
plt.subplots_adjust(left=0.15, right=0.82 if len(fig.axes) > 1 else 0.85)
|
|
183
|
+
|
|
184
|
+
out_ext = os.path.splitext(out_path)[1].lower()
|
|
185
|
+
save_kwargs = {"bbox_inches": "tight", "facecolor": "white", "edgecolor": "none"}
|
|
186
|
+
if out_ext == ".svg":
|
|
187
|
+
save_kwargs["format"] = "svg"
|
|
188
|
+
elif out_ext == ".pdf":
|
|
189
|
+
save_kwargs["format"] = "pdf"
|
|
190
|
+
|
|
191
|
+
fig.savefig(out_path, **save_kwargs)
|
|
192
|
+
plt.close(fig)
|
|
193
|
+
|
|
194
|
+
sz = os.path.getsize(out_path)
|
|
195
|
+
print(f"Saved: {out_path} ({sz:,} bytes)", file=sys.stderr)
|