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,153 @@
1
+ #!/usr/bin/env python3
2
+ """Chart plugin base class for like-sci-figures.
3
+
4
+ Why this exists
5
+ ---------------
6
+ Before this file, adding a new chart type required editing 4 files:
7
+ 1. scripts/fig_XXX.py (rendering function)
8
+ 2. scripts/gen_figure.py (GENERATORS dict entry)
9
+ 3. scripts/registry.py (chart type registry entry)
10
+ 4. examples/XXX.json (example data file)
11
+
12
+ With ChartPlugin, a new chart type is self-contained: subclass ChartPlugin,
13
+ set `name`, implement `render()`, and the rest (auto-registration, palette
14
+ integration, example lookup, documentation) happens automatically.
15
+
16
+ Usage
17
+ -----
18
+ # In fig_my_chart.py
19
+ from chart_plugin import ChartPlugin
20
+
21
+ class MyChart(ChartPlugin):
22
+ name = "my_chart"
23
+ zh = "我的图表"
24
+ kind = "specialized"
25
+
26
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
27
+ # Your rendering logic here
28
+ ax.plot(data["x"], data["y"], color=colors[0])
29
+ return None # colorbar artist if needed, else None
30
+
31
+ # Auto-registered when gen_figure.py imports this module.
32
+
33
+ Plugin lifecycle
34
+ ----------------
35
+ 1. Module is loaded (by gen_figure.py's auto-discovery or explicit import).
36
+ 2. ChartPlugin subclass is instantiated (singleton per type).
37
+ 3. `register()` is called, adding the plugin to the GENERATORS dict.
38
+ 4. When `--type my_chart` is used, `render()` is called with the parsed data.
39
+
40
+ Palette integration is automatic: `colors` is passed from the palette system,
41
+ so any ChartPlugin subclass works with `--palette` without extra code.
42
+ """
43
+ import os
44
+
45
+
46
+ class ChartPlugin:
47
+ """Base class for chart types. Subclass and set `name` to register."""
48
+
49
+ # ── Override these ───────────────────────────────────────────────────
50
+ name = "base" # canonical slug for --type
51
+ zh = "Base Chart" # Chinese display name
52
+ kind = "specialized" # statistics | relational | specialized
53
+ aliases = () # tuple of alias slugs
54
+
55
+ # ── Data schema hint (for documentation) ─────────────────────────────
56
+ required_keys = () # tuple of required data keys
57
+ optional_keys = () # tuple of optional data keys
58
+ data_format = "json" # json | csv | both
59
+
60
+ # ── Rendering ────────────────────────────────────────────────────────
61
+ def render(self, data, ax, colors, cjk_fp, **kwargs):
62
+ """Render the chart on `ax`.
63
+
64
+ Args:
65
+ data: parsed data dict (or list for subplots)
66
+ ax: matplotlib axes object
67
+ colors: list of hex colours from the current palette
68
+ cjk_fp: CJK font path (str or None)
69
+ **kwargs: chart-specific flags (show_values, trend, etc.)
70
+
71
+ Returns:
72
+ Optional colorbar artist (for heatmaps/surfaces), else None.
73
+ """
74
+ raise NotImplementedError("ChartPlugin subclasses must implement render()")
75
+
76
+ # ── Validation ───────────────────────────────────────────────────────
77
+ def validate_data(self, data):
78
+ """Check that the data has the required keys.
79
+
80
+ Returns a list of error strings (empty = valid).
81
+ Override to add custom validation logic.
82
+ """
83
+ errors = []
84
+ if not isinstance(data, dict):
85
+ errors.append("Data must be a dict, got %s" % type(data).__name__)
86
+ return errors
87
+ for key in self.required_keys:
88
+ if key not in data:
89
+ errors.append("Missing required key: %r" % key)
90
+ return errors
91
+
92
+ # ── Auto-registration ────────────────────────────────────────────────
93
+ def register(self):
94
+ """Register this plugin with the GENERATORS dict.
95
+
96
+ Called automatically by gen_figure.py's discovery mechanism.
97
+ """
98
+ from gen_figure import GENERATORS
99
+ GENERATORS[self.name] = self
100
+ for alias in self.aliases:
101
+ GENERATORS[alias] = self
102
+
103
+ def __call__(self, data, ax, colors, cjk_fp, **kwargs):
104
+ """Make the plugin callable like a generator function."""
105
+ return self.render(data, ax, colors, cjk_fp, **kwargs)
106
+
107
+ def __repr__(self):
108
+ return "%s(name=%r, kind=%r)" % (
109
+ type(self).__name__, self.name, self.kind)
110
+
111
+
112
+ def discover_plugins(plugins_dir=None):
113
+ """Auto-discover all ChartPlugin subclasses in scripts/fig_*.py.
114
+
115
+ Args:
116
+ plugins_dir: directory to scan (default: scripts/ directory)
117
+
118
+ Returns:
119
+ dict mapping chart type name -> ChartPlugin instance.
120
+ """
121
+ import importlib
122
+ import pkgutil
123
+ import sys
124
+
125
+ if plugins_dir is None:
126
+ plugins_dir = os.path.dirname(os.path.abspath(__file__))
127
+
128
+ plugins = {}
129
+ sys_path = sys.path[:]
130
+ sys.path.insert(0, plugins_dir)
131
+ try:
132
+ for module_info in pkgutil.iter_modules([plugins_dir]):
133
+ if not module_info.name.startswith("fig_"):
134
+ continue
135
+ try:
136
+ module = importlib.import_module(module_info.name)
137
+ except Exception:
138
+ continue # skip broken modules
139
+ for attr_name in dir(module):
140
+ attr = getattr(module, attr_name)
141
+ if (isinstance(attr, type) and issubclass(attr, ChartPlugin)
142
+ and attr is not ChartPlugin
143
+ and hasattr(attr, 'name')
144
+ and attr.name != "base"):
145
+ # Skip duplicate registrations (alias already added)
146
+ if attr.name in plugins:
147
+ continue
148
+ instance = attr()
149
+ instance.register()
150
+ plugins[attr.name] = instance
151
+ finally:
152
+ sys.path[:] = sys_path
153
+ return plugins
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """Data loader: JSON, CSV, TSV with intelligent format detection."""
3
+ import json, csv, os
4
+
5
+
6
+ def _is_number(s):
7
+ """Check if string represents a number."""
8
+ try:
9
+ float(s)
10
+ return True
11
+ except (ValueError, TypeError):
12
+ return False
13
+
14
+
15
+ def load_data(path, chart_type=None):
16
+ """
17
+ Load data from JSON or CSV/TSV.
18
+
19
+ Returns a dict appropriate for the chart type:
20
+ - bar/line: {"labels": [], "series": {}, "errors": {}}
21
+ - scatter: {"x": [], "y": [], "groups": []}
22
+ - heatmap: {"matrix": [[]], "row_labels": [], "col_labels": []}
23
+ - box/violin: {"labels": [], "series": {}}
24
+ - forest: {"labels": [], "estimates": [], "ci_low": [], "ci_high": []}
25
+ - pie: {"labels": [], "values": []}
26
+ - radar: {"labels": [], "series": {}}
27
+ - histogram: {"series": {}} or {"values": []}
28
+ - 3d: {"x": [], "y": [], "z": []} or {"matrix": [[]]}
29
+ - subplots: list of individual chart data dicts
30
+ """
31
+ ext = os.path.splitext(path)[1].lower()
32
+ if ext == '.json':
33
+ with open(path, 'r', encoding='utf-8') as f:
34
+ data = json.load(f)
35
+ return _validate_and_convert(data, chart_type)
36
+ elif ext in ('.csv', '.tsv'):
37
+ return _load_tabular(path, ext, chart_type)
38
+ else:
39
+ raise ValueError(f"Unsupported format: {ext}. Use .json, .csv, or .tsv")
40
+
41
+
42
+ def _normalize_series_list(series):
43
+ """Convert list-of-objects series to dict format.
44
+
45
+ Handles: [{"name": "A", "values": [1,2], "errors": [0.1,0.2]}, ...]
46
+ -> ({"A": [1,2]}, {"A": [0.1,0.2]})
47
+ Also accepts: [{"label": "A", "data": [1,2]}, ...]
48
+ """
49
+ if not isinstance(series, list):
50
+ return series, {}
51
+ result = {}
52
+ errors = {}
53
+ for item in series:
54
+ if not isinstance(item, dict):
55
+ continue
56
+ name = item.get("name") or item.get("label") or item.get("key", f"Series {len(result)+1}")
57
+ values = item.get("values") or item.get("data") or item.get("y") or item.get("value", [])
58
+ if isinstance(values, list) and values:
59
+ result[name] = values
60
+ err = item.get("errors") or item.get("error") or item.get("std") or item.get("sem")
61
+ if err is not None:
62
+ errors[name] = err
63
+ return result if result else series, errors if errors else {}
64
+
65
+
66
+ def _validate_and_convert(data, chart_type):
67
+ """Validate JSON data against chart type and fill missing fields."""
68
+ if not isinstance(data, dict):
69
+ return data # Could be a list for subplots
70
+
71
+ # Check for common alternate keys and normalize
72
+ key_map = {
73
+ "x": ["x", "xs", "x_data", "x_values"],
74
+ "y": ["y", "ys", "y_data", "y_values"],
75
+ "z": ["z", "zs", "z_data", "z_values"],
76
+ "labels": ["labels", "label", "categories", "names", "x_labels"],
77
+ "series": ["series", "datasets", "data", "groups"],
78
+ "matrix": ["matrix", "data", "grid", "array"],
79
+ "estimates": ["estimates", "effects", "or", "rr", "hr"],
80
+ "ci_low": ["ci_low", "lower", "ci_lower", "lo"],
81
+ "ci_high": ["ci_high", "upper", "ci_upper", "hi"],
82
+ "errors": ["errors", "error", "std", "sem"],
83
+ "row_labels": ["row_labels", "y_labels", "rows", "y_ticks"],
84
+ "col_labels": ["col_labels", "x_labels", "cols", "x_ticks", "columns"],
85
+ "groups": ["groups", "group", "categories", "category", "color_by"],
86
+ }
87
+
88
+ normalized = {}
89
+ for canonical, aliases in key_map.items():
90
+ for alias in aliases:
91
+ if alias in data:
92
+ normalized[canonical] = data[alias]
93
+ break
94
+
95
+ # Keep any keys not in the alias map
96
+ for k, v in data.items():
97
+ found = False
98
+ for aliases in key_map.values():
99
+ if k in aliases:
100
+ found = True
101
+ break
102
+ if not found:
103
+ normalized[k] = v
104
+
105
+ # Convert list-of-objects series to dict format
106
+ if "series" in normalized and isinstance(normalized["series"], list):
107
+ series_dict, embedded_errors = _normalize_series_list(normalized["series"])
108
+ normalized["series"] = series_dict
109
+ if embedded_errors and "errors" not in normalized:
110
+ normalized["errors"] = embedded_errors
111
+ # Also normalize errors if they are in list-of-objects format
112
+ if "errors" in normalized and isinstance(normalized["errors"], list):
113
+ errs, _ = _normalize_series_list(normalized["errors"])
114
+ if errs:
115
+ normalized["errors"] = errs
116
+
117
+ return normalized
118
+
119
+
120
+ def _load_tabular(path, ext, chart_type):
121
+ """Load CSV/TSV and auto-detect structure."""
122
+ delimiter = '\t' if ext == '.tsv' else ','
123
+ with open(path, 'r', encoding='utf-8') as f:
124
+ reader = csv.reader(f, delimiter=delimiter)
125
+ headers = next(reader)
126
+ rows = list(reader)
127
+
128
+ if not rows:
129
+ return {"labels": [], "series": {}}
130
+
131
+ # Identify numeric columns (>50% numeric threshold)
132
+ numeric_col_indices = []
133
+ for i, h in enumerate(headers):
134
+ if i == 0:
135
+ continue
136
+ num_count = sum(1 for r in rows if i < len(r) and _is_number(r[i]))
137
+ if num_count > len(rows) * 0.5:
138
+ numeric_col_indices.append(i)
139
+
140
+ # Filter bad rows (non-numeric in numeric columns)
141
+ bad_rows = set()
142
+ for i in numeric_col_indices:
143
+ for ri, r in enumerate(rows):
144
+ if ri < len(r) and not _is_number(r[i]):
145
+ bad_rows.add(ri)
146
+
147
+ good_rows = [ri for ri in range(len(rows)) if ri not in bad_rows]
148
+
149
+ # Build series
150
+ series = {}
151
+ for i in numeric_col_indices:
152
+ vals = [float(rows[ri][i]) for ri in good_rows if ri < len(rows)]
153
+ if vals:
154
+ series[headers[i]] = vals
155
+
156
+ labels = [rows[ri][0] for ri in good_rows if ri < len(rows)]
157
+
158
+ # --- Chart-specific conversions ---
159
+ if chart_type in ('scatter',) and series:
160
+ return _convert_to_scatter(headers, rows, good_rows, numeric_col_indices)
161
+
162
+ if chart_type in ('box', 'boxplot', 'violin') and series:
163
+ return _convert_to_grouped(headers, rows, good_rows, labels, numeric_col_indices)
164
+
165
+ if chart_type in ('heatmap',) and series:
166
+ matrix = list(series.values())
167
+ return {
168
+ "matrix": matrix,
169
+ "row_labels": list(series.keys()),
170
+ "col_labels": labels,
171
+ }
172
+
173
+ if chart_type in ('pie',) and series:
174
+ first_key = list(series.keys())[0]
175
+ return {"labels": labels, "values": series[first_key]}
176
+
177
+ if chart_type in ('histogram',) and series:
178
+ first_key = list(series.keys())
179
+ if len(first_key) == 1:
180
+ return {"values": series[first_key[0]], "name": first_key[0]}
181
+ return {"series": series}
182
+
183
+ if chart_type in ('radar',):
184
+ return {"labels": labels, "series": series}
185
+
186
+ # Default: bar/line format
187
+ return {"labels": labels, "series": series}
188
+
189
+
190
+ def _convert_to_scatter(headers, rows, good_rows, numeric_col_indices):
191
+ """Convert tabular data to scatter format."""
192
+ all_numeric = []
193
+ for i, h in enumerate(headers):
194
+ num_count = sum(1 for ri in good_rows if ri < len(rows) and _is_number(rows[ri][i]))
195
+ if num_count > len(good_rows) * 0.5:
196
+ all_numeric.append((i, h))
197
+
198
+ if len(all_numeric) < 2:
199
+ col_i, col_name = all_numeric[0]
200
+ y_vals = [float(rows[ri][col_i]) for ri in good_rows if ri < len(rows)]
201
+ labels = [rows[ri][0] for ri in good_rows]
202
+ if labels and all(_is_number(l) for l in labels):
203
+ return {"x": [float(l) for l in labels], "y": y_vals}
204
+ return {"x": list(range(len(y_vals))), "y": y_vals}
205
+
206
+ col_i, col_x_name = all_numeric[0]
207
+ col_j, col_y_name = all_numeric[1]
208
+ x_vals = [float(rows[ri][col_i]) for ri in good_rows if ri < len(rows) and col_i < len(rows[ri])]
209
+ y_vals = [float(rows[ri][col_j]) for ri in good_rows if ri < len(rows) and col_j < len(rows[ri])]
210
+
211
+ # Try to find grouping column
212
+ groups = None
213
+ _group_hints = {'group', 'groups', 'category', 'categories', 'class', 'label', 'type'}
214
+ candidates = []
215
+ for i, h in enumerate(headers):
216
+ if i in [idx for idx, _ in all_numeric]:
217
+ continue
218
+ vals = [rows[ri][i] for ri in good_rows if ri < len(rows)]
219
+ unique = set(vals)
220
+ if 1 < len(unique) <= 20:
221
+ score = (0, len(unique), i)
222
+ if h.lower().strip() in _group_hints:
223
+ score = (-1, len(unique), i)
224
+ candidates.append((score, vals, h))
225
+
226
+ if candidates:
227
+ candidates.sort()
228
+ groups = candidates[0][1]
229
+
230
+ result = {"x": x_vals, "y": y_vals}
231
+ if groups:
232
+ result["groups"] = groups
233
+ return result
234
+
235
+
236
+ def _convert_to_grouped(headers, rows, good_rows, labels, numeric_col_indices):
237
+ """Convert tabular to box/violin (grouped long-format) format."""
238
+ first_col_vals = [rows[ri][0] for ri in good_rows if ri < len(rows)]
239
+ unique_groups = list(dict.fromkeys(first_col_vals))
240
+
241
+ if len(unique_groups) < len(first_col_vals) * 0.8 and len(unique_groups) >= 2:
242
+ num_col_idx = numeric_col_indices[0] if numeric_col_indices else None
243
+ if num_col_idx is not None:
244
+ grouped = {g: [] for g in unique_groups}
245
+ for ri in good_rows:
246
+ if ri < len(rows) and num_col_idx < len(rows[ri]):
247
+ g = rows[ri][0]
248
+ v = rows[ri][num_col_idx]
249
+ if _is_number(v):
250
+ grouped[g].append(float(v))
251
+ if any(len(v) >= 2 for v in grouped.values()):
252
+ return {"labels": unique_groups, "series": grouped}
253
+
254
+ # Fallback: wide format
255
+ series = {}
256
+ for i in numeric_col_indices:
257
+ vals = [float(rows[ri][i]) for ri in good_rows if ri < len(rows)]
258
+ if vals:
259
+ series[headers[i]] = vals
260
+ return {"labels": labels if labels else list(series.keys()), "series": series}
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """3D chart generator: surface, contour, 3D scatter, wireframe."""
3
+ import numpy as np
4
+ import matplotlib.pyplot as plt
5
+
6
+
7
+ def gen_surface(data, ax, colors, cjk_fp=None, **kwargs):
8
+ """Generate 3D surface plot.
9
+
10
+ Accepts either:
11
+ - "matrix": 2D array (rows = y, cols = x). Optional "x"/"y" coordinate vectors.
12
+ - "z": 2D array directly (with optional "x"/"y"), or 1D grid values whose
13
+ length equals len(x)*len(y) in row-major order. Otherwise falls back to a
14
+ 3D scatter plot.
15
+ """
16
+ matrix = data.get("matrix")
17
+ x = data.get("x")
18
+ y = data.get("y")
19
+ z = data.get("z")
20
+
21
+ if matrix is not None:
22
+ Z = np.array(matrix, dtype=float)
23
+ if x is not None and y is not None:
24
+ X, Y = np.meshgrid(np.array(x, dtype=float), np.array(y, dtype=float))
25
+ else:
26
+ X, Y = np.meshgrid(range(Z.shape[1]), range(Z.shape[0]))
27
+ elif z is not None:
28
+ Z = np.array(z, dtype=float)
29
+ if Z.ndim == 2:
30
+ if x is not None and y is not None:
31
+ X, Y = np.meshgrid(np.array(x, dtype=float), np.array(y, dtype=float))
32
+ else:
33
+ X, Y = np.meshgrid(range(Z.shape[1]), range(Z.shape[0]))
34
+ elif Z.ndim == 1 and x is not None and y is not None:
35
+ X = np.array(x, dtype=float)
36
+ Y = np.array(y, dtype=float)
37
+ if len(Z) == len(X) * len(Y):
38
+ # meshgrid default 'xy' indexing matches row-major (C-order) Z
39
+ X, Y = np.meshgrid(X, Y)
40
+ Z = Z.reshape(X.shape)
41
+ else:
42
+ return gen_scatter3d(data, ax, colors, cjk_fp, **kwargs)
43
+ else:
44
+ return gen_scatter3d(data, ax, colors, cjk_fp, **kwargs)
45
+ else:
46
+ # 3D scatter fallback — x, y, z vectors
47
+ return gen_scatter3d(data, ax, colors, cjk_fp, **kwargs)
48
+
49
+ cmap = kwargs.get("cmap", "viridis")
50
+ surf = ax.plot_surface(X, Y, Z, cmap=cmap, alpha=0.9, linewidth=0, antialiased=True,
51
+ edgecolor='none')
52
+
53
+ # Contour projection on the floor
54
+ if kwargs.get("contour_floor", False):
55
+ ax.contour(X, Y, Z, zdir='z', offset=Z.min(), cmap=cmap, alpha=0.5, linewidths=0.5)
56
+
57
+ return surf
58
+
59
+
60
+ def gen_contour(data, ax, colors, cjk_fp=None, **kwargs):
61
+ """Generate 2D contour plot."""
62
+ matrix = data.get("matrix")
63
+ if matrix is None:
64
+ return None
65
+ matrix = np.array(matrix, dtype=float)
66
+
67
+ x = data.get("x")
68
+ y = data.get("y")
69
+ if x is not None and y is not None:
70
+ X, Y = np.meshgrid(np.array(x, dtype=float), np.array(y, dtype=float))
71
+ else:
72
+ X, Y = np.meshgrid(range(matrix.shape[1]), range(matrix.shape[0]))
73
+
74
+ cmap = kwargs.get("cmap", "viridis")
75
+ levels = kwargs.get("contour_levels", 10)
76
+ filled = kwargs.get("filled", True)
77
+
78
+ if filled:
79
+ cf = ax.contourf(X, Y, matrix, levels=levels, cmap=cmap, alpha=0.9)
80
+ ax.contour(X, Y, matrix, levels=levels, colors='black', linewidths=0.3, alpha=0.4)
81
+ else:
82
+ cf = ax.contour(X, Y, matrix, levels=levels, cmap=cmap, linewidths=1.2)
83
+
84
+ ax.clabel(cf, inline=True, fontsize=8)
85
+ ax.set_aspect('equal')
86
+ return cf
87
+
88
+
89
+ def gen_scatter3d(data, ax, colors, cjk_fp=None, **kwargs):
90
+ """Generate 3D scatter plot."""
91
+ x = np.array(data.get("x", []), dtype=float)
92
+ y = np.array(data.get("y", []), dtype=float)
93
+ z = np.array(data.get("z", []), dtype=float)
94
+ groups = data.get("groups", None)
95
+
96
+ if groups is None:
97
+ ax.scatter(x, y, z, s=40, c=colors[0], alpha=0.7)
98
+ else:
99
+ unique_groups = sorted(set(groups), key=list(groups).index)
100
+ for i, g in enumerate(unique_groups):
101
+ mask = np.array([j for j in range(len(groups)) if groups[j] == g])
102
+ ax.scatter(x[mask], y[mask], z[mask], s=40,
103
+ c=colors[i % len(colors)], alpha=0.7, label=g)
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Area / stacked-area chart generator."""
3
+ import numpy as np
4
+ from fig_core import set_ticklabels
5
+
6
+
7
+ def gen_area(data, ax, colors, cjk_fp=None, **kwargs):
8
+ """Generate area chart.
9
+
10
+ Data schema (JSON):
11
+ {
12
+ "labels": [x categories or numbers],
13
+ "series": {"name": [values], ...},
14
+ "stacked": true | false (optional, default: passed via --stacked)
15
+ }
16
+ When stacked, series fill cumulatively; otherwise each is filled from zero.
17
+ """
18
+ labels = data.get("labels", [])
19
+ series = data.get("series", {})
20
+ stacked = data.get("stacked", kwargs.get("stacked", True))
21
+ markers = ['o', 's', 'D', '^', 'v', 'p', '*', 'h', '+', 'x']
22
+
23
+ numeric = all(
24
+ isinstance(l, (int, float)) or
25
+ (isinstance(l, str) and l.replace('.', '', 1).replace('-', '', 1).isdigit())
26
+ for l in labels
27
+ )
28
+ if numeric and labels:
29
+ x = np.array([float(l) for l in labels])
30
+ else:
31
+ x = np.arange(len(labels)) if labels else np.arange(
32
+ max((len(v) for v in series.values()), default=0))
33
+
34
+ names = list(series.keys())
35
+ if stacked:
36
+ bottom = np.zeros(len(x))
37
+ for i, name in enumerate(names):
38
+ vals = np.array(series[name], dtype=float)
39
+ c = colors[i % len(colors)]
40
+ ax.fill_between(x[:len(vals)], bottom, bottom + vals, color=c, alpha=0.6,
41
+ label=name, zorder=2)
42
+ ax.plot(x[:len(vals)], bottom + vals, color=c, linewidth=1.2, zorder=3)
43
+ bottom = bottom + vals
44
+ else:
45
+ for i, name in enumerate(names):
46
+ vals = np.array(series[name], dtype=float)
47
+ c = colors[i % len(colors)]
48
+ ax.fill_between(x[:len(vals)], 0, vals, color=c, alpha=0.4, zorder=2)
49
+ ax.plot(x[:len(vals)], vals, marker=markers[i % len(markers)], color=c,
50
+ linewidth=2, label=name, zorder=3)
51
+
52
+ if not numeric and labels:
53
+ ax.set_xticks(x)
54
+ set_ticklabels(ax, labels, 'x', cjk_fp)
55
+ if len(x):
56
+ ax.set_xlim(x.min(), x.max())
57
+ return None
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env python3
2
+ """Bar chart generator: grouped bar charts with error bars and significance markers."""
3
+ import numpy as np
4
+
5
+
6
+ def gen_bar(data, ax, colors, cjk_fp=None, **kwargs):
7
+ """Generate grouped bar chart."""
8
+ labels = data.get("labels", [])
9
+ series = data.get("series", {})
10
+ error_data = data.get("errors", {})
11
+ significance = data.get("significance", {})
12
+
13
+ n_groups = len(labels)
14
+ n_series = len(series)
15
+ x = np.arange(n_groups)
16
+ w = 0.8 / max(n_series, 1)
17
+
18
+ for i, (name, values) in enumerate(series.items()):
19
+ offset = (i - (n_series - 1) / 2) * w
20
+ errs = error_data.get(name, None)
21
+ if isinstance(errs, list):
22
+ if len(errs) != len(values):
23
+ errs = None
24
+ elif isinstance(errs, (int, float)):
25
+ errs = [errs] * len(values)
26
+
27
+ bars = ax.bar(x + offset, values, w, yerr=errs,
28
+ color=colors[i % len(colors)], edgecolor='white', linewidth=0.5,
29
+ capsize=3, error_kw={'linewidth': 1}, label=name)
30
+
31
+ if kwargs.get("show_values", False):
32
+ for j, v in enumerate(values):
33
+ err = errs[j] if errs and j < len(errs) else 0
34
+ ax.text(x[j] + offset, v + err + np.nanmax(values) * 0.01,
35
+ f'{v:.1f}', ha='center', va='bottom', fontsize=7,
36
+ color=colors[i % len(colors)])
37
+
38
+ # Significance brackets
39
+ if significance:
40
+ _draw_significance(ax, x, significance)
41
+
42
+ ax.set_xticks(x)
43
+ from fig_core import set_ticklabels
44
+ set_ticklabels(ax, labels, 'x', cjk_fp)
45
+
46
+
47
+ def _draw_significance(ax, x, significance):
48
+ """Draw significance annotations.
49
+
50
+ - key "i,j" -> a bracket spanning group i and group j, with the label
51
+ centered above; multiple brackets stack automatically.
52
+ - key "i" -> a standalone label above group i (legacy/fallback).
53
+ Labels "NS"/"ns"/"n.s." are drawn in gray (non-significant).
54
+ """
55
+ ymax = ax.get_ylim()[1]
56
+ ax.set_ylim(top=ymax * 1.18) # headroom for brackets
57
+ ymax = ax.get_ylim()[1]
58
+ h = ymax * 0.025 # bracket tick height
59
+ level = 0
60
+ for key, label in significance.items():
61
+ parts = [p.strip() for p in key.split(",")]
62
+ is_sig = label not in ('NS', 'ns', 'n.s.')
63
+ fc = '#C0392B' if is_sig else 'gray'
64
+ fw = 'bold' if is_sig else 'normal'
65
+ if len(parts) >= 2:
66
+ try:
67
+ i, j = int(parts[0]), int(parts[1])
68
+ except ValueError:
69
+ continue
70
+ if i >= len(x) or j >= len(x):
71
+ continue
72
+ y = ymax * (0.86 - 0.07 * level)
73
+ x0, x1 = x[i], x[j]
74
+ ax.plot([x0, x0, x1, x1], [y - h, y, y, y - h], lw=1, color='#333333')
75
+ ax.text((x0 + x1) / 2, y + h, label, ha='center', va='bottom',
76
+ fontsize=9, fontweight=fw, color=fc)
77
+ level += 1
78
+ else:
79
+ try:
80
+ idx = int(parts[0])
81
+ except ValueError:
82
+ continue
83
+ if idx >= len(x):
84
+ continue
85
+ ax.annotate(label, (x[idx], ymax * 0.92), ha='center', va='bottom',
86
+ fontsize=9, fontweight=fw, color=fc)
87
+
88
+
89
+ def gen_stacked_bar(data, ax, colors, cjk_fp=None, **kwargs):
90
+ """Generate stacked bar chart (one bar per group, series stacked)."""
91
+ labels = data.get("labels", [])
92
+ series = data.get("series", {})
93
+ error_data = data.get("errors", {})
94
+ x = np.arange(len(labels))
95
+ w = 0.6
96
+ bottom = np.zeros(len(labels))
97
+
98
+ for i, (name, values) in enumerate(series.items()):
99
+ vals = np.array(values, dtype=float)
100
+ ax.bar(x, vals, w, bottom=bottom, label=name,
101
+ color=colors[i % len(colors)], edgecolor='white', linewidth=0.5)
102
+ if kwargs.get("show_values", False):
103
+ for j, v in enumerate(vals):
104
+ ax.text(x[j], bottom[j] + v / 2, f'{v:.1f}', ha='center', va='center',
105
+ fontsize=7, color='white' if i == 0 else 'black')
106
+ # optional error bars on the stacked total
107
+ errs = error_data.get(name)
108
+ if isinstance(errs, list) and len(errs) == len(vals):
109
+ ax.errorbar(x, bottom + vals, yerr=errs, fmt='none',
110
+ ecolor='#333333', capsize=3, lw=1)
111
+ bottom = bottom + vals
112
+
113
+ ax.set_xticks(x)
114
+ from fig_core import set_ticklabels
115
+ set_ticklabels(ax, labels, 'x', cjk_fp)
116
+ if len(bottom) > 0:
117
+ ax.set_ylim(0, bottom.max() * 1.1)