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