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,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)