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