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,201 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scaffolding tool for adding new chart types to like-sci-figures.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
Adding a new chart type used to require manual edits to 4 files. This tool
|
|
7
|
+
generates the boilerplate in one command, so the developer only needs to
|
|
8
|
+
fill in the rendering logic and example data.
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
-----
|
|
12
|
+
python scripts/add_chart.py --name my_chart --zh "我的图表"
|
|
13
|
+
python scripts/add_chart.py --name my_chart --kind statistics --aliases myplot,my
|
|
14
|
+
|
|
15
|
+
What it generates
|
|
16
|
+
-----------------
|
|
17
|
+
scripts/fig_my_chart.py ChartPlugin subclass with render() stub
|
|
18
|
+
examples/my_chart.json Example data file
|
|
19
|
+
registry.py entry Auto-appended (with marker comment)
|
|
20
|
+
|
|
21
|
+
After running this tool, edit the generated files and run:
|
|
22
|
+
python scripts/gen_figure.py --verify-all # check it renders
|
|
23
|
+
python scripts/generate_showcases.py # update preview images
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import textwrap
|
|
30
|
+
|
|
31
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
32
|
+
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
|
|
33
|
+
EXAMPLES_DIR = os.path.join(SKILL_DIR, "examples")
|
|
34
|
+
REGISTRY_PATH = os.path.join(SCRIPT_DIR, "registry.py")
|
|
35
|
+
|
|
36
|
+
# Marker comments for idempotent registry updates.
|
|
37
|
+
_REGISTRY_START = "# ── Auto-generated entries (add_chart.py) ──"
|
|
38
|
+
_REGISTRY_END = "# ── End auto-generated entries ──"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_plugin_module(chart_name, zh_name, kind, aliases):
|
|
42
|
+
"""Return the source code for fig_<name>.py."""
|
|
43
|
+
alias_list = list(aliases) if aliases else []
|
|
44
|
+
alias_line = "aliases = %s" % repr(tuple(alias_list)) if alias_list else "aliases = ()"
|
|
45
|
+
return textwrap.dedent(f"""\
|
|
46
|
+
#!/usr/bin/env python3
|
|
47
|
+
\"\"\"Chart type: {chart_name} ({zh_name}).
|
|
48
|
+
|
|
49
|
+
Generated by add_chart.py. Fill in the render() method and example data.
|
|
50
|
+
\"\"\"
|
|
51
|
+
from chart_plugin import ChartPlugin
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class {chart_name.capitalize().replace('_', '')}Chart(ChartPlugin):
|
|
55
|
+
name = \"{chart_name}\"
|
|
56
|
+
zh = \"{zh_name}\"
|
|
57
|
+
kind = \"{kind}\"
|
|
58
|
+
{alias_line}
|
|
59
|
+
required_keys = (\"x\", \"y\")
|
|
60
|
+
optional_keys = ()
|
|
61
|
+
data_format = \"json\"
|
|
62
|
+
|
|
63
|
+
def render(self, data, ax, colors, cjk_fp, **kwargs):
|
|
64
|
+
\"\"\"Render the chart.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
data: parsed data dict
|
|
68
|
+
ax: matplotlib axes object
|
|
69
|
+
colors: list of hex colours
|
|
70
|
+
cjk_fp: CJK font path or None
|
|
71
|
+
**kwargs: chart-specific flags
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Optional colorbar artist, else None.
|
|
75
|
+
\"\"\"
|
|
76
|
+
# TODO: Implement rendering logic
|
|
77
|
+
# Example:
|
|
78
|
+
# x = data.get(\"x\", [])
|
|
79
|
+
# y = data.get(\"y\", [])
|
|
80
|
+
# ax.plot(x, y, color=colors[0])
|
|
81
|
+
# return None
|
|
82
|
+
|
|
83
|
+
raise NotImplementedError(
|
|
84
|
+
f\"Render logic not implemented for {{self.name}}\")
|
|
85
|
+
""")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_example_data(chart_name):
|
|
89
|
+
"""Return a minimal valid JSON example for the chart type."""
|
|
90
|
+
example = {
|
|
91
|
+
"labels": ["A", "B", "C", "D", "E"],
|
|
92
|
+
"series": {
|
|
93
|
+
"Series A": [10, 20, 15, 30, 25],
|
|
94
|
+
"Series B": [5, 12, 8, 18, 15],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return json.dumps(example, ensure_ascii=False, indent=2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def update_registry(chart_name, zh_name, example_file, kind, aliases):
|
|
101
|
+
"""Append a new entry to registry.py's _REGISTRY list."""
|
|
102
|
+
alias_tuple = tuple(list(aliases) + ([example_file] if False else []))
|
|
103
|
+
# Build the registry entry line.
|
|
104
|
+
entry = f' ("{chart_name}", "{zh_name}", "{example_file}", "{kind}", ({", ".join(repr(a) for a in aliases)})), '
|
|
105
|
+
with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
|
|
106
|
+
content = f.read()
|
|
107
|
+
|
|
108
|
+
# Check if entry already exists.
|
|
109
|
+
if f'"{chart_name}"' in content:
|
|
110
|
+
print(f" (entry for {chart_name} already exists in registry.py)")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Insert before the closing bracket of _REGISTRY.
|
|
114
|
+
marker = " (\"subplots\""
|
|
115
|
+
idx = content.find(marker)
|
|
116
|
+
if idx == -1:
|
|
117
|
+
# Try to find the end of the list.
|
|
118
|
+
idx = content.rfind("]")
|
|
119
|
+
if idx == -1:
|
|
120
|
+
print("ERROR: Could not find insertion point in registry.py", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
# Insert before the marker (at the end of the list, before the closing bracket).
|
|
124
|
+
# Find the closing bracket.
|
|
125
|
+
bracket_idx = content.find("]", idx)
|
|
126
|
+
if bracket_idx == -1:
|
|
127
|
+
print("ERROR: Could not find closing bracket in registry.py", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
# Insert before the closing bracket.
|
|
131
|
+
insert_pos = content.rfind("\n", 0, bracket_idx) + 1
|
|
132
|
+
indent = " "
|
|
133
|
+
entry_line = f'{indent}("{chart_name}", "{zh_name}", "{example_file}", "{kind}", {repr(tuple(aliases))}),\n'
|
|
134
|
+
|
|
135
|
+
new_content = content[:insert_pos] + entry_line + content[insert_pos:]
|
|
136
|
+
with open(REGISTRY_PATH, "w", encoding="utf-8") as f:
|
|
137
|
+
f.write(new_content)
|
|
138
|
+
print(f" Updated registry.py: added {chart_name}")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main():
|
|
143
|
+
ap = argparse.ArgumentParser(
|
|
144
|
+
prog="add_chart.py",
|
|
145
|
+
description="Scaffold a new chart type for like-sci-figures.",
|
|
146
|
+
)
|
|
147
|
+
ap.add_argument("--name", required=True,
|
|
148
|
+
help="Chart type slug (e.g. my_chart)")
|
|
149
|
+
ap.add_argument("--zh", required=True,
|
|
150
|
+
help="Chinese display name (e.g. 我的图表)")
|
|
151
|
+
ap.add_argument("--kind", default="specialized",
|
|
152
|
+
choices=["statistics", "relational", "specialized"],
|
|
153
|
+
help="Chart category (default: specialized)")
|
|
154
|
+
ap.add_argument("--aliases", default="",
|
|
155
|
+
help="Comma-separated alias slugs (e.g. myplot,my)")
|
|
156
|
+
ap.add_argument("--force", action="store_true",
|
|
157
|
+
help="Overwrite existing files")
|
|
158
|
+
args = ap.parse_args()
|
|
159
|
+
|
|
160
|
+
chart_name = args.name
|
|
161
|
+
zh_name = args.zh
|
|
162
|
+
kind = args.kind
|
|
163
|
+
aliases = [a.strip() for a in args.aliases.split(",") if a.strip()]
|
|
164
|
+
|
|
165
|
+
# ── Generate plugin module ─────────────────────────────────────────
|
|
166
|
+
module_path = os.path.join(SCRIPT_DIR, f"fig_{chart_name}.py")
|
|
167
|
+
if os.path.exists(module_path) and not args.force:
|
|
168
|
+
print(f"SKIP: {module_path} already exists (use --force to overwrite)")
|
|
169
|
+
else:
|
|
170
|
+
code = generate_plugin_module(chart_name, zh_name, kind, aliases)
|
|
171
|
+
with open(module_path, "w", encoding="utf-8") as f:
|
|
172
|
+
f.write(code)
|
|
173
|
+
print(f" Created: {module_path}")
|
|
174
|
+
|
|
175
|
+
# ── Generate example data ───────────────────────────────────────────
|
|
176
|
+
example_path = os.path.join(EXAMPLES_DIR, f"{chart_name}.json")
|
|
177
|
+
if os.path.exists(example_path) and not args.force:
|
|
178
|
+
print(f"SKIP: {example_path} already exists (use --force to overwrite)")
|
|
179
|
+
else:
|
|
180
|
+
data = generate_example_data(chart_name)
|
|
181
|
+
with open(example_path, "w", encoding="utf-8") as f:
|
|
182
|
+
f.write(data)
|
|
183
|
+
print(f" Created: {example_path}")
|
|
184
|
+
|
|
185
|
+
# ── Update registry ─────────────────────────────────────────────────
|
|
186
|
+
rc = update_registry(chart_name, zh_name, f"{chart_name}.json", kind, aliases)
|
|
187
|
+
if rc != 0:
|
|
188
|
+
return rc
|
|
189
|
+
|
|
190
|
+
print()
|
|
191
|
+
print("Done. Next steps:")
|
|
192
|
+
print(f" 1. Edit fig_{chart_name}.py and implement render()")
|
|
193
|
+
print(f" 2. Edit examples/{chart_name}.json with real data")
|
|
194
|
+
print(f" 3. Test: python scripts/gen_figure.py --type {chart_name} \\")
|
|
195
|
+
print(f" --data examples/{chart_name}.json --out test_{chart_name}.png")
|
|
196
|
+
print(f" 4. Update previews: python scripts/generate_showcases.py --force")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
sys.exit(main())
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build an interactive HTML palette gallery for like-sci-figures.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
When the agent asks the user to pick a palette, a flat text list or even a
|
|
7
|
+
tall poster is awkward. This generator produces a single self-contained
|
|
8
|
+
`references/palette_gallery.html`:
|
|
9
|
+
|
|
10
|
+
* One compact card per palette (slug, 中文名, 类型徽章, 适用领域, 色卡条)
|
|
11
|
+
* Hovering a card floats up its pre-generated thumbnail
|
|
12
|
+
(previews/<slug>.png) — the exact "hover → preview" UX
|
|
13
|
+
* A fixed preview pane on the right shows the hovered palette large
|
|
14
|
+
* Click a card → copies the slug to the clipboard (to paste into --palette)
|
|
15
|
+
* A search box filters by name / domain / slug
|
|
16
|
+
|
|
17
|
+
The previews are referenced by RELATIVE path (`../previews/<slug>.png`) so the
|
|
18
|
+
file stays small and stays in sync with the generated assets. Pass
|
|
19
|
+
`--bundle` to inline the PNGs as base64 for a single portable file.
|
|
20
|
+
|
|
21
|
+
Usage
|
|
22
|
+
-----
|
|
23
|
+
build_gallery.py # write references/palette_gallery.html
|
|
24
|
+
build_gallery.py --bundle # inline images as base64 (single file)
|
|
25
|
+
build_gallery.py --out PATH # write to an explicit absolute PATH
|
|
26
|
+
# The script prints "PRESENT_PATH=<abs path>" — present THAT path.
|
|
27
|
+
# (Never a relative/$(pwd) path: the static server returns 403 for a
|
|
28
|
+
# file that does not exist at the presented location.)
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import base64
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
|
|
37
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
38
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
39
|
+
|
|
40
|
+
from palettes import list_palettes, palette_info # noqa: E402
|
|
41
|
+
|
|
42
|
+
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
|
|
43
|
+
PREVIEW_DIR = os.path.join(SKILL_DIR, "previews")
|
|
44
|
+
OUT_PATH = os.path.join(SKILL_DIR, "references", "palette_gallery.html")
|
|
45
|
+
OUT_BUNDLE = os.path.join(SKILL_DIR, "references", "palette_gallery.bundled.html")
|
|
46
|
+
|
|
47
|
+
KIND_ZH = {"qualitative": "定性", "sequential": "顺序", "diverging": "发散"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _b64(path):
|
|
51
|
+
with open(path, "rb") as fh:
|
|
52
|
+
return "data:image/png;base64," + base64.b64encode(fh.read()).decode("ascii")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _atomic_write(path, text):
|
|
56
|
+
"""Write text to `path` atomically (temp file + os.replace) to avoid
|
|
57
|
+
leaving a half-written / locked file that a preview server or browser
|
|
58
|
+
might be holding."""
|
|
59
|
+
import tempfile
|
|
60
|
+
d = os.path.dirname(path)
|
|
61
|
+
if d and not os.path.isdir(d):
|
|
62
|
+
os.makedirs(d, exist_ok=True)
|
|
63
|
+
fd, tmp = tempfile.mkstemp(dir=d or ".", suffix=".tmp")
|
|
64
|
+
try:
|
|
65
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
66
|
+
fh.write(text)
|
|
67
|
+
os.replace(tmp, path)
|
|
68
|
+
except BaseException:
|
|
69
|
+
try:
|
|
70
|
+
os.remove(tmp)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build(bundle=False, out_path=None):
|
|
77
|
+
slugs = [s for s in list_palettes() if palette_info(s)]
|
|
78
|
+
cards = []
|
|
79
|
+
for slug in slugs:
|
|
80
|
+
info = palette_info(slug)
|
|
81
|
+
colors = list(info.get("colors") or [])
|
|
82
|
+
domain = info.get("domain") or []
|
|
83
|
+
cards.append({
|
|
84
|
+
"slug": slug,
|
|
85
|
+
"zh": info.get("zh") or slug,
|
|
86
|
+
"kind": info.get("kind") or "",
|
|
87
|
+
"desc": (info.get("desc") or "").strip(),
|
|
88
|
+
"domain": domain,
|
|
89
|
+
"colors": colors,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
# Image source per slug
|
|
93
|
+
img_src = {}
|
|
94
|
+
for c in cards:
|
|
95
|
+
p = os.path.join(PREVIEW_DIR, "%s.png" % c["slug"])
|
|
96
|
+
if bundle and os.path.exists(p):
|
|
97
|
+
img_src[c["slug"]] = _b64(p)
|
|
98
|
+
elif os.path.exists(p):
|
|
99
|
+
img_src[c["slug"]] = "../previews/%s.png" % c["slug"]
|
|
100
|
+
else:
|
|
101
|
+
img_src[c["slug"]] = ""
|
|
102
|
+
|
|
103
|
+
data_json = json.dumps(
|
|
104
|
+
[dict(c, img=img_src[c["slug"]]) for c in cards],
|
|
105
|
+
ensure_ascii=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
html = _TEMPLATE.replace("/*__DATA__*/", data_json)
|
|
109
|
+
target = out_path or (OUT_BUNDLE if bundle else OUT_PATH)
|
|
110
|
+
_atomic_write(target, html)
|
|
111
|
+
|
|
112
|
+
size = os.path.getsize(target)
|
|
113
|
+
print("Gallery: %d palettes -> %s (%.1f KB, %s)"
|
|
114
|
+
% (len(cards), target, size / 1024.0,
|
|
115
|
+
"bundled base64" if bundle else "relative paths"))
|
|
116
|
+
# The absolute path to present. This file always lives next to the skill
|
|
117
|
+
# (CWD-independent), and WorkBuddy's internal static server serves it fine
|
|
118
|
+
# (verified HTTP 200). Present THIS path — never a relative/$(pwd) path,
|
|
119
|
+
# or the server returns 403 Forbidden for the non-existent file.
|
|
120
|
+
print("PRESENT_PATH=%s" % os.path.abspath(target))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_TEMPLATE = r"""<!DOCTYPE html>
|
|
125
|
+
<html lang="zh-CN">
|
|
126
|
+
<head>
|
|
127
|
+
<meta charset="utf-8">
|
|
128
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
129
|
+
<title>LIKE Sci Figures — 配色方案画廊</title>
|
|
130
|
+
<style>
|
|
131
|
+
:root{
|
|
132
|
+
--green:#0B6E4F; --green-soft:#F0F6F3; --ink:#1a1a1a; --muted:#777;
|
|
133
|
+
--line:#e6e6e6; --bg:#fafbfc;
|
|
134
|
+
}
|
|
135
|
+
*{box-sizing:border-box;}
|
|
136
|
+
body{
|
|
137
|
+
margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
|
|
138
|
+
"PingFang SC","Microsoft YaHei",sans-serif; color:var(--ink); background:var(--bg);
|
|
139
|
+
}
|
|
140
|
+
header{
|
|
141
|
+
background:var(--green); color:#fff; padding:14px 22px; position:sticky; top:0; z-index:20;
|
|
142
|
+
display:flex; align-items:center; justify-content:space-between;
|
|
143
|
+
}
|
|
144
|
+
header h1{font-size:18px; margin:0; font-weight:600;}
|
|
145
|
+
header .sub{font-size:13px; color:#CDE7DD; margin-top:2px;}
|
|
146
|
+
header .author{font-size:14px; color:#CDE7DD; letter-spacing:1px;}
|
|
147
|
+
.toolbar{padding:12px 22px; border-bottom:1px solid var(--line); background:#fff;}
|
|
148
|
+
.toolbar input{
|
|
149
|
+
width:100%; max-width:420px; padding:9px 12px; border:1px solid var(--line);
|
|
150
|
+
border-radius:8px; font-size:14px; outline:none;
|
|
151
|
+
}
|
|
152
|
+
.toolbar input:focus{border-color:var(--green);}
|
|
153
|
+
.hint{font-size:12px; color:var(--muted); margin-top:6px;}
|
|
154
|
+
.layout{display:flex; gap:0; align-items:flex-start;}
|
|
155
|
+
.list{flex:1; padding:16px 22px 60px; display:grid;
|
|
156
|
+
grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:14px;}
|
|
157
|
+
.card{
|
|
158
|
+
background:#fff; border:1px solid var(--line); border-radius:12px; padding:14px;
|
|
159
|
+
cursor:pointer; transition:box-shadow .15s, transform .15s, border-color .15s;
|
|
160
|
+
position:relative;
|
|
161
|
+
}
|
|
162
|
+
.card:hover{box-shadow:0 6px 22px rgba(11,110,79,.14); border-color:var(--green);
|
|
163
|
+
transform:translateY(-2px);}
|
|
164
|
+
.card.sel{border-color:var(--green); box-shadow:0 0 0 2px rgba(11,110,79,.25);}
|
|
165
|
+
.row1{display:flex; align-items:center; gap:8px; flex-wrap:wrap;}
|
|
166
|
+
.slug{font-family:ui-monospace,Menlo,Consolas,monospace; font-size:13px; font-weight:600; color:var(--green);}
|
|
167
|
+
.zh{font-size:16px; font-weight:700; margin-left:2px;}
|
|
168
|
+
.slug2{font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px; color:var(--muted); margin:2px 0 8px;}
|
|
169
|
+
.badge{font-size:11px; color:#fff; padding:2px 7px; border-radius:999px;}
|
|
170
|
+
.b-qual{background:#0066CC;} .b-seq{background:#2E7D32;} .b-div{background:#B71C1C;}
|
|
171
|
+
.domtags{display:flex; flex-wrap:wrap; align-items:center; gap:5px; margin:4px 0 10px;}
|
|
172
|
+
.domlabel{font-size:11px; color:var(--green); font-weight:600; margin-right:2px;}
|
|
173
|
+
.domtag{font-size:11px; color:#0B6E4F; background:#EAF4EF; border:1px solid #CBE3D8; border-radius:999px; padding:2px 8px;}
|
|
174
|
+
.swatch{display:flex; height:18px; border-radius:5px; overflow:hidden; border:1px solid var(--line);}
|
|
175
|
+
.swatch span{flex:1;}
|
|
176
|
+
.pvtag{position:absolute; top:12px; right:12px; font-size:11px; color:var(--muted);
|
|
177
|
+
border:1px solid var(--line); border-radius:6px; padding:2px 7px; background:var(--green-soft);}
|
|
178
|
+
.card:hover .pvtag{color:var(--green); border-color:var(--green);}
|
|
179
|
+
/* fixed preview pane */
|
|
180
|
+
.pane{position:sticky; top:64px; width:360px; flex:0 0 360px; padding:16px 18px 24px;
|
|
181
|
+
border-left:1px solid var(--line); background:#fff; height:calc(100vh - 64px);
|
|
182
|
+
display:flex; flex-direction:column; align-items:center; gap:10px;}
|
|
183
|
+
.pane img{width:100%; border:1px solid var(--line); border-radius:10px; background:#fff;}
|
|
184
|
+
.pane .ph{font-size:13px; color:var(--muted); text-align:center;}
|
|
185
|
+
.pane .pname{font-weight:600;}
|
|
186
|
+
/* floating tooltip preview */
|
|
187
|
+
#tip{position:fixed; z-index:50; pointer-events:none; display:none;
|
|
188
|
+
border:2px solid var(--green); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.25);
|
|
189
|
+
background:#fff; padding:4px;}
|
|
190
|
+
#tip img{display:block; width:300px; border-radius:6px;}
|
|
191
|
+
.toast{position:fixed; bottom:26px; left:50%; transform:translateX(-50%);
|
|
192
|
+
background:var(--green); color:#fff; padding:10px 18px; border-radius:10px;
|
|
193
|
+
font-size:14px; opacity:0; transition:opacity .25s; z-index:60;}
|
|
194
|
+
.toast.show{opacity:1;}
|
|
195
|
+
@media(max-width:820px){ .pane{display:none;} }
|
|
196
|
+
</style>
|
|
197
|
+
</head>
|
|
198
|
+
<body>
|
|
199
|
+
<header>
|
|
200
|
+
<div>
|
|
201
|
+
<h1>LIKE Sci Figures — 配色方案画廊</h1>
|
|
202
|
+
<div class="sub">悬停卡片查看缩略图 · 点击复制 slug 用于 --palette</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div class="author">LIKE</div>
|
|
205
|
+
</header>
|
|
206
|
+
<div class="toolbar">
|
|
207
|
+
<input id="q" placeholder="搜索:名称 / 领域 / slug(如 医学、nature、wx13)">
|
|
208
|
+
<div class="hint">共 <span id="cnt">0</span> 套配色 · 右侧为悬停大图预览</div>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="layout">
|
|
211
|
+
<div class="list" id="list"></div>
|
|
212
|
+
<div class="pane">
|
|
213
|
+
<div class="pname" id="pname">将鼠标移到左侧任一配色</div>
|
|
214
|
+
<img id="pimg" alt="preview" style="display:none">
|
|
215
|
+
<div class="ph" id="pdesc">悬停即可预览该配色的 5 类代表图表效果</div>
|
|
216
|
+
<input id="slugbox" readonly value="" title="点击卡片复制;若自动复制失败可在此手动 Ctrl+C"
|
|
217
|
+
style="width:100%;padding:9px 8px;border:1px solid var(--line);border-radius:8px;
|
|
218
|
+
font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;text-align:center;
|
|
219
|
+
color:var(--green);background:var(--green-soft);margin-top:6px;">
|
|
220
|
+
<div class="ph">点击左侧卡片即复制此 slug 用于 --palette</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
<div id="tip"><img id="tipimg" src="" alt=""></div>
|
|
224
|
+
<div class="toast" id="toast"></div>
|
|
225
|
+
|
|
226
|
+
<script>
|
|
227
|
+
const DATA = /*__DATA__*/;
|
|
228
|
+
const list = document.getElementById('list');
|
|
229
|
+
const q = document.getElementById('q');
|
|
230
|
+
const tip = document.getElementById('tip');
|
|
231
|
+
const tipimg = document.getElementById('tipimg');
|
|
232
|
+
const pimg = document.getElementById('pimg');
|
|
233
|
+
const pname = document.getElementById('pname');
|
|
234
|
+
const pdesc = document.getElementById('pdesc');
|
|
235
|
+
const slugbox = document.getElementById('slugbox');
|
|
236
|
+
const cnt = document.getElementById('cnt');
|
|
237
|
+
const toast = document.getElementById('toast');
|
|
238
|
+
|
|
239
|
+
const badgeClass = {qualitative:'b-qual', sequential:'b-seq', diverging:'b-div'};
|
|
240
|
+
const kindZh = {qualitative:'定性', sequential:'顺序', diverging:'发散'};
|
|
241
|
+
|
|
242
|
+
function swatch(colors){
|
|
243
|
+
if(!colors || !colors.length) return '';
|
|
244
|
+
return '<div class="swatch">'+colors.map(c=>'<span style="background:'+c+'"></span>').join('')+'</div>';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function cardHTML(d, i){
|
|
248
|
+
const bc = badgeClass[d.kind]||'b-qual';
|
|
249
|
+
const kz = kindZh[d.kind]||d.kind;
|
|
250
|
+
const dom = (d.domain&&d.domain.length)? d.domain.map(x=>'<span class="domtag">'+x+'</span>').join('') : '';
|
|
251
|
+
return '<div class="card" data-i="'+i+'">'
|
|
252
|
+
+ '<div class="pvtag">预览 ▸</div>'
|
|
253
|
+
+ '<div class="row1"><span class="zh">'+d.zh+'</span>'
|
|
254
|
+
+ '<span class="badge '+bc+'">'+kz+'</span></div>'
|
|
255
|
+
+ '<div class="slug2">'+d.slug+'</div>'
|
|
256
|
+
+ (dom? '<div class="domtags"><span class="domlabel">适用</span>'+dom+'</div>' : '')
|
|
257
|
+
+ swatch(d.colors)
|
|
258
|
+
+ '</div>';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function render(filter){
|
|
262
|
+
const f = (filter||'').trim().toLowerCase();
|
|
263
|
+
const items = DATA.map((d,i)=>({d,i})).filter(o=>{
|
|
264
|
+
if(!f) return true;
|
|
265
|
+
const hay = (o.d.slug+' '+o.d.zh+' '+(o.d.domain||[]).join(' ')+' '+(o.d.desc||'')).toLowerCase();
|
|
266
|
+
return hay.indexOf(f)>=0;
|
|
267
|
+
});
|
|
268
|
+
list.innerHTML = items.map(o=>cardHTML(o.d,o.i)).join('');
|
|
269
|
+
cnt.textContent = items.length;
|
|
270
|
+
// bind
|
|
271
|
+
list.querySelectorAll('.card').forEach(el=>{
|
|
272
|
+
const idx = +el.getAttribute('data-i');
|
|
273
|
+
const d = DATA[idx];
|
|
274
|
+
el.addEventListener('mouseenter', ()=>showPreview(d));
|
|
275
|
+
el.addEventListener('mousemove', moveTip);
|
|
276
|
+
el.addEventListener('mouseleave', hideTip);
|
|
277
|
+
el.addEventListener('click', ()=>pick(d, el));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function showPreview(d){
|
|
282
|
+
pname.textContent = d.slug+' · '+d.zh;
|
|
283
|
+
pdesc.textContent = d.desc || '';
|
|
284
|
+
slugbox.value = d.slug;
|
|
285
|
+
if(d.img){ pimg.src=d.img; pimg.style.display='block'; }
|
|
286
|
+
tipimg.src = d.img||'';
|
|
287
|
+
tip.style.display='block';
|
|
288
|
+
}
|
|
289
|
+
function moveTip(e){
|
|
290
|
+
let x = e.clientX + 18, y = e.clientY + 18;
|
|
291
|
+
const w = 308;
|
|
292
|
+
let rw = tipimg.naturalWidth || 0, rh = tipimg.naturalHeight || 0;
|
|
293
|
+
const h = (rw && rh) ? (rh * (300 / rw) + 10) : 220;
|
|
294
|
+
if(x + w > window.innerWidth) x = e.clientX - w - 18;
|
|
295
|
+
if(y + h > window.innerHeight) y = e.clientY - h - 18;
|
|
296
|
+
if(x < 4) x = 4;
|
|
297
|
+
if(y < 4) y = 4;
|
|
298
|
+
tip.style.left = x+'px'; tip.style.top = y+'px';
|
|
299
|
+
}
|
|
300
|
+
function hideTip(){ tip.style.display='none'; }
|
|
301
|
+
|
|
302
|
+
// Robust copy: clipboard API first, then legacy execCommand, then manual.
|
|
303
|
+
function copyText(text){
|
|
304
|
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
305
|
+
return navigator.clipboard.writeText(text)
|
|
306
|
+
.then(()=>true)
|
|
307
|
+
.catch(()=>legacyCopy(text));
|
|
308
|
+
}
|
|
309
|
+
return Promise.resolve(legacyCopy(text));
|
|
310
|
+
}
|
|
311
|
+
function legacyCopy(text){
|
|
312
|
+
try{
|
|
313
|
+
const ta=document.createElement('textarea');
|
|
314
|
+
ta.value=text; ta.setAttribute('readonly','');
|
|
315
|
+
ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.left='0';
|
|
316
|
+
document.body.appendChild(ta); ta.focus(); ta.select();
|
|
317
|
+
const ok=document.execCommand('copy');
|
|
318
|
+
document.body.removeChild(ta);
|
|
319
|
+
return ok;
|
|
320
|
+
}catch(e){ return false; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function pick(d, el){
|
|
324
|
+
document.querySelectorAll('.card.sel').forEach(c=>c.classList.remove('sel'));
|
|
325
|
+
el.classList.add('sel');
|
|
326
|
+
slugbox.value = d.slug;
|
|
327
|
+
copyText(d.slug).then(ok=>{
|
|
328
|
+
if(ok){
|
|
329
|
+
toast.textContent = '已复制: '+d.slug+' (用于 --palette '+d.slug+')';
|
|
330
|
+
}else{
|
|
331
|
+
toast.textContent = '自动复制失败,请手动复制:'+d.slug;
|
|
332
|
+
slugbox.focus(); slugbox.select();
|
|
333
|
+
}
|
|
334
|
+
toast.classList.add('show');
|
|
335
|
+
clearTimeout(pick._t);
|
|
336
|
+
pick._t = setTimeout(()=>toast.classList.remove('show'), 2400);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
q.addEventListener('input', ()=>render(q.value));
|
|
341
|
+
render('');
|
|
342
|
+
</script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main():
|
|
349
|
+
ap = argparse.ArgumentParser(description="Build interactive palette gallery HTML")
|
|
350
|
+
ap.add_argument("--bundle", action="store_true",
|
|
351
|
+
help="Inline preview PNGs as base64 (single portable file)")
|
|
352
|
+
ap.add_argument("--out", default=None,
|
|
353
|
+
help="Write the gallery to this absolute path instead of "
|
|
354
|
+
"references/palette_gallery.bundled.html")
|
|
355
|
+
args = ap.parse_args()
|
|
356
|
+
return build(bundle=args.bundle, out_path=args.out)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == "__main__":
|
|
360
|
+
sys.exit(main())
|