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