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.
Files changed (83) hide show
  1. package/LICENSE +56 -0
  2. package/README.md +93 -2
  3. package/cli.js +79 -0
  4. package/examples/area.json +9 -0
  5. package/examples/bar.json +15 -0
  6. package/examples/bland_altman.json +128 -0
  7. package/examples/bode.json +8 -0
  8. package/examples/box.json +8 -0
  9. package/examples/bubble.json +7 -0
  10. package/examples/contour.json +10 -0
  11. package/examples/corr_matrix.json +99 -0
  12. package/examples/dendrogram.json +5 -0
  13. package/examples/density.json +8 -0
  14. package/examples/dot_plot.json +8 -0
  15. package/examples/ecdf.json +377 -0
  16. package/examples/errorbar.json +7 -0
  17. package/examples/forest.json +12 -0
  18. package/examples/heatmap.json +10 -0
  19. package/examples/hexbin.json +6009 -0
  20. package/examples/histogram.json +6 -0
  21. package/examples/kaplan_meier.json +6 -0
  22. package/examples/line.json +11 -0
  23. package/examples/manhattan.json +7 -0
  24. package/examples/panels.json +28 -0
  25. package/examples/pca.json +12 -0
  26. package/examples/periodogram.json +7 -0
  27. package/examples/pie.json +4 -0
  28. package/examples/radar.json +7 -0
  29. package/examples/ridge.json +11 -0
  30. package/examples/roc.json +6 -0
  31. package/examples/sankey.json +9 -0
  32. package/examples/scatter3d.json +6 -0
  33. package/examples/slope.json +6 -0
  34. package/examples/stacked_bar.json +12 -0
  35. package/examples/surface.json +11 -0
  36. package/examples/taylor.json +9 -0
  37. package/examples/ternary.json +18 -0
  38. package/examples/venn.json +12 -0
  39. package/examples/violin.json +9 -0
  40. package/examples/volcano.json +7 -0
  41. package/examples/waterfall.json +5 -0
  42. package/examples/windrose.json +407 -0
  43. package/package.json +33 -4
  44. package/requirements.txt +9 -0
  45. package/scripts/add_chart.py +201 -0
  46. package/scripts/build_gallery.py +360 -0
  47. package/scripts/chart_plugin.py +153 -0
  48. package/scripts/data_loader.py +260 -0
  49. package/scripts/fig_3d.py +103 -0
  50. package/scripts/fig_area.py +57 -0
  51. package/scripts/fig_bar.py +117 -0
  52. package/scripts/fig_biomed.py +289 -0
  53. package/scripts/fig_box.py +60 -0
  54. package/scripts/fig_bubble.py +65 -0
  55. package/scripts/fig_core.py +195 -0
  56. package/scripts/fig_datasci.py +276 -0
  57. package/scripts/fig_distribution.py +59 -0
  58. package/scripts/fig_engineering.py +224 -0
  59. package/scripts/fig_environ.py +239 -0
  60. package/scripts/fig_errorbar.py +49 -0
  61. package/scripts/fig_forest.py +54 -0
  62. package/scripts/fig_heatmap.py +51 -0
  63. package/scripts/fig_line.py +39 -0
  64. package/scripts/fig_pca.py +101 -0
  65. package/scripts/fig_pie.py +52 -0
  66. package/scripts/fig_radar.py +32 -0
  67. package/scripts/fig_sankey.py +196 -0
  68. package/scripts/fig_scatter.py +44 -0
  69. package/scripts/fig_subplots.py +124 -0
  70. package/scripts/fig_taylor.py +80 -0
  71. package/scripts/fig_venn.py +80 -0
  72. package/scripts/fig_windrose.py +82 -0
  73. package/scripts/font_detector.py +85 -0
  74. package/scripts/gen_figure.py +583 -0
  75. package/scripts/generate_samples.py +322 -0
  76. package/scripts/palette_picker.py +244 -0
  77. package/scripts/palette_pref.py +179 -0
  78. package/scripts/palette_preview.py +334 -0
  79. package/scripts/palettes.py +461 -0
  80. package/scripts/registry.py +138 -0
  81. package/scripts/render_utils.py +233 -0
  82. package/scripts/showcase.py +179 -0
  83. 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)