like-sci-figures 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +56 -0
- package/README.md +93 -2
- package/cli.js +79 -0
- package/examples/area.json +9 -0
- package/examples/bar.json +15 -0
- package/examples/bland_altman.json +128 -0
- package/examples/bode.json +8 -0
- package/examples/box.json +8 -0
- package/examples/bubble.json +7 -0
- package/examples/contour.json +10 -0
- package/examples/corr_matrix.json +99 -0
- package/examples/dendrogram.json +5 -0
- package/examples/density.json +8 -0
- package/examples/dot_plot.json +8 -0
- package/examples/ecdf.json +377 -0
- package/examples/errorbar.json +7 -0
- package/examples/forest.json +12 -0
- package/examples/heatmap.json +10 -0
- package/examples/hexbin.json +6009 -0
- package/examples/histogram.json +6 -0
- package/examples/kaplan_meier.json +6 -0
- package/examples/line.json +11 -0
- package/examples/manhattan.json +7 -0
- package/examples/panels.json +28 -0
- package/examples/pca.json +12 -0
- package/examples/periodogram.json +7 -0
- package/examples/pie.json +4 -0
- package/examples/radar.json +7 -0
- package/examples/ridge.json +11 -0
- package/examples/roc.json +6 -0
- package/examples/sankey.json +9 -0
- package/examples/scatter3d.json +6 -0
- package/examples/slope.json +6 -0
- package/examples/stacked_bar.json +12 -0
- package/examples/surface.json +11 -0
- package/examples/taylor.json +9 -0
- package/examples/ternary.json +18 -0
- package/examples/venn.json +12 -0
- package/examples/violin.json +9 -0
- package/examples/volcano.json +7 -0
- package/examples/waterfall.json +5 -0
- package/examples/windrose.json +407 -0
- package/package.json +33 -4
- package/requirements.txt +9 -0
- package/scripts/add_chart.py +201 -0
- package/scripts/build_gallery.py +360 -0
- package/scripts/chart_plugin.py +153 -0
- package/scripts/data_loader.py +260 -0
- package/scripts/fig_3d.py +103 -0
- package/scripts/fig_area.py +57 -0
- package/scripts/fig_bar.py +117 -0
- package/scripts/fig_biomed.py +289 -0
- package/scripts/fig_box.py +60 -0
- package/scripts/fig_bubble.py +65 -0
- package/scripts/fig_core.py +195 -0
- package/scripts/fig_datasci.py +276 -0
- package/scripts/fig_distribution.py +59 -0
- package/scripts/fig_engineering.py +224 -0
- package/scripts/fig_environ.py +239 -0
- package/scripts/fig_errorbar.py +49 -0
- package/scripts/fig_forest.py +54 -0
- package/scripts/fig_heatmap.py +51 -0
- package/scripts/fig_line.py +39 -0
- package/scripts/fig_pca.py +101 -0
- package/scripts/fig_pie.py +52 -0
- package/scripts/fig_radar.py +32 -0
- package/scripts/fig_sankey.py +196 -0
- package/scripts/fig_scatter.py +44 -0
- package/scripts/fig_subplots.py +124 -0
- package/scripts/fig_taylor.py +80 -0
- package/scripts/fig_venn.py +80 -0
- package/scripts/fig_windrose.py +82 -0
- package/scripts/font_detector.py +85 -0
- package/scripts/gen_figure.py +583 -0
- package/scripts/generate_samples.py +322 -0
- package/scripts/palette_picker.py +244 -0
- package/scripts/palette_pref.py +179 -0
- package/scripts/palette_preview.py +334 -0
- package/scripts/palettes.py +461 -0
- package/scripts/registry.py +138 -0
- package/scripts/render_utils.py +233 -0
- package/scripts/showcase.py +179 -0
- package/scripts/style_manager.py +267 -0
|
@@ -0,0 +1,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
|