acmer-coach 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 +28 -0
- package/README.md +258 -0
- package/assets/extension/luogu/README.md +104 -0
- package/assets/extension/luogu/content.js +137 -0
- package/assets/extension/luogu/manifest.json +22 -0
- package/assets/knowledge/DEPENDS.yaml +391 -0
- package/assets/knowledge/LICENSE +41 -0
- package/assets/knowledge/MAP.yaml +2540 -0
- package/assets/knowledge/NODE_ENTRY.yaml +465 -0
- package/assets/knowledge/NODE_META.yaml +457 -0
- package/assets/knowledge/skilltree.html +1702 -0
- package/assets/oiwiki/LICENSE +49 -0
- package/assets/oiwiki/chunks.jsonl +2177 -0
- package/assets/oiwiki/node-pages.json +1208 -0
- package/assets/pool/LICENSE +83 -0
- package/assets/pool/problems.jsonl.gz +0 -0
- package/assets/rules/coach-rules.md +425 -0
- package/build-node-meta.mjs +98 -0
- package/coach/analyze_map.py +138 -0
- package/coach/build_map.py +230 -0
- package/coach/build_node_pages.py +105 -0
- package/coach/check_page_syntax.mjs +48 -0
- package/coach/coach-metrics.py +313 -0
- package/coach/export_oiwiki_chunks.py +100 -0
- package/coach/export_problem_pool.py +64 -0
- package/coach/extract_oiwiki.py +110 -0
- package/coach/oiwiki_tree.json +2987 -0
- package/coach/render_map.py +226 -0
- package/coach/render_skilltree.py +197 -0
- package/coach/serve_map.py +181 -0
- package/coach/skilltree_template.html +1702 -0
- package/coach/validate_map.py +184 -0
- package/coach/verify_drag.mjs +51 -0
- package/coach/verify_interact.mjs +82 -0
- package/coach/verify_serve.py +138 -0
- package/cordis.patch.yml +10 -0
- package/dev/parity-check.mjs +169 -0
- package/dev/run-all.sh +70 -0
- package/dev/setup-check.mjs +191 -0
- package/dev/w1-check.mjs +170 -0
- package/dev/wiki-check.mjs +100 -0
- package/dev.sh +142 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +325 -0
- package/index.js +5820 -0
- package/lib/client.js +209 -0
- package/lib/setup.js +455 -0
- package/package.json +33 -0
- package/progress-cli.mjs +67 -0
- package/verify-shape.mjs +315 -0
- package/verify.mjs +3535 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
render_map.py v2 — 竖向可折叠树 + 依赖高亮
|
|
4
|
+
|
|
5
|
+
v1 的问题:
|
|
6
|
+
「线 和线重叠完了 啥也看不清楚 …… 你可以做成竖着的一棵树一样的
|
|
7
|
+
每一个"学科"你可以做一个可以点击就让他不显示的功能」
|
|
8
|
+
|
|
9
|
+
所以 v2 改成:不做图形化的 DAG(那必然线乱),改成**竖向的折叠树**
|
|
10
|
+
- 学科可折叠(点标题开合)
|
|
11
|
+
- 二级分组(OI-wiki 的分组)也可折叠
|
|
12
|
+
- 每个节点的前置用小字列在旁边(不用连线)
|
|
13
|
+
- 点节点 → 高亮它的完整依赖链(向上追溯),依赖它的节点也会亮
|
|
14
|
+
|
|
15
|
+
为什么不做连线:OI-wiki 的分组树里,节点多数只跨 1-3 条依赖边,
|
|
16
|
+
用文字列出比画线可靠得多 —— 画线在 351 个节点上必然糊成一团。
|
|
17
|
+
|
|
18
|
+
用法: python render_map.py [输出路径] 默认 ~/Desktop/教练地图.html
|
|
19
|
+
"""
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
|
|
24
|
+
for _s in (sys.stdout, sys.stderr):
|
|
25
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
26
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
27
|
+
|
|
28
|
+
MAP = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "MAP.yaml")
|
|
29
|
+
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
|
30
|
+
os.path.expanduser("~"), "Desktop", "教练地图.html")
|
|
31
|
+
|
|
32
|
+
DOMAIN_ORDER = ["算法基础", "基本算法", "搜索", "动态规划", "字符串", "数学",
|
|
33
|
+
"数据结构", "数据结构进阶", "图论", "计算几何", "杂项", "专题"]
|
|
34
|
+
DOMAIN_COLOR = {
|
|
35
|
+
"算法基础": "#58a6ff", "基本算法": "#58a6ff", "搜索": "#d29922",
|
|
36
|
+
"动态规划": "#f778ba", "字符串": "#3fb950", "数学": "#bc8cff",
|
|
37
|
+
"数据结构": "#39c5cf", "数据结构进阶": "#39c5cf",
|
|
38
|
+
"图论": "#ff7b72", "计算几何": "#e3b341", "杂项": "#8b949e", "专题": "#8b949e",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
import yaml
|
|
44
|
+
with open(MAP, "r", encoding="utf-8") as f:
|
|
45
|
+
doc = yaml.safe_load(f)
|
|
46
|
+
nodes = doc["nodes"]
|
|
47
|
+
parallels = doc.get("parallel_branches") or []
|
|
48
|
+
|
|
49
|
+
by_id = {n["id"]: n for n in nodes}
|
|
50
|
+
name_of = {n["id"]: n["name"] for n in nodes}
|
|
51
|
+
|
|
52
|
+
# 依赖(只算显式 depends —— parent 是分类不是前置,见文件头注释)
|
|
53
|
+
deps = {n["id"]: [d for d in (n.get("depends") or []) if d in by_id] for n in nodes}
|
|
54
|
+
rev = defaultdict(list)
|
|
55
|
+
for i, ds in deps.items():
|
|
56
|
+
for d in ds:
|
|
57
|
+
rev[d].append(i)
|
|
58
|
+
|
|
59
|
+
# 分组:学科 → parent 分组 → 节点
|
|
60
|
+
grouped = defaultdict(lambda: defaultdict(list))
|
|
61
|
+
for n in nodes:
|
|
62
|
+
grouped[n["domain"]][n.get("parent") or ""].append(n)
|
|
63
|
+
|
|
64
|
+
order = {d: i for i, d in enumerate(DOMAIN_ORDER)}
|
|
65
|
+
domains = sorted(grouped.keys(), key=lambda d: (order.get(d, 99), d))
|
|
66
|
+
|
|
67
|
+
# ── 生成 HTML ──
|
|
68
|
+
parts = []
|
|
69
|
+
for dom in domains:
|
|
70
|
+
color = DOMAIN_COLOR.get(dom, "#8b949e")
|
|
71
|
+
total = sum(len(v) for v in grouped[dom].values())
|
|
72
|
+
parts.append(f'<section class="dom" data-dom="{dom}">')
|
|
73
|
+
parts.append(
|
|
74
|
+
f'<h2 style="--c:{color}"><span class="caret">▾</span>{dom}'
|
|
75
|
+
f'<em>{total}</em></h2><div class="body">')
|
|
76
|
+
for grp in sorted(grouped[dom]):
|
|
77
|
+
items = sorted(grouped[dom][grp], key=lambda n: n["name"])
|
|
78
|
+
if grp:
|
|
79
|
+
parts.append(f'<div class="grp"><h3><span class="caret">▾</span>'
|
|
80
|
+
f'{grp}<em>{len(items)}</em></h3><div class="body">')
|
|
81
|
+
for n in items:
|
|
82
|
+
ds = deps[n["id"]]
|
|
83
|
+
revs = rev[n["id"]]
|
|
84
|
+
dep_html = ""
|
|
85
|
+
if ds:
|
|
86
|
+
dep_html = ('<span class="dep">← ' +
|
|
87
|
+
"、".join(f'<b data-id="{d}">{name_of[d]}</b>' for d in ds) +
|
|
88
|
+
'</span>')
|
|
89
|
+
rev_html = f'<span class="cnt">{len(revs)}</span>' if revs else ""
|
|
90
|
+
parts.append(
|
|
91
|
+
f'<div class="node" data-id="{n["id"]}" data-deps="{",".join(ds)}" '
|
|
92
|
+
f'data-rev="{",".join(revs)}">'
|
|
93
|
+
f'<span class="nm">{n["name"]}</span>{dep_html}{rev_html}</div>')
|
|
94
|
+
if grp:
|
|
95
|
+
parts.append('</div></div>')
|
|
96
|
+
parts.append('</div></section>')
|
|
97
|
+
|
|
98
|
+
body = "\n".join(parts)
|
|
99
|
+
n_dep = sum(len(v) for v in deps.values())
|
|
100
|
+
n_entry = sum(1 for v in deps.values() if not v)
|
|
101
|
+
par_html = "".join(
|
|
102
|
+
f'<li><b>{p["a"]}</b> ∥ <b>{p["b"]}</b> —— {p["note"]}</li>' for p in parallels)
|
|
103
|
+
|
|
104
|
+
html = f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
|
105
|
+
<title>教练地图 v2</title><style>
|
|
106
|
+
:root {{ --bg:#0d1117; --fg:#e6edf3; --dim:#8b949e; --line:#21262d; --panel:#161b22; }}
|
|
107
|
+
* {{ box-sizing:border-box; }}
|
|
108
|
+
body {{ margin:0; background:var(--bg); color:var(--fg);
|
|
109
|
+
font:14px/1.6 "Segoe UI","Microsoft YaHei",sans-serif; }}
|
|
110
|
+
header {{ padding:20px 28px; border-bottom:1px solid var(--line);
|
|
111
|
+
position:sticky; top:0; background:var(--bg); z-index:10; }}
|
|
112
|
+
h1 {{ margin:0 0 8px; font-size:18px; font-weight:600; }}
|
|
113
|
+
.stat {{ color:var(--dim); font-size:13px; }}
|
|
114
|
+
.stat b {{ color:var(--fg); }}
|
|
115
|
+
.toolbar {{ margin-top:10px; display:flex; gap:8px; flex-wrap:wrap; }}
|
|
116
|
+
.toolbar button {{ background:var(--panel); color:var(--fg); border:1px solid var(--line);
|
|
117
|
+
padding:5px 12px; border-radius:6px; cursor:pointer; font-size:12.5px; }}
|
|
118
|
+
.toolbar button:hover {{ border-color:#58a6ff; }}
|
|
119
|
+
main {{ padding:12px 28px 60px; max-width:1100px; }}
|
|
120
|
+
section.dom {{ margin:14px 0; }}
|
|
121
|
+
h2 {{ margin:0; padding:9px 12px; border-radius:8px 8px 0 0; cursor:pointer;
|
|
122
|
+
font-size:15px; font-weight:600; background:var(--panel);
|
|
123
|
+
border-left:3px solid var(--c); user-select:none; }}
|
|
124
|
+
h2 em, h3 em {{ font-style:normal; color:var(--dim); font-size:12px;
|
|
125
|
+
margin-left:8px; font-weight:400; }}
|
|
126
|
+
h2 .caret, h3 .caret {{ display:inline-block; width:16px; transition:transform .15s;
|
|
127
|
+
color:var(--dim); }}
|
|
128
|
+
.collapsed > .body {{ display:none; }}
|
|
129
|
+
.collapsed .caret {{ transform:rotate(-90deg); }}
|
|
130
|
+
h3 {{ margin:6px 0 0; padding:6px 12px 6px 26px; font-size:13.5px; font-weight:500;
|
|
131
|
+
color:var(--dim); cursor:pointer; user-select:none; }}
|
|
132
|
+
h3:hover {{ color:var(--fg); }}
|
|
133
|
+
.body {{ padding-left:14px; }}
|
|
134
|
+
section.dom > .body > .grp > .body {{ border-left:1px dashed var(--line); margin-left:12px; }}
|
|
135
|
+
.node {{ padding:4px 12px 4px 26px; border-radius:5px; cursor:pointer;
|
|
136
|
+
display:flex; align-items:baseline; gap:10px; }}
|
|
137
|
+
.node:hover {{ background:#1c2128; }}
|
|
138
|
+
.nm {{ min-width:200px; }}
|
|
139
|
+
.dep {{ color:var(--dim); font-size:12px; }}
|
|
140
|
+
.dep b {{ color:#58a6ff; font-weight:400; cursor:pointer; }}
|
|
141
|
+
.dep b:hover {{ text-decoration:underline; }}
|
|
142
|
+
.cnt {{ color:#484f58; font-size:11px; }}
|
|
143
|
+
.node.hl {{ background:#1f6feb22; box-shadow:inset 2px 0 0 #1f6feb; }}
|
|
144
|
+
.node.dep-hl {{ background:#3fb95022; box-shadow:inset 2px 0 0 #3fb950; }}
|
|
145
|
+
.node.rev-hl {{ background:#d2992222; box-shadow:inset 2px 0 0 #d29922; }}
|
|
146
|
+
.legend {{ display:flex; gap:16px; font-size:12px; color:var(--dim); margin-top:8px; }}
|
|
147
|
+
.legend i {{ font-style:normal; padding:1px 7px; border-radius:4px; }}
|
|
148
|
+
.par {{ margin-top:26px; padding:16px 18px; background:var(--panel);
|
|
149
|
+
border-radius:8px; font-size:13px; color:var(--dim); }}
|
|
150
|
+
.par b {{ color:var(--fg); font-weight:500; }}
|
|
151
|
+
.par ul {{ margin:8px 0 0; padding-left:18px; }}
|
|
152
|
+
.par li {{ line-height:1.9; }}
|
|
153
|
+
</style></head><body>
|
|
154
|
+
<header>
|
|
155
|
+
<h1>教练地图 v2 —— 完整知识树</h1>
|
|
156
|
+
<div class="stat">
|
|
157
|
+
<b>{len(nodes)}</b> 个节点 · <b>{n_dep}</b> 条依赖边 ·
|
|
158
|
+
其中 <b>{n_entry}</b> 个未标注前置 · 来源 OI-wiki 导航全量
|
|
159
|
+
</div>
|
|
160
|
+
<div class="toolbar">
|
|
161
|
+
<button onclick="allOpen()">全部展开</button>
|
|
162
|
+
<button onclick="allClose()">全部折叠</button>
|
|
163
|
+
<button onclick="onlyDomains()">只留学科</button>
|
|
164
|
+
<button onclick="clearHL()">清除高亮</button>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="legend">
|
|
167
|
+
<span><i style="background:#1f6feb22;box-shadow:inset 2px 0 0 #1f6feb"> </i> 你点的</span>
|
|
168
|
+
<span><i style="background:#3fb95022;box-shadow:inset 2px 0 0 #3fb950"> </i> 它的前置</span>
|
|
169
|
+
<span><i style="background:#d2992222;box-shadow:inset 2px 0 0 #d29922"> </i> 依赖它的</span>
|
|
170
|
+
</div>
|
|
171
|
+
</header>
|
|
172
|
+
<main>
|
|
173
|
+
{body}
|
|
174
|
+
<div class="par">
|
|
175
|
+
<b>平行分支</b>(不是依赖,是「选一条走即可」)
|
|
176
|
+
<ul>{par_html}</ul>
|
|
177
|
+
</div>
|
|
178
|
+
</main>
|
|
179
|
+
<script>
|
|
180
|
+
document.querySelectorAll('h2,h3').forEach(h => h.onclick = e => {{
|
|
181
|
+
if (e.target.tagName === 'B') return;
|
|
182
|
+
h.parentElement.classList.toggle('collapsed');
|
|
183
|
+
}});
|
|
184
|
+
function allOpen() {{ document.querySelectorAll('.dom,.grp').forEach(e => e.classList.remove('collapsed')); }}
|
|
185
|
+
function allClose() {{ document.querySelectorAll('.dom,.grp').forEach(e => e.classList.add('collapsed')); }}
|
|
186
|
+
function onlyDomains() {{ allClose(); document.querySelectorAll('.dom').forEach(e => e.classList.remove('collapsed')); }}
|
|
187
|
+
function clearHL() {{ document.querySelectorAll('.node').forEach(e =>
|
|
188
|
+
e.classList.remove('hl','dep-hl','rev-hl')); }}
|
|
189
|
+
function mark(id, cls) {{
|
|
190
|
+
const el = document.querySelector(`.node[data-id="${{CSS.escape(id)}}"]`);
|
|
191
|
+
if (el) {{ el.classList.add(cls); return el; }}
|
|
192
|
+
return null;
|
|
193
|
+
}}
|
|
194
|
+
document.querySelectorAll('.node').forEach(el => el.onclick = ev => {{
|
|
195
|
+
if (ev.target.tagName === 'B') {{ // 点前置名 → 跳到它
|
|
196
|
+
const id = ev.target.dataset.id;
|
|
197
|
+
const dst = mark(id, 'hl');
|
|
198
|
+
if (dst) {{ dst.scrollIntoView({{block:'center', behavior:'smooth'}}); }}
|
|
199
|
+
return;
|
|
200
|
+
}}
|
|
201
|
+
clearHL();
|
|
202
|
+
el.classList.add('hl');
|
|
203
|
+
// 向上追溯全部前置(传递闭包)
|
|
204
|
+
const seen = new Set(), stack = [...(el.dataset.deps || '').split(',').filter(Boolean)];
|
|
205
|
+
while (stack.length) {{
|
|
206
|
+
const id = stack.pop();
|
|
207
|
+
if (seen.has(id)) continue;
|
|
208
|
+
seen.add(id);
|
|
209
|
+
const n = document.querySelector(`.node[data-id="${{CSS.escape(id)}}"]`);
|
|
210
|
+
if (n) {{ n.classList.add('dep-hl'); stack.push(...(n.dataset.deps||'').split(',').filter(Boolean)); }}
|
|
211
|
+
}}
|
|
212
|
+
// 直接依赖它的(一层,多了会糊)
|
|
213
|
+
(el.dataset.rev || '').split(',').filter(Boolean).forEach(id => mark(id, 'rev-hl'));
|
|
214
|
+
}});
|
|
215
|
+
</script></body></html>"""
|
|
216
|
+
|
|
217
|
+
with open(OUT, "w", encoding="utf-8") as f:
|
|
218
|
+
f.write(html)
|
|
219
|
+
print(f"渲染完成 → {OUT}")
|
|
220
|
+
print(f" 节点 {len(nodes)} · 依赖边 {n_dep} · 未标注前置 {n_entry}")
|
|
221
|
+
print(f" 学科 {len(domains)} 个")
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main())
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
render_skilltree.py — 把 MAP.yaml + DEPENDS.yaml 渲染成技能树(单文件 HTML)
|
|
4
|
+
|
|
5
|
+
它是个**视图**:只读两份 YAML,不存任何数据。改地图请改 DEPENDS.yaml。
|
|
6
|
+
|
|
7
|
+
布局不用 dagre(省一个 CDN 依赖,也方便把间距调成中文标签舒服的值):
|
|
8
|
+
1. 层 = 最长路深度(跟 analyze_map.py 同一套算法)
|
|
9
|
+
2. 层内顺序 = 重心法(barycenter)迭代 6 轮,减少连线交叉
|
|
10
|
+
3. 每层居中排布,节点宽度随标签长度自适应
|
|
11
|
+
|
|
12
|
+
用法: python render_skilltree.py [地图.yaml] [输出.html]
|
|
13
|
+
不给参数就用 COACH_DATA_DIR(默认 ~/.dsh/knowledge)下的 MAP.yaml → skilltree.html
|
|
14
|
+
改完地图记得重跑一次,否则页面还是旧的。
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
for _s in (sys.stdout, sys.stderr):
|
|
24
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
25
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
|
|
27
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
# 默认数据目录:和插件同一套规矩(COACH_DATA_DIR 优先,否则 ~/.dsh/knowledge)
|
|
29
|
+
_DATA = os.environ.get("COACH_DATA_DIR") or os.path.join(
|
|
30
|
+
os.path.expanduser("~"), ".dsh", "knowledge")
|
|
31
|
+
MAP = sys.argv[1] if len(sys.argv) > 1 else os.path.join(_DATA, "MAP.yaml")
|
|
32
|
+
OUT = sys.argv[2] if len(sys.argv) > 2 else os.path.join(_DATA, "skilltree.html")
|
|
33
|
+
|
|
34
|
+
# 学科配色。VP 的规矩:这些颜色只用于描边/淡底(非文字 UI 组件,够 3:1 即可),
|
|
35
|
+
# 正文一律用 --text-1,所以不需要为它们压深。
|
|
36
|
+
DOMAIN_COLOR = {
|
|
37
|
+
"算法基础": ("#2b7fe0", "#4aa3f7"),
|
|
38
|
+
"数据结构": ("#0e9aa7", "#2dd4bf"),
|
|
39
|
+
"图论": ("#6d4aca", "#a78bfa"),
|
|
40
|
+
"动态规划": ("#d97706", "#ffa726"),
|
|
41
|
+
"字符串": ("#16a24a", "#3fd968"),
|
|
42
|
+
"数学": ("#c026d3", "#e879f9"),
|
|
43
|
+
"搜索": ("#0891b2", "#22d3ee"),
|
|
44
|
+
"计算几何": ("#e11d48", "#fb7185"),
|
|
45
|
+
"杂项": ("#7a8fa6", "#94a3b8"),
|
|
46
|
+
"专题": ("#b45309", "#fbbf24"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
NODE_H = 44 # 节点高
|
|
50
|
+
NODE_GAP = 12 # 同层水平间隙
|
|
51
|
+
ROW_GAP = 26 # 折行之间的间隙
|
|
52
|
+
LAYER_GAP = 64 # 层间垂直间隙
|
|
53
|
+
PAD = 48
|
|
54
|
+
W_MAX = 2240 # 单行最大宽度,超过就折行
|
|
55
|
+
# 为什么必须折行:第 2 层有 122 个节点,一字排开是 14696×1004 的一条带子
|
|
56
|
+
# (14.7:1),缩到屏幕里每个字只有 5px,等于看不见。折行后接近 4:3。
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def text_units(s):
|
|
60
|
+
"""粗估标签宽度:CJK/全角算 1,其余算 0.55。"""
|
|
61
|
+
return sum(1.0 if ord(c) > 0x2E80 else 0.55 for c in s)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build():
|
|
65
|
+
doc = yaml.safe_load(open(MAP, encoding="utf-8"))
|
|
66
|
+
nodes = doc["nodes"]
|
|
67
|
+
name_of = {n["id"]: n["name"] for n in nodes}
|
|
68
|
+
dom_of = {n["id"]: n["domain"] for n in nodes}
|
|
69
|
+
# tier / entry 是**标注**,来自 NODE_META.yaml 和 NODE_ENTRY.yaml,
|
|
70
|
+
# 由 build_map.py 合并进 MAP.yaml。缺了就是没标 / 没数据,**别拿 0 冒充**。
|
|
71
|
+
tier_of = {n["id"]: (n.get("tier") or "") for n in nodes}
|
|
72
|
+
entry_of = {n["id"]: int(n.get("entry") or 0) for n in nodes}
|
|
73
|
+
pool_of = {n["id"]: int(n.get("pool") or 0) for n in nodes}
|
|
74
|
+
ids = [n["id"] for n in nodes]
|
|
75
|
+
|
|
76
|
+
dep = {}
|
|
77
|
+
for n in nodes:
|
|
78
|
+
dep[n["id"]] = [d for d in (n.get("depends") or []) if d in name_of]
|
|
79
|
+
|
|
80
|
+
succ = defaultdict(list)
|
|
81
|
+
for u, ps in dep.items():
|
|
82
|
+
for p in ps:
|
|
83
|
+
succ[p].append(u)
|
|
84
|
+
|
|
85
|
+
# ── 1. 分层:最长路 ──
|
|
86
|
+
memo = {}
|
|
87
|
+
|
|
88
|
+
def depth(u, stack=frozenset()):
|
|
89
|
+
if u in memo:
|
|
90
|
+
return memo[u]
|
|
91
|
+
ps = dep.get(u) or []
|
|
92
|
+
if u in stack or not ps:
|
|
93
|
+
memo[u] = 0
|
|
94
|
+
return 0
|
|
95
|
+
memo[u] = max(depth(p, stack | {u}) for p in ps) + 1
|
|
96
|
+
return memo[u]
|
|
97
|
+
|
|
98
|
+
for i in ids:
|
|
99
|
+
depth(i)
|
|
100
|
+
|
|
101
|
+
layers = defaultdict(list)
|
|
102
|
+
for i in ids:
|
|
103
|
+
layers[memo[i]].append(i)
|
|
104
|
+
max_depth = max(layers)
|
|
105
|
+
|
|
106
|
+
# ── 2. 层内排序:重心法 ──
|
|
107
|
+
for l in sorted(layers):
|
|
108
|
+
layers[l].sort(key=lambda u: (dom_of[u], name_of[u]))
|
|
109
|
+
|
|
110
|
+
# 宽度:左侧圆点(15) + 点后间距(12) + 文字 + 右侧留白(12)
|
|
111
|
+
w = {u: max(104, min(226, 39 + text_units(name_of[u]) * 13.2)) for u in ids}
|
|
112
|
+
|
|
113
|
+
for _ in range(6):
|
|
114
|
+
cx = {}
|
|
115
|
+
for l in sorted(layers):
|
|
116
|
+
cur = 0.0
|
|
117
|
+
for u in layers[l]:
|
|
118
|
+
cx[u] = cur + w[u] / 2
|
|
119
|
+
cur += w[u] + NODE_GAP
|
|
120
|
+
for l in sorted(layers)[1:]:
|
|
121
|
+
def bary(u):
|
|
122
|
+
ps = [p for p in dep[u] if p in cx]
|
|
123
|
+
return sum(cx[p] for p in ps) / len(ps) if ps else cx[u]
|
|
124
|
+
layers[l].sort(key=bary)
|
|
125
|
+
|
|
126
|
+
# ── 3. 落坐标:每层按 W_MAX 折行 ──
|
|
127
|
+
pos, bands = {}, []
|
|
128
|
+
y_cursor = PAD
|
|
129
|
+
layout = [] # (层, 行列表, 行高)
|
|
130
|
+
widest = 0.0
|
|
131
|
+
for l in sorted(layers):
|
|
132
|
+
ns = layers[l]
|
|
133
|
+
rows, cur = [[]], 0.0
|
|
134
|
+
for u in ns:
|
|
135
|
+
if cur > 0 and cur + w[u] > W_MAX:
|
|
136
|
+
rows.append([])
|
|
137
|
+
cur = 0.0
|
|
138
|
+
rows[-1].append(u)
|
|
139
|
+
cur += w[u] + NODE_GAP
|
|
140
|
+
h = len(rows) * NODE_H + (len(rows) - 1) * ROW_GAP
|
|
141
|
+
layout.append((l, rows, h))
|
|
142
|
+
for row in rows:
|
|
143
|
+
widest = max(widest, sum(w[u] for u in row) + NODE_GAP * (len(row) - 1))
|
|
144
|
+
|
|
145
|
+
# 画布宽度按**实际最宽的那一行**算,不是按 W_MAX 上限 ——
|
|
146
|
+
# 否则右边会留一大片死白(W_MAX 是折行阈值,不是真实宽度)。
|
|
147
|
+
canvas_w = widest + PAD * 2
|
|
148
|
+
for l, rows, h in layout:
|
|
149
|
+
bands.append({"depth": l, "count": len(layers[l]), "y": y_cursor - LAYER_GAP / 2,
|
|
150
|
+
"h": h + LAYER_GAP, "rows": len(rows)})
|
|
151
|
+
for ri, row in enumerate(rows):
|
|
152
|
+
total = sum(w[u] for u in row) + NODE_GAP * (len(row) - 1)
|
|
153
|
+
x = PAD + (widest - total) / 2
|
|
154
|
+
y = y_cursor + ri * (NODE_H + ROW_GAP)
|
|
155
|
+
for u in row:
|
|
156
|
+
pos[u] = [round(x, 1), y, w[u], NODE_H]
|
|
157
|
+
x += w[u] + NODE_GAP
|
|
158
|
+
y_cursor += h + LAYER_GAP
|
|
159
|
+
|
|
160
|
+
edges = [[p, u] for u, ps in dep.items() for p in ps]
|
|
161
|
+
|
|
162
|
+
data = {
|
|
163
|
+
"nodes": [
|
|
164
|
+
{
|
|
165
|
+
"id": i, "name": name_of[i], "domain": dom_of[i],
|
|
166
|
+
"depth": memo[i], "x": pos[i][0], "y": pos[i][1],
|
|
167
|
+
"w": pos[i][2], "h": pos[i][3],
|
|
168
|
+
"pre": dep[i], "suc": sorted(succ[i]),
|
|
169
|
+
"tier": tier_of[i], "entry": entry_of[i], "pool": pool_of[i],
|
|
170
|
+
}
|
|
171
|
+
for i in ids
|
|
172
|
+
],
|
|
173
|
+
"edges": edges,
|
|
174
|
+
"bands": bands,
|
|
175
|
+
"size": [round(canvas_w, 1), round(y_cursor - LAYER_GAP + PAD, 1)],
|
|
176
|
+
"domainColors": DOMAIN_COLOR,
|
|
177
|
+
}
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main():
|
|
182
|
+
data = build()
|
|
183
|
+
tpl = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skilltree_template.html")
|
|
184
|
+
html = open(tpl, encoding="utf-8").read()
|
|
185
|
+
html = html.replace("/*__DATA__*/null", json.dumps(data, ensure_ascii=False))
|
|
186
|
+
open(OUT, "w", encoding="utf-8").write(html)
|
|
187
|
+
|
|
188
|
+
print(f"生成完成 → {OUT}")
|
|
189
|
+
print(f" 节点 {len(data['nodes'])} 连线 {len(data['edges'])}")
|
|
190
|
+
print(f" 画布 {data['size'][0]} × {data['size'][1]} px")
|
|
191
|
+
print(f" 层数 {len(data['bands'])} 每层节点 " +
|
|
192
|
+
" / ".join(str(b["count"]) for b in data["bands"]))
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
sys.exit(main())
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
serve_map.py — 起个本地端口看技能树,并让页面能标进度
|
|
4
|
+
|
|
5
|
+
skilltree.html 是**视图**:节点/边从 MAP.yaml 来,进度从 PROGRESS.yaml 来,
|
|
6
|
+
自己一份数据都不存(原则 4:单一真相源)。
|
|
7
|
+
|
|
8
|
+
── 为什么不在这里读写 YAML ────────────────────────────────────
|
|
9
|
+
看着更省事,但会让 PROGRESS.yaml 有**两个写入实现**(这边 Python 一个、
|
|
10
|
+
插件 JS 一个),两套 YAML 库、两种输出风格;而且 PyYAML 不吃注释,
|
|
11
|
+
文件头那段状态说明第一次写就没了。
|
|
12
|
+
|
|
13
|
+
所以本文件**不 import yaml**,所有进度读写都 shell 出去跑
|
|
14
|
+
`progress-cli.mjs` —— 那是插件自己的代码(原子写、损坏时抛错、
|
|
15
|
+
文件头重贴,全复用)。这里只做一件事:把 HTTP 翻译成一次 CLI 调用。
|
|
16
|
+
|
|
17
|
+
页面一度把标记存在浏览器 localStorage —— 那是**第二份真相**。
|
|
18
|
+
这个项目在别处刚花一整轮治同一个病,不能自己家再长一个。
|
|
19
|
+
|
|
20
|
+
用法: python serve_map.py → 自动开浏览器,Ctrl+C 停
|
|
21
|
+
"""
|
|
22
|
+
import functools
|
|
23
|
+
import http.server
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import socketserver
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import webbrowser
|
|
31
|
+
|
|
32
|
+
for _s in (sys.stdout, sys.stderr):
|
|
33
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
34
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
35
|
+
|
|
36
|
+
PORT = 8770
|
|
37
|
+
ROOT = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge")
|
|
38
|
+
|
|
39
|
+
# 跑「已安装副本」里的 CLI,不是插件源码目录 ——
|
|
40
|
+
# 源码目录下 import 不到 yaml 和 @deepseek-ai/dsh-tools(实测 ERR_MODULE_NOT_FOUND)
|
|
41
|
+
CLI = os.path.join(
|
|
42
|
+
os.path.expanduser("~"), ".dsh", "profiles", "web", "node_modules",
|
|
43
|
+
"acmer-coach", "progress-cli.mjs",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_cli(*args):
|
|
48
|
+
"""跑一次进度 CLI,返回它那行 JSON。
|
|
49
|
+
|
|
50
|
+
失败**必须抛**:调用方要靠它区分「真的没进度」和「读写出错了」。
|
|
51
|
+
吞掉异常返回空对象 = 界面显示一切正常,实际什么都没动。
|
|
52
|
+
"""
|
|
53
|
+
node = shutil.which("node")
|
|
54
|
+
if not node:
|
|
55
|
+
raise RuntimeError("找不到 node")
|
|
56
|
+
p = subprocess.run(
|
|
57
|
+
[node, CLI, *args],
|
|
58
|
+
capture_output=True, text=True, encoding="utf-8", timeout=20,
|
|
59
|
+
)
|
|
60
|
+
lines = [l for l in (p.stdout or "").splitlines() if l.strip()]
|
|
61
|
+
if not lines:
|
|
62
|
+
raise RuntimeError((p.stderr or "").strip() or "progress-cli 没有输出")
|
|
63
|
+
return json.loads(lines[-1])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
67
|
+
def log_message(self, *a):
|
|
68
|
+
pass # 别刷屏
|
|
69
|
+
|
|
70
|
+
def end_headers(self):
|
|
71
|
+
# 免得改了 YAML 重新生成 html 后浏览器还拿旧的
|
|
72
|
+
self.send_header("Cache-Control", "no-store")
|
|
73
|
+
super().end_headers()
|
|
74
|
+
|
|
75
|
+
def _json(self, code, obj):
|
|
76
|
+
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
77
|
+
self.send_response(code)
|
|
78
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
79
|
+
self.send_header("Content-Length", str(len(body)))
|
|
80
|
+
self.end_headers()
|
|
81
|
+
self.wfile.write(body)
|
|
82
|
+
|
|
83
|
+
def do_GET(self):
|
|
84
|
+
path = self.path.split("?")[0]
|
|
85
|
+
if path == "/api/progress":
|
|
86
|
+
try:
|
|
87
|
+
self._json(200, run_cli("get"))
|
|
88
|
+
except Exception as e: # 全兜住报给前端 —— 别让页面以为"没数据"
|
|
89
|
+
self._json(500, {"ok": False, "reject": str(e)})
|
|
90
|
+
return
|
|
91
|
+
if path == "/api/schedule":
|
|
92
|
+
try:
|
|
93
|
+
self._json(200, run_cli("sched-get"))
|
|
94
|
+
except Exception as e:
|
|
95
|
+
self._json(500, {"ok": False, "reject": str(e)})
|
|
96
|
+
return
|
|
97
|
+
# 访问根路径不该给**目录列表** ——
|
|
98
|
+
# 那长得像文件系统,不像技能树,还得手敲 /skilltree.html。
|
|
99
|
+
# 重定向过去,地址栏也跟着对。
|
|
100
|
+
if path == "/":
|
|
101
|
+
self.send_response(302)
|
|
102
|
+
self.send_header("Location", "/skilltree.html")
|
|
103
|
+
self.end_headers()
|
|
104
|
+
return
|
|
105
|
+
super().do_GET()
|
|
106
|
+
|
|
107
|
+
def do_POST(self):
|
|
108
|
+
path = self.path.split("?")[0]
|
|
109
|
+
if path == "/api/schedule":
|
|
110
|
+
# 日程写入。**本文件不碰 YAML** —— 理由和上面一样(单一写实现),
|
|
111
|
+
# 所以还是 shell 出去跑 CLI,只是换个命令。
|
|
112
|
+
try:
|
|
113
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
114
|
+
req = json.loads(self.rfile.read(n) or b"{}")
|
|
115
|
+
res = run_cli(
|
|
116
|
+
"sched-set",
|
|
117
|
+
str(req.get("date") or ""),
|
|
118
|
+
str(req.get("field") or ""),
|
|
119
|
+
json.dumps(req.get("value") or [], ensure_ascii=False),
|
|
120
|
+
)
|
|
121
|
+
self._json(200 if res.get("ok") else 400, res)
|
|
122
|
+
except Exception as e: # noqa: BLE001
|
|
123
|
+
self._json(500, {"ok": False, "reject": str(e)})
|
|
124
|
+
return
|
|
125
|
+
if path != "/api/progress":
|
|
126
|
+
self.send_error(404)
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
130
|
+
req = json.loads(self.rfile.read(n) or b"{}")
|
|
131
|
+
op = req.get("op")
|
|
132
|
+
node = str(req.get("node") or "")
|
|
133
|
+
if op == "mark":
|
|
134
|
+
res = run_cli("mark", node, str(req.get("status") or ""))
|
|
135
|
+
elif op == "cursor":
|
|
136
|
+
res = run_cli("cursor", node)
|
|
137
|
+
else:
|
|
138
|
+
self._json(400, {"ok": False, "reject": f"未知 op「{op}」,只有 mark / cursor"})
|
|
139
|
+
return
|
|
140
|
+
self._json(200 if res.get("ok") else 400, res)
|
|
141
|
+
except Exception as e: # noqa: BLE001
|
|
142
|
+
self._json(500, {"ok": False, "reject": str(e)})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Server(socketserver.TCPServer):
|
|
146
|
+
allow_reuse_address = True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main():
|
|
150
|
+
if not os.path.exists(os.path.join(ROOT, "skilltree.html")):
|
|
151
|
+
print("❌ 还没有 skilltree.html —— 先跑 python render_skilltree.py")
|
|
152
|
+
return 1
|
|
153
|
+
if not os.path.exists(CLI):
|
|
154
|
+
print(f"❌ 找不到进度 CLI:{CLI}")
|
|
155
|
+
print(" 先跑 bash ~/.dsh/plugins/acmer-coach/dev.sh 把插件装进 profile")
|
|
156
|
+
return 1
|
|
157
|
+
# 启动先探一次:读不通就别假装能用,直接说清楚
|
|
158
|
+
try:
|
|
159
|
+
p = run_cli("get")
|
|
160
|
+
print(f"🐋 进度读得到:游标 {p.get('cursor') or '(未设)'},"
|
|
161
|
+
f"{len(p.get('nodes') or {})} 个节点有状态")
|
|
162
|
+
except Exception as e: # noqa: BLE001
|
|
163
|
+
print(f"❌ 读 PROGRESS.yaml 失败:{e}")
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
url = f"http://127.0.0.1:{PORT}/skilltree.html"
|
|
167
|
+
with Server(("127.0.0.1", PORT), functools.partial(Handler, directory=ROOT)) as httpd:
|
|
168
|
+
print(f"🐋 技能树 → {url}")
|
|
169
|
+
print(" Ctrl+C 停")
|
|
170
|
+
# --no-open:自动化验页面时用,免得抢人屏幕
|
|
171
|
+
if "--no-open" not in sys.argv:
|
|
172
|
+
webbrowser.open(url)
|
|
173
|
+
try:
|
|
174
|
+
httpd.serve_forever()
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
print("\n停了")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
sys.exit(main())
|