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,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sankey diagram generator (layered flows), pure matplotlib, no extra deps."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from matplotlib.path import Path
|
|
6
|
+
import matplotlib.patches as mpatches
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def gen_sankey(data, ax, colors, cjk_fp=None, **kwargs):
|
|
10
|
+
"""Generate a Sankey diagram.
|
|
11
|
+
|
|
12
|
+
Data schema (JSON):
|
|
13
|
+
{
|
|
14
|
+
"nodes": ["Source A", "Source B", "Receptor", ...],
|
|
15
|
+
"links": [
|
|
16
|
+
{"source": "Source A", "target": "Receptor", "value": 30},
|
|
17
|
+
...
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
Node layers are inferred by longest-path from sources (no incoming links).
|
|
21
|
+
Link widths are proportional to their value; colors follow the source node.
|
|
22
|
+
"""
|
|
23
|
+
nodes = data.get("nodes", [])
|
|
24
|
+
links = data.get("links", data.get("flows", []))
|
|
25
|
+
if not nodes or not links:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
idx = {name: i for i, name in enumerate(nodes)}
|
|
29
|
+
L = []
|
|
30
|
+
for lk in links:
|
|
31
|
+
if isinstance(lk, dict) and "source" in lk and "target" in lk:
|
|
32
|
+
s = lk["source"]; t = lk["target"]; v = float(lk.get("value", lk.get("weight", 1)))
|
|
33
|
+
else:
|
|
34
|
+
s, t, v = lk[0], lk[1], float(lk[2])
|
|
35
|
+
if isinstance(s, str): s = idx.get(s, 0)
|
|
36
|
+
if isinstance(t, str): t = idx.get(t, len(nodes) - 1)
|
|
37
|
+
L.append((int(s), int(t), v))
|
|
38
|
+
if not L:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
n = len(nodes)
|
|
42
|
+
ins = np.zeros(n); outs = np.zeros(n)
|
|
43
|
+
for s, t, v in L:
|
|
44
|
+
outs[s] += v; ins[t] += v
|
|
45
|
+
thru = np.maximum(ins, outs)
|
|
46
|
+
max_thru = thru.max() if thru.max() > 0 else 1.0
|
|
47
|
+
|
|
48
|
+
# Layer assignment (longest path from sources)
|
|
49
|
+
layer = [-1] * n
|
|
50
|
+
for i in range(n):
|
|
51
|
+
if ins[i] == 0:
|
|
52
|
+
layer[i] = 0
|
|
53
|
+
if all(l < 0 for l in layer):
|
|
54
|
+
layer = [0] * n
|
|
55
|
+
guard = 0
|
|
56
|
+
changed = True
|
|
57
|
+
while changed and guard < n + 3:
|
|
58
|
+
changed = False
|
|
59
|
+
for s, t, v in L:
|
|
60
|
+
if layer[s] >= 0 and (layer[t] < 0 or layer[t] < layer[s] + 1):
|
|
61
|
+
layer[t] = layer[s] + 1
|
|
62
|
+
changed = True
|
|
63
|
+
guard += 1
|
|
64
|
+
max_layer = max((l for l in layer if l >= 0), default=0)
|
|
65
|
+
for i in range(n):
|
|
66
|
+
if layer[i] < 0:
|
|
67
|
+
layer[i] = max_layer
|
|
68
|
+
|
|
69
|
+
by_layer = defaultdict(list)
|
|
70
|
+
for i in range(n):
|
|
71
|
+
by_layer[layer[i]].append(i)
|
|
72
|
+
n_layers = max(by_layer) + 1
|
|
73
|
+
|
|
74
|
+
height_unit = 1.0
|
|
75
|
+
gap = 0.18
|
|
76
|
+
nw = 0.03
|
|
77
|
+
|
|
78
|
+
def layer_height(li, order):
|
|
79
|
+
return sum(thru[i] * height_unit for i in order) + gap * (len(order) - 1)
|
|
80
|
+
|
|
81
|
+
# adjacency (for barycenter ordering)
|
|
82
|
+
preds = defaultdict(list); succs = defaultdict(list)
|
|
83
|
+
for s, t, v in L:
|
|
84
|
+
succs[s].append(t); preds[t].append(s)
|
|
85
|
+
|
|
86
|
+
# initial order: top-to-bottom as declared in `nodes`
|
|
87
|
+
order = {li: list(by_layer[li]) for li in range(n_layers)}
|
|
88
|
+
center = {}
|
|
89
|
+
|
|
90
|
+
def recompute_centers():
|
|
91
|
+
max_h_local = max((layer_height(li, order[li]) for li in range(n_layers)), default=1)
|
|
92
|
+
if max_h_local <= 0:
|
|
93
|
+
max_h_local = 1
|
|
94
|
+
for li in range(n_layers):
|
|
95
|
+
h = layer_height(li, order[li])
|
|
96
|
+
y = (max_h_local - h) / 2 + h
|
|
97
|
+
for i in order[li]:
|
|
98
|
+
center[i] = y - thru[i] * height_unit / 2
|
|
99
|
+
y -= (thru[i] * height_unit + gap)
|
|
100
|
+
return max_h_local
|
|
101
|
+
|
|
102
|
+
max_h = recompute_centers()
|
|
103
|
+
|
|
104
|
+
# barycenter sweeps (top = larger y) to minimise ribbon crossings
|
|
105
|
+
for _ in range(4):
|
|
106
|
+
for li in range(1, n_layers):
|
|
107
|
+
order[li].sort(
|
|
108
|
+
key=lambda i: -(sum(center[p] for p in preds[i]) / len(preds[i])
|
|
109
|
+
if preds[i] else center[i])
|
|
110
|
+
)
|
|
111
|
+
recompute_centers()
|
|
112
|
+
for li in range(n_layers - 2, -1, -1):
|
|
113
|
+
order[li].sort(
|
|
114
|
+
key=lambda i: -(sum(center[t] for t in succs[i]) / len(succs[i])
|
|
115
|
+
if succs[i] else center[i])
|
|
116
|
+
)
|
|
117
|
+
recompute_centers()
|
|
118
|
+
|
|
119
|
+
# node vertical extents (top-to-bottom per layer, centred)
|
|
120
|
+
node_y = {}
|
|
121
|
+
for li in range(n_layers):
|
|
122
|
+
h = layer_height(li, order[li])
|
|
123
|
+
y = (max_h - h) / 2 + h
|
|
124
|
+
for i in order[li]:
|
|
125
|
+
th = thru[i] * height_unit
|
|
126
|
+
node_y[i] = (y - th, y)
|
|
127
|
+
y -= (th + gap)
|
|
128
|
+
|
|
129
|
+
total_w = max(n_layers + (n_layers - 1) * 0.6, 1.0)
|
|
130
|
+
xs = [0.5 + li * (total_w / n_layers if n_layers > 1 else total_w) for li in range(n_layers)]
|
|
131
|
+
|
|
132
|
+
# Assign ribbon slots on both ends so ribbons do not cross:
|
|
133
|
+
# outgoing at a node ordered by target vertical position (top first),
|
|
134
|
+
# incoming at a node ordered by source vertical position (top first).
|
|
135
|
+
out_links = defaultdict(list); in_links = defaultdict(list)
|
|
136
|
+
for k, (s, t, v) in enumerate(L):
|
|
137
|
+
out_links[s].append(k); in_links[t].append(k)
|
|
138
|
+
|
|
139
|
+
y_bot_s = {}; y_bot_t = {}
|
|
140
|
+
for s, ks in out_links.items():
|
|
141
|
+
ks.sort(key=lambda k: -center[L[k][1]])
|
|
142
|
+
top = node_y[s][1]
|
|
143
|
+
for k in ks:
|
|
144
|
+
vh = L[k][2] / max_thru * max_h
|
|
145
|
+
y_bot_s[k] = top - vh
|
|
146
|
+
top -= vh
|
|
147
|
+
for t, ks in in_links.items():
|
|
148
|
+
ks.sort(key=lambda k: -center[L[k][0]])
|
|
149
|
+
top = node_y[t][1]
|
|
150
|
+
for k in ks:
|
|
151
|
+
vh = L[k][2] / max_thru * max_h
|
|
152
|
+
y_bot_t[k] = top - vh
|
|
153
|
+
top -= vh
|
|
154
|
+
|
|
155
|
+
# Draw links
|
|
156
|
+
for k, (s, t, v) in enumerate(L):
|
|
157
|
+
c = colors[s % len(colors)]
|
|
158
|
+
vh = v / max_thru * max_h
|
|
159
|
+
y0s = y_bot_s[k]
|
|
160
|
+
y0t = y_bot_t[k]
|
|
161
|
+
xs0 = xs[layer[s]] + nw
|
|
162
|
+
xt0 = xs[layer[t]]
|
|
163
|
+
xm = (xs0 + xt0) / 2.0
|
|
164
|
+
verts = [
|
|
165
|
+
(xs0, y0s),
|
|
166
|
+
(xm, y0s), (xm, y0t), (xt0, y0t),
|
|
167
|
+
(xt0, y0t + vh),
|
|
168
|
+
(xm, y0t + vh), (xm, y0s + vh), (xs0, y0s + vh),
|
|
169
|
+
(xs0, y0s),
|
|
170
|
+
]
|
|
171
|
+
codes = [
|
|
172
|
+
Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4,
|
|
173
|
+
Path.LINETO, Path.CURVE4, Path.CURVE4, Path.CURVE4, Path.CLOSEPOLY,
|
|
174
|
+
]
|
|
175
|
+
ax.add_patch(mpatches.PathPatch(Path(verts, codes), facecolor=c, alpha=0.4,
|
|
176
|
+
edgecolor='none', zorder=1))
|
|
177
|
+
|
|
178
|
+
# Draw nodes
|
|
179
|
+
for i in range(n):
|
|
180
|
+
li = layer[i]
|
|
181
|
+
x = xs[li]
|
|
182
|
+
b, tp = node_y[i]
|
|
183
|
+
ax.add_patch(mpatches.Rectangle((x, b), nw, tp - b, facecolor=colors[i % len(colors)],
|
|
184
|
+
edgecolor='white', linewidth=0.8, zorder=3))
|
|
185
|
+
# labels
|
|
186
|
+
if li == 0:
|
|
187
|
+
ax.text(x - 0.04, (b + tp) / 2, str(nodes[i]), ha='right', va='center', fontsize=9)
|
|
188
|
+
elif li == max_layer:
|
|
189
|
+
ax.text(x + nw + 0.04, (b + tp) / 2, str(nodes[i]), ha='left', va='center', fontsize=9)
|
|
190
|
+
else:
|
|
191
|
+
ax.text(x + nw / 2, tp + 0.05, str(nodes[i]), ha='center', va='bottom', fontsize=8)
|
|
192
|
+
|
|
193
|
+
ax.set_xlim(-0.1, total_w + 0.1)
|
|
194
|
+
ax.set_ylim(-0.2, max_h + 0.3)
|
|
195
|
+
ax.axis('off')
|
|
196
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scatter plot generator with trend line and grouping."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_scatter(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate scatter plot with optional trend line and grouping."""
|
|
8
|
+
x_data = np.array(data.get("x", []), dtype=float)
|
|
9
|
+
y_data = np.array(data.get("y", []), dtype=float)
|
|
10
|
+
groups = data.get("groups", None)
|
|
11
|
+
|
|
12
|
+
if groups is None:
|
|
13
|
+
ax.scatter(x_data, y_data, s=60, color=colors[0],
|
|
14
|
+
edgecolors='white', linewidth=0.5, alpha=0.7, zorder=3)
|
|
15
|
+
else:
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
# Preserve insertion order of group labels
|
|
18
|
+
seen = {}
|
|
19
|
+
unique_groups = []
|
|
20
|
+
for g in groups:
|
|
21
|
+
if g not in seen:
|
|
22
|
+
seen[g] = True
|
|
23
|
+
unique_groups.append(g)
|
|
24
|
+
|
|
25
|
+
for i, g in enumerate(unique_groups):
|
|
26
|
+
mask = np.array([j for j in range(len(groups)) if groups[j] == g])
|
|
27
|
+
c = colors[i % len(colors)]
|
|
28
|
+
ax.scatter(x_data[mask], y_data[mask], s=60, c=c,
|
|
29
|
+
edgecolors='white', linewidth=0.5, alpha=0.7,
|
|
30
|
+
label=g, zorder=3)
|
|
31
|
+
|
|
32
|
+
# Trend line
|
|
33
|
+
if kwargs.get("trend", True) and len(x_data) >= 3:
|
|
34
|
+
z = np.polyfit(x_data, y_data, 1)
|
|
35
|
+
p = np.poly1d(z)
|
|
36
|
+
x_line = np.linspace(x_data.min(), x_data.max(), 100)
|
|
37
|
+
ax.plot(x_line, p(x_line), '--', color='gray', alpha=0.6, linewidth=1.2, zorder=2)
|
|
38
|
+
r = np.corrcoef(x_data, y_data)[0, 1]
|
|
39
|
+
ax.text(0.95, 0.05, f'r = {r:.3f}', transform=ax.transAxes,
|
|
40
|
+
ha='right', va='bottom', fontsize=9, fontstyle='italic', color='gray',
|
|
41
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8,
|
|
42
|
+
edgecolor='lightgray'))
|
|
43
|
+
|
|
44
|
+
ax.xaxis.grid(True, alpha=0.3, linestyle='--')
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Subplots / multi-panel figure generator."""
|
|
3
|
+
import os, sys
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
7
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
8
|
+
|
|
9
|
+
from fig_core import apply_base_style, set_axis_labels, set_title, set_ticklabels
|
|
10
|
+
from fig_core import finalize_and_save, has_cjk
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def gen_subplots(subplot_data, fig, axes, colors, cjk_fp=None, **kwargs):
|
|
14
|
+
"""
|
|
15
|
+
Generate multi-panel subplot figure.
|
|
16
|
+
|
|
17
|
+
subplot_data should be a list of dicts, each with:
|
|
18
|
+
- "type": chart type
|
|
19
|
+
- "data": data dict for that chart
|
|
20
|
+
- "title" (optional): subplot title
|
|
21
|
+
- "xlabel", "ylabel" (optional)
|
|
22
|
+
- "position" (optional): (row, col) tuple, auto-layout if not provided
|
|
23
|
+
"""
|
|
24
|
+
layout = kwargs.get("layout", "auto") # "auto", (rows, cols), or "1x3"
|
|
25
|
+
titles = kwargs.get("titles", [])
|
|
26
|
+
sharex = kwargs.get("sharex", False)
|
|
27
|
+
sharey = kwargs.get("sharey", False)
|
|
28
|
+
|
|
29
|
+
if isinstance(subplot_data, list) and len(subplot_data) > 0:
|
|
30
|
+
|
|
31
|
+
n = len(subplot_data)
|
|
32
|
+
# Determine layout
|
|
33
|
+
if isinstance(layout, tuple) and len(layout) == 2:
|
|
34
|
+
rows, cols = layout
|
|
35
|
+
elif layout == "auto":
|
|
36
|
+
rows = int(n ** 0.5)
|
|
37
|
+
cols = (n + rows - 1) // rows
|
|
38
|
+
else:
|
|
39
|
+
# Parse "RxC" format
|
|
40
|
+
try:
|
|
41
|
+
parts = layout.lower().replace("x", " ").split()
|
|
42
|
+
rows, cols = int(parts[0]), int(parts[1])
|
|
43
|
+
except Exception:
|
|
44
|
+
rows, cols = 1, n
|
|
45
|
+
|
|
46
|
+
fig2, axes2 = plt.subplots(rows, cols, figsize=(cols * 5, rows * 4))
|
|
47
|
+
if rows * cols == 1:
|
|
48
|
+
axes_arr = [axes2]
|
|
49
|
+
elif rows == 1 or cols == 1:
|
|
50
|
+
axes_arr = list(axes2) if hasattr(axes2, '__iter__') else [axes2]
|
|
51
|
+
else:
|
|
52
|
+
axes_arr = [axes2[r, c] for r in range(rows) for c in range(cols)]
|
|
53
|
+
|
|
54
|
+
# Radar panels require a polar axes; replace the plain axes at that slot
|
|
55
|
+
for idx, item in enumerate(subplot_data):
|
|
56
|
+
if idx >= len(axes_arr):
|
|
57
|
+
break
|
|
58
|
+
if item.get("type") == "radar":
|
|
59
|
+
old_ax = axes_arr[idx]
|
|
60
|
+
old_ax.remove()
|
|
61
|
+
axes_arr[idx] = fig2.add_subplot(rows, cols, idx + 1, polar=True)
|
|
62
|
+
|
|
63
|
+
# Import generators on demand
|
|
64
|
+
from fig_bar import gen_bar
|
|
65
|
+
from fig_heatmap import gen_heatmap
|
|
66
|
+
from fig_scatter import gen_scatter
|
|
67
|
+
from fig_line import gen_line
|
|
68
|
+
from fig_box import gen_box, gen_violin
|
|
69
|
+
from fig_forest import gen_forest
|
|
70
|
+
from fig_distribution import gen_histogram, gen_density
|
|
71
|
+
from fig_pie import gen_pie
|
|
72
|
+
from fig_radar import gen_radar
|
|
73
|
+
from fig_bubble import gen_bubble
|
|
74
|
+
from fig_area import gen_area
|
|
75
|
+
from fig_errorbar import gen_errorbar
|
|
76
|
+
from fig_venn import gen_venn
|
|
77
|
+
|
|
78
|
+
GEN_MAP = {
|
|
79
|
+
"bar": gen_bar, "grouped_bar": gen_bar,
|
|
80
|
+
"heatmap": gen_heatmap,
|
|
81
|
+
"scatter": gen_scatter,
|
|
82
|
+
"line": gen_line,
|
|
83
|
+
"box": gen_box, "boxplot": gen_box,
|
|
84
|
+
"violin": gen_violin,
|
|
85
|
+
"forest": gen_forest,
|
|
86
|
+
"histogram": gen_histogram, "density": gen_density,
|
|
87
|
+
"pie": gen_pie, "donut": gen_pie,
|
|
88
|
+
"radar": gen_radar,
|
|
89
|
+
"bubble": gen_bubble,
|
|
90
|
+
"area": gen_area,
|
|
91
|
+
"errorbar": gen_errorbar,
|
|
92
|
+
"venn": gen_venn,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for idx, item in enumerate(subplot_data):
|
|
96
|
+
if idx >= len(axes_arr):
|
|
97
|
+
break
|
|
98
|
+
ax = axes_arr[idx]
|
|
99
|
+
chart_type = item.get("type", "bar")
|
|
100
|
+
chart_data = item.get("data", {})
|
|
101
|
+
|
|
102
|
+
gen_func = GEN_MAP.get(chart_type)
|
|
103
|
+
if gen_func:
|
|
104
|
+
extra = gen_func(chart_data, ax, colors, cjk_fp)
|
|
105
|
+
else:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if chart_type not in ("radar", "venn"):
|
|
109
|
+
apply_base_style(ax)
|
|
110
|
+
set_axis_labels(ax, item.get("xlabel", ""), item.get("ylabel", ""), cjk_fp)
|
|
111
|
+
set_title(ax, item.get("title", ""), cjk_fp)
|
|
112
|
+
|
|
113
|
+
# Hide unused axes
|
|
114
|
+
for idx in range(n, len(axes_arr)):
|
|
115
|
+
axes_arr[idx].set_visible(False)
|
|
116
|
+
|
|
117
|
+
# Tighten panel spacing so titles/labels do not overlap.
|
|
118
|
+
try:
|
|
119
|
+
fig2.tight_layout(pad=1.2, h_pad=1.5, w_pad=1.2)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
return fig2
|
|
124
|
+
return None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Taylor diagram generator (model-vs-observation skill evaluation)."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_taylor(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate a Taylor diagram.
|
|
8
|
+
|
|
9
|
+
Data schema (JSON):
|
|
10
|
+
{
|
|
11
|
+
"obs_std": 1.0, # standard deviation of reference
|
|
12
|
+
"models": [
|
|
13
|
+
{"name": "M1", "std": 1.2, "corr": 0.85},
|
|
14
|
+
...
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
Polar axes: angle = arccos(correlation), radius = standard deviation.
|
|
18
|
+
The reference sits at (angle 0, radius obs_std). RMSD contours are drawn
|
|
19
|
+
around the reference point.
|
|
20
|
+
"""
|
|
21
|
+
obs_std = float(data.get("obs_std", 1.0))
|
|
22
|
+
models = data.get("models", [])
|
|
23
|
+
if not models:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
refs = []
|
|
27
|
+
for m in models:
|
|
28
|
+
std = float(m.get("std", m.get("std_ratio", 1.0)))
|
|
29
|
+
corr = float(m.get("corr", m.get("R", 0.0)))
|
|
30
|
+
corr = max(min(corr, 1.0), -1.0)
|
|
31
|
+
refs.append((str(m.get("name", "")), std, np.arccos(corr)))
|
|
32
|
+
|
|
33
|
+
max_r = max(obs_std, max(r for _, r, _ in refs)) * 1.12
|
|
34
|
+
|
|
35
|
+
corr_ticks = [0.0, 0.2, 0.4, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0]
|
|
36
|
+
theta_ticks = [np.degrees(np.arccos(c)) for c in corr_ticks]
|
|
37
|
+
rticks = np.linspace(0, max_r, 5)[1:]
|
|
38
|
+
|
|
39
|
+
ax.set_rlim(0, max_r)
|
|
40
|
+
ax.set_thetamin(0)
|
|
41
|
+
ax.set_thetamax(180)
|
|
42
|
+
ax.set_thetagrids(theta_ticks, labels=[f"{c:g}" for c in corr_ticks])
|
|
43
|
+
ax.set_rgrids(rticks, labels=[f"{r:.1f}" for r in rticks], angle=45)
|
|
44
|
+
|
|
45
|
+
# Reference point
|
|
46
|
+
ax.plot([0], [obs_std], 'k*', markersize=13, zorder=5)
|
|
47
|
+
ref_text = ax.text(0.05, obs_std * 1.04, 'REF', ha='left', va='bottom',
|
|
48
|
+
fontsize=10, fontweight='bold', zorder=6)
|
|
49
|
+
|
|
50
|
+
# RMSD contours centered at reference (r=obs_std, theta=0)
|
|
51
|
+
for rmsd in np.linspace(0.25, max_r, 4):
|
|
52
|
+
th = np.linspace(0, 2 * np.pi, 240)
|
|
53
|
+
cx, cy = obs_std, 0.0
|
|
54
|
+
xs = cx + rmsd * np.cos(th)
|
|
55
|
+
ys = cy + rmsd * np.sin(th)
|
|
56
|
+
R = np.sqrt(xs ** 2 + ys ** 2)
|
|
57
|
+
T = np.mod(np.arctan2(ys, xs), 2 * np.pi)
|
|
58
|
+
mask = (T >= 0) & (T <= np.pi)
|
|
59
|
+
if mask.sum() > 1:
|
|
60
|
+
pts = np.column_stack([T[mask], R[mask]])
|
|
61
|
+
pts = pts[np.argsort(pts[:, 0])]
|
|
62
|
+
ax.plot(pts[:, 0], pts[:, 1], color='lightgray', linewidth=0.8, zorder=1)
|
|
63
|
+
|
|
64
|
+
# Model points (labels placed just outside each marker, then de-overlapped
|
|
65
|
+
# so they never collide with each other or the correlation tick labels)
|
|
66
|
+
model_texts = []
|
|
67
|
+
for i, (name, r, theta) in enumerate(refs):
|
|
68
|
+
c = colors[i % len(colors)]
|
|
69
|
+
ax.plot([theta], [r], 'o', color=c, markersize=9, label=name, zorder=4)
|
|
70
|
+
if name:
|
|
71
|
+
t = ax.text(theta, r * 1.06, name, ha='center', va='bottom',
|
|
72
|
+
fontsize=8, zorder=6)
|
|
73
|
+
model_texts.append(t)
|
|
74
|
+
|
|
75
|
+
from fig_core import separate_labels
|
|
76
|
+
separate_labels(ax, [ref_text] + model_texts,
|
|
77
|
+
clamp=[(0.03, np.pi - 0.03), (0.02 * max_r, None)])
|
|
78
|
+
|
|
79
|
+
ax.set_title("Taylor Diagram", fontsize=11, fontweight='bold', pad=12)
|
|
80
|
+
return None
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Venn / Euler diagram generator (2- and 3-set) — pure matplotlib, no extra deps."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
from matplotlib.patches import Circle
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def gen_venn(data, ax, colors, cjk_fp=None, **kwargs):
|
|
8
|
+
"""Generate a 2- or 3-set Venn diagram.
|
|
9
|
+
|
|
10
|
+
Data schema (JSON):
|
|
11
|
+
{
|
|
12
|
+
"regions": {"A": n, "B": n, "AB": n} # 2-set
|
|
13
|
+
"regions": {"A":, "B":, "C":, "AB":, "AC":, "BC":, "ABC":} # 3-set
|
|
14
|
+
"labels": ["A", "B"(, "C")] (optional)
|
|
15
|
+
}
|
|
16
|
+
The number of sets is inferred from the presence of key "C"/"ABC".
|
|
17
|
+
"""
|
|
18
|
+
regions = data.get("regions", {})
|
|
19
|
+
labels = data.get("labels")
|
|
20
|
+
|
|
21
|
+
if "C" in regions or "ABC" in regions:
|
|
22
|
+
_venn3(ax, regions, (labels or ["A", "B", "C"]), colors)
|
|
23
|
+
else:
|
|
24
|
+
_venn2(ax, regions, (labels or ["A", "B"]), colors)
|
|
25
|
+
|
|
26
|
+
ax.set_aspect('equal')
|
|
27
|
+
ax.axis('off')
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _circle(ax, center, r, color):
|
|
32
|
+
c = Circle(center, r, fill=True, color=color, alpha=0.5,
|
|
33
|
+
edgecolor='white', linewidth=1.2, zorder=1)
|
|
34
|
+
ax.add_patch(c)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _venn2(ax, r, labels, colors):
|
|
38
|
+
ca, cb = colors[0], colors[1 % len(colors)]
|
|
39
|
+
_circle(ax, (-0.5, 0), 1.0, ca)
|
|
40
|
+
_circle(ax, (0.5, 0), 1.0, cb)
|
|
41
|
+
ax.text(-1.15, 0.0, str(r.get("A", 0)), ha='center', va='center', fontsize=12)
|
|
42
|
+
ax.text(1.15, 0.0, str(r.get("B", 0)), ha='center', va='center', fontsize=12)
|
|
43
|
+
ax.text(0.0, 0.0, str(r.get("AB", 0)), ha='center', va='center', fontsize=12)
|
|
44
|
+
ax.text(-0.5, 1.2, str(labels[0]), ha='center', va='bottom', fontsize=12, fontweight='bold')
|
|
45
|
+
ax.text(0.5, 1.2, str(labels[1]), ha='center', va='bottom', fontsize=12, fontweight='bold')
|
|
46
|
+
ax.set_xlim(-1.7, 1.7)
|
|
47
|
+
ax.set_ylim(-1.25, 1.5)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _venn3(ax, r, labels, colors):
|
|
51
|
+
# triangle of circles
|
|
52
|
+
centers = {
|
|
53
|
+
'A': (0.0, 0.65),
|
|
54
|
+
'B': (-0.55, -0.4),
|
|
55
|
+
'C': (0.55, -0.4),
|
|
56
|
+
}
|
|
57
|
+
for k, c in centers.items():
|
|
58
|
+
_circle(ax, c, 1.0, colors[list('ABC').index(k) % len(colors)])
|
|
59
|
+
# region label positions (approximate standard 3-set Venn layout)
|
|
60
|
+
pos = {
|
|
61
|
+
'A': (0.0, 1.25),
|
|
62
|
+
'B': (-1.15, -0.55),
|
|
63
|
+
'C': (1.15, -0.55),
|
|
64
|
+
'AB': (-0.32, 0.30),
|
|
65
|
+
'AC': (0.32, 0.30),
|
|
66
|
+
'BC': (0.0, -0.62),
|
|
67
|
+
'ABC': (0.0, -0.08),
|
|
68
|
+
}
|
|
69
|
+
for key, (px, py) in pos.items():
|
|
70
|
+
if key in r:
|
|
71
|
+
ax.text(px, py, str(r[key]), ha='center', va='center', fontsize=11)
|
|
72
|
+
# set labels placed just outside each circle (above A, lower-corner B/C)
|
|
73
|
+
label_pos = {'A': (0.0, 1.75), 'B': (-1.5, -1.25), 'C': (1.5, -1.25)}
|
|
74
|
+
for i, k in enumerate('ABC'):
|
|
75
|
+
if i < len(labels):
|
|
76
|
+
lx, ly = label_pos[k]
|
|
77
|
+
ax.text(lx, ly, str(labels[i]), ha='center', va='center',
|
|
78
|
+
fontsize=12, fontweight='bold')
|
|
79
|
+
ax.set_xlim(-1.9, 1.9)
|
|
80
|
+
ax.set_ylim(-1.5, 1.9)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wind rose generator (polar stacked bars by direction sector and speed bin)."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def gen_windrose(data, ax, colors, cjk_fp=None, **kwargs):
|
|
7
|
+
"""Generate a wind rose.
|
|
8
|
+
|
|
9
|
+
Two input modes:
|
|
10
|
+
|
|
11
|
+
Mode A — raw observations (most flexible):
|
|
12
|
+
{
|
|
13
|
+
"direction": [degrees, ...], # 0=N, 90=E ...
|
|
14
|
+
"speed": [m/s, ...],
|
|
15
|
+
"speed_bins":[0, 3, 6, 9, 12, 999] (optional, default shown),
|
|
16
|
+
"sectors": 16 (optional),
|
|
17
|
+
"normalize": true (optional -> show % instead of counts)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Mode B — pre-aggregated:
|
|
21
|
+
{
|
|
22
|
+
"directions": ["N","NNE",...] or [names],
|
|
23
|
+
"matrix": [[count per speed-bin], ...] # len(directions) x n_bins
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
sectors = int(data.get("sectors", kwargs.get("sectors") or 16))
|
|
27
|
+
speed_bins = data.get("speed_bins") or [0, 3, 6, 9, 12, 1e9]
|
|
28
|
+
normalize = bool(data.get("normalize", False))
|
|
29
|
+
|
|
30
|
+
directions = data.get("direction")
|
|
31
|
+
speeds = data.get("speed")
|
|
32
|
+
|
|
33
|
+
if directions is not None and speeds is not None:
|
|
34
|
+
directions = np.array(directions, dtype=float)
|
|
35
|
+
speeds = np.array(speeds, dtype=float)
|
|
36
|
+
bin_idx = np.digitize(speeds, speed_bins[1:-1]) # 0 .. n_bins-1
|
|
37
|
+
n_bins = len(speed_bins) - 1
|
|
38
|
+
seg = 2 * np.pi / sectors
|
|
39
|
+
counts = np.zeros((sectors, n_bins))
|
|
40
|
+
for d, b in zip(directions, bin_idx):
|
|
41
|
+
theta = np.deg2rad((90.0 - d) % 360.0) # N up, CW
|
|
42
|
+
sec = int(theta // seg) % sectors
|
|
43
|
+
counts[sec, b] += 1
|
|
44
|
+
else:
|
|
45
|
+
dirs = data.get("directions", [])
|
|
46
|
+
matrix = np.array(data.get("matrix", []), dtype=float)
|
|
47
|
+
sectors = len(dirs)
|
|
48
|
+
n_bins = matrix.shape[1] if matrix.ndim == 2 else 1
|
|
49
|
+
counts = matrix if matrix.ndim == 2 else matrix.reshape(-1, 1)
|
|
50
|
+
|
|
51
|
+
if normalize:
|
|
52
|
+
total = counts.sum()
|
|
53
|
+
if total > 0:
|
|
54
|
+
counts = counts / total * 100.0
|
|
55
|
+
|
|
56
|
+
# Speed-bin legend labels
|
|
57
|
+
if len(speed_bins) - 1 == n_bins:
|
|
58
|
+
bin_names = []
|
|
59
|
+
for b in range(n_bins):
|
|
60
|
+
lo, hi = speed_bins[b], speed_bins[b + 1]
|
|
61
|
+
bin_names.append(f"≥{lo:g}" if hi >= 1e8 else f"{lo:g}–{hi:g}")
|
|
62
|
+
else:
|
|
63
|
+
bin_names = [f"Bin {b + 1}" for b in range(n_bins)]
|
|
64
|
+
|
|
65
|
+
seg = 2 * np.pi / sectors
|
|
66
|
+
theta = (np.arange(sectors) + 0.5) * seg
|
|
67
|
+
width = seg * 0.9
|
|
68
|
+
bottom = np.zeros(sectors)
|
|
69
|
+
for b in range(n_bins):
|
|
70
|
+
vals = counts[:, b]
|
|
71
|
+
ax.bar(theta, vals, width=width, bottom=bottom, color=colors[b % len(colors)],
|
|
72
|
+
edgecolor='white', linewidth=0.3, zorder=2, label=bin_names[b])
|
|
73
|
+
bottom = bottom + vals
|
|
74
|
+
|
|
75
|
+
ax.set_theta_zero_location('N')
|
|
76
|
+
ax.set_theta_direction(-1)
|
|
77
|
+
if normalize:
|
|
78
|
+
ax.set_rlabel_position(90)
|
|
79
|
+
# speed-bin legend, placed outside the polar area
|
|
80
|
+
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1.0), fontsize=8,
|
|
81
|
+
framealpha=0.9, title="m/s")
|
|
82
|
+
return None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Auto-detect CJK and special fonts for matplotlib. Returns font path or None."""
|
|
3
|
+
import subprocess, os, json
|
|
4
|
+
|
|
5
|
+
CJK_FONT_PRIORITY = [
|
|
6
|
+
# Noto CJK (most Linux distros)
|
|
7
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
8
|
+
"/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf",
|
|
9
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
|
10
|
+
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
|
|
11
|
+
# macOS
|
|
12
|
+
"/System/Library/Fonts/PingFang.ttc",
|
|
13
|
+
"/System/Library/Fonts/STHeiti Medium.ttc",
|
|
14
|
+
"/Library/Fonts/Arial Unicode.ttf",
|
|
15
|
+
# Windows
|
|
16
|
+
"C:/Windows/Fonts/msyh.ttc", # Microsoft YaHei
|
|
17
|
+
"C:/Windows/Fonts/simhei.ttf", # SimHei
|
|
18
|
+
"C:/Windows/Fonts/simsun.ttc", # SimSun
|
|
19
|
+
# Common Linux
|
|
20
|
+
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
|
21
|
+
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
|
22
|
+
"/usr/share/fonts/truetype/arphic/uming.ttc",
|
|
23
|
+
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
|
24
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Additional serif/math fonts for scienceplots styles
|
|
28
|
+
SERIF_FONT_PRIORITY = [
|
|
29
|
+
"C:/Windows/Fonts/times.ttf",
|
|
30
|
+
"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
|
|
31
|
+
"/System/Library/Fonts/Times New Roman.ttf",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
SANS_FONT_PRIORITY = [
|
|
35
|
+
"C:/Windows/Fonts/arial.ttf",
|
|
36
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
|
37
|
+
"/System/Library/Fonts/Helvetica.ttf",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_cjk_font():
|
|
42
|
+
"""Return path to first available CJK font, or None."""
|
|
43
|
+
for p in CJK_FONT_PRIORITY:
|
|
44
|
+
if os.path.exists(p):
|
|
45
|
+
return p
|
|
46
|
+
# Fallback: fc-list
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["fc-list", ":lang=zh", "file"],
|
|
50
|
+
capture_output=True, text=True, timeout=5
|
|
51
|
+
)
|
|
52
|
+
if result.stdout.strip():
|
|
53
|
+
for line in result.stdout.strip().split('\n'):
|
|
54
|
+
if ':' in line:
|
|
55
|
+
return line.split(':')[0].strip()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_serif_font():
|
|
62
|
+
"""Return path to first available serif font, or None."""
|
|
63
|
+
for p in SERIF_FONT_PRIORITY:
|
|
64
|
+
if os.path.exists(p):
|
|
65
|
+
return p
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_sans_font():
|
|
70
|
+
"""Return path to first available sans-serif font, or None."""
|
|
71
|
+
for p in SANS_FONT_PRIORITY:
|
|
72
|
+
if os.path.exists(p):
|
|
73
|
+
return p
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
cjk = detect_cjk_font()
|
|
79
|
+
serif = detect_serif_font()
|
|
80
|
+
sans = detect_sans_font()
|
|
81
|
+
print(json.dumps({
|
|
82
|
+
"cjk": {"found": cjk is not None, "path": cjk},
|
|
83
|
+
"serif": {"found": serif is not None, "path": serif},
|
|
84
|
+
"sans": {"found": sans is not None, "path": sans},
|
|
85
|
+
}))
|