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,322 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate product preview assets: samples/ and showcases/.
|
|
3
|
+
|
|
4
|
+
samples/
|
|
5
|
+
36 standalone PNG files — one per chart type, rendered with
|
|
6
|
+
palette=wx01_blue_red and style=nature. These are the per-chart
|
|
7
|
+
sample images used in the product page / docs.
|
|
8
|
+
|
|
9
|
+
showcases/
|
|
10
|
+
34 full-grid PNG files — one per palette, each showing all 36
|
|
11
|
+
chart types rendered with that palette. These are the per-palette
|
|
12
|
+
showcase images used in the palette gallery.
|
|
13
|
+
|
|
14
|
+
Usage
|
|
15
|
+
-----
|
|
16
|
+
generate_samples.py # generate both
|
|
17
|
+
generate_samples.py --samples-only # only samples/
|
|
18
|
+
generate_samples.py --showcases-only # only showcases/
|
|
19
|
+
generate_samples.py --force # overwrite existing
|
|
20
|
+
generate_samples.py --style ieee # different style
|
|
21
|
+
generate_samples.py --dry-run # print plan, no renders
|
|
22
|
+
|
|
23
|
+
Notes
|
|
24
|
+
-----
|
|
25
|
+
* Reuses render_utils.render_chart() and render_utils.compose_grid()
|
|
26
|
+
so there is zero duplicated rendering code.
|
|
27
|
+
* ThreadPoolExecutor for parallel renders (default 4 workers).
|
|
28
|
+
* Requires Pillow.
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import math
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
39
|
+
|
|
40
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
41
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
42
|
+
|
|
43
|
+
from registry import CHART_EXAMPLES, CHART_TYPES, example_path, ZH_NAME
|
|
44
|
+
from render_utils import render_chart, compose_grid, load_fonts
|
|
45
|
+
from palettes import list_palettes, palette_info
|
|
46
|
+
|
|
47
|
+
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
|
|
48
|
+
SAMPLES_DIR = os.path.join(SKILL_DIR, "samples")
|
|
49
|
+
SHOWCASES_DIR = os.path.join(SKILL_DIR, "showcases")
|
|
50
|
+
|
|
51
|
+
# Sample images: higher resolution for standalone viewing
|
|
52
|
+
SAMPLE_W = 6.0
|
|
53
|
+
SAMPLE_H = 4.0
|
|
54
|
+
SAMPLE_DPI = 200
|
|
55
|
+
|
|
56
|
+
# Showcase thumbnails: compact grid
|
|
57
|
+
THUMB_W = 3.2
|
|
58
|
+
THUMB_H = 2.4
|
|
59
|
+
THUMB_DPI = 120
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── samples/ generation ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_sample(chart_type, style, palette, out_dir):
|
|
66
|
+
"""Render one chart type as a standalone sample PNG."""
|
|
67
|
+
data = example_path(chart_type)
|
|
68
|
+
if not data:
|
|
69
|
+
return chart_type, None, "no example file"
|
|
70
|
+
out = os.path.join(out_dir, "%s.png" % chart_type)
|
|
71
|
+
png, err, dt = render_chart(
|
|
72
|
+
chart_type, data, out,
|
|
73
|
+
style=style, palette=palette,
|
|
74
|
+
width=SAMPLE_W, height=SAMPLE_H, dpi=SAMPLE_DPI,
|
|
75
|
+
)
|
|
76
|
+
return chart_type, png, err, dt
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate_samples(style="nature", palette="wx01_blue_red", workers=4,
|
|
80
|
+
force=False, dry_run=False):
|
|
81
|
+
"""Render all 36 chart types as individual sample PNGs."""
|
|
82
|
+
chart_order = sorted(CHART_EXAMPLES)
|
|
83
|
+
n = len(chart_order)
|
|
84
|
+
|
|
85
|
+
if dry_run:
|
|
86
|
+
print("[samples] would render %d charts -> samples/" % n)
|
|
87
|
+
for ct in chart_order:
|
|
88
|
+
print(" %-20s -> samples/%s.png" % (ct, ct))
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
os.makedirs(SAMPLES_DIR, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
results = {}
|
|
94
|
+
errors = []
|
|
95
|
+
t0 = time.time()
|
|
96
|
+
|
|
97
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
98
|
+
for ct, png, err, dt in pool.map(
|
|
99
|
+
lambda ct: _render_sample(ct, style, palette, SAMPLES_DIR),
|
|
100
|
+
chart_order
|
|
101
|
+
):
|
|
102
|
+
if png:
|
|
103
|
+
results[ct] = png
|
|
104
|
+
else:
|
|
105
|
+
errors.append("%s: %s" % (ct, err))
|
|
106
|
+
|
|
107
|
+
elapsed = time.time() - t0
|
|
108
|
+
ok = len(results)
|
|
109
|
+
|
|
110
|
+
# Write manifest
|
|
111
|
+
manifest = {
|
|
112
|
+
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
113
|
+
"style": style,
|
|
114
|
+
"palette": palette,
|
|
115
|
+
"width_inch": SAMPLE_W,
|
|
116
|
+
"height_inch": SAMPLE_H,
|
|
117
|
+
"dpi": SAMPLE_DPI,
|
|
118
|
+
"charts": [],
|
|
119
|
+
}
|
|
120
|
+
for ct in chart_order:
|
|
121
|
+
path = os.path.join(SAMPLES_DIR, "%s.png" % ct)
|
|
122
|
+
entry = {
|
|
123
|
+
"type": ct,
|
|
124
|
+
"zh": ZH_NAME.get(ct, ct),
|
|
125
|
+
"file": "%s.png" % ct,
|
|
126
|
+
"status": "ok" if ct in results else "failed",
|
|
127
|
+
}
|
|
128
|
+
if os.path.exists(path):
|
|
129
|
+
entry["bytes"] = os.path.getsize(path)
|
|
130
|
+
manifest["charts"].append(entry)
|
|
131
|
+
|
|
132
|
+
manifest_path = os.path.join(SAMPLES_DIR, "manifest.json")
|
|
133
|
+
with open(manifest_path, "w", encoding="utf-8") as fh:
|
|
134
|
+
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
|
135
|
+
fh.write("\n")
|
|
136
|
+
|
|
137
|
+
print("Samples: %d ok, %d failed (%.1fs) -> %s"
|
|
138
|
+
% (ok, len(errors), elapsed, SAMPLES_DIR))
|
|
139
|
+
if errors:
|
|
140
|
+
for e in errors:
|
|
141
|
+
print(" FAIL %s" % e, file=sys.stderr)
|
|
142
|
+
|
|
143
|
+
return 0 if not errors else 1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── showcases/ generation ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _render_palette_showcase(palette_slug, style, workers=4,
|
|
150
|
+
force=False, dry_run=False):
|
|
151
|
+
"""Render one palette's full 36-chart grid."""
|
|
152
|
+
info = palette_info(palette_slug)
|
|
153
|
+
if not info:
|
|
154
|
+
return palette_slug, None, "unknown palette"
|
|
155
|
+
|
|
156
|
+
out_path = os.path.join(SHOWCASES_DIR, "%s.png" % palette_slug)
|
|
157
|
+
if os.path.exists(out_path) and not force:
|
|
158
|
+
print(" %-20s (exists, skipped)" % palette_slug)
|
|
159
|
+
return palette_slug, out_path, None
|
|
160
|
+
|
|
161
|
+
if dry_run:
|
|
162
|
+
print(" %-20s would render" % palette_slug)
|
|
163
|
+
return palette_slug, None, None
|
|
164
|
+
|
|
165
|
+
tmpdir = tempfile.mkdtemp(prefix="sf_show_%s_" % palette_slug)
|
|
166
|
+
chart_order = sorted(CHART_EXAMPLES)
|
|
167
|
+
thumbs = {}
|
|
168
|
+
|
|
169
|
+
def _do(ct):
|
|
170
|
+
data = example_path(ct)
|
|
171
|
+
png, err, _ = render_chart(
|
|
172
|
+
ct, data,
|
|
173
|
+
os.path.join(tmpdir, "%s.png" % ct),
|
|
174
|
+
style=style, palette=palette_slug,
|
|
175
|
+
width=THUMB_W, height=THUMB_H, dpi=THUMB_DPI,
|
|
176
|
+
)
|
|
177
|
+
return ct, png, err
|
|
178
|
+
|
|
179
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
180
|
+
for ct, png, err in pool.map(_do, chart_order):
|
|
181
|
+
if png:
|
|
182
|
+
thumbs[ct] = png
|
|
183
|
+
|
|
184
|
+
# Compose grid
|
|
185
|
+
cols = max(1, int(math.ceil(math.sqrt(len(chart_order)))))
|
|
186
|
+
n_colors = len(info.get("colors", []))
|
|
187
|
+
domain_str = "/".join(info.get("domain", ["跨学科通用"]))
|
|
188
|
+
# 去掉 zh 里的序号前缀("13 Nature 旗舰…" → "Nature 旗舰…"),
|
|
189
|
+
# 避免与标题末尾的配色代码 wx13_… 重复
|
|
190
|
+
zh_name = re.sub(r"^\s*\d+\s*", "", info.get("zh", palette_slug))
|
|
191
|
+
canvas = compose_grid(
|
|
192
|
+
thumbnails=thumbs,
|
|
193
|
+
chart_order=chart_order,
|
|
194
|
+
# 标题行: 中文标题(配色中文名) + 配色代码(slug)
|
|
195
|
+
header_text="%s · %s" % (zh_name, palette_slug),
|
|
196
|
+
# 副标题: 适用领域 + 色数
|
|
197
|
+
subtitle_text="适用领域:%s · %d 色"
|
|
198
|
+
% (domain_str, n_colors),
|
|
199
|
+
cols=cols,
|
|
200
|
+
cell_w=360, cell_h=280, label_h=28, gap=10, pad=16, header_h=72,
|
|
201
|
+
footer_text=None,
|
|
202
|
+
)
|
|
203
|
+
canvas.save(out_path, dpi=(160, 160))
|
|
204
|
+
return palette_slug, out_path, None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def generate_showcases(style="nature", workers=4, force=False, dry_run=False):
|
|
208
|
+
"""Render all 34 palettes as full 36-chart grids."""
|
|
209
|
+
palettes = list_palettes()
|
|
210
|
+
n = len(palettes)
|
|
211
|
+
|
|
212
|
+
if dry_run:
|
|
213
|
+
print("[showcases] would render %d palettes -> showcases/" % n)
|
|
214
|
+
for p in palettes:
|
|
215
|
+
print(" %-20s -> showcases/%s.png" % (p, p))
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
os.makedirs(SHOWCASES_DIR, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
results = {}
|
|
221
|
+
errors = []
|
|
222
|
+
t0 = time.time()
|
|
223
|
+
|
|
224
|
+
for slug in palettes:
|
|
225
|
+
print(" Rendering palette: %s ..." % slug)
|
|
226
|
+
slug_r, png, err = _render_palette_showcase(
|
|
227
|
+
slug, style, workers=workers, force=force, dry_run=False
|
|
228
|
+
)
|
|
229
|
+
if png:
|
|
230
|
+
results[slug] = png
|
|
231
|
+
elif err:
|
|
232
|
+
errors.append("%s: %s" % (slug, err))
|
|
233
|
+
|
|
234
|
+
elapsed = time.time() - t0
|
|
235
|
+
|
|
236
|
+
# Write manifest
|
|
237
|
+
manifest = {
|
|
238
|
+
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
239
|
+
"style": style,
|
|
240
|
+
"palettes": [],
|
|
241
|
+
}
|
|
242
|
+
for slug in palettes:
|
|
243
|
+
info = palette_info(slug)
|
|
244
|
+
path = os.path.join(SHOWCASES_DIR, "%s.png" % slug)
|
|
245
|
+
entry = {
|
|
246
|
+
"palette": slug,
|
|
247
|
+
"zh": info.get("zh", "") if info else "",
|
|
248
|
+
"kind": info.get("kind", "") if info else "",
|
|
249
|
+
"colors": info.get("colors", []) if info else [],
|
|
250
|
+
"file": "%s.png" % slug,
|
|
251
|
+
"status": "ok" if slug in results else "failed",
|
|
252
|
+
}
|
|
253
|
+
if os.path.exists(path):
|
|
254
|
+
entry["bytes"] = os.path.getsize(path)
|
|
255
|
+
manifest["palettes"].append(entry)
|
|
256
|
+
|
|
257
|
+
manifest_path = os.path.join(SHOWCASES_DIR, "manifest.json")
|
|
258
|
+
with open(manifest_path, "w", encoding="utf-8") as fh:
|
|
259
|
+
json.dump(manifest, fh, ensure_ascii=False, indent=2)
|
|
260
|
+
fh.write("\n")
|
|
261
|
+
|
|
262
|
+
print("Showcases: %d ok, %d failed (%.1fs) -> %s"
|
|
263
|
+
% (len(results), len(errors), elapsed, SHOWCASES_DIR))
|
|
264
|
+
if errors:
|
|
265
|
+
for e in errors:
|
|
266
|
+
print(" FAIL %s" % e, file=sys.stderr)
|
|
267
|
+
|
|
268
|
+
return 0 if not errors else 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── CLI ──────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def main():
|
|
275
|
+
ap = argparse.ArgumentParser(
|
|
276
|
+
prog="generate_samples.py",
|
|
277
|
+
description="Generate samples/ (per-chart PNGs) and showcases/ "
|
|
278
|
+
"(per-palette grids) for the LIKE Sci Figures product page.",
|
|
279
|
+
)
|
|
280
|
+
ap.add_argument("--style", default="nature",
|
|
281
|
+
help="Journal style (default: %(default)s)")
|
|
282
|
+
ap.add_argument("--palette", default="wx01_blue_red",
|
|
283
|
+
help="Palette for samples/ (default: %(default)s)")
|
|
284
|
+
ap.add_argument("--workers", type=int, default=4,
|
|
285
|
+
help="Parallel render workers (default: %(default)s)")
|
|
286
|
+
ap.add_argument("--force", action="store_true",
|
|
287
|
+
help="Overwrite existing files")
|
|
288
|
+
ap.add_argument("--samples-only", action="store_true",
|
|
289
|
+
help="Only generate samples/")
|
|
290
|
+
ap.add_argument("--showcases-only", action="store_true",
|
|
291
|
+
help="Only generate showcases/")
|
|
292
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
293
|
+
help="Print plan without rendering")
|
|
294
|
+
args = ap.parse_args()
|
|
295
|
+
|
|
296
|
+
rc = 0
|
|
297
|
+
do_samples = not args.showcases_only
|
|
298
|
+
do_showcases = not args.samples_only
|
|
299
|
+
|
|
300
|
+
if do_samples:
|
|
301
|
+
print("=" * 60)
|
|
302
|
+
print("GENERATING SAMPLES/")
|
|
303
|
+
print("=" * 60)
|
|
304
|
+
rc |= generate_samples(
|
|
305
|
+
style=args.style, palette=args.palette,
|
|
306
|
+
workers=args.workers, force=args.force, dry_run=args.dry_run,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if do_showcases:
|
|
310
|
+
print("=" * 60)
|
|
311
|
+
print("GENERATING SHOWCASES/")
|
|
312
|
+
print("=" * 60)
|
|
313
|
+
rc |= generate_showcases(
|
|
314
|
+
style=args.style, workers=args.workers,
|
|
315
|
+
force=args.force, dry_run=args.dry_run,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return rc
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
sys.exit(main())
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the colour-palette comparison poster shown at first skill invocation.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
This skill ships 34 curated palettes, but the *user* should decide which one
|
|
7
|
+
to use (the workflow in SKILL.md asks for it the first time the skill is used
|
|
8
|
+
in a task). Judging many palettes from a text table is unreliable, so this
|
|
9
|
+
script renders each palette on the same handful of representative charts and
|
|
10
|
+
stitches the results into one poster:
|
|
11
|
+
|
|
12
|
+
references/palette-picker.png
|
|
13
|
+
|
|
14
|
+
Rows = the palettes (name, kind, colour count, full swatch strip, domain)
|
|
15
|
+
Columns = representative chart types, all rendered from the same examples/ data
|
|
16
|
+
|
|
17
|
+
Every thumbnail is produced by calling gen_figure.py for real, so what the
|
|
18
|
+
user sees in the poster is byte-for-byte what the skill will produce in the
|
|
19
|
+
task.
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
palette_picker.py # write references/palette-picker.png
|
|
24
|
+
palette_picker.py --out /tmp/pk.png # write somewhere else
|
|
25
|
+
palette_picker.py --charts bar,scatter,heatmap
|
|
26
|
+
palette_picker.py --style ieee --force
|
|
27
|
+
palette_picker.py --dry-run # render thumbnails, no poster
|
|
28
|
+
|
|
29
|
+
Notes
|
|
30
|
+
-----
|
|
31
|
+
* The poster is a build artefact: it is skipped when the target already exists
|
|
32
|
+
(regenerate with --force), so an agent can present it immediately without
|
|
33
|
+
waiting for ~50 figure renders.
|
|
34
|
+
* Requires Pillow for the compositing step. Thumbnails themselves need only
|
|
35
|
+
matplotlib (via gen_figure.py).
|
|
36
|
+
* Chart-type → example mapping lives in registry.py; shared rendering helpers
|
|
37
|
+
live in render_utils.py. This module only owns the picker-specific layout.
|
|
38
|
+
"""
|
|
39
|
+
import argparse
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import tempfile
|
|
43
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
44
|
+
|
|
45
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
46
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
47
|
+
|
|
48
|
+
from registry import example_path, ZH_NAME
|
|
49
|
+
from render_utils import render_chart, load_fonts
|
|
50
|
+
|
|
51
|
+
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
|
|
52
|
+
DEFAULT_OUT = os.path.join(SKILL_DIR, "references", "palette-picker.png")
|
|
53
|
+
|
|
54
|
+
# Representative charts: categorical, trend, grouped points, proportion, and a
|
|
55
|
+
# continuous colour band (the heatmap shows how a palette reads as a colormap).
|
|
56
|
+
DEFAULT_CHARTS = ["bar", "line", "scatter", "pie", "heatmap"]
|
|
57
|
+
|
|
58
|
+
KIND_ZH = {"qualitative": "定性", "sequential": "顺序", "diverging": "发散"}
|
|
59
|
+
KIND_RGB = {"qualitative": (0, 102, 204),
|
|
60
|
+
"sequential": (46, 125, 50),
|
|
61
|
+
"diverging": (183, 28, 28)}
|
|
62
|
+
|
|
63
|
+
# ── poster geometry ────────────────────────────────────────────────────────
|
|
64
|
+
HEADER_H = 106
|
|
65
|
+
COL_HEAD_H = 44
|
|
66
|
+
PAD = 16
|
|
67
|
+
LABEL_W = 420
|
|
68
|
+
CELL_W = 300
|
|
69
|
+
CELL_H = 210
|
|
70
|
+
GAP = 14
|
|
71
|
+
SWATCH_CELL = 46
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render_thumbnail(chart, style, palette, tmpdir):
|
|
75
|
+
"""Render one (chart, palette) thumbnail via render_utils."""
|
|
76
|
+
out = os.path.join(tmpdir, "%s_%s.png" % (palette, chart))
|
|
77
|
+
data = example_path(chart)
|
|
78
|
+
if not data:
|
|
79
|
+
return palette, chart, None, "no example file for %s" % chart
|
|
80
|
+
png, err, _ = render_chart(chart, data, out, style=style, palette=palette,
|
|
81
|
+
width=4.0, height=2.8, dpi=120)
|
|
82
|
+
return palette, chart, png, err
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate(out_path, charts=DEFAULT_CHARTS, style="nature", workers=4,
|
|
86
|
+
overwrite=False, dry_run=False):
|
|
87
|
+
"""Render every (palette x chart) thumbnail and compose the poster."""
|
|
88
|
+
from palettes import list_palettes, palette_info
|
|
89
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
90
|
+
|
|
91
|
+
palettes = [s for s in list_palettes() if palette_info(s)]
|
|
92
|
+
|
|
93
|
+
if dry_run:
|
|
94
|
+
print("would render %d thumbnails (%d palettes x %d charts)"
|
|
95
|
+
% (len(palettes) * len(charts), len(palettes), len(charts)))
|
|
96
|
+
return 0
|
|
97
|
+
if out_path and os.path.exists(out_path) and not overwrite:
|
|
98
|
+
print("(poster already exists, skipped — use --force to rebuild)")
|
|
99
|
+
print(out_path)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
tmpdir = tempfile.mkdtemp(prefix="sf_picker_")
|
|
103
|
+
todo = [(slug, chart) for slug in palettes for chart in charts]
|
|
104
|
+
thumbs = {}
|
|
105
|
+
errors = []
|
|
106
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
107
|
+
for slug, chart, png, err in pool.map(
|
|
108
|
+
lambda p: _render_thumbnail(p[1], style, p[0], tmpdir),
|
|
109
|
+
todo):
|
|
110
|
+
if png:
|
|
111
|
+
thumbs[(slug, chart)] = png
|
|
112
|
+
else:
|
|
113
|
+
errors.append("%s/%s: %s" % (slug, chart, err))
|
|
114
|
+
|
|
115
|
+
# ── geometry ─────────────────────────────────────────────────────────
|
|
116
|
+
width = LABEL_W + len(charts) * CELL_W + (len(charts) + 1) * GAP + PAD
|
|
117
|
+
row_h = CELL_H + GAP
|
|
118
|
+
height = PAD + HEADER_H + COL_HEAD_H + len(palettes) * row_h + PAD
|
|
119
|
+
canvas = Image.new("RGB", (width, height), "white")
|
|
120
|
+
d = ImageDraw.Draw(canvas)
|
|
121
|
+
fonts, font_used = load_fonts((30, 26, 20, 17, 14))
|
|
122
|
+
f30 = fonts.get(30) or ImageFont.load_default()
|
|
123
|
+
f20 = fonts.get(20) or ImageFont.load_default()
|
|
124
|
+
f17 = fonts.get(17) or ImageFont.load_default()
|
|
125
|
+
f14 = fonts.get(14) or ImageFont.load_default()
|
|
126
|
+
|
|
127
|
+
# ── title ────────────────────────────────────────────────────────────
|
|
128
|
+
d.rectangle([0, 0, width, HEADER_H], fill="#0B6E4F")
|
|
129
|
+
d.text((PAD + 2, 12), "LIKE Sci Figures — 配色方案选择",
|
|
130
|
+
fill="white", font=f30)
|
|
131
|
+
d.text((PAD + 2, 54),
|
|
132
|
+
"%d 套精选科研配色 × %d 类代表图表 · 风格: %s · 适合多领域学术出版"
|
|
133
|
+
% (len(palettes), len(charts), style),
|
|
134
|
+
fill="#D7E1EB", font=f20)
|
|
135
|
+
|
|
136
|
+
# LIKE watermark on the right side of header
|
|
137
|
+
tag = "LIKE"
|
|
138
|
+
bbox = d.textbbox((0, 0), tag, font=f20)
|
|
139
|
+
tag_w = bbox[2] - bbox[0]
|
|
140
|
+
d.text((width - PAD - tag_w - 2, 16), tag,
|
|
141
|
+
fill="#D7E1EB", font=f20)
|
|
142
|
+
|
|
143
|
+
# ── column headers ───────────────────────────────────────────────────
|
|
144
|
+
y0 = PAD + HEADER_H
|
|
145
|
+
for i, chart in enumerate(charts):
|
|
146
|
+
x = LABEL_W + i * (CELL_W + GAP) + GAP
|
|
147
|
+
d.text((x + 8, y0 + 11), ZH_NAME.get(chart, chart),
|
|
148
|
+
fill="#111111", font=f20)
|
|
149
|
+
hy = y0 + COL_HEAD_H
|
|
150
|
+
|
|
151
|
+
# ── one row per palette ──────────────────────────────────────────────
|
|
152
|
+
for r, slug in enumerate(palettes):
|
|
153
|
+
info = palette_info(slug)
|
|
154
|
+
colors = list(info.get("colors") or [])
|
|
155
|
+
y = hy + r * row_h
|
|
156
|
+
d.rectangle([PAD, y, PAD + width - 2 * PAD, y + CELL_H],
|
|
157
|
+
fill="#FAFBFC", outline="#E4E7EB")
|
|
158
|
+
|
|
159
|
+
lx = PAD + GAP
|
|
160
|
+
d.text((lx, y + 10), slug, fill="#111111", font=f17)
|
|
161
|
+
kind = info.get("kind") or ""
|
|
162
|
+
badge_w = 62
|
|
163
|
+
d.rounded_rectangle([lx, y + 36, lx + badge_w, y + 36 + 24],
|
|
164
|
+
radius=4, fill=KIND_RGB.get(kind, (90, 90, 90)))
|
|
165
|
+
d.text((lx + 8, y + 40), KIND_ZH.get(kind, kind),
|
|
166
|
+
fill="white", font=f14)
|
|
167
|
+
d.text((lx + badge_w + 10, y + 40), "%d 色" % len(colors),
|
|
168
|
+
fill="#555555", font=f14)
|
|
169
|
+
|
|
170
|
+
# Domain tags
|
|
171
|
+
domain_list = info.get("domain") or []
|
|
172
|
+
domain_text = "适用:" + "/".join(domain_list[:3])
|
|
173
|
+
d.text((lx, y + 66), domain_text[:40],
|
|
174
|
+
fill="#777777", font=f14)
|
|
175
|
+
|
|
176
|
+
for i, hexc in enumerate(colors):
|
|
177
|
+
cx = lx + (i % SWATCH_CELL) * (CELL_W / float(SWATCH_CELL))
|
|
178
|
+
cy = y + 90 + (i // SWATCH_CELL) * 30
|
|
179
|
+
d.rectangle([cx, cy, cx + CELL_W / float(SWATCH_CELL), cy + 26],
|
|
180
|
+
fill=hexc)
|
|
181
|
+
if CELL_W / float(SWATCH_CELL) >= 34:
|
|
182
|
+
d.text((cx + 4, cy + 5), hexc, fill="#000000", font=f14)
|
|
183
|
+
|
|
184
|
+
d.text((lx, y + 148), (info.get("zh") or "")[:18],
|
|
185
|
+
fill="#444444", font=f14)
|
|
186
|
+
d.text((lx, y + 172), (info.get("desc") or "")[:28],
|
|
187
|
+
fill="#777777", font=f14)
|
|
188
|
+
|
|
189
|
+
for i, chart in enumerate(charts):
|
|
190
|
+
px = LABEL_W + i * (CELL_W + GAP) + GAP
|
|
191
|
+
png = thumbs.get((slug, chart))
|
|
192
|
+
if png and os.path.exists(png):
|
|
193
|
+
im = Image.open(png).convert("RGB")
|
|
194
|
+
if im.width != CELL_W or im.height != CELL_H:
|
|
195
|
+
im = im.resize((CELL_W, CELL_H), Image.LANCZOS)
|
|
196
|
+
d.rectangle([px, y, px + CELL_W, y + CELL_H],
|
|
197
|
+
fill="white", outline="#DDDDDD")
|
|
198
|
+
canvas.paste(im, (px, y))
|
|
199
|
+
else:
|
|
200
|
+
d.rectangle([px, y, px + CELL_W, y + CELL_H],
|
|
201
|
+
fill="#F1F3F5", outline="#DDDDDD")
|
|
202
|
+
d.text((px + 10, y + 95), "渲染失败", fill="#999999",
|
|
203
|
+
font=f14)
|
|
204
|
+
|
|
205
|
+
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
|
206
|
+
canvas.save(out_path, dpi=(160, 160))
|
|
207
|
+
if errors:
|
|
208
|
+
print("WARNING: %d thumbnail(s) failed to render:" % len(errors),
|
|
209
|
+
file=sys.stderr)
|
|
210
|
+
for e in errors[:10]:
|
|
211
|
+
print(" " + e, file=sys.stderr)
|
|
212
|
+
print("Palette picker poster: %s (%d x %d px, %d bytes, font=%s)"
|
|
213
|
+
% (out_path, width, height, os.path.getsize(out_path),
|
|
214
|
+
font_used or "default"))
|
|
215
|
+
print("Charts shown: " + ", ".join(ZH_NAME.get(c, c) for c in charts))
|
|
216
|
+
return 0 if not errors else 1
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
ap = argparse.ArgumentParser(
|
|
221
|
+
prog="palette_picker.py",
|
|
222
|
+
description="Build the palette comparison poster shown to the user "
|
|
223
|
+
"when the skill is first used in a task.")
|
|
224
|
+
ap.add_argument("--out", default=DEFAULT_OUT,
|
|
225
|
+
help="Poster output path (default: references/palette-picker.png)")
|
|
226
|
+
ap.add_argument("--charts", default=",".join(DEFAULT_CHARTS),
|
|
227
|
+
help="Comma separated representative chart types "
|
|
228
|
+
"(default: %(default)s)")
|
|
229
|
+
ap.add_argument("--style", default="nature",
|
|
230
|
+
help="Journal style used for every thumbnail (default: %(default)s)")
|
|
231
|
+
ap.add_argument("--workers", type=int, default=4, help="Parallel renders")
|
|
232
|
+
ap.add_argument("--force", action="store_true",
|
|
233
|
+
help="Rebuild even if the poster already exists")
|
|
234
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
235
|
+
help="Print what would be rendered and exit")
|
|
236
|
+
args = ap.parse_args()
|
|
237
|
+
charts = [c.strip() for c in args.charts.split(",") if c.strip()]
|
|
238
|
+
return generate(args.out, charts=charts, style=args.style,
|
|
239
|
+
workers=args.workers, overwrite=args.force,
|
|
240
|
+
dry_run=args.dry_run) or 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|