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,276 @@
1
+ #!/usr/bin/env python3
2
+ """Data science chart types: ridge, ternary, dot_plot, dendrogram.
3
+
4
+ These chart types are commonly used in data science, ecology, materials
5
+ science, and clustering analysis. Each is implemented as a ChartPlugin
6
+ subclass for automatic palette integration.
7
+ """
8
+ import numpy as np
9
+ from chart_plugin import ChartPlugin
10
+
11
+
12
+ def _load_cjk_font(font_path):
13
+ if not font_path:
14
+ return None
15
+ try:
16
+ from matplotlib import font_manager as fm
17
+ return fm.FontProperties(fname=font_path)
18
+ except Exception:
19
+ return None
20
+
21
+
22
+ class RidgePlot(ChartPlugin):
23
+ """Ridge plot (joyplot) for comparing multiple distributions.
24
+
25
+ Overlaid density curves offset vertically, one per group.
26
+ """
27
+ name = "ridge"
28
+ zh = "岭图"
29
+ kind = "statistics"
30
+ required_keys = ("series",)
31
+ optional_keys = ("labels", "bandwidth", "fill")
32
+ data_format = "json"
33
+
34
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
35
+ series = data["series"]
36
+ labels = data.get("labels") or list(series.keys())
37
+ bandwidth = float(data.get("bandwidth", 0.5))
38
+ fill = data.get("fill", True)
39
+
40
+ keys = list(series.keys())
41
+ n = len(keys)
42
+ if n == 0:
43
+ return None
44
+
45
+ # Plot each distribution, offset vertically.
46
+ for i, key in enumerate(keys):
47
+ values = np.asarray(series[key], dtype=float)
48
+ if len(values) < 2:
49
+ continue
50
+ # Compute KDE (simple Gaussian kernel).
51
+ x_range = np.linspace(values.min() - bandwidth,
52
+ values.max() + bandwidth, 200)
53
+ kde = np.array([_gaussian_kde(v, x_range, bandwidth) for v in values])
54
+ kde_sum = kde.sum(axis=0)
55
+
56
+ color = colors[i % len(colors)] if colors else "#1F77B4"
57
+ ax.fill_between(x_range, i, i + kde_sum, alpha=0.3 if fill else 0,
58
+ color=color)
59
+ ax.plot(x_range, i + kde_sum, color=color, linewidth=1.5)
60
+ fp = _load_cjk_font(cjk_fp)
61
+ ax.text(x_range.min() - 0.1, i, labels[i % len(labels)],
62
+ ha="right", va="center", fontsize=8, fontproperties=fp)
63
+
64
+ ax.set_yticks([])
65
+ ax.set_xlabel("Value")
66
+ ax.invert_yaxis()
67
+ return None
68
+
69
+
70
+ def _gaussian_kde(values, x_range, bandwidth):
71
+ """Simple 1D Gaussian kernel density estimate."""
72
+ return np.exp(-0.5 * ((x_range - values) / bandwidth) ** 2) / \
73
+ (bandwidth * np.sqrt(2 * np.pi))
74
+
75
+
76
+ class TernaryPlot(ChartPlugin):
77
+ """Ternary plot for three-component compositions (A + B + C = 1).
78
+
79
+ Each point is (A, B, C) where A+B+C=1. Used in geology, materials science,
80
+ and mixture analysis.
81
+ """
82
+ name = "ternary"
83
+ zh = "三元相图"
84
+ kind = "specialized"
85
+ required_keys = ("points",)
86
+ optional_keys = ("labels", "A_label", "B_label", "C_label")
87
+ data_format = "json"
88
+
89
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
90
+ points = data["points"]
91
+ labels = data.get("labels") or []
92
+ a_label = data.get("A_label", "A")
93
+ b_label = data.get("B_label", "B")
94
+ c_label = data.get("C_label", "C")
95
+
96
+ fp = _load_cjk_font(cjk_fp)
97
+
98
+ # Convert ternary coordinates to Cartesian.
99
+ # A = (0, 1), B = (1, 0), C = (0.5, 0) (equilateral triangle).
100
+ ax.set_xlim(0, 1)
101
+ ax.set_ylim(0, 1)
102
+ ax.set_aspect("equal")
103
+ ax.axis("off")
104
+
105
+ # Draw triangle.
106
+ triangle = [(0, 0), (1, 0), (0.5, 1), (0, 0)]
107
+ xs = [p[0] for p in triangle]
108
+ ys = [p[1] for p in triangle]
109
+ ax.plot(xs, ys, "k-", linewidth=1.5)
110
+
111
+ # Grid lines.
112
+ for i in range(1, 5):
113
+ frac = i / 5
114
+ ax.plot([frac * 0.5, 1 - frac * 0.5], [frac, frac], "k-",
115
+ linewidth=0.5, alpha=0.3)
116
+
117
+ # Label corners.
118
+ ax.text(0.5, 1.02, a_label, ha="center", va="bottom", fontsize=10,
119
+ fontproperties=fp)
120
+ ax.text(1.02, -0.02, b_label, ha="left", va="center", fontsize=10,
121
+ fontproperties=fp)
122
+ ax.text(-0.02, -0.02, c_label, ha="right", va="center", fontsize=10,
123
+ fontproperties=fp)
124
+
125
+ # Plot points.
126
+ if isinstance(points, dict):
127
+ # Grouped points: {group_name: [(a, b, c), ...]}
128
+ groups = list(points.keys())
129
+ for gi, group in enumerate(groups):
130
+ pts = points[group]
131
+ color = colors[gi % len(colors)] if colors else "#1F77B4"
132
+ for j, pt in enumerate(pts):
133
+ a, b, c = pt[0], pt[1], pt[2]
134
+ # Ternary to Cartesian: x = b + a/2, y = a*sqrt(3)/2
135
+ x = b + a / 2
136
+ y = a * np.sqrt(3) / 2
137
+ ax.scatter(x, y, s=50, color=color,
138
+ edgecolors="black", linewidths=0.5)
139
+ if labels and j < len(labels[group]):
140
+ ax.annotate(str(labels[group][j]), (x, y),
141
+ textcoords="offset points", xytext=(5, 5),
142
+ fontsize=7, fontproperties=fp)
143
+ else:
144
+ # Single group of points: [(a, b, c), ...]
145
+ for j, pt in enumerate(points):
146
+ a, b, c = pt[0], pt[1], pt[2]
147
+ x = b + a / 2
148
+ y = a * np.sqrt(3) / 2
149
+ ax.scatter(x, y, s=50, color=colors[0] if colors else "#1F77B4",
150
+ edgecolors="black", linewidths=0.5)
151
+ if labels and j < len(labels):
152
+ ax.annotate(str(labels[j]), (x, y),
153
+ textcoords="offset points", xytext=(5, 5),
154
+ fontsize=7, fontproperties=fp)
155
+ return None
156
+
157
+
158
+ class DotPlot(ChartPlugin):
159
+ """Dot plot (strip plot) for showing distributions as individual points.
160
+
161
+ Used in ecology, bioinformatics, and sequence alignment visualization.
162
+ """
163
+ name = "dot_plot"
164
+ zh = "点图"
165
+ kind = "statistics"
166
+ required_keys = ("x", "y")
167
+ optional_keys = ("labels", "group", "size", "color_by")
168
+ data_format = "json"
169
+
170
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
171
+ x = np.asarray(data["x"])
172
+ y = np.asarray(data["y"])
173
+ labels = data.get("labels") or []
174
+ group = data.get("group")
175
+ sizes = data.get("size") or [50] * len(x)
176
+ color_by = data.get("color_by")
177
+
178
+ if group and color_by:
179
+ # Color by group.
180
+ unique_groups = sorted(set(group))
181
+ for i, grp in enumerate(unique_groups):
182
+ mask = np.array(group) == grp
183
+ color = colors[i % len(colors)] if colors else "#1F77B4"
184
+ ax.scatter(x[mask], y[mask], s=sizes, color=color,
185
+ edgecolors="black", linewidths=0.5,
186
+ label=str(grp))
187
+ # Legend is placed outside the axes by finalize_and_save.
188
+ elif group:
189
+ # Jitter x by group.
190
+ unique_groups = sorted(set(group))
191
+ for i, grp in enumerate(unique_groups):
192
+ mask = np.array(group) == grp
193
+ jitter = np.random.uniform(-0.1, 0.1, size=len(x[mask]))
194
+ color = colors[i % len(colors)] if colors else "#1F77B4"
195
+ ax.scatter(x[mask] + jitter, y[mask], s=sizes,
196
+ color=color, edgecolors="black", linewidths=0.5)
197
+ else:
198
+ # Single color.
199
+ ax.scatter(x, y, s=sizes, color=colors[0] if colors else "#1F77B4",
200
+ edgecolors="black", linewidths=0.5)
201
+
202
+ # Label points.
203
+ if labels and len(labels) > 0:
204
+ for i, label in enumerate(labels):
205
+ if i < len(x):
206
+ fp = _load_cjk_font(cjk_fp)
207
+ ax.annotate(str(label), (x[i], y[i]),
208
+ textcoords="offset points", xytext=(5, 5),
209
+ fontsize=7, fontproperties=fp)
210
+
211
+ ax.set_xlabel("X")
212
+ ax.set_ylabel("Y")
213
+ return None
214
+
215
+
216
+ class DendrogramPlot(ChartPlugin):
217
+ """Dendrogram for hierarchical clustering visualization.
218
+
219
+ Data format:
220
+ leaves: list of leaf labels
221
+ merges: list of [left_idx, right_idx, height] tuples
222
+ - indices < n_leaves refer to leaves
223
+ - indices >= n_leaves refer to internal nodes (0-based from merges)
224
+ """
225
+ name = "dendrogram"
226
+ zh = "树状图"
227
+ kind = "specialized"
228
+ required_keys = ("leaves", "merges")
229
+ optional_keys = ("threshold",)
230
+ data_format = "json"
231
+
232
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
233
+ leaves = data["leaves"]
234
+ merges = data["merges"]
235
+ threshold = data.get("threshold", None)
236
+ n_leaves = len(leaves)
237
+ fp = _load_cjk_font(cjk_fp)
238
+
239
+ # Assign x positions to leaves.
240
+ leaf_x = {i: i + 1 for i in range(n_leaves)}
241
+ # x positions for internal nodes (computed after merge).
242
+ node_x = {}
243
+
244
+ max_h = max(m[2] for m in merges) if merges else 1.0
245
+ ax.set_xlim(0.5, n_leaves + 0.5)
246
+ ax.set_ylim(0, max_h * 1.15)
247
+
248
+ # Draw leaves.
249
+ for i, label in enumerate(leaves):
250
+ x = leaf_x[i]
251
+ ax.plot([x, x], [0, 0.01], "k-", linewidth=0.5)
252
+ ax.text(x, -0.02, str(label), ha="center", va="top",
253
+ fontsize=7, fontproperties=fp, rotation=45)
254
+
255
+ # Draw merges bottom-up.
256
+ for mi, (left, right, h) in enumerate(merges):
257
+ # Resolve x positions for children.
258
+ lx = leaf_x.get(left, node_x.get(left, 0))
259
+ rx = leaf_x.get(right, node_x.get(right, 0))
260
+ # Assign x for this internal node (midpoint).
261
+ node_x[mi] = (lx + rx) / 2
262
+ # Draw U-shape.
263
+ ax.plot([lx, lx], [0, h], "k-", linewidth=1.0)
264
+ ax.plot([rx, rx], [0, h], "k-", linewidth=1.0)
265
+ ax.plot([lx, rx], [h, h], "k-", linewidth=1.5)
266
+
267
+ # Threshold line.
268
+ if threshold is not None:
269
+ ax.axhline(threshold, color="red", linestyle="--",
270
+ linewidth=1.0, alpha=0.7)
271
+
272
+ ax.set_xlabel("Cluster")
273
+ ax.set_ylabel("Distance")
274
+ ax.set_yticks([])
275
+ ax.tick_params(axis="x", length=0)
276
+ return None
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Distribution chart generator: histogram and density/KDE plots."""
3
+ import numpy as np
4
+ from scipy import stats
5
+
6
+
7
+ def gen_histogram(data, ax, colors, cjk_fp=None, **kwargs):
8
+ """Generate histogram with optional KDE overlay."""
9
+ series = data.get("series", {})
10
+ values = data.get("values", [])
11
+
12
+ if not values and series:
13
+ # Take first series
14
+ values = list(series.values())[0] if series else []
15
+
16
+ if not values:
17
+ return
18
+
19
+ values = np.array(values, dtype=float)
20
+ bins = kwargs.get("bins", "auto")
21
+ density = kwargs.get("density", False)
22
+ alpha = kwargs.get("alpha", 0.7)
23
+
24
+ ax.hist(values, bins=bins, density=density, alpha=alpha, color=colors[0],
25
+ edgecolor='white', linewidth=0.5, label=data.get("name", ""))
26
+
27
+ # KDE overlay
28
+ if kwargs.get("kde", True) and len(values) >= 3:
29
+ kde_x = np.linspace(values.min(), values.max(), 200)
30
+ try:
31
+ kde = stats.gaussian_kde(values)
32
+ ax2 = ax.twinx()
33
+ # Move the right spine outward so its label is not clipped.
34
+ ax2.spines['right'].set_position(('outward', 12))
35
+ ax2.plot(kde_x, kde(kde_x), color=colors[1] if len(colors) > 1 else '#333333',
36
+ linewidth=2, alpha=0.8)
37
+ ax2.set_ylabel('Density', fontsize=8)
38
+ ax2.yaxis.label.set_color(colors[1] if len(colors) > 1 else '#333333')
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ def gen_density(data, ax, colors, cjk_fp=None, **kwargs):
44
+ """Generate density/KDE plot for one or more series."""
45
+ series = data.get("series", {})
46
+ values_list = data.get("values", [])
47
+
48
+ if values_list and not series:
49
+ series = {"Data": values_list}
50
+
51
+ for i, (name, values) in enumerate(series.items()):
52
+ vals = np.array(values, dtype=float)
53
+ xs = np.linspace(vals.min() - (vals.std() or 1), vals.max() + (vals.std() or 1), 200)
54
+ try:
55
+ kde = stats.gaussian_kde(vals)
56
+ ax.plot(xs, kde(xs), color=colors[i % len(colors)], linewidth=2, label=name)
57
+ ax.fill_between(xs, kde(xs), color=colors[i % len(colors)], alpha=0.15)
58
+ except Exception:
59
+ pass
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """Engineering/finance chart types: slope, waterfall, bode, periodogram.
3
+
4
+ These chart types are commonly used in finance (waterfall, slope), control
5
+ systems (bode), and signal processing (periodogram). Each is implemented as
6
+ a ChartPlugin subclass for automatic palette integration.
7
+ """
8
+ import numpy as np
9
+ from chart_plugin import ChartPlugin
10
+
11
+
12
+ def _load_cjk_font(font_path):
13
+ if not font_path:
14
+ return None
15
+ try:
16
+ from matplotlib import font_manager as fm
17
+ return fm.FontProperties(fname=font_path)
18
+ except Exception:
19
+ return None
20
+
21
+
22
+ class SlopeChart(ChartPlugin):
23
+ """Slope chart for comparing values across two time points.
24
+
25
+ Simple but effective for showing change over time between two categories.
26
+ """
27
+ name = "slope"
28
+ zh = "斜率图"
29
+ kind = "relational"
30
+ required_keys = ("labels", "before", "after")
31
+ optional_keys = ("x_labels", "y_min", "y_max")
32
+ data_format = "json"
33
+
34
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
35
+ labels = data["labels"]
36
+ before = np.asarray(data["before"], dtype=float)
37
+ after = np.asarray(data["after"], dtype=float)
38
+ x_labels = data.get("x_labels", ["Before", "After"])
39
+ y_min = data.get("y_min", None)
40
+ y_max = data.get("y_max", None)
41
+
42
+ fp = _load_cjk_font(cjk_fp)
43
+ n = len(labels)
44
+ if n == 0:
45
+ return None
46
+
47
+ for i in range(n):
48
+ color = colors[i % len(colors)] if colors else "#1F77B4"
49
+ ax.plot([0, 1], [before[i], after[i]], color=color,
50
+ linewidth=2, alpha=0.8)
51
+ # Labels on left (before).
52
+ ax.text(-0.02, before[i], str(labels[i]), ha="right",
53
+ va="center", fontsize=8, fontproperties=fp, color=color)
54
+ # Labels on right (after).
55
+ ax.text(1.02, after[i], str(labels[i]), ha="left",
56
+ va="center", fontsize=8, fontproperties=fp, color=color)
57
+ # Value labels.
58
+ ax.text(0, before[i], " %s" % format(before[i], ".1f"),
59
+ ha="left", va="center", fontsize=7, color=color,
60
+ fontproperties=fp)
61
+ ax.text(1, after[i], format(after[i], ".1f"),
62
+ ha="right", va="center", fontsize=7, color=color,
63
+ fontproperties=fp)
64
+
65
+ ax.set_xticks([0, 1])
66
+ ax.set_xticklabels(x_labels, fontsize=10)
67
+ if y_min is not None:
68
+ ax.set_ylim(y_min, y_max)
69
+
70
+ ax.set_xlim(-0.15, 1.15)
71
+ ax.set_ylabel("Value")
72
+ return None
73
+
74
+
75
+ class WaterfallChart(ChartPlugin):
76
+ """Waterfall chart showing cumulative changes from a starting value.
77
+
78
+ Used in finance (P&L bridge), project management, and financial reporting.
79
+ """
80
+ name = "waterfall"
81
+ zh = "瀑布图"
82
+ kind = "statistics"
83
+ required_keys = ("labels", "values")
84
+ optional_keys = ("total_label", "show_values")
85
+ data_format = "json"
86
+
87
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
88
+ labels = data["labels"]
89
+ values = np.asarray(data["values"], dtype=float)
90
+ show_values = data.get("show_values", True)
91
+ fp = _load_cjk_font(cjk_fp)
92
+ n = len(labels)
93
+ if n == 0:
94
+ return None
95
+
96
+ # Compute cumulative positions.
97
+ cumsum = np.cumsum(values)
98
+ start = np.zeros(n)
99
+ start[1:] = cumsum[:-1]
100
+
101
+ # Colors: positive = green-ish, negative = red-ish, total = blue-ish.
102
+ pos_color = colors[0] if colors else "#2CA02C"
103
+ neg_color = colors[1] if len(colors) > 1 else "#D62728"
104
+ total_color = colors[2] if len(colors) > 2 else "#1F77B4"
105
+
106
+ for i in range(n):
107
+ v = values[i]
108
+ if i == n - 1 and abs(v) < 0.01:
109
+ # Last bar might be the total.
110
+ color = total_color
111
+ elif v >= 0:
112
+ color = pos_color
113
+ else:
114
+ color = neg_color
115
+ ax.bar(i, v, bottom=start[i], color=color, edgecolor="black",
116
+ linewidth=0.5, width=0.6)
117
+
118
+ # Value label.
119
+ if show_values:
120
+ y_pos = start[i] + v / 2
121
+ ax.text(i, y_pos, format(v, ".1f"), ha="center",
122
+ va="center", fontsize=7, fontproperties=fp)
123
+
124
+ # Draw connecting lines.
125
+ for i in range(n - 1):
126
+ ax.plot([i + 0.3, i + 1 - 0.3], [cumsum[i], cumsum[i]],
127
+ "k--", linewidth=0.5, alpha=0.5)
128
+
129
+ ax.set_xticks(range(n))
130
+ ax.set_xticklabels(labels, fontsize=8, fontproperties=fp)
131
+ ax.set_ylabel("Value")
132
+ return None
133
+
134
+
135
+ class BodePlot(ChartPlugin):
136
+ """Bode plot for control systems (magnitude + phase vs frequency).
137
+
138
+ Two panels: magnitude (dB) and phase (degrees) on log frequency scale.
139
+ """
140
+ name = "bode"
141
+ zh = "博德图"
142
+ kind = "specialized"
143
+ required_keys = ("freq", "mag", "phase")
144
+ optional_keys = ("gain_db", "phase_deg", "freq_hz")
145
+ data_format = "json"
146
+
147
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
148
+ freq = np.asarray(data["freq"], dtype=float)
149
+ mag = np.asarray(data["mag"], dtype=float)
150
+ phase = np.asarray(data["phase"], dtype=float)
151
+ gain_db = data.get("gain_db", True)
152
+ phase_deg = data.get("phase_deg", True)
153
+ freq_hz = data.get("freq_hz", True)
154
+
155
+ fp = _load_cjk_font(cjk_fp)
156
+ n = len(freq)
157
+ if n == 0:
158
+ return None
159
+
160
+ # Magnitude panel (top half).
161
+ mag_y = 0.55 + 0.4 * (mag - mag.min()) / (mag.max() - mag.min() + 1e-10)
162
+ ax.plot(freq, mag_y, color=colors[0] if colors else "#1F77B4",
163
+ linewidth=2)
164
+ ax.fill_between(freq, 0.55, mag_y, alpha=0.15,
165
+ color=colors[0] if colors else "#1F77B4")
166
+
167
+ # Phase panel (bottom half).
168
+ phase_y = 0.45 - 0.4 * (phase - phase.min()) / (phase.max() - phase.min() + 1e-10)
169
+ ax.plot(freq, phase_y, color=colors[1] if len(colors) > 1 else "#D62728",
170
+ linewidth=2, linestyle="--")
171
+
172
+ # Separator line.
173
+ ax.axhline(0.5, color="grey", linewidth=0.5, linestyle="-")
174
+
175
+ # Labels.
176
+ ax.set_xlabel("Frequency (Hz)" if freq_hz else "Frequency (rad/s)")
177
+ ax.set_ylabel("Magnitude (dB)" if gain_db else "Magnitude")
178
+ ax.text(0.02, 0.95, "Magnitude", transform=ax.transAxes,
179
+ fontsize=9, fontproperties=fp)
180
+ ax.text(0.02, 0.05, "Phase (%s)" % ("deg" if phase_deg else "rad"),
181
+ transform=ax.transAxes, fontsize=9, fontproperties=fp)
182
+ return None
183
+
184
+
185
+ class PeriodogramPlot(ChartPlugin):
186
+ """Periodogram (power spectral density) for signal analysis.
187
+
188
+ Shows frequency content of a signal. Used in signal processing,
189
+ seismology, and vibration analysis.
190
+ """
191
+ name = "periodogram"
192
+ zh = "周期图"
193
+ kind = "specialized"
194
+ required_keys = ("freq", "psd")
195
+ optional_keys = ("x_labels", "ylabel", "log_scale")
196
+ data_format = "json"
197
+
198
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
199
+ freq = np.asarray(data["freq"], dtype=float)
200
+ psd = np.asarray(data["psd"], dtype=float)
201
+ x_labels = data.get("x_labels", "Frequency (Hz)")
202
+ ylabel = data.get("ylabel", "PSD")
203
+ log_scale = data.get("log_scale", False)
204
+
205
+ fp = _load_cjk_font(cjk_fp)
206
+ n = len(freq)
207
+ if n == 0:
208
+ return None
209
+
210
+ if log_scale:
211
+ psd_plot = np.log10(np.clip(psd, 1e-20, None))
212
+ else:
213
+ psd_plot = psd
214
+
215
+ ax.plot(freq, psd_plot, color=colors[0] if colors else "#1F77B4",
216
+ linewidth=1.5)
217
+ ax.fill_between(freq, ax.get_ylim()[0], psd_plot, alpha=0.2,
218
+ color=colors[0] if colors else "#1F77B4")
219
+
220
+ ax.set_xlabel(x_labels)
221
+ ax.set_ylabel(ylabel + " (dB)" if log_scale else ylabel)
222
+ if log_scale:
223
+ ax.set_yscale("linear")
224
+ return None