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,1702 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- ⚠️ style 里的背景色是**故意内联**的,不是重复。
|
|
3
|
+
坑:刷新的一瞬间会闪一下黑。因为这页常被嵌在 dsh 的暗色界面里
|
|
4
|
+
(iframe),在 CSS 解析完之前,透出来的是父页面的深色底 ——
|
|
5
|
+
看着就像"黑的技能树"。内联背景在 HTML 一到达就生效,不给透底的机会。
|
|
6
|
+
改配色时记得这一处也要跟着改(就这一行,别的地方还是走变量)。 -->
|
|
7
|
+
<html lang="zh-CN" data-theme="light" style="background:#eef5fc">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<!-- 声明本页是浅色的。不加这条的话,系统处于暗色模式时浏览器会用深色
|
|
12
|
+
渲染"还没加载完"的页面(默认背景/滚动条/表单控件)—— 和上面内联背景
|
|
13
|
+
是防的同一件事,两个都要。 -->
|
|
14
|
+
<meta name="color-scheme" content="light">
|
|
15
|
+
<title>小鲸教练 · 知识地图</title>
|
|
16
|
+
<style>
|
|
17
|
+
/* ═══ 色板照抄 VP 平台 frontend/src/index.css ═══
|
|
18
|
+
双轨制:色块轨(--X) 管描边/淡底,只要 3:1;文字轨(--X-text) 管 color:,压深过 4.5:1。
|
|
19
|
+
学科色同理 —— 只做描边和淡底,正文字色一律 --text-1,所以不必压深。 */
|
|
20
|
+
:root{
|
|
21
|
+
--bg-0:#eef5fc; --bg-1:#ffffff; --bg-2:#f6fafe; --bg-3:#e6effa;
|
|
22
|
+
--fill-0:rgba(13,27,42,.028); --fill-1:rgba(13,27,42,.045); --fill-2:rgba(13,27,42,.07);
|
|
23
|
+
--header-bg:rgba(255,255,255,.82); --surface:rgba(255,255,255,.72);
|
|
24
|
+
--stroke:rgba(13,27,42,.085); --stroke-strong:rgba(13,27,42,.16);
|
|
25
|
+
--text-1:#0d1b2a; --text-2:#3d4f66; --text-3:#5a6b82; --text-4:#62708a; --text-5:#78889e;
|
|
26
|
+
--brand:#2b7fe0; --brand-fill:#1a6fd4; --brand-soft:rgba(43,127,224,.10);
|
|
27
|
+
--brand-glow:rgba(43,127,224,.22); --brand-text:#14589f;
|
|
28
|
+
--ac:#16a24a; --ac-text:#0f7a3d; --wa:#dc2626; --tle:#d97706;
|
|
29
|
+
--ac-soft:rgba(22,162,74,.09); --ac-s:rgba(22,162,74,.38);
|
|
30
|
+
--radius-xs:6px; --radius-s:10px; --radius-m:14px; --radius-l:20px; --radius-pill:999px;
|
|
31
|
+
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
|
|
32
|
+
--shadow-1:0 1px 2px rgba(13,27,42,.05),0 4px 12px rgba(13,27,42,.05);
|
|
33
|
+
--shadow-2:0 2px 8px rgba(13,27,42,.07),0 16px 40px rgba(13,27,42,.09);
|
|
34
|
+
--glow-brand:0 0 0 1px rgba(43,127,224,.18),0 0 20px rgba(43,127,224,.11);
|
|
35
|
+
--ease-spring:cubic-bezier(.32,.72,0,1); --ease-out:cubic-bezier(.16,1,.3,1);
|
|
36
|
+
--speed-1:150ms; --speed-2:280ms;
|
|
37
|
+
--card-stroke:rgba(13,27,42,.13); --node-tint:.05; --dim:.16;
|
|
38
|
+
/* 重点色阶。**不用色相,用"从实到虚"** —— 10 个学科色已经把色相占满了,
|
|
39
|
+
再塞 4 个新色相就分不清哪个维度是哪套。重点本来就是"等级"不是"类别"。
|
|
40
|
+
skip 额外配虚线描边(形状通道),不只靠颜色区分。 */
|
|
41
|
+
--tier-core:#1a6fd4; --tier-normal:#0e9aa7; --tier-rare:#93a4b8; --tier-skip:#c2ccd8;
|
|
42
|
+
/* planned 块(教练排的训练)。**不占新色相** —— 10 个学科色 + 6 个事由色
|
|
43
|
+
已经把色相分完了,再塞一个谁也说不清哪套是哪套。
|
|
44
|
+
换成**另一套视觉语言**:busy 是实心渐变(已经发生的事实),
|
|
45
|
+
planned 是描边 + 半透明底(还没发生的计划)。扫一眼就分得开。 */
|
|
46
|
+
--pl-ink:#14589f; --pl-line:rgba(43,127,224,.62); --pl-solid:rgba(43,127,224,.26);
|
|
47
|
+
--pl-1:rgba(43,127,224,.34); --pl-2:rgba(43,127,224,.20); --pl-3:rgba(43,127,224,.09);
|
|
48
|
+
}
|
|
49
|
+
html[data-theme="dark"]{
|
|
50
|
+
--bg-0:#071528; --bg-1:#0d2138; --bg-2:#122a46; --bg-3:#17334f;
|
|
51
|
+
--fill-0:rgba(255,255,255,.02); --fill-1:rgba(255,255,255,.055); --fill-2:rgba(255,255,255,.09);
|
|
52
|
+
--header-bg:rgba(13,33,56,.72); --surface:rgba(255,255,255,.045);
|
|
53
|
+
--stroke:rgba(255,255,255,.075); --stroke-strong:rgba(255,255,255,.14);
|
|
54
|
+
--text-1:#eef4fb; --text-2:#a9bcd4; --text-3:#8ba0bd; --text-4:#7e93af; --text-5:#748aa6;
|
|
55
|
+
--brand:#4aa3f7; --brand-fill:#1f6fd0; --brand-soft:rgba(74,163,247,.14);
|
|
56
|
+
--brand-glow:rgba(74,163,247,.28); --brand-text:#9ed0ff;
|
|
57
|
+
--ac:#3fd968; --ac-text:#3fd968; --wa:#ff5c52; --tle:#ffa726;
|
|
58
|
+
--ac-soft:rgba(63,217,104,.12); --ac-s:rgba(63,217,104,.42);
|
|
59
|
+
--shadow-1:0 1px 2px rgba(0,0,0,.3),0 4px 12px rgba(0,0,0,.25);
|
|
60
|
+
--shadow-2:0 2px 8px rgba(0,0,0,.35),0 16px 40px rgba(0,0,0,.35);
|
|
61
|
+
--glow-brand:0 0 0 1px rgba(74,163,247,.28),0 0 20px rgba(74,163,247,.22);
|
|
62
|
+
--card-stroke:rgba(255,255,255,.11); --node-tint:.10; --dim:.12;
|
|
63
|
+
--tier-core:#4aa3f7; --tier-normal:#2dd4bf; --tier-rare:#7e93af; --tier-skip:#4c5c72;
|
|
64
|
+
--pl-ink:#9ed0ff; --pl-line:rgba(74,163,247,.58); --pl-solid:rgba(74,163,247,.24);
|
|
65
|
+
--pl-1:rgba(74,163,247,.32); --pl-2:rgba(74,163,247,.19); --pl-3:rgba(74,163,247,.09);
|
|
66
|
+
}
|
|
67
|
+
*{box-sizing:border-box}
|
|
68
|
+
html,body{height:100%;margin:0}
|
|
69
|
+
body{
|
|
70
|
+
background:var(--bg-0); color:var(--text-1);
|
|
71
|
+
font:500 14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI Variable Text","Segoe UI",
|
|
72
|
+
"PingFang SC","Microsoft YaHei",Roboto,sans-serif;
|
|
73
|
+
-webkit-font-smoothing:antialiased; overflow:hidden;
|
|
74
|
+
display:grid; grid-template-rows:auto 1fr; grid-template-columns:auto 1fr;
|
|
75
|
+
grid-template-areas:"hdr hdr" "side stage";
|
|
76
|
+
transition:background var(--speed-2) var(--ease-out);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ── 顶栏 ── */
|
|
80
|
+
.hdr{
|
|
81
|
+
grid-area:hdr; display:flex; align-items:center; gap:var(--sp-4);
|
|
82
|
+
padding:10px var(--sp-5); background:var(--header-bg);
|
|
83
|
+
backdrop-filter:blur(20px) saturate(1.4); border-bottom:1px solid var(--stroke);
|
|
84
|
+
z-index:20;
|
|
85
|
+
}
|
|
86
|
+
.brand{display:flex;align-items:center;gap:9px;font-weight:650;font-size:15px;letter-spacing:-.01em}
|
|
87
|
+
.brand .vp-ico{color:var(--brand)}
|
|
88
|
+
.brand small{color:var(--text-3);font-weight:500;font-size:12.5px;letter-spacing:0}
|
|
89
|
+
|
|
90
|
+
/* 图标统一用 VP 那套内联 SVG(components/Icon.jsx 的 ICO_PATHS)。
|
|
91
|
+
规格:24 视口 / 1.75 线宽 / 圆头圆角 / currentColor —— 颜色由父级 color 决定。 */
|
|
92
|
+
.vp-ico{flex:none;vertical-align:-.14em;display:inline-block}
|
|
93
|
+
.icon-btn .vp-ico,.leg .vp-ico{vertical-align:0}
|
|
94
|
+
.search .vp-ico{color:var(--text-5);pointer-events:none}
|
|
95
|
+
.hdr .spacer{flex:1}
|
|
96
|
+
|
|
97
|
+
.seg{display:flex;padding:2px;background:var(--fill-1);border-radius:var(--radius-pill);gap:2px}
|
|
98
|
+
.seg button{
|
|
99
|
+
appearance:none;border:0;background:transparent;color:var(--text-3);
|
|
100
|
+
font:inherit;font-size:12.5px;font-weight:550;padding:5px 13px;border-radius:var(--radius-pill);
|
|
101
|
+
cursor:pointer;transition:all var(--speed-1) var(--ease-out);white-space:nowrap;
|
|
102
|
+
}
|
|
103
|
+
.seg button:hover{color:var(--text-1)}
|
|
104
|
+
.seg button[aria-pressed="true"]{background:var(--bg-1);color:var(--text-1);box-shadow:var(--shadow-1)}
|
|
105
|
+
|
|
106
|
+
.search{
|
|
107
|
+
display:flex;align-items:center;gap:6px;padding:0 11px;height:32px;min-width:190px;
|
|
108
|
+
background:var(--fill-1);border:1px solid transparent;border-radius:var(--radius-pill);
|
|
109
|
+
transition:all var(--speed-1) var(--ease-out);
|
|
110
|
+
}
|
|
111
|
+
.search:focus-within{background:var(--bg-1);border-color:var(--brand);box-shadow:var(--glow-brand)}
|
|
112
|
+
.search input{
|
|
113
|
+
appearance:none;border:0;background:transparent;color:var(--text-1);
|
|
114
|
+
font:inherit;font-size:13px;width:100%;outline:none;
|
|
115
|
+
}
|
|
116
|
+
.search input::placeholder{color:var(--text-5)}
|
|
117
|
+
.icon-btn{
|
|
118
|
+
appearance:none;border:1px solid var(--stroke);background:var(--bg-1);color:var(--text-2);
|
|
119
|
+
width:32px;height:32px;border-radius:var(--radius-s);cursor:pointer;font-size:15px;
|
|
120
|
+
display:grid;place-items:center;transition:all var(--speed-1) var(--ease-out);
|
|
121
|
+
}
|
|
122
|
+
.icon-btn:hover{border-color:var(--stroke-strong);color:var(--text-1);transform:translateY(-1px)}
|
|
123
|
+
|
|
124
|
+
/* ── 左侧图例 / 筛选 ── */
|
|
125
|
+
.side{
|
|
126
|
+
grid-area:side; width:216px; padding:var(--sp-4) var(--sp-3) var(--sp-4) var(--sp-4);
|
|
127
|
+
overflow-y:auto; border-right:1px solid var(--stroke); background:var(--bg-2);
|
|
128
|
+
}
|
|
129
|
+
.side h2{
|
|
130
|
+
margin:0 0 10px; font-size:11px; font-weight:650; letter-spacing:.07em;
|
|
131
|
+
text-transform:uppercase; color:var(--text-4);
|
|
132
|
+
}
|
|
133
|
+
.leg{display:flex;flex-direction:column;gap:2px}
|
|
134
|
+
.leg button{
|
|
135
|
+
appearance:none;border:0;background:transparent;font:inherit;text-align:left;
|
|
136
|
+
display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:var(--radius-xs);
|
|
137
|
+
cursor:pointer;color:var(--text-2);font-size:13px;transition:all var(--speed-1) var(--ease-out);
|
|
138
|
+
}
|
|
139
|
+
.leg button:hover{background:var(--fill-1);color:var(--text-1)}
|
|
140
|
+
.leg button[aria-pressed="false"] .nm{opacity:.38;text-decoration:line-through}
|
|
141
|
+
.leg button[aria-pressed="false"] .dot{opacity:.28}
|
|
142
|
+
.dot{width:9px;height:9px;border-radius:3px;flex:none;transition:opacity var(--speed-1)}
|
|
143
|
+
.leg .nm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
144
|
+
.leg .ct{color:var(--text-5);font-size:11.5px;font-variant-numeric:tabular-nums}
|
|
145
|
+
.side hr{border:0;border-top:1px solid var(--stroke);margin:var(--sp-4) 0}
|
|
146
|
+
.meta{font-size:12px;color:var(--text-3);line-height:1.75;font-variant-numeric:tabular-nums}
|
|
147
|
+
.meta b{color:var(--text-1);font-weight:600}
|
|
148
|
+
.note{
|
|
149
|
+
margin-top:var(--sp-4); padding:9px 11px; border-radius:var(--radius-s);
|
|
150
|
+
background:var(--brand-soft); border:1px solid var(--stroke);
|
|
151
|
+
font-size:11.5px; line-height:1.6; color:var(--text-3);
|
|
152
|
+
}
|
|
153
|
+
.note b{color:var(--brand-text)}
|
|
154
|
+
/* 写进度失败时变红。没有现成的 danger token,就地补一对(明暗各一)。 */
|
|
155
|
+
.note.err{background:#fdecea;border-color:#f3b7b0;color:#a3241a}
|
|
156
|
+
html[data-theme="dark"] .note.err{background:#3a1a17;border-color:#6b2b24;color:#ff9d92}
|
|
157
|
+
.note code{font-size:11px}
|
|
158
|
+
|
|
159
|
+
/* ── 画布 ── */
|
|
160
|
+
.stage{
|
|
161
|
+
grid-area:stage; position:relative; overflow:hidden; cursor:grab;
|
|
162
|
+
background:
|
|
163
|
+
radial-gradient(1100px 520px at 22% -12%,var(--brand-soft),transparent 62%),
|
|
164
|
+
var(--bg-0);
|
|
165
|
+
/* 图里每个标签都是 <text>,默认全都能被框选 —— 拖动平移时会糊出一片蓝色高亮。
|
|
166
|
+
画布整体禁掉选区;详情面板和输入框再单独放开。 */
|
|
167
|
+
user-select:none; -webkit-user-select:none;
|
|
168
|
+
-webkit-user-drag:none; touch-action:none;
|
|
169
|
+
}
|
|
170
|
+
.stage.dragging{cursor:grabbing}
|
|
171
|
+
.stage.dragging *{cursor:grabbing !important}
|
|
172
|
+
/* 面板里的知识点名是可以复制的,别一起禁了 */
|
|
173
|
+
.panel, .panel *{user-select:text; -webkit-user-select:text}
|
|
174
|
+
svg{display:block;transform-origin:0 0;will-change:transform}
|
|
175
|
+
.band{fill:var(--fill-0)}
|
|
176
|
+
.bandline{stroke:var(--stroke);stroke-width:1;stroke-dasharray:2 6}
|
|
177
|
+
.bandlabel{fill:var(--text-5);font-size:11px;font-weight:600;letter-spacing:.06em;
|
|
178
|
+
font-variant-numeric:tabular-nums}
|
|
179
|
+
.bandcount{fill:var(--text-5);font-size:10.5px;font-weight:500}
|
|
180
|
+
|
|
181
|
+
.edge{stroke:var(--stroke-strong);stroke-width:1.25;fill:none;transition:all var(--speed-1) var(--ease-out)}
|
|
182
|
+
.edge.hot{stroke:var(--brand);stroke-width:2.1}
|
|
183
|
+
.edge.dim{opacity:var(--dim)}
|
|
184
|
+
#arrow{fill:var(--stroke-strong)}
|
|
185
|
+
#arrowHot{fill:var(--brand)}
|
|
186
|
+
|
|
187
|
+
/* 节点 = 中性白卡片 + 一个学科色圆点。
|
|
188
|
+
早先试过整格铺学科淡底 —— 349 格铺满颜色就是一面彩色药丸墙,
|
|
189
|
+
颜色互相打架,读不出层级。颜色降级成圆点后,跟左侧图例也呼应上了。 */
|
|
190
|
+
.node{cursor:pointer}
|
|
191
|
+
/* fallback 值**不是装饰**:节点是顶层同步循环造出来的,而 --nc-fill 要等
|
|
192
|
+
fetch 进度回来才设上。中间那几十毫秒里 var() 取不到值 → SVG 默认黑,
|
|
193
|
+
那个瞬间被抓到过(满屏黑块)。给了 fallback 至少是淡色不吓人。 */
|
|
194
|
+
.node .chip{
|
|
195
|
+
fill:var(--nc-fill,#e7eef8); stroke:var(--card-stroke); stroke-width:1;
|
|
196
|
+
transition:all var(--speed-1) var(--ease-out);
|
|
197
|
+
}
|
|
198
|
+
.node .ndot{fill:var(--nc,#b9c8d8);transition:fill var(--speed-1) var(--ease-out)}
|
|
199
|
+
.node text{
|
|
200
|
+
fill:var(--text-1); font-size:12.5px; font-weight:550;
|
|
201
|
+
text-anchor:start; dominant-baseline:central; pointer-events:none;
|
|
202
|
+
}
|
|
203
|
+
.node:hover .chip{stroke:var(--nc-s);stroke-width:1.5}
|
|
204
|
+
.node.hot .chip{stroke:var(--nc);stroke-width:1.8}
|
|
205
|
+
.node.hot text{font-weight:680}
|
|
206
|
+
.node.dim{opacity:var(--dim)}
|
|
207
|
+
/* skip 类加虚线描边 —— **形状通道**,不只靠颜色。
|
|
208
|
+
「这个不用学」是个强判断,不该只体现在一个更浅的蓝色上(色觉障碍、屏幕差都看不见)。 */
|
|
209
|
+
.node.tier-skip .chip{stroke-dasharray:4 3}
|
|
210
|
+
/* 段位标签:贴在节点右端,弱到不该抢名字的注意力 */
|
|
211
|
+
.node .entry{
|
|
212
|
+
text-anchor:end; fill:var(--text-5); font-size:10.5px;
|
|
213
|
+
font-weight:600; font-variant-numeric:tabular-nums;
|
|
214
|
+
}
|
|
215
|
+
.node.done .chip{fill:var(--ac-soft);stroke:var(--ac-s)}
|
|
216
|
+
.node.done .ndot{fill:var(--ac)}
|
|
217
|
+
/* 「在学」单独一种 —— 它和「学过」不是一回事,不能共用样式。
|
|
218
|
+
用琥珀(--tle 那支),和「学过」的绿、「已验证」的绿底区分开。 */
|
|
219
|
+
.node.studying .chip{fill:var(--tle);fill-opacity:.10;stroke:var(--tle);stroke-opacity:.55}
|
|
220
|
+
.node.studying .ndot{fill:var(--tle)}
|
|
221
|
+
/* 「游标」用**形状**区分而不是颜色 —— 颜色已经被状态占满了。
|
|
222
|
+
虚线加粗,扫一眼就知道今天该看哪。 */
|
|
223
|
+
.node.cursor .chip{stroke:var(--brand);stroke-width:2.2;stroke-dasharray:6 3}
|
|
224
|
+
.node.cursor text{font-weight:680}
|
|
225
|
+
|
|
226
|
+
/* ── 详情面板 ── */
|
|
227
|
+
.panel{
|
|
228
|
+
position:absolute;top:var(--sp-4);right:var(--sp-4);width:290px;max-height:calc(100% - 32px);
|
|
229
|
+
overflow-y:auto;background:var(--bg-1);border:1px solid var(--stroke);
|
|
230
|
+
border-radius:var(--radius-m);box-shadow:var(--shadow-2);padding:var(--sp-4);
|
|
231
|
+
transform:translateX(calc(100% + 24px));opacity:0;pointer-events:none;
|
|
232
|
+
transition:transform var(--speed-2) var(--ease-spring),opacity var(--speed-2) var(--ease-out);
|
|
233
|
+
}
|
|
234
|
+
.panel.on{transform:none;opacity:1;pointer-events:auto}
|
|
235
|
+
.panel .ttl{font-size:16px;font-weight:650;letter-spacing:-.01em;margin:0 0 6px;line-height:1.35}
|
|
236
|
+
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:var(--sp-3)}
|
|
237
|
+
.tag{
|
|
238
|
+
font-size:11.5px;font-weight:550;padding:3px 9px;border-radius:var(--radius-pill);
|
|
239
|
+
background:var(--fill-1);color:var(--text-2);
|
|
240
|
+
}
|
|
241
|
+
.tag.dom{background:var(--nc);color:var(--text-1);border:1px solid var(--nc-s)}
|
|
242
|
+
/* 面板里的状态标签:四种各一色,和图上一致 */
|
|
243
|
+
.tag.st{background:var(--fill-2);color:var(--text-1)}
|
|
244
|
+
.tag.st[data-st="studying"]{background:var(--tle);color:#fff}
|
|
245
|
+
.tag.st[data-st="learned"]{background:var(--brand-fill);color:#fff}
|
|
246
|
+
.tag.st[data-st="verified"]{background:var(--ac);color:#fff}
|
|
247
|
+
.tag.st[data-st="none"]{background:var(--fill-2);color:var(--text-3)}
|
|
248
|
+
.tag.cur{background:var(--brand-soft);color:var(--brand-text);border:1px dashed var(--brand)}
|
|
249
|
+
/* 「多久没碰了」—— 从日期算出来的,不是手标的状态。
|
|
250
|
+
7 天以内不额外提示(刚学的本来就正常),8~30 天转琥珀,30 天开外转红:
|
|
251
|
+
那时候基本等于忘了。**是提醒不是判决** —— 它不拦任何操作。 */
|
|
252
|
+
.tag.age{background:var(--fill-1);color:var(--text-3)}
|
|
253
|
+
.tag.age-warm{background:rgba(217,119,6,.11);color:#8a5208}
|
|
254
|
+
.tag.age-stale{background:rgba(220,38,38,.11);color:#a11d1d}
|
|
255
|
+
|
|
256
|
+
/* ══ 日程视图(L6 时间容量)════════════════════════════════════════
|
|
257
|
+
审美跟 VP 一套:蓝白、卡片、圆角、轻阴影、悬停有回馈。
|
|
258
|
+
|
|
259
|
+
几个刻意的决定(都不是随手定的):
|
|
260
|
+
· **10 天全展开,不做翻页** —— 滚动窗口本来就是"一眼看全"才叫窗口
|
|
261
|
+
· **回顾期(今天-3 ~ 今天-1)压暗** —— 过去的天只读,但得看得见
|
|
262
|
+
(不然会忘掉前天干了什么),所以不是藏起来是压暗
|
|
263
|
+
· **1 小时一格**,不做半小时 —— 半小时太碎,点起来像扫雷
|
|
264
|
+
· **busy 用斜纹不用纯色** —— 纯色会和 planned 的实心块抢眼;
|
|
265
|
+
斜纹在语义上是"背景",实心块才是"内容"
|
|
266
|
+
· **今天单独一列高亮**,不靠颜色深浅认 —— 10 列里认"哪列是今天"
|
|
267
|
+
是高频动作,得一眼看到
|
|
268
|
+
*/
|
|
269
|
+
.viewseg{display:flex;gap:2px;background:var(--fill-1);padding:3px;border-radius:var(--radius-s)}
|
|
270
|
+
.viewseg button{
|
|
271
|
+
font:inherit;font-size:12.5px;font-weight:550;color:var(--text-3);
|
|
272
|
+
background:transparent;border:0;border-radius:7px;padding:5px 13px;cursor:pointer;
|
|
273
|
+
transition:background var(--speed-1),color var(--speed-1);
|
|
274
|
+
}
|
|
275
|
+
.viewseg button:hover{color:var(--text-1)}
|
|
276
|
+
.viewseg button[aria-pressed="true"]{background:var(--bg-1);color:var(--brand-text);box-shadow:var(--shadow-1)}
|
|
277
|
+
|
|
278
|
+
/* ⚠️ body 本身是 grid("hdr hdr" / "side stage")—— 不显式给 grid-area 的话,
|
|
279
|
+
日程会被自动放进**侧栏那一列**,卡片就只有 2/3 宽(踩过)。
|
|
280
|
+
切到日程时把模板改成整行,它就占满宽度了。 */
|
|
281
|
+
.sched{grid-area:sched;display:none}
|
|
282
|
+
body[data-view="sched"]{grid-template-areas:"hdr hdr" "sched sched"}
|
|
283
|
+
body[data-view="sched"] .sched{display:flex;flex-direction:column}
|
|
284
|
+
body[data-view="sched"] #stage,
|
|
285
|
+
body[data-view="sched"] .side,
|
|
286
|
+
body[data-view="sched"] #panel{display:none!important}
|
|
287
|
+
|
|
288
|
+
/* overflow 必须是 auto 不能是 hidden —— 内容(工具栏+网格+两行图例)
|
|
289
|
+
会超过视口高度,hidden 的话底下的颜色对照直接被裁掉,等于没写。
|
|
290
|
+
「备注没改」这种反馈,一部分原因就是它压根看不见。 */
|
|
291
|
+
.sched{padding:var(--sp-5) var(--sp-5) var(--sp-4);overflow-y:auto;overflow-x:hidden}
|
|
292
|
+
|
|
293
|
+
.sched .card{flex:none;display:flex;flex-direction:column;margin-bottom:var(--sp-3);height:min(660px,60vh)}
|
|
294
|
+
.sched .legend2{flex:none}
|
|
295
|
+
|
|
296
|
+
/* ── 时间面:**不要格子,要连续的时间轴** ─────────────────────────
|
|
297
|
+
第一版是"一格一小时",太粗了(「1h 分块太大了」)——
|
|
298
|
+
他要的是拉一段起止时间,而不是点格子。所以:
|
|
299
|
+
· 视觉上只留**每小时一条淡线**当刻度(参考),不做离散格子
|
|
300
|
+
· 交互是**按住拖**:落点吸附到 15 分钟,拖到哪算到哪
|
|
301
|
+
· 松手弹个小框写「什么事」—— 备注是给小鲸看的,不是装饰
|
|
302
|
+
数据结构没变(busy 本来就是 { from, to, label } 任意区间),只换交互。 */
|
|
303
|
+
.sgrid{display:grid;grid-template-columns:52px repeat(10,minmax(86px,1fr));
|
|
304
|
+
grid-template-rows:auto 1fr;height:100%;min-height:0}
|
|
305
|
+
.sgrid .corner{background:var(--bg-2);border-bottom:1px solid var(--stroke)}
|
|
306
|
+
.sgrid .dh{padding:8px 6px 7px;text-align:center;background:var(--bg-2);
|
|
307
|
+
border-bottom:1px solid var(--stroke);border-left:1px solid var(--stroke)}
|
|
308
|
+
.sgrid .dh .dow{font-size:11px;color:var(--text-4);font-weight:550}
|
|
309
|
+
.sgrid .dh .dnum{font-size:14px;color:var(--text-1);font-weight:650;font-variant-numeric:tabular-nums}
|
|
310
|
+
.sgrid .dh.today{background:var(--brand-soft)}
|
|
311
|
+
.sgrid .dh.today .dnum{color:var(--brand-text)}
|
|
312
|
+
.sgrid .dh.past{opacity:.55}
|
|
313
|
+
|
|
314
|
+
.sgrid .stimes{position:relative;background:var(--bg-2);border-right:1px solid var(--stroke)}
|
|
315
|
+
.sgrid .stimes span{position:absolute;right:8px;transform:translateY(-50%);
|
|
316
|
+
font-size:10.5px;color:var(--text-5);font-variant-numeric:tabular-nums;line-height:1}
|
|
317
|
+
/* ⚠️ 这里**故意不画横向分割线**。这是明确要求去掉的:
|
|
318
|
+
格子里那些每小时一条的横线看着乱,而左边的时间标签已经够定位了。
|
|
319
|
+
(第一版还踩过 gradient + 缩放的坑,那些线在 150% 下会整条消失 ——
|
|
320
|
+
干脆不要了,两个问题一起没。)
|
|
321
|
+
留着的两条线各有用途:列边框(竖,分日期)、悬停跟随线(交互反馈)。 */
|
|
322
|
+
.sgrid .dcol{position:relative;border-left:1px solid var(--stroke);
|
|
323
|
+
cursor:crosshair;overflow:hidden;touch-action:none}
|
|
324
|
+
.sgrid .dcol:hover{background-color:var(--fill-0)}
|
|
325
|
+
.sgrid .dcol.past{opacity:.45}
|
|
326
|
+
.sgrid .dcol.today{background-color:rgba(43,127,224,.045)}
|
|
327
|
+
|
|
328
|
+
/* 一段「有事」:实心暖色块 + 备注文字。跨小时是一条连续块,
|
|
329
|
+
不是一格一格拼的 —— 拼的话接缝处会有 1px 缝,看着像没对齐。 */
|
|
330
|
+
/* 「有事」的色块。**颜色按事由自动分**(一条明确要求:不要全一个黄)。
|
|
331
|
+
每套都压得够深,白字在 AA 以上;深色主题下靠 --ev-*-dark 换一档,
|
|
332
|
+
不靠 opacity —— 半透明会让白字对比度掉下去。 */
|
|
333
|
+
/* 色相**故意收敛在蓝—靛—青**这一片(VP 的主色域),只留一个暖色给比赛。
|
|
334
|
+
第一版用了红紫蓝绿青灰六个色相,反馈是「跟随机的一样」——
|
|
335
|
+
六个色相摆一起就是彩虹,再准的映射看着也乱。邻近色域才显得是一套。 */
|
|
336
|
+
:root{
|
|
337
|
+
--ev-amber:#d97706; --ev-amber-2:#c2660a; /* 兜底(默认) */
|
|
338
|
+
--ev-blue:#3b7ddd; --ev-blue-2:#2b64bd; /* 上课 */
|
|
339
|
+
--ev-violet:#6366f1;--ev-violet-2:#4f52d6; /* 活动 / 宣讲 */
|
|
340
|
+
--ev-red:#d9534f; --ev-red-2:#bf3f3b; /* 比赛(唯一暖色,用来跳出来) */
|
|
341
|
+
--ev-teal:#0d9488; --ev-teal-2:#0a7a70; /* 运动 */
|
|
342
|
+
--ev-cyan:#0e7490; --ev-cyan-2:#0b5f75; /* 工作 / 兼职 */
|
|
343
|
+
--ev-slate:#5c718a; --ev-slate-2:#4a5c72; /* 生活(吃饭睡觉,最淡) */
|
|
344
|
+
}
|
|
345
|
+
.bbar{position:absolute;left:3px;right:3px;border-radius:5px;
|
|
346
|
+
color:#fff;font-size:11px;font-weight:550;line-height:1.25;padding:3px 7px;
|
|
347
|
+
overflow:hidden;cursor:pointer;transition:filter var(--speed-1);
|
|
348
|
+
background:linear-gradient(180deg,var(--ev-amber),var(--ev-amber-2));
|
|
349
|
+
box-shadow:0 1px 4px rgba(120,70,0,.25)}
|
|
350
|
+
.bbar:hover{filter:brightness(1.07)}
|
|
351
|
+
/* 文字按**块高**分三档。踩过:时间选得短,字直接被压没了。
|
|
352
|
+
判据用**时长(分钟)**不用像素 —— 像素依赖列高(窗口一缩就变),
|
|
353
|
+
分钟数是数据本身的属性,稳定。
|
|
354
|
+
≥75 分钟(1¼h):时间一行 + 事由一行,信息全
|
|
355
|
+
40~74 分钟(≈45min):只放事由,挤一行居中
|
|
356
|
+
<40 分钟:不放字,只留色条;完整信息挂 title,悬停可见 */
|
|
357
|
+
.bbar.s-full{padding:3px 7px}
|
|
358
|
+
.bbar.s-full .t{display:block;font-size:10px;opacity:.82;font-weight:500}
|
|
359
|
+
.bbar.s-mid{display:flex;align-items:center;padding:0 7px;font-size:10.5px;
|
|
360
|
+
white-space:nowrap;overflow:hidden}
|
|
361
|
+
.bbar.s-mini{padding:0}
|
|
362
|
+
.bbar.c-blue {background:linear-gradient(180deg,var(--ev-blue),var(--ev-blue-2));
|
|
363
|
+
box-shadow:0 1px 4px rgba(30,70,150,.22)}
|
|
364
|
+
.bbar.c-violet{background:linear-gradient(180deg,var(--ev-violet),var(--ev-violet-2));
|
|
365
|
+
box-shadow:0 1px 4px rgba(70,70,180,.22)}
|
|
366
|
+
.bbar.c-red {background:linear-gradient(180deg,var(--ev-red),var(--ev-red-2));
|
|
367
|
+
box-shadow:0 1px 4px rgba(150,45,40,.24)}
|
|
368
|
+
.bbar.c-teal {background:linear-gradient(180deg,var(--ev-teal),var(--ev-teal-2));
|
|
369
|
+
box-shadow:0 1px 4px rgba(8,90,82,.22)}
|
|
370
|
+
.bbar.c-cyan {background:linear-gradient(180deg,var(--ev-cyan),var(--ev-cyan-2));
|
|
371
|
+
box-shadow:0 1px 4px rgba(8,70,90,.22)}
|
|
372
|
+
.bbar.c-slate {background:linear-gradient(180deg,var(--ev-slate),var(--ev-slate-2));
|
|
373
|
+
box-shadow:0 1px 4px rgba(40,55,75,.20)}
|
|
374
|
+
/* 没写事由的:斜纹,说明"这里有安排但没说是什么" */
|
|
375
|
+
.bbar.nolabel{background:repeating-linear-gradient(45deg,
|
|
376
|
+
rgba(217,119,6,.36) 0 5px, rgba(217,119,6,.15) 5px 10px);color:#7a4300;
|
|
377
|
+
box-shadow:none}
|
|
378
|
+
|
|
379
|
+
/* ── planned 块:教练排的训练 ────────────────────────────────────
|
|
380
|
+
和 busy 走**两套视觉语言**(变量那儿的注释写了理由):一个是描边空心的
|
|
381
|
+
计划,一个是实心渐变的事实。
|
|
382
|
+
循环块用**一段渐变**画出 40/10/10 的比例 —— 不用分割元素,多一层 DOM
|
|
383
|
+
就多一处能画错的地方;而那个比例是块的灵魂:40 分钟到点必须停,
|
|
384
|
+
止损点就守在这儿。
|
|
385
|
+
**只读**:页面上不给改动入口。给了就会手滑改计划,
|
|
386
|
+
那"计划 vs 实际"的对照就没了 —— 而那个对照正是排计划的意义。 */
|
|
387
|
+
.pbar{position:absolute;left:3px;right:3px;border-radius:5px;
|
|
388
|
+
border:1.5px solid var(--pl-line);background:var(--pl-solid);
|
|
389
|
+
color:var(--pl-ink);font-size:11px;font-weight:600;line-height:1.25;
|
|
390
|
+
padding:2px 6px;overflow:hidden;cursor:default}
|
|
391
|
+
.pbar .t{display:block;font-size:10px;opacity:.78;font-weight:500}
|
|
392
|
+
.pbar.k-cycle{background:linear-gradient(180deg,
|
|
393
|
+
var(--pl-1) 0 66.67%, var(--pl-2) 66.67% 83.33%, var(--pl-3) 83.33% 100%)}
|
|
394
|
+
.pbar.s-mini{padding:0;border-width:1px}
|
|
395
|
+
|
|
396
|
+
/* 拖选时的预览条 */
|
|
397
|
+
.selbar{position:absolute;left:3px;right:3px;border-radius:5px;pointer-events:none;
|
|
398
|
+
background:rgba(217,119,6,.22);border:1px dashed rgba(217,119,6,.7)}
|
|
399
|
+
|
|
400
|
+
/* 悬停指示:一条横贯全宽的细线 + 跟着鼠标的时间标签。
|
|
401
|
+
踩过「鼠标放上去看不到位置的时间,很容易选错」——
|
|
402
|
+
光有刻度线不够,得让他在按下去**之前**就知道会选到哪一分钟。 */
|
|
403
|
+
.hoverline{position:fixed;height:1px;background:var(--brand);
|
|
404
|
+
opacity:.6;pointer-events:none;z-index:40;transition:opacity var(--speed-1)}
|
|
405
|
+
.hoverlabel{position:fixed;pointer-events:none;z-index:41;
|
|
406
|
+
background:var(--text-1);color:var(--bg-1);
|
|
407
|
+
font:600 11px/1 var(--dsw-font-family,inherit);
|
|
408
|
+
font-variant-numeric:tabular-nums;padding:3px 7px;border-radius:5px;
|
|
409
|
+
white-space:nowrap;box-shadow:var(--shadow-1)}
|
|
410
|
+
|
|
411
|
+
/* 写「什么事」的小浮层 */
|
|
412
|
+
.pop{position:fixed;z-index:60;width:236px;padding:12px;
|
|
413
|
+
background:var(--bg-1);border:1px solid var(--stroke-strong);border-radius:var(--radius-s);
|
|
414
|
+
box-shadow:var(--shadow-2)}
|
|
415
|
+
.pop .pt{font-size:12.5px;font-weight:650;color:var(--text-1);margin-bottom:8px;
|
|
416
|
+
font-variant-numeric:tabular-nums}
|
|
417
|
+
.pop input{width:100%;box-sizing:border-box;font:inherit;font-size:13px;
|
|
418
|
+
padding:7px 9px;border:1px solid var(--stroke-strong);border-radius:var(--radius-xs);
|
|
419
|
+
background:var(--bg-0);color:var(--text-1);outline:none}
|
|
420
|
+
.pop input:focus{border-color:var(--brand);box-shadow:var(--glow-brand)}
|
|
421
|
+
.pop .prow{display:flex;gap:6px;margin-top:10px}
|
|
422
|
+
.pop .prow button{flex:1;font:inherit;font-size:12.5px;font-weight:600;
|
|
423
|
+
padding:6px 0;border-radius:var(--radius-xs);cursor:pointer;border:1px solid var(--stroke-strong);
|
|
424
|
+
background:var(--bg-2);color:var(--text-2)}
|
|
425
|
+
.pop .prow button.pri{background:var(--brand-fill);border-color:var(--brand-fill);color:#fff}
|
|
426
|
+
.pop .prow button:hover{filter:brightness(1.05)}
|
|
427
|
+
.pop .prow button.del{flex:0 0 62px;color:var(--wa);border-color:rgba(220,38,38,.3)}
|
|
428
|
+
/* 「今天」按钮:纯图标按钮的宽度放不下两个字,会被挤成竖排 */
|
|
429
|
+
#schedToday{width:auto;padding:0 11px;font-size:12.5px;font-weight:600;white-space:nowrap}
|
|
430
|
+
.schedbar{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-4)}
|
|
431
|
+
.schedbar h2{margin:0;font-size:15px;font-weight:650;color:var(--text-1)}
|
|
432
|
+
.schedbar .span{font-size:12.5px;color:var(--text-4);font-variant-numeric:tabular-nums}
|
|
433
|
+
.schedbar .spacer{flex:1}
|
|
434
|
+
|
|
435
|
+
.card{
|
|
436
|
+
background:var(--bg-1);border:1px solid var(--stroke);border-radius:var(--radius-m);
|
|
437
|
+
box-shadow:var(--shadow-1);overflow:hidden;
|
|
438
|
+
}
|
|
439
|
+
.grid{display:grid;grid-template-columns:56px repeat(10,minmax(88px,1fr))}
|
|
440
|
+
.grid .corner{background:var(--bg-2);border-bottom:1px solid var(--stroke)}
|
|
441
|
+
.grid .dh{
|
|
442
|
+
padding:8px 6px 7px;text-align:center;background:var(--bg-2);
|
|
443
|
+
border-bottom:1px solid var(--stroke);border-left:1px solid var(--stroke);
|
|
444
|
+
}
|
|
445
|
+
.grid .dh .dow{font-size:11px;color:var(--text-4);font-weight:550}
|
|
446
|
+
.grid .dh .dnum{font-size:14px;color:var(--text-1);font-weight:650;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
|
447
|
+
.grid .dh .dmon{font-size:10px;color:var(--text-5)}
|
|
448
|
+
.grid .dh.today{background:var(--brand-soft)}
|
|
449
|
+
.grid .dh.today .dnum{color:var(--brand-text)}
|
|
450
|
+
.grid .dh.past{opacity:.55}
|
|
451
|
+
.grid .hh{
|
|
452
|
+
font-size:10.5px;color:var(--text-5);text-align:right;padding:0 7px 0 0;
|
|
453
|
+
height:22px;line-height:22px;background:var(--bg-2);border-right:1px solid var(--stroke);
|
|
454
|
+
font-variant-numeric:tabular-nums;
|
|
455
|
+
}
|
|
456
|
+
.grid .cell{
|
|
457
|
+
height:22px;border-left:1px solid var(--stroke);border-top:1px dashed var(--stroke);
|
|
458
|
+
cursor:pointer;position:relative;transition:background var(--speed-1);
|
|
459
|
+
}
|
|
460
|
+
.grid .cell:hover{background:var(--brand-soft)}
|
|
461
|
+
.grid .cell.busy{
|
|
462
|
+
background-color:rgba(217,119,6,.08);
|
|
463
|
+
background-image:repeating-linear-gradient(45deg,
|
|
464
|
+
rgba(217,119,6,.30) 0 4px, transparent 4px 9px);
|
|
465
|
+
cursor:pointer;
|
|
466
|
+
}
|
|
467
|
+
.grid .cell.busy:hover{background-color:rgba(217,119,6,.16)}
|
|
468
|
+
.grid .cell.past{opacity:.45}
|
|
469
|
+
.grid .cell.today{border-left-color:var(--brand)}
|
|
470
|
+
|
|
471
|
+
/* 排好的块:浮在格子上的实心条 */
|
|
472
|
+
.blk{
|
|
473
|
+
position:absolute;left:2px;right:2px;top:1px;bottom:1px;
|
|
474
|
+
border-radius:5px;background:linear-gradient(180deg,var(--brand),var(--brand-fill));
|
|
475
|
+
color:#fff;font-size:10px;line-height:20px;padding:0 6px;overflow:hidden;white-space:nowrap;
|
|
476
|
+
box-shadow:0 1px 3px rgba(43,127,224,.35);pointer-events:none;
|
|
477
|
+
}
|
|
478
|
+
.blk.k-resolve{background:linear-gradient(180deg,#5b9be8,#4a86d8)}
|
|
479
|
+
.blk.k-rewrite{background:linear-gradient(180deg,#7fb3ee,#6a9fe0)}
|
|
480
|
+
.blk.k-review{background:linear-gradient(180deg,#0e9aa7,#0b8792)}
|
|
481
|
+
.blk.k-test{background:linear-gradient(180deg,#7c5cd6,#6a4bc4)}
|
|
482
|
+
|
|
483
|
+
.legend2{display:flex;gap:16px;align-items:center;margin-top:var(--sp-3);font-size:12px;color:var(--text-3);flex-wrap:wrap}
|
|
484
|
+
.legend2 i{display:inline-block;width:14px;height:10px;border-radius:3px;margin-right:5px;vertical-align:middle}
|
|
485
|
+
.legend2 i.busy{background-color:rgba(217,119,6,.10);background-image:repeating-linear-gradient(45deg,rgba(217,119,6,.32) 0 3px,transparent 3px 6px)}
|
|
486
|
+
.legend2 i.plan{background:var(--pl-solid);border:1.5px solid var(--pl-line)}
|
|
487
|
+
.legend2 .hint{margin-left:auto;color:var(--text-4)}
|
|
488
|
+
.legend3{display:flex;gap:14px;align-items:center;margin-top:7px;
|
|
489
|
+
font-size:11.5px;color:var(--text-3);flex-wrap:wrap}
|
|
490
|
+
.legend3 .lt{color:var(--text-5);font-weight:600;letter-spacing:.02em}
|
|
491
|
+
.legend3 .lt2{color:var(--text-4)}
|
|
492
|
+
.legend3 span{display:inline-flex;align-items:center;gap:5px}
|
|
493
|
+
.legend3 .sw{display:inline-block;width:12px;height:9px;border-radius:3px}
|
|
494
|
+
.sw-blue{background:var(--ev-blue)}
|
|
495
|
+
.sw-violet{background:var(--ev-violet)}
|
|
496
|
+
.sw-red{background:var(--ev-red)}
|
|
497
|
+
.sw-teal{background:var(--ev-teal)}
|
|
498
|
+
.sw-cyan{background:var(--ev-cyan)}
|
|
499
|
+
.sw-slate{background:var(--ev-slate)}
|
|
500
|
+
.sec{margin-top:var(--sp-4)}
|
|
501
|
+
.sec h3{
|
|
502
|
+
margin:0 0 7px;font-size:11px;font-weight:650;letter-spacing:.07em;
|
|
503
|
+
text-transform:uppercase;color:var(--text-4);
|
|
504
|
+
}
|
|
505
|
+
/* 检测记录 / 判因记录。数据是 L5 才落盘的,之前写进去了界面上看不见 ——
|
|
506
|
+
那等于没写,是这个项目一直在治的病。 */
|
|
507
|
+
.hist{display:flex;flex-direction:column;gap:4px;margin-top:4px}
|
|
508
|
+
.hrow{
|
|
509
|
+
display:flex;align-items:baseline;gap:var(--sp-2);
|
|
510
|
+
padding:5px 8px;border-radius:var(--radius-xs);
|
|
511
|
+
background:var(--fill-0);border:1px solid var(--stroke);
|
|
512
|
+
font-size:12.5px;
|
|
513
|
+
}
|
|
514
|
+
.hrow b{flex:none;font-weight:680}
|
|
515
|
+
.ho-passed{color:var(--ac-text)}
|
|
516
|
+
.ho-overTime{color:var(--tle)}
|
|
517
|
+
.ho-failed{color:var(--wa)}
|
|
518
|
+
.hc{color:var(--brand-text)}
|
|
519
|
+
.hd{flex:none;color:var(--text-4);font-variant-numeric:tabular-nums}
|
|
520
|
+
.hs{flex:1;min-width:0;color:var(--text-2);
|
|
521
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
522
|
+
.he{flex:none;color:var(--text-5);font-variant-numeric:tabular-nums}
|
|
523
|
+
|
|
524
|
+
.chips{display:flex;flex-wrap:wrap;gap:5px}
|
|
525
|
+
.chip{
|
|
526
|
+
appearance:none;font:inherit;font-size:12px;padding:4px 10px;border-radius:var(--radius-xs);
|
|
527
|
+
background:var(--fill-1);border:1px solid var(--stroke);color:var(--text-2);
|
|
528
|
+
cursor:pointer;transition:all var(--speed-1) var(--ease-out);
|
|
529
|
+
}
|
|
530
|
+
.chip:hover{background:var(--bg-2);border-color:var(--stroke-strong);color:var(--text-1)}
|
|
531
|
+
.none{font-size:12px;color:var(--text-5)}
|
|
532
|
+
.acts{display:flex;gap:7px;margin-top:var(--sp-4);padding-top:var(--sp-4);border-top:1px solid var(--stroke)}
|
|
533
|
+
.btn{
|
|
534
|
+
appearance:none;font:inherit;font-size:13px;font-weight:550;flex:1;padding:8px 12px;
|
|
535
|
+
border-radius:var(--radius-s);cursor:pointer;border:1px solid var(--stroke);
|
|
536
|
+
background:var(--bg-1);color:var(--text-2);transition:all var(--speed-1) var(--ease-out);
|
|
537
|
+
display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
|
538
|
+
}
|
|
539
|
+
.btn:hover{border-color:var(--stroke-strong);color:var(--text-1)}
|
|
540
|
+
.btn.primary{background:var(--brand-fill);border-color:var(--brand-fill);color:#fff}
|
|
541
|
+
.btn.primary:hover{filter:brightness(1.08)}
|
|
542
|
+
.btn.primary[data-on="1"]{background:var(--ac);border-color:var(--ac)}
|
|
543
|
+
|
|
544
|
+
.hint{
|
|
545
|
+
position:absolute;left:var(--sp-5);bottom:var(--sp-4);font-size:11.5px;color:var(--text-5);
|
|
546
|
+
background:var(--surface);backdrop-filter:blur(10px);padding:6px 12px;border-radius:var(--radius-pill);
|
|
547
|
+
border:1px solid var(--stroke);pointer-events:none;
|
|
548
|
+
}
|
|
549
|
+
kbd{
|
|
550
|
+
font:inherit;font-size:10.5px;padding:1px 5px;border-radius:4px;
|
|
551
|
+
background:var(--fill-2);color:var(--text-3);border:1px solid var(--stroke);
|
|
552
|
+
}
|
|
553
|
+
</style>
|
|
554
|
+
<script>
|
|
555
|
+
/* 主题要在**首次渲染之前**定下来。
|
|
556
|
+
踩过:「调成暗色后刷新又变亮」—— 因为主题原来只写在 HTML 属性上,
|
|
557
|
+
刷新就没了。存 localStorage 能解决持久化,但**必须在这段同步脚本里读**:
|
|
558
|
+
放到 DOMContentLoaded 或者主脚本里,页面会先按浅色画一遍、再翻成暗色 ——
|
|
559
|
+
对暗色用户就是白闪一下(和上一轮那个黑闪是同一个病的两面)。
|
|
560
|
+
所以它得在 <head> 里、同步执行,抢在浏览器第一次画之前。 */
|
|
561
|
+
try {
|
|
562
|
+
if (localStorage.getItem("coach.theme") === "dark") {
|
|
563
|
+
document.documentElement.dataset.theme = "dark";
|
|
564
|
+
document.documentElement.style.background = "#071528"; // 和内联背景同步,防透底
|
|
565
|
+
}
|
|
566
|
+
} catch (e) { /* 隐私模式下 localStorage 会抛,忽略即可,退回浅色 */ }
|
|
567
|
+
</script>
|
|
568
|
+
</head>
|
|
569
|
+
<body>
|
|
570
|
+
|
|
571
|
+
<header class="hdr">
|
|
572
|
+
<div class="brand"><span data-ico="whale" data-size="20"></span><span>小鲸教练 · 知识地图</span>
|
|
573
|
+
<small id="hdrCount"></small></div>
|
|
574
|
+
<div class="viewseg" id="viewSeg" role="group" aria-label="视图">
|
|
575
|
+
<button data-view="tree" aria-pressed="true">技能树</button>
|
|
576
|
+
<button data-view="sched" aria-pressed="false">日程</button>
|
|
577
|
+
</div>
|
|
578
|
+
<div class="spacer"></div>
|
|
579
|
+
<div class="seg" id="modeSeg" role="group" aria-label="着色模式">
|
|
580
|
+
<button data-mode="domain" aria-pressed="true">按学科</button>
|
|
581
|
+
<button data-mode="state" aria-pressed="false">按状态</button>
|
|
582
|
+
<button data-mode="tier" aria-pressed="false">按重点</button>
|
|
583
|
+
</div>
|
|
584
|
+
<label class="search"><span data-ico="search" data-size="15"></span>
|
|
585
|
+
<input id="q" type="search" placeholder="搜知识点…" aria-label="搜索知识点"></label>
|
|
586
|
+
<button class="icon-btn" id="wires" title="显示全部连线(会糊,慎用)" data-ico="link"></button>
|
|
587
|
+
<button class="icon-btn" id="fit" title="回到全图(铺满宽度)" data-ico="expand"></button>
|
|
588
|
+
<button class="icon-btn" id="theme" title="明暗切换" data-ico="moon"></button>
|
|
589
|
+
</header>
|
|
590
|
+
|
|
591
|
+
<aside class="side">
|
|
592
|
+
<h2 id="legTitle">学科</h2>
|
|
593
|
+
<div class="leg" id="legend"></div>
|
|
594
|
+
<hr>
|
|
595
|
+
<div class="meta" id="meta"></div>
|
|
596
|
+
<div class="note" id="note">
|
|
597
|
+
载入中…(这段是占位,真实状态由 <code>./api/progress</code> 覆盖)
|
|
598
|
+
</div>
|
|
599
|
+
</aside>
|
|
600
|
+
|
|
601
|
+
<main class="stage" id="stage">
|
|
602
|
+
<svg id="cv">
|
|
603
|
+
<defs>
|
|
604
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
|
605
|
+
markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">
|
|
606
|
+
<path d="M0,1 L9,5 L0,9 z"/>
|
|
607
|
+
</marker>
|
|
608
|
+
<marker id="arrowHot" viewBox="0 0 10 10" refX="9" refY="5"
|
|
609
|
+
markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">
|
|
610
|
+
<path d="M0,1 L9,5 L0,9 z"/>
|
|
611
|
+
</marker>
|
|
612
|
+
</defs>
|
|
613
|
+
<g id="bands"></g><g id="edges"></g><g id="nodes"></g>
|
|
614
|
+
</svg>
|
|
615
|
+
<div class="panel" id="panel"></div>
|
|
616
|
+
<div class="hint">滚轮缩放 · 拖拽平移 · <b>点节点看前置链和「多久没碰了」</b> ·
|
|
617
|
+
选中后 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> = 未学/在学/学过(标完不跳,想往前走按
|
|
618
|
+
<kbd>↓</kbd>/<kbd>→</kbd>)· 点空白/<kbd>Esc</kbd> 取消</div>
|
|
619
|
+
</main>
|
|
620
|
+
|
|
621
|
+
<!-- 日程视图(L6 时间容量)。和技能树同一张页面、同一套样式 ——
|
|
622
|
+
两份实现就是两份真相,所以它是"另一个视图"不是"另一个页面"。 -->
|
|
623
|
+
<section class="sched" id="sched">
|
|
624
|
+
<div class="schedbar">
|
|
625
|
+
<h2>日程</h2>
|
|
626
|
+
<span class="span" id="schedSpan"></span>
|
|
627
|
+
<div class="spacer"></div>
|
|
628
|
+
<button class="icon-btn" id="schedPrev" title="窗口往前挪 3 天" data-ico="back"></button>
|
|
629
|
+
<button class="icon-btn" id="schedToday" title="回到今天">今天</button>
|
|
630
|
+
<button class="icon-btn" id="schedNext" title="窗口往后挪 3 天" data-ico="fwd"></button>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="card"><div class="sgrid" id="schedGrid"></div></div>
|
|
633
|
+
<div class="legend2">
|
|
634
|
+
<span><i class="busy"></i>有事(拖一段标出来)</span>
|
|
635
|
+
<span><i class="plan"></i>教练排的块:40 自己做 + 10 解决遗留 + 10 重写,排完固化</span>
|
|
636
|
+
<span class="hint">按住拖出起止时间,松手写「什么事」· 点已有的块可改可删 · 回顾期只读</span>
|
|
637
|
+
</div>
|
|
638
|
+
<!-- 颜色对照。只写"按事由自动分"等于没说 —— 得让他一眼查到哪个色是哪类事,
|
|
639
|
+
不然每次都得回忆(「备注没改」就是这么来的)。 -->
|
|
640
|
+
<div class="legend3">
|
|
641
|
+
<span class="lt">颜色对照</span>
|
|
642
|
+
<span><i class="sw sw-blue"></i>上课 / 作业 / 考试</span>
|
|
643
|
+
<span><i class="sw sw-violet"></i>活动 / 宣讲 / 开会</span>
|
|
644
|
+
<span><i class="sw sw-red"></i>比赛</span>
|
|
645
|
+
<span><i class="sw sw-teal"></i>运动</span>
|
|
646
|
+
<span><i class="sw sw-cyan"></i>工作 / 兼职</span>
|
|
647
|
+
<span><i class="sw sw-slate"></i>吃饭 / 睡觉 / 陪人</span>
|
|
648
|
+
<span class="lt2">没写事由的 → 斜纹</span>
|
|
649
|
+
</div>
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<script>
|
|
653
|
+
const DATA = {"nodes": [{"id": "RMQ", "name": "RMQ", "domain": "专题", "depth": 3, "x": 809.6, "y": 1352, "w": 104, "h": 44, "pre": ["倍增", "线段树"], "suc": [], "tier": "normal", "entry": 1600, "pool": 14}, {"id": "并查集应用", "name": "并查集应用", "domain": "专题", "depth": 1, "x": 1202.6, "y": 366, "w": 105.0, "h": 44, "pre": ["并查集"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "括号序列", "name": "括号序列", "domain": "专题", "depth": 2, "x": 469.4, "y": 894, "w": 104, "h": 44, "pre": ["栈", "动态规划基础"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "线段树与离线询问", "name": "线段树与离线询问", "domain": "专题", "depth": 2, "x": 521.3, "y": 964, "w": 144.6, "h": 44, "pre": ["线段树", "离线算法"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "DAG 上的 DP", "name": "DAG 上的 DP", "domain": "动态规划", "depth": 4, "x": 693.2, "y": 1600, "w": 116.22, "h": 44, "pre": ["动态规划基础", "拓扑排序"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "DP 优化", "name": "DP 优化", "domain": "动态规划", "depth": 2, "x": 1156.1, "y": 964, "w": 104, "h": 44, "pre": ["动态规划基础"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "其它 DP 方法", "name": "其它 DP 方法", "domain": "动态规划", "depth": 2, "x": 1272.1, "y": 964, "w": 120.84, "h": 44, "pre": ["动态规划基础"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "动态 DP", "name": "动态 DP", "domain": "动态规划", "depth": 4, "x": 1743.8, "y": 1600, "w": 104, "h": 44, "pre": ["动态规划基础", "树链剖分"], "suc": [], "tier": "rare", "entry": 2600, "pool": 28}, {"id": "动态规划基础", "name": "动态规划基础", "domain": "动态规划", "depth": 1, "x": 778.2, "y": 506, "w": 118.19999999999999, "h": 44, "pre": ["递归 & 分治", "枚举"], "suc": ["DAG 上的 DP", "DP 优化", "其它 DP 方法", "动态 DP", "区间 DP", "单调队列/单调栈优化", "括号序列", "数位 DP", "树形 DP", "概率 DP", "状压 DP", "背包 DP", "计数 DP"], "tier": "core", "entry": 0, "pool": 0}, {"id": "区间 DP", "name": "区间 DP", "domain": "动态规划", "depth": 2, "x": 1405.0, "y": 964, "w": 104, "h": 44, "pre": ["动态规划基础"], "suc": ["四边形不等式优化", "状态设计优化"], "tier": "core", "entry": 1700, "pool": 108}, {"id": "插头 DP", "name": "插头 DP", "domain": "动态规划", "depth": 3, "x": 943.5, "y": 1422, "w": 104, "h": 44, "pre": ["状压 DP"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "数位 DP", "name": "数位 DP", "domain": "动态规划", "depth": 2, "x": 398.3, "y": 824, "w": 104, "h": 44, "pre": ["动态规划基础", "位运算"], "suc": [], "tier": "core", "entry": 2000, "pool": 96}, {"id": "树形 DP", "name": "树形 DP", "domain": "动态规划", "depth": 2, "x": 1753.0, "y": 964, "w": 104, "h": 44, "pre": ["动态规划基础", "DFS(搜索)"], "suc": [], "tier": "core", "entry": 1700, "pool": 334}, {"id": "概率 DP", "name": "概率 DP", "domain": "动态规划", "depth": 2, "x": 1825.2, "y": 824, "w": 104, "h": 44, "pre": ["动态规划基础", "概率论"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "状压 DP", "name": "状压 DP", "domain": "动态规划", "depth": 2, "x": 1521.0, "y": 964, "w": 104, "h": 44, "pre": ["动态规划基础"], "suc": ["插头 DP", "斯坦纳树"], "tier": "core", "entry": 2000, "pool": 251}, {"id": "背包 DP", "name": "背包 DP", "domain": "动态规划", "depth": 2, "x": 1637.0, "y": 964, "w": 104, "h": 44, "pre": ["动态规划基础"], "suc": [], "tier": "core", "entry": 1500, "pool": 250}, {"id": "计数 DP", "name": "计数 DP", "domain": "动态规划", "depth": 2, "x": 1515.8, "y": 894, "w": 104, "h": 44, "pre": ["动态规划基础", "排列组合"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "记忆化搜索", "name": "记忆化搜索", "domain": "动态规划", "depth": 1, "x": 908.4, "y": 506, "w": 105.0, "h": 44, "pre": ["递归 & 分治"], "suc": [], "tier": "core", "entry": 1600, "pool": 209}, {"id": "单调队列/单调栈优化", "name": "单调队列/单调栈优化", "domain": "动态规划", "depth": 2, "x": 1631.8, "y": 894, "w": 165.06, "h": 44, "pre": ["动态规划基础", "单调队列", "单调栈"], "suc": ["斜率优化"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "四边形不等式优化", "name": "四边形不等式优化", "domain": "动态规划", "depth": 3, "x": 656.7, "y": 1422, "w": 144.6, "h": 44, "pre": ["区间 DP"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "斜率优化", "name": "斜率优化", "domain": "动态规划", "depth": 3, "x": 1157.6, "y": 1352, "w": 104, "h": 44, "pre": ["单调队列/单调栈优化"], "suc": [], "tier": "normal", "entry": 2300, "pool": 58}, {"id": "状态设计优化", "name": "状态设计优化", "domain": "动态规划", "depth": 3, "x": 813.3, "y": 1422, "w": 118.19999999999999, "h": 44, "pre": ["区间 DP"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "2-SAT", "name": "2-SAT", "domain": "图论", "depth": 4, "x": 802.8, "y": 1530, "w": 104, "h": 44, "pre": ["强连通分量"], "suc": [], "tier": "normal", "entry": 2300, "pool": 25}, {"id": "BFS(图论)", "name": "BFS(图论)", "domain": "图论", "depth": 2, "x": 208.3, "y": 684, "w": 113.58, "h": 44, "pre": ["图的存储", "队列"], "suc": ["最短路", "网络流"], "tier": "core", "entry": 0, "pool": 0}, {"id": "DFS(图论)", "name": "DFS(图论)", "domain": "图论", "depth": 2, "x": 796.7, "y": 684, "w": 113.58, "h": 44, "pre": ["图的存储", "递归 & 分治"], "suc": ["割点和桥", "双连通分量", "增广路", "强连通分量", "支配树", "最近公共祖先", "树上启发式合并", "树分治", "树链剖分"], "tier": "core", "entry": 0, "pool": 0}, {"id": "LGV 引理", "name": "LGV 引理", "domain": "图论", "depth": 4, "x": 1514.0, "y": 1530, "w": 104, "h": 44, "pre": ["组合数学", "行列式"], "suc": [], "tier": "rare", "entry": 2600, "pool": 8}, {"id": "Prüfer 序列", "name": "Prüfer 序列", "domain": "图论", "depth": 3, "x": 326.9, "y": 1212, "w": 116.22, "h": 44, "pre": ["树基础"], "suc": ["图论计数"], "tier": "normal", "entry": 1700, "pool": 15}, {"id": "Stoer–Wagner 算法", "name": "Stoer–Wagner 算法", "domain": "图论", "depth": 2, "x": 300.4, "y": 614, "w": 159.78, "h": 44, "pre": ["图的存储"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "k 短路", "name": "k 短路", "domain": "图论", "depth": 4, "x": 1630.0, "y": 1530, "w": 104, "h": 44, "pre": ["最短路"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "二分图", "name": "二分图", "domain": "图论", "depth": 2, "x": 472.1, "y": 614, "w": 104, "h": 44, "pre": ["图的存储"], "suc": ["二分图最大匹配"], "tier": "core", "entry": 1700, "pool": 230}, {"id": "同余最短路", "name": "同余最短路", "domain": "图论", "depth": 4, "x": 460.2, "y": 1600, "w": 105.0, "h": 44, "pre": ["最短路", "线性同余方程"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "哈密顿图", "name": "哈密顿图", "domain": "图论", "depth": 2, "x": 588.1, "y": 614, "w": 104, "h": 44, "pre": ["图的存储"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "图上随机游走", "name": "图上随机游走", "domain": "图论", "depth": 2, "x": 2139.9, "y": 614, "w": 118.19999999999999, "h": 44, "pre": ["图的存储", "概率论"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "图的匹配", "name": "图的匹配", "domain": "图论", "depth": 1, "x": 62.8, "y": 226, "w": 104, "h": 44, "pre": ["图论相关概念"], "suc": ["图匹配", "增广路", "弦图"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "图的存储", "name": "图的存储", "domain": "图论", "depth": 1, "x": 178.8, "y": 226, "w": 104, "h": 44, "pre": ["图论相关概念"], "suc": ["BFS(图论)", "DFS(图论)", "Stoer–Wagner 算法", "二分图", "哈密顿图", "图上随机游走", "图的着色", "最大团搜索算法", "有向无环图", "树基础", "欧拉图", "环计数问题", "连通性相关"], "tier": "core", "entry": 0, "pool": 0}, {"id": "图的着色", "name": "图的着色", "domain": "图论", "depth": 2, "x": 704.1, "y": 614, "w": 104, "h": 44, "pre": ["图的存储"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "图论相关概念", "name": "图论相关概念", "domain": "图论", "depth": 0, "x": 83.7, "y": 48, "w": 118.19999999999999, "h": 44, "pre": [], "suc": ["图的匹配", "图的存储"], "tier": "core", "entry": 0, "pool": 0}, {"id": "差分约束", "name": "差分约束", "domain": "图论", "depth": 4, "x": 1746.0, "y": 1530, "w": 104, "h": 44, "pre": ["最短路"], "suc": [], "tier": "normal", "entry": 1700, "pool": 32}, {"id": "平面图", "name": "平面图", "domain": "图论", "depth": 3, "x": 803.2, "y": 1212, "w": 104, "h": 44, "pre": ["欧拉图"], "suc": [], "tier": "rare", "entry": 2600, "pool": 15}, {"id": "弦图", "name": "弦图", "domain": "图论", "depth": 2, "x": 184.4, "y": 614, "w": 104, "h": 44, "pre": ["图的匹配"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "拆点", "name": "拆点", "domain": "图论", "depth": 4, "x": 1049.0, "y": 1530, "w": 104, "h": 44, "pre": ["网络流"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "拓扑排序", "name": "拓扑排序", "domain": "图论", "depth": 3, "x": 210.9, "y": 1212, "w": 104, "h": 44, "pre": ["有向无环图"], "suc": ["DAG 上的 DP"], "tier": "core", "entry": 1600, "pool": 221}, {"id": "支配树", "name": "支配树", "domain": "图论", "depth": 3, "x": 281.0, "y": 1282, "w": 104, "h": 44, "pre": ["DFS(图论)"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "斯坦纳树", "name": "斯坦纳树", "domain": "图论", "depth": 3, "x": 1059.5, "y": 1422, "w": 104, "h": 44, "pre": ["状压 DP"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最大团搜索算法", "name": "最大团搜索算法", "domain": "图论", "depth": 2, "x": 514.3, "y": 824, "w": 131.39999999999998, "h": 44, "pre": ["图的存储", "DFS(搜索)"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最小树形图", "name": "最小树形图", "domain": "图论", "depth": 2, "x": 1050.8, "y": 894, "w": 105.0, "h": 44, "pre": ["最小生成树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最小环", "name": "最小环", "domain": "图论", "depth": 4, "x": 1862.0, "y": 1530, "w": 104, "h": 44, "pre": ["最短路"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最小生成树", "name": "最小生成树", "domain": "图论", "depth": 1, "x": 101.5, "y": 436, "w": 105.0, "h": 44, "pre": ["并查集", "排序"], "suc": ["最小树形图", "最小直径生成树"], "tier": "core", "entry": 0, "pool": 0}, {"id": "最小直径生成树", "name": "最小直径生成树", "domain": "图论", "depth": 4, "x": 821.4, "y": 1600, "w": 131.39999999999998, "h": 44, "pre": ["最小生成树", "最短路"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最短路", "name": "最短路", "domain": "图论", "depth": 3, "x": 772.4, "y": 1282, "w": 104, "h": 44, "pre": ["二叉堆", "BFS(图论)"], "suc": ["k 短路", "同余最短路", "差分约束", "最小环", "最小直径生成树"], "tier": "core", "entry": 1600, "pool": 748}, {"id": "有向无环图", "name": "有向无环图", "domain": "图论", "depth": 2, "x": 820.1, "y": 614, "w": 105.0, "h": 44, "pre": ["图的存储"], "suc": ["拓扑排序"], "tier": "core", "entry": 0, "pool": 0}, {"id": "树上问题", "name": "树上问题", "domain": "图论", "depth": 3, "x": 455.2, "y": 1212, "w": 104, "h": 44, "pre": ["树基础"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "欧拉图", "name": "欧拉图", "domain": "图论", "depth": 2, "x": 1053.1, "y": 614, "w": 104, "h": 44, "pre": ["图的存储"], "suc": ["平面图"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "环计数问题", "name": "环计数问题", "domain": "图论", "depth": 2, "x": 1169.1, "y": 614, "w": 105.0, "h": 44, "pre": ["图的存储"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "矩阵树定理", "name": "矩阵树定理", "domain": "图论", "depth": 4, "x": 1313.6, "y": 1600, "w": 105.0, "h": 44, "pre": ["高斯消元", "行列式"], "suc": [], "tier": "rare", "entry": 2600, "pool": 18}, {"id": "网络流", "name": "网络流", "domain": "图论", "depth": 3, "x": 165.0, "y": 1282, "w": 104, "h": 44, "pre": ["BFS(图论)"], "suc": ["上下界网络流", "拆点", "最大流", "最小割", "费用流"], "tier": "normal", "entry": 2000, "pool": 361}, {"id": "连通性相关", "name": "连通性相关", "domain": "图论", "depth": 2, "x": 1286.1, "y": 614, "w": 105.0, "h": 44, "pre": ["图的存储"], "suc": ["割点和桥", "双连通分量", "强连通分量", "点/边连通度"], "tier": "core", "entry": 0, "pool": 0}, {"id": "一般图最大匹配", "name": "一般图最大匹配", "domain": "图论", "depth": 4, "x": 427.4, "y": 1530, "w": 131.39999999999998, "h": 44, "pre": ["增广路"], "suc": ["一般图最大权匹配"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "一般图最大权匹配", "name": "一般图最大权匹配", "domain": "图论", "depth": 6, "x": 726.3, "y": 1886, "w": 144.6, "h": 44, "pre": ["一般图最大匹配", "二分图最大权匹配"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "二分图最大匹配", "name": "二分图最大匹配", "domain": "图论", "depth": 4, "x": 168.0, "y": 1530, "w": 131.39999999999998, "h": 44, "pre": ["增广路", "二分图"], "suc": ["二分图最大权匹配"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "二分图最大权匹配", "name": "二分图最大权匹配", "domain": "图论", "depth": 5, "x": 494.4, "y": 1778, "w": 144.6, "h": 44, "pre": ["二分图最大匹配"], "suc": ["一般图最大权匹配"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "图匹配", "name": "图匹配", "domain": "图论", "depth": 2, "x": 68.4, "y": 614, "w": 104, "h": 44, "pre": ["图的匹配"], "suc": [], "tier": "rare", "entry": 1600, "pool": 80}, {"id": "增广路", "name": "增广路", "domain": "图论", "depth": 3, "x": 1289.6, "y": 1212, "w": 104, "h": 44, "pre": ["图的匹配", "DFS(图论)"], "suc": ["一般图最大匹配", "二分图最大匹配", "最大流"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "AHU 算法", "name": "AHU 算法", "domain": "图论", "depth": 4, "x": 52.0, "y": 1530, "w": 104, "h": 44, "pre": ["树哈希"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "动态树分治", "name": "动态树分治", "domain": "图论", "depth": 4, "x": 1397.0, "y": 1530, "w": 105.0, "h": 44, "pre": ["树分治"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最近公共祖先", "name": "最近公共祖先", "domain": "图论", "depth": 3, "x": 1552.9, "y": 1282, "w": 118.19999999999999, "h": 44, "pre": ["倍增", "DFS(图论)"], "suc": ["虚树"], "tier": "core", "entry": 1700, "pool": 265}, {"id": "树上启发式合并", "name": "树上启发式合并", "domain": "图论", "depth": 3, "x": 397.0, "y": 1282, "w": 131.39999999999998, "h": 44, "pre": ["DFS(图论)"], "suc": [], "tier": "normal", "entry": 2000, "pool": 42}, {"id": "树上随机游走", "name": "树上随机游走", "domain": "图论", "depth": 3, "x": 1035.2, "y": 1212, "w": 118.19999999999999, "h": 44, "pre": ["树基础", "概率论"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "树分治", "name": "树分治", "domain": "图论", "depth": 3, "x": 540.4, "y": 1282, "w": 104, "h": 44, "pre": ["DFS(图论)"], "suc": ["动态树分治"], "tier": "normal", "entry": 2000, "pool": 25}, {"id": "树哈希", "name": "树哈希", "domain": "图论", "depth": 3, "x": 94.9, "y": 1212, "w": 104, "h": 44, "pre": ["树基础", "字符串哈希"], "suc": ["AHU 算法"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "树基础", "name": "树基础", "domain": "图论", "depth": 2, "x": 937.1, "y": 614, "w": 104, "h": 44, "pre": ["图的存储"], "suc": ["Prüfer 序列", "树上问题", "树上随机游走", "树哈希", "树的直径", "树的重心"], "tier": "core", "entry": 0, "pool": 0}, {"id": "树的直径", "name": "树的直径", "domain": "图论", "depth": 3, "x": 571.2, "y": 1212, "w": 104, "h": 44, "pre": ["树基础"], "suc": [], "tier": "core", "entry": 1700, "pool": 46}, {"id": "树的重心", "name": "树的重心", "domain": "图论", "depth": 3, "x": 687.2, "y": 1212, "w": 104, "h": 44, "pre": ["树基础"], "suc": [], "tier": "core", "entry": 2000, "pool": 14}, {"id": "树链剖分", "name": "树链剖分", "domain": "图论", "depth": 3, "x": 1436.9, "y": 1282, "w": 104, "h": 44, "pre": ["DFS(图论)", "线段树"], "suc": ["动态 DP"], "tier": "normal", "entry": 2000, "pool": 273}, {"id": "虚树", "name": "虚树", "domain": "图论", "depth": 4, "x": 344.2, "y": 1600, "w": 104, "h": 44, "pre": ["最近公共祖先"], "suc": [], "tier": "normal", "entry": 2600, "pool": 47}, {"id": "上下界网络流", "name": "上下界网络流", "domain": "图论", "depth": 4, "x": 918.8, "y": 1530, "w": 118.19999999999999, "h": 44, "pre": ["网络流"], "suc": [], "tier": "rare", "entry": 2600, "pool": 13}, {"id": "最大流", "name": "最大流", "domain": "图论", "depth": 4, "x": 570.8, "y": 1530, "w": 104, "h": 44, "pre": ["网络流", "增广路"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "最小割", "name": "最小割", "domain": "图论", "depth": 4, "x": 1165.0, "y": 1530, "w": 104, "h": 44, "pre": ["网络流"], "suc": [], "tier": "normal", "entry": 2600, "pool": 61}, {"id": "费用流", "name": "费用流", "domain": "图论", "depth": 4, "x": 1281.0, "y": 1530, "w": 104, "h": 44, "pre": ["网络流"], "suc": [], "tier": "normal", "entry": 2000, "pool": 111}, {"id": "割点和桥", "name": "割点和桥", "domain": "图论", "depth": 3, "x": 2010.6, "y": 1212, "w": 104, "h": 44, "pre": ["连通性相关", "DFS(图论)"], "suc": [], "tier": "core", "entry": 0, "pool": 0}, {"id": "双连通分量", "name": "双连通分量", "domain": "图论", "depth": 3, "x": 2126.6, "y": 1212, "w": 105.0, "h": 44, "pre": ["连通性相关", "DFS(图论)"], "suc": ["圆方树"], "tier": "core", "entry": 2000, "pool": 48}, {"id": "圆方树", "name": "圆方树", "domain": "图论", "depth": 4, "x": 686.8, "y": 1530, "w": 104, "h": 44, "pre": ["双连通分量"], "suc": [], "tier": "rare", "entry": 2600, "pool": 31}, {"id": "强连通分量", "name": "强连通分量", "domain": "图论", "depth": 3, "x": 48.0, "y": 1282, "w": 105.0, "h": 44, "pre": ["连通性相关", "DFS(图论)"], "suc": ["2-SAT"], "tier": "core", "entry": 1700, "pool": 71}, {"id": "点/边连通度", "name": "点/边连通度", "domain": "图论", "depth": 3, "x": 1165.4, "y": 1212, "w": 112.25999999999999, "h": 44, "pre": ["连通性相关"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "AC 自动机", "name": "AC 自动机", "domain": "字符串", "depth": 3, "x": 919.2, "y": 1212, "w": 104, "h": 44, "pre": ["字典树 (Trie)", "前缀函数与 KMP 算法"], "suc": [], "tier": "normal", "entry": 2300, "pool": 51}, {"id": "Boyer–Moore 算法", "name": "Boyer–Moore 算法", "domain": "字符串", "depth": 2, "x": 1519.1, "y": 614, "w": 152.52, "h": 44, "pre": ["字符串匹配"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Lyndon 分解", "name": "Lyndon 分解", "domain": "字符串", "depth": 1, "x": 294.8, "y": 226, "w": 116.22, "h": 44, "pre": ["字符串基础"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "Main–Lorentz 算法", "name": "Main–Lorentz 算法", "domain": "字符串", "depth": 3, "x": 1405.6, "y": 1212, "w": 159.78, "h": 44, "pre": ["前缀函数与 KMP 算法"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Manacher", "name": "Manacher", "domain": "字符串", "depth": 2, "x": 1683.7, "y": 614, "w": 104, "h": 44, "pre": ["字符串匹配"], "suc": [], "tier": "normal", "entry": 1600, "pool": 21}, {"id": "Z 函数(扩展 KMP)", "name": "Z 函数(扩展 KMP)", "domain": "字符串", "depth": 3, "x": 1577.4, "y": 1212, "w": 161.76, "h": 44, "pre": ["前缀函数与 KMP 算法"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "前缀函数与 KMP 算法", "name": "前缀函数与 KMP 算法", "domain": "字符串", "depth": 2, "x": 1799.7, "y": 614, "w": 167.7, "h": 44, "pre": ["字符串匹配"], "suc": ["AC 自动机", "Main–Lorentz 算法", "Z 函数(扩展 KMP)"], "tier": "core", "entry": 0, "pool": 0}, {"id": "后缀平衡树", "name": "后缀平衡树", "domain": "字符串", "depth": 2, "x": 54.0, "y": 1034, "w": 105.0, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "后缀数组 (SA)", "name": "后缀数组 (SA)", "domain": "字符串", "depth": 3, "x": 1450.7, "y": 1352, "w": 128.1, "h": 44, "pre": ["倍增", "基数排序"], "suc": ["最优原地后缀排序算法"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "后缀树", "name": "后缀树", "domain": "字符串", "depth": 3, "x": 1751.2, "y": 1212, "w": 104, "h": 44, "pre": ["后缀自动机 (SAM)"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "后缀自动机 (SAM)", "name": "后缀自动机 (SAM)", "domain": "字符串", "depth": 2, "x": 1979.4, "y": 614, "w": 148.56, "h": 44, "pre": ["字典树 (Trie)", "自动机"], "suc": ["后缀树", "广义后缀自动机"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "回文树", "name": "回文树", "domain": "字符串", "depth": 2, "x": 1403.1, "y": 614, "w": 104, "h": 44, "pre": ["字典树 (Trie)"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "字典树 (Trie)", "name": "字典树 (Trie)", "domain": "字符串", "depth": 1, "x": 423.0, "y": 226, "w": 129.42000000000002, "h": 44, "pre": ["字符串基础"], "suc": ["AC 自动机", "可持久化字典树", "后缀自动机 (SAM)", "回文树"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "字符串匹配", "name": "字符串匹配", "domain": "字符串", "depth": 1, "x": 564.5, "y": 226, "w": 105.0, "h": 44, "pre": ["字符串基础"], "suc": ["Boyer–Moore 算法", "Manacher", "前缀函数与 KMP 算法"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "字符串哈希", "name": "字符串哈希", "domain": "字符串", "depth": 1, "x": 681.5, "y": 226, "w": 105.0, "h": 44, "pre": ["字符串基础"], "suc": ["树哈希"], "tier": "core", "entry": 0, "pool": 0}, {"id": "字符串基础", "name": "字符串基础", "domain": "字符串", "depth": 0, "x": 213.9, "y": 48, "w": 105.0, "h": 44, "pre": [], "suc": ["Lyndon 分解", "字典树 (Trie)", "字符串匹配", "字符串哈希", "序列自动机", "最小表示法", "标准库", "自动机", "高精度计算"], "tier": "core", "entry": 0, "pool": 0}, {"id": "广义后缀自动机", "name": "广义后缀自动机", "domain": "字符串", "depth": 3, "x": 1867.2, "y": 1212, "w": 131.39999999999998, "h": 44, "pre": ["后缀自动机 (SAM)"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "序列自动机", "name": "序列自动机", "domain": "字符串", "depth": 1, "x": 798.5, "y": 226, "w": 105.0, "h": 44, "pre": ["字符串基础"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "最小表示法", "name": "最小表示法", "domain": "字符串", "depth": 1, "x": 915.5, "y": 226, "w": 105.0, "h": 44, "pre": ["字符串基础"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "标准库", "name": "标准库", "domain": "字符串", "depth": 1, "x": 1032.5, "y": 226, "w": 104, "h": 44, "pre": ["字符串基础"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "自动机", "name": "自动机", "domain": "字符串", "depth": 1, "x": 1148.5, "y": 226, "w": 104, "h": 44, "pre": ["字符串基础"], "suc": ["后缀自动机 (SAM)"], "tier": "skip", "entry": 2000, "pool": 233}, {"id": "最优原地后缀排序算法", "name": "最优原地后缀排序算法", "domain": "字符串", "depth": 4, "x": 1560.8, "y": 1600, "w": 171.0, "h": 44, "pre": ["后缀数组 (SA)"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "A*", "name": "A*", "domain": "搜索", "depth": 3, "x": 2031.1, "y": 1282, "w": 104, "h": 44, "pre": ["启发式搜索", "二叉堆"], "suc": ["IDA*"], "tier": "rare", "entry": 1700, "pool": 22}, {"id": "Alpha-Beta 剪枝", "name": "Alpha-Beta 剪枝", "domain": "搜索", "depth": 3, "x": 1175.5, "y": 1422, "w": 145.26, "h": 44, "pre": ["优化"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "BFS(搜索)", "name": "BFS(搜索)", "domain": "搜索", "depth": 1, "x": 2030.8, "y": 366, "w": 113.58, "h": 44, "pre": ["队列"], "suc": ["15-puzzle", "双向搜索", "启发式搜索", "迭代加深搜索"], "tier": "core", "entry": 0, "pool": 0}, {"id": "DFS(搜索)", "name": "DFS(搜索)", "domain": "搜索", "depth": 1, "x": 1025.4, "y": 506, "w": 113.58, "h": 44, "pre": ["递归 & 分治"], "suc": ["Dancing Links", "优化", "回溯法", "最大团搜索算法", "树形 DP", "迭代加深搜索"], "tier": "core", "entry": 0, "pool": 0}, {"id": "Dancing Links", "name": "Dancing Links", "domain": "搜索", "depth": 2, "x": 1869.0, "y": 964, "w": 133.38, "h": 44, "pre": ["DFS(搜索)"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "IDA*", "name": "IDA*", "domain": "搜索", "depth": 4, "x": 2165.1, "y": 1600, "w": 104, "h": 44, "pre": ["迭代加深搜索", "A*"], "suc": [], "tier": "rare", "entry": 2000, "pool": 10}, {"id": "优化", "name": "优化", "domain": "搜索", "depth": 2, "x": 2014.4, "y": 964, "w": 104, "h": 44, "pre": ["DFS(搜索)"], "suc": ["Alpha-Beta 剪枝"], "tier": "skip", "entry": 1600, "pool": 4599}, {"id": "双向搜索", "name": "双向搜索", "domain": "搜索", "depth": 2, "x": 701.4, "y": 894, "w": 104, "h": 44, "pre": ["BFS(搜索)"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "启发式搜索", "name": "启发式搜索", "domain": "搜索", "depth": 2, "x": 817.4, "y": 894, "w": 105.0, "h": 44, "pre": ["BFS(搜索)"], "suc": ["A*"], "tier": "rare", "entry": 2000, "pool": 8}, {"id": "回溯法", "name": "回溯法", "domain": "搜索", "depth": 2, "x": 2130.4, "y": 964, "w": 104, "h": 44, "pre": ["DFS(搜索)"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "迭代加深搜索", "name": "迭代加深搜索", "domain": "搜索", "depth": 2, "x": 391.1, "y": 964, "w": 118.19999999999999, "h": 44, "pre": ["DFS(搜索)", "BFS(搜索)"], "suc": ["IDA*"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "Berlekamp–Massey 算法", "name": "Berlekamp–Massey 算法", "domain": "数学", "depth": 5, "x": 999.0, "y": 1778, "w": 188.82000000000002, "h": 44, "pre": ["常系数齐次线性递推"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Schreier–Sims 算法", "name": "Schreier–Sims 算法", "domain": "数学", "depth": 4, "x": 1134.6, "y": 1600, "w": 167.04000000000002, "h": 44, "pre": ["群论"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "二进制集合操作", "name": "二进制集合操作", "domain": "数学", "depth": 1, "x": 1264.5, "y": 226, "w": 131.39999999999998, "h": 44, "pre": ["位运算"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "位运算", "name": "位运算", "domain": "数学", "depth": 0, "x": 330.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["二进制集合操作", "字节顺序", "快速沃尔什变换", "数位 DP", "格雷码", "线性基"], "tier": "core", "entry": 1500, "pool": 504}, {"id": "傅里叶-莫茨金消元法", "name": "傅里叶-莫茨金消元法", "domain": "数学", "depth": 1, "x": 183.1, "y": 366, "w": 165.06, "h": 44, "pre": ["线性规划"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "博弈论", "name": "博弈论", "domain": "数学", "depth": 0, "x": 446.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["公平组合游戏"], "tier": "normal", "entry": 1600, "pool": 417}, {"id": "复数", "name": "复数", "domain": "数学", "depth": 0, "x": 678.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["代数基本定理", "多项式初等函数", "快速傅里叶变换"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "多项式与生成函数", "name": "多项式与生成函数", "domain": "数学", "depth": 0, "x": 794.9, "y": 48, "w": 144.6, "h": 44, "pre": [], "suc": ["多项式初等函数", "多项式多点求值|快速插值", "多项式平移|连续点值平移", "拉格朗日插值"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "平衡三进制", "name": "平衡三进制", "domain": "数学", "depth": 1, "x": 969.6, "y": 366, "w": 105.0, "h": 44, "pre": ["进位制"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "序理论", "name": "序理论", "domain": "数学", "depth": 0, "x": 951.5, "y": 48, "w": 104, "h": 44, "pre": [], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "弧度制与坐标系", "name": "弧度制与坐标系", "domain": "数学", "depth": 0, "x": 1067.5, "y": 48, "w": 131.39999999999998, "h": 44, "pre": [], "suc": ["二维计算几何基础"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "快速幂", "name": "快速幂", "domain": "数学", "depth": 3, "x": 925.6, "y": 1352, "w": 104, "h": 44, "pre": ["倍增"], "suc": ["常系数齐次线性递推"], "tier": "core", "entry": 1600, "pool": 350}, {"id": "数值算法", "name": "数值算法", "domain": "数学", "depth": 0, "x": 1210.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["数值积分", "牛顿迭代法"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "数论", "name": "数论", "domain": "数学", "depth": 1, "x": 1332.9, "y": 296, "w": 104, "h": 44, "pre": ["数论基础"], "suc": [], "tier": "normal", "entry": 1600, "pool": 1130}, {"id": "杨氏矩阵", "name": "杨氏矩阵", "domain": "数学", "depth": 2, "x": 1448.8, "y": 824, "w": 104, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "概率论", "name": "概率论", "domain": "数学", "depth": 0, "x": 1442.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["图上随机游走", "基本概念", "树上随机游走", "概率 DP", "随机化"], "tier": "normal", "entry": 2000, "pool": 95}, {"id": "符号", "name": "符号", "domain": "数学", "depth": 0, "x": 1558.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "线性代数", "name": "线性代数", "domain": "数学", "depth": 0, "x": 1674.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["矩阵"], "tier": "rare", "entry": 1600, "pool": 267}, {"id": "线性规划", "name": "线性规划", "domain": "数学", "depth": 0, "x": 1790.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["傅里叶-莫茨金消元法"], "tier": "skip", "entry": 2600, "pool": 15}, {"id": "组合数学", "name": "组合数学", "domain": "数学", "depth": 0, "x": 1906.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["LGV 引理", "图论计数", "抽屉原理", "排列组合", "斐波那契数列", "普通生成函数", "置换和排列"], "tier": "normal", "entry": 1600, "pool": 1021}, {"id": "置换和排列", "name": "置换和排列", "domain": "数学", "depth": 1, "x": 852.6, "y": 366, "w": 105.0, "h": 44, "pre": ["组合数学"], "suc": ["置换群"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "群论", "name": "群论", "domain": "数学", "depth": 3, "x": 577.6, "y": 1352, "w": 104, "h": 44, "pre": ["置换群"], "suc": ["Schreier–Sims 算法"], "tier": "skip", "entry": 2600, "pool": 17}, {"id": "进位制", "name": "进位制", "domain": "数学", "depth": 0, "x": 2022.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["平衡三进制"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "高精度计算", "name": "高精度计算", "domain": "数学", "depth": 1, "x": 66.1, "y": 366, "w": 105.0, "h": 44, "pre": ["模拟", "字符串基础"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "公平组合游戏", "name": "公平组合游戏", "domain": "数学", "depth": 1, "x": 1755.9, "y": 226, "w": 118.19999999999999, "h": 44, "pre": ["博弈论"], "suc": ["反常游戏", "非公平组合游戏"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "反常游戏", "name": "反常游戏", "domain": "数学", "depth": 2, "x": 537.3, "y": 684, "w": 104, "h": 44, "pre": ["公平组合游戏"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "非公平组合游戏", "name": "非公平组合游戏", "domain": "数学", "depth": 2, "x": 653.3, "y": 684, "w": 131.39999999999998, "h": 44, "pre": ["公平组合游戏"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "Chirp Z 变换", "name": "Chirp Z 变换", "domain": "数学", "depth": 2, "x": 1038.3, "y": 684, "w": 123.48, "h": 44, "pre": ["快速傅里叶变换"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "代数基本定理", "name": "代数基本定理", "domain": "数学", "depth": 1, "x": 76.6, "y": 296, "w": 118.19999999999999, "h": 44, "pre": ["复数"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "多项式初等函数", "name": "多项式初等函数", "domain": "数学", "depth": 1, "x": 350.2, "y": 296, "w": 131.39999999999998, "h": 44, "pre": ["复数", "多项式与生成函数"], "suc": ["多项式牛顿迭代"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "多项式多点求值|快速插值", "name": "多项式多点求值|快速插值", "domain": "数学", "depth": 2, "x": 333.9, "y": 684, "w": 191.46, "h": 44, "pre": ["多项式与生成函数", "快速傅里叶变换"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "多项式平移|连续点值平移", "name": "多项式平移|连续点值平移", "domain": "数学", "depth": 1, "x": 493.6, "y": 296, "w": 191.46, "h": 44, "pre": ["多项式与生成函数"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "多项式牛顿迭代", "name": "多项式牛顿迭代", "domain": "数学", "depth": 2, "x": 1173.8, "y": 684, "w": 131.39999999999998, "h": 44, "pre": ["多项式初等函数", "牛顿迭代法"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "常系数齐次线性递推", "name": "常系数齐次线性递推", "domain": "数学", "depth": 4, "x": 964.8, "y": 1600, "w": 157.8, "h": 44, "pre": ["矩阵", "快速幂"], "suc": ["Berlekamp–Massey 算法"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "快速傅里叶变换", "name": "快速傅里叶变换", "domain": "数学", "depth": 1, "x": 206.8, "y": 296, "w": 131.39999999999998, "h": 44, "pre": ["复数"], "suc": ["Chirp Z 变换", "多项式多点求值|快速插值", "快速数论变换", "快速沃尔什变换"], "tier": "rare", "entry": 2400, "pool": 196}, {"id": "快速数论变换", "name": "快速数论变换", "domain": "数学", "depth": 6, "x": 1249.0, "y": 1886, "w": 118.19999999999999, "h": 44, "pre": ["快速傅里叶变换", "原根"], "suc": [], "tier": "rare", "entry": 2400, "pool": 215}, {"id": "快速沃尔什变换", "name": "快速沃尔什变换", "domain": "数学", "depth": 2, "x": 64.9, "y": 684, "w": 131.39999999999998, "h": 44, "pre": ["快速傅里叶变换", "位运算"], "suc": [], "tier": "rare", "entry": 2400, "pool": 116}, {"id": "指数生成函数", "name": "指数生成函数", "domain": "数学", "depth": 2, "x": 1695.0, "y": 824, "w": 118.19999999999999, "h": 44, "pre": ["普通生成函数"], "suc": ["伯努利数", "符号化方法"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "普通生成函数", "name": "普通生成函数", "domain": "数学", "depth": 1, "x": 722.4, "y": 366, "w": 118.19999999999999, "h": 44, "pre": ["组合数学"], "suc": ["指数生成函数", "狄利克雷生成函数"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "狄利克雷生成函数", "name": "狄利克雷生成函数", "domain": "数学", "depth": 2, "x": 174.3, "y": 754, "w": 144.6, "h": 44, "pre": ["普通生成函数", "数论基础"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "符号化方法", "name": "符号化方法", "domain": "数学", "depth": 3, "x": 460.6, "y": 1352, "w": 105.0, "h": 44, "pre": ["指数生成函数"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "拉格朗日插值", "name": "拉格朗日插值", "domain": "数学", "depth": 1, "x": 697.1, "y": 296, "w": 118.19999999999999, "h": 44, "pre": ["多项式与生成函数"], "suc": [], "tier": "rare", "entry": 2400, "pool": 10}, {"id": "数值积分", "name": "数值积分", "domain": "数学", "depth": 1, "x": 983.9, "y": 296, "w": 104, "h": 44, "pre": ["数值算法"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "牛顿迭代法", "name": "牛顿迭代法", "domain": "数学", "depth": 1, "x": 1099.9, "y": 296, "w": 105.0, "h": 44, "pre": ["数值算法"], "suc": ["多项式牛顿迭代"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "高斯消元", "name": "高斯消元", "domain": "数学", "depth": 2, "x": 282.3, "y": 824, "w": 104, "h": 44, "pre": ["矩阵"], "suc": ["矩阵树定理"], "tier": "normal", "entry": 2000, "pool": 107}, {"id": "Meissel–Lehmer 算法", "name": "Meissel–Lehmer 算法", "domain": "数学", "depth": 3, "x": 1004.4, "y": 1282, "w": 174.29999999999998, "h": 44, "pre": ["筛法", "数论分块"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Min_25 筛", "name": "Min_25 筛", "domain": "数学", "depth": 6, "x": 1496.2, "y": 1886, "w": 104, "h": 44, "pre": ["筛法", "杜教筛"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Pell 方程", "name": "Pell 方程", "domain": "数学", "depth": 8, "x": 1111.2, "y": 2102, "w": 104, "h": 44, "pre": ["连分数", "二次域"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Powerful Number 筛", "name": "Powerful Number 筛", "domain": "数学", "depth": 5, "x": 1547.8, "y": 1778, "w": 168.36, "h": 44, "pre": ["筛法", "莫比乌斯反演"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Stern–Brocot 树与 Farey 序列", "name": "Stern–Brocot 树与 Farey 序列", "domain": "数学", "depth": 2, "x": 1186.5, "y": 754, "w": 226, "h": 44, "pre": ["连分数"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "中国剩余定理", "name": "中国剩余定理", "domain": "数学", "depth": 3, "x": 1190.7, "y": 1282, "w": 118.19999999999999, "h": 44, "pre": ["线性同余方程"], "suc": [], "tier": "normal", "entry": 1600, "pool": 14}, {"id": "乘法逆元", "name": "乘法逆元", "domain": "数学", "depth": 5, "x": 1199.8, "y": 1778, "w": 104, "h": 44, "pre": ["欧拉定理 & 费马小定理", "线性同余方程"], "suc": ["二次剩余", "卢卡斯定理"], "tier": "core", "entry": 0, "pool": 0}, {"id": "二次剩余", "name": "二次剩余", "domain": "数学", "depth": 6, "x": 1133.0, "y": 1886, "w": 104, "h": 44, "pre": ["乘法逆元"], "suc": ["二次域"], "tier": "rare", "entry": 2600, "pool": 8}, {"id": "二次域", "name": "二次域", "domain": "数学", "depth": 7, "x": 1111.2, "y": 1994, "w": 104, "h": 44, "pre": ["二次剩余"], "suc": ["Pell 方程"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "分解质因数", "name": "分解质因数", "domain": "数学", "depth": 2, "x": 953.5, "y": 754, "w": 105.0, "h": 44, "pre": ["素数"], "suc": ["欧拉函数"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "剩余", "name": "剩余", "domain": "数学", "depth": 2, "x": 330.9, "y": 754, "w": 104, "h": 44, "pre": ["同余方程"], "suc": [], "tier": "rare", "entry": 2000, "pool": 57}, {"id": "升幂引理", "name": "升幂引理", "domain": "数学", "depth": 5, "x": 651.0, "y": 1778, "w": 104, "h": 44, "pre": ["欧拉定理 & 费马小定理"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "卢卡斯定理", "name": "卢卡斯定理", "domain": "数学", "depth": 6, "x": 1379.2, "y": 1886, "w": 105.0, "h": 44, "pre": ["排列组合", "乘法逆元"], "suc": [], "tier": "normal", "entry": 2000, "pool": 13}, {"id": "原根", "name": "原根", "domain": "数学", "depth": 5, "x": 767.0, "y": 1778, "w": 104, "h": 44, "pre": ["欧拉定理 & 费马小定理"], "suc": ["快速数论变换", "离散对数"], "tier": "rare", "entry": 2600, "pool": 13}, {"id": "同余方程", "name": "同余方程", "domain": "数学", "depth": 1, "x": 1216.9, "y": 296, "w": 104, "h": 44, "pre": ["数论基础"], "suc": ["剩余", "威尔逊定理", "欧拉函数", "线性同余方程"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "威尔逊定理", "name": "威尔逊定理", "domain": "数学", "depth": 2, "x": 577.1, "y": 754, "w": 105.0, "h": 44, "pre": ["素数", "同余方程"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "循环连分数", "name": "循环连分数", "domain": "数学", "depth": 2, "x": 1424.5, "y": 754, "w": 105.0, "h": 44, "pre": ["连分数"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "数论分块", "name": "数论分块", "domain": "数学", "depth": 1, "x": 1448.9, "y": 296, "w": 104, "h": 44, "pre": ["数论基础"], "suc": ["Meissel–Lehmer 算法", "杜教筛", "类欧几里德算法"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "数论基础", "name": "数论基础", "domain": "数学", "depth": 0, "x": 1326.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["同余方程", "数论", "数论分块", "最大公约数", "狄利克雷生成函数", "素数", "连分数"], "tier": "core", "entry": 0, "pool": 0}, {"id": "最大公约数", "name": "最大公约数", "domain": "数学", "depth": 1, "x": 1564.9, "y": 296, "w": 105.0, "h": 44, "pre": ["数论基础"], "suc": ["类欧几里德算法", "线性同余方程", "裴蜀定理"], "tier": "core", "entry": 1500, "pool": 114}, {"id": "杜教筛", "name": "杜教筛", "domain": "数学", "depth": 5, "x": 1315.8, "y": 1778, "w": 104, "h": 44, "pre": ["莫比乌斯反演", "数论分块"], "suc": ["Min_25 筛"], "tier": "rare", "entry": 2600, "pool": 17}, {"id": "欧拉函数", "name": "欧拉函数", "domain": "数学", "depth": 3, "x": 888.4, "y": 1282, "w": 104, "h": 44, "pre": ["分解质因数", "同余方程"], "suc": ["欧拉定理 & 费马小定理", "莫比乌斯反演"], "tier": "core", "entry": 1700, "pool": 70}, {"id": "欧拉定理 & 费马小定理", "name": "欧拉定理 & 费马小定理", "domain": "数学", "depth": 4, "x": 1978.0, "y": 1530, "w": 179.57999999999998, "h": 44, "pre": ["欧拉函数"], "suc": ["乘法逆元", "升幂引理", "原根"], "tier": "core", "entry": 0, "pool": 0}, {"id": "洲阁筛", "name": "洲阁筛", "domain": "数学", "depth": 5, "x": 1728.1, "y": 1778, "w": 104, "h": 44, "pre": ["筛法", "莫比乌斯反演"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "离散对数", "name": "离散对数", "domain": "数学", "depth": 6, "x": 882.9, "y": 1886, "w": 104, "h": 44, "pre": ["原根"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "筛法", "name": "筛法", "domain": "数学", "depth": 2, "x": 1070.5, "y": 754, "w": 104, "h": 44, "pre": ["素数"], "suc": ["Meissel–Lehmer 算法", "Min_25 筛", "Powerful Number 筛", "洲阁筛"], "tier": "core", "entry": 1600, "pool": 116}, {"id": "类欧几里德算法", "name": "类欧几里德算法", "domain": "数学", "depth": 2, "x": 694.1, "y": 754, "w": 131.39999999999998, "h": 44, "pre": ["最大公约数", "数论分块"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "素数", "name": "素数", "domain": "数学", "depth": 1, "x": 1681.9, "y": 296, "w": 104, "h": 44, "pre": ["数论基础"], "suc": ["分解质因数", "威尔逊定理", "筛法"], "tier": "core", "entry": 1500, "pool": 224}, {"id": "线性同余方程", "name": "线性同余方程", "domain": "数学", "depth": 2, "x": 446.9, "y": 754, "w": 118.19999999999999, "h": 44, "pre": ["最大公约数", "同余方程"], "suc": ["中国剩余定理", "乘法逆元", "同余最短路"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "莫比乌斯反演", "name": "莫比乌斯反演", "domain": "数学", "depth": 4, "x": 1430.6, "y": 1600, "w": 118.19999999999999, "h": 44, "pre": ["容斥原理", "欧拉函数"], "suc": ["Powerful Number 筛", "杜教筛", "洲阁筛"], "tier": "normal", "entry": 2000, "pool": 131}, {"id": "裴蜀定理", "name": "裴蜀定理", "domain": "数学", "depth": 2, "x": 837.5, "y": 754, "w": 104, "h": 44, "pre": ["最大公约数"], "suc": [], "tier": "core", "entry": 0, "pool": 0}, {"id": "连分数", "name": "连分数", "domain": "数学", "depth": 1, "x": 1797.9, "y": 296, "w": 104, "h": 44, "pre": ["数论基础"], "suc": ["Pell 方程", "Stern–Brocot 树与 Farey 序列", "循环连分数"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "基本概念", "name": "基本概念", "domain": "数学", "depth": 1, "x": 1913.9, "y": 296, "w": 104, "h": 44, "pre": ["概率论"], "suc": ["条件概率与独立性"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "条件概率与独立性", "name": "条件概率与独立性", "domain": "数学", "depth": 2, "x": 1541.5, "y": 754, "w": 144.6, "h": 44, "pre": ["基本概念"], "suc": ["随机变量"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "概率不等式", "name": "概率不等式", "domain": "数学", "depth": 4, "x": 57.4, "y": 1600, "w": 105.0, "h": 44, "pre": ["随机变量"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "随机变量", "name": "随机变量", "domain": "数学", "depth": 3, "x": 1320.9, "y": 1282, "w": 104, "h": 44, "pre": ["条件概率与独立性"], "suc": ["概率不等式", "随机变量的数字特征"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "随机变量的数字特征", "name": "随机变量的数字特征", "domain": "数学", "depth": 4, "x": 174.4, "y": 1600, "w": 157.8, "h": 44, "pre": ["随机变量"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "Jordan标准型", "name": "Jordan标准型", "domain": "数学", "depth": 6, "x": 998.9, "y": 1886, "w": 122.16000000000001, "h": 44, "pre": ["对角化"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "内积和外积", "name": "内积和外积", "domain": "数学", "depth": 1, "x": 1886.1, "y": 226, "w": 105.0, "h": 44, "pre": ["向量"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "初等变换", "name": "初等变换", "domain": "数学", "depth": 2, "x": 49.3, "y": 824, "w": 104, "h": 44, "pre": ["矩阵"], "suc": ["行列式"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "向量", "name": "向量", "domain": "数学", "depth": 0, "x": 562.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": ["三维计算几何基础", "二维计算几何基础", "内积和外积", "线性空间"], "tier": "skip", "entry": 2000, "pool": 31}, {"id": "对角化", "name": "对角化", "domain": "数学", "depth": 5, "x": 883.0, "y": 1778, "w": 104, "h": 44, "pre": ["特征多项式"], "suc": ["Jordan标准型"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "特征多项式", "name": "特征多项式", "domain": "数学", "depth": 4, "x": 2169.5, "y": 1530, "w": 105.0, "h": 44, "pre": ["行列式", "线性映射"], "suc": ["对角化"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "矩阵", "name": "矩阵", "domain": "数学", "depth": 1, "x": 2145.9, "y": 296, "w": 104, "h": 44, "pre": ["线性代数"], "suc": ["初等变换", "单纯形算法", "常系数齐次线性递推", "高斯消元"], "tier": "core", "entry": 1700, "pool": 333}, {"id": "线性基", "name": "线性基", "domain": "数学", "depth": 1, "x": 1407.9, "y": 226, "w": 104, "h": 44, "pre": ["位运算"], "suc": [], "tier": "normal", "entry": 2000, "pool": 79}, {"id": "线性映射", "name": "线性映射", "domain": "数学", "depth": 2, "x": 922.3, "y": 684, "w": 104, "h": 44, "pre": ["线性空间"], "suc": ["特征多项式"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "线性空间", "name": "线性空间", "domain": "数学", "depth": 1, "x": 2003.1, "y": 226, "w": 104, "h": 44, "pre": ["向量"], "suc": ["线性映射"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "行列式", "name": "行列式", "domain": "数学", "depth": 3, "x": 1683.1, "y": 1282, "w": 104, "h": 44, "pre": ["初等变换"], "suc": ["LGV 引理", "特征多项式", "矩阵树定理"], "tier": "rare", "entry": 2600, "pool": 9}, {"id": "单纯形算法", "name": "单纯形算法", "domain": "数学", "depth": 2, "x": 165.3, "y": 824, "w": 105.0, "h": 44, "pre": ["矩阵"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Entringer Number", "name": "Entringer Number", "domain": "数学", "depth": 2, "x": 657.7, "y": 824, "w": 155.16, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Eulerian Number", "name": "Eulerian Number", "domain": "数学", "depth": 2, "x": 824.9, "y": 824, "w": 147.89999999999998, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "伯努利数", "name": "伯努利数", "domain": "数学", "depth": 3, "x": 344.6, "y": 1352, "w": 104, "h": 44, "pre": ["指数生成函数"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "分拆数", "name": "分拆数", "domain": "数学", "depth": 2, "x": 984.8, "y": 824, "w": 104, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "卡特兰数", "name": "卡特兰数", "domain": "数学", "depth": 2, "x": 1100.8, "y": 824, "w": 104, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "normal", "entry": 1600, "pool": 13}, {"id": "图论计数", "name": "图论计数", "domain": "数学", "depth": 4, "x": 311.4, "y": 1530, "w": 104, "h": 44, "pre": ["组合数学", "Prüfer 序列"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "容斥原理", "name": "容斥原理", "domain": "数学", "depth": 2, "x": 1216.8, "y": 824, "w": 104, "h": 44, "pre": ["排列组合"], "suc": ["斯特林数", "莫比乌斯反演", "错位排列"], "tier": "core", "entry": 2000, "pool": 224}, {"id": "康托展开", "name": "康托展开", "domain": "数学", "depth": 2, "x": 1332.8, "y": 824, "w": 104, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "normal", "entry": 1700, "pool": 8}, {"id": "抽屉原理", "name": "抽屉原理", "domain": "数学", "depth": 1, "x": 360.2, "y": 366, "w": 104, "h": 44, "pre": ["组合数学"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "排列组合", "name": "排列组合", "domain": "数学", "depth": 1, "x": 476.2, "y": 366, "w": 104, "h": 44, "pre": ["组合数学"], "suc": ["Entringer Number", "Eulerian Number", "分拆数", "卡特兰数", "卢卡斯定理", "容斥原理", "康托展开", "杨氏矩阵", "范德蒙德卷积", "计数 DP"], "tier": "core", "entry": 1600, "pool": 249}, {"id": "斐波那契数列", "name": "斐波那契数列", "domain": "数学", "depth": 1, "x": 592.2, "y": 366, "w": 118.19999999999999, "h": 44, "pre": ["组合数学"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "斯特林数", "name": "斯特林数", "domain": "数学", "depth": 3, "x": 112.6, "y": 1352, "w": 104, "h": 44, "pre": ["容斥原理"], "suc": ["贝尔数"], "tier": "normal", "entry": 2400, "pool": 9}, {"id": "范德蒙德卷积", "name": "范德蒙德卷积", "domain": "数学", "depth": 2, "x": 1564.8, "y": 824, "w": 118.19999999999999, "h": 44, "pre": ["排列组合"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "贝尔数", "name": "贝尔数", "domain": "数学", "depth": 4, "x": 577.2, "y": 1600, "w": 104, "h": 44, "pre": ["斯特林数"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "错位排列", "name": "错位排列", "domain": "数学", "depth": 3, "x": 228.6, "y": 1352, "w": 104, "h": 44, "pre": ["容斥原理"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "置换群", "name": "置换群", "domain": "数学", "depth": 2, "x": 1941.2, "y": 824, "w": 104, "h": 44, "pre": ["置换和排列"], "suc": ["群论"], "tier": "rare", "entry": 2400, "pool": 13}, {"id": "K-D Tree", "name": "K-D Tree", "domain": "数据结构", "depth": 2, "x": 1404.3, "y": 1034, "w": 104, "h": 44, "pre": ["线段树"], "suc": [], "tier": "rare", "entry": 2600, "pool": 23}, {"id": "PQ 树", "name": "PQ 树", "domain": "数据结构", "depth": 0, "x": 2138.9, "y": 48, "w": 104, "h": 44, "pre": [], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "ST 表", "name": "ST 表", "domain": "数据结构", "depth": 3, "x": 1041.6, "y": 1352, "w": 104, "h": 44, "pre": ["倍增"], "suc": [], "tier": "core", "entry": 1700, "pool": 126}, {"id": "二叉搜索树 & 平衡树", "name": "二叉搜索树 & 平衡树", "domain": "数据结构", "depth": 1, "x": 1151.0, "y": 506, "w": 166.38, "h": 44, "pre": ["递归 & 分治"], "suc": ["2-3 树", "AA 树", "AVL 树", "B 树", "Leafy Tree", "Size Balanced Tree", "Splay 树", "Treap", "WBLT", "可持久化平衡树", "后缀平衡树", "平衡树套线段树", "替罪羊树", "笛卡尔树", "红黑树", "线段树套平衡树"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "划分树", "name": "划分树", "domain": "数据结构", "depth": 2, "x": 1520.3, "y": 1034, "w": 104, "h": 44, "pre": ["线段树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "动态树", "name": "动态树", "domain": "数据结构", "depth": 3, "x": 1592.2, "y": 1422, "w": 104, "h": 44, "pre": ["Splay 树"], "suc": ["Euler Tour Tree", "Link Cut Tree"], "tier": "rare", "entry": 2400, "pool": 113}, {"id": "区间最值操作 & 区间历史最值", "name": "区间最值操作 & 区间历史最值", "domain": "数据结构", "depth": 2, "x": 1636.3, "y": 1034, "w": 219.18, "h": 44, "pre": ["线段树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "单调栈", "name": "单调栈", "domain": "数据结构", "depth": 1, "x": 1449.8, "y": 366, "w": 104, "h": 44, "pre": ["栈"], "suc": ["单调队列/单调栈优化", "析合树", "笛卡尔树"], "tier": "core", "entry": 1700, "pool": 133}, {"id": "单调队列", "name": "单调队列", "domain": "数据结构", "depth": 1, "x": 2156.4, "y": 366, "w": 104, "h": 44, "pre": ["队列"], "suc": ["单调队列/单调栈优化"], "tier": "core", "entry": 1700, "pool": 147}, {"id": "可持久化数据结构", "name": "可持久化数据结构", "domain": "数据结构", "depth": 2, "x": 1867.5, "y": 1034, "w": 144.6, "h": 44, "pre": ["线段树"], "suc": ["可持久化可并堆", "可持久化块状数组", "可持久化字典树", "可持久化平衡树"], "tier": "normal", "entry": 2000, "pool": 36}, {"id": "哈希表", "name": "哈希表", "domain": "数据结构", "depth": 1, "x": 1682.8, "y": 366, "w": 104, "h": 44, "pre": ["链表"], "suc": [], "tier": "core", "entry": 1500, "pool": 23}, {"id": "块状数据结构", "name": "块状数据结构", "domain": "数据结构", "depth": 2, "x": 793.9, "y": 964, "w": 118.19999999999999, "h": 44, "pre": ["前缀和 & 差分"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "堆", "name": "堆", "domain": "数据结构", "depth": 0, "x": 519.4, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["二叉堆"], "tier": "core", "entry": 1500, "pool": 126}, {"id": "并查集", "name": "并查集", "domain": "数据结构", "depth": 0, "x": 635.4, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["并查集复杂度", "并查集应用", "最小生成树"], "tier": "core", "entry": 1600, "pool": 640}, {"id": "手指树", "name": "手指树", "domain": "数据结构", "depth": 1, "x": 1798.8, "y": 366, "w": 104, "h": 44, "pre": ["链表"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "李超线段树", "name": "李超线段树", "domain": "数据结构", "depth": 2, "x": 2167.5, "y": 1034, "w": 105.0, "h": 44, "pre": ["线段树"], "suc": [], "tier": "normal", "entry": 2600, "pool": 38}, {"id": "析合树", "name": "析合树", "domain": "数据结构", "depth": 2, "x": 585.4, "y": 894, "w": 104, "h": 44, "pre": ["单调栈"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "栈", "name": "栈", "domain": "数据结构", "depth": 0, "x": 751.4, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["单调栈", "括号序列", "表达式求值"], "tier": "core", "entry": 1500, "pool": 290}, {"id": "树套树", "name": "树套树", "domain": "数据结构", "depth": 2, "x": 1111.2, "y": 1104, "w": 104, "h": 44, "pre": ["线段树"], "suc": ["分块套树状数组", "平衡树套线段树", "树状数组套权值线段树", "线段树套平衡树", "线段树套线段树"], "tier": "rare", "entry": 2300, "pool": 46}, {"id": "树状数组", "name": "树状数组", "domain": "数据结构", "depth": 3, "x": 693.6, "y": 1352, "w": 104, "h": 44, "pre": ["前缀和 & 差分", "倍增"], "suc": ["树状数组套权值线段树"], "tier": "core", "entry": 1700, "pool": 667}, {"id": "线段树", "name": "线段树", "domain": "数据结构", "depth": 1, "x": 1329.3, "y": 506, "w": 104, "h": 44, "pre": ["递归 & 分治"], "suc": ["K-D Tree", "RMQ", "划分树", "区间最值操作 & 区间历史最值", "可持久化数据结构", "可持久化线段树", "李超线段树", "树套树", "树链剖分", "珂朵莉树/颜色段均摊", "线段树与离线询问"], "tier": "core", "entry": 2000, "pool": 1563}, {"id": "跳表", "name": "跳表", "domain": "数据结构", "depth": 1, "x": 1914.8, "y": 366, "w": 104, "h": 44, "pre": ["链表"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "链表", "name": "链表", "domain": "数据结构", "depth": 0, "x": 867.4, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["哈希表", "块状链表", "手指树", "跳表"], "tier": "core", "entry": 1500, "pool": 76}, {"id": "队列", "name": "队列", "domain": "数据结构", "depth": 0, "x": 983.4, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["BFS(图论)", "BFS(搜索)", "单调队列"], "tier": "core", "entry": 1500, "pool": 290}, {"id": "霍夫曼树", "name": "霍夫曼树", "domain": "数据结构", "depth": 2, "x": 65.7, "y": 894, "w": 104, "h": 44, "pre": ["二叉堆"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "2-3 树", "name": "2-3 树", "domain": "数据结构", "depth": 2, "x": 171.0, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "AA 树", "name": "AA 树", "domain": "数据结构", "depth": 2, "x": 287.0, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "AVL 树", "name": "AVL 树", "domain": "数据结构", "depth": 2, "x": 403.0, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "B 树", "name": "B 树", "domain": "数据结构", "depth": 2, "x": 519.0, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": ["B+ 树"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "B+ 树", "name": "B+ 树", "domain": "数据结构", "depth": 3, "x": 1476.2, "y": 1422, "w": 104, "h": 44, "pre": ["B 树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Leafy Tree", "name": "Leafy Tree", "domain": "数据结构", "depth": 2, "x": 635.0, "y": 1034, "w": 111.6, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Size Balanced Tree", "name": "Size Balanced Tree", "domain": "数据结构", "depth": 2, "x": 758.6, "y": 1034, "w": 169.68, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Splay 树", "name": "Splay 树", "domain": "数据结构", "depth": 2, "x": 940.3, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": ["动态树"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "Treap", "name": "Treap", "domain": "数据结构", "depth": 2, "x": 1808.8, "y": 894, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树", "二叉堆"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "WBLT", "name": "WBLT", "domain": "数据结构", "depth": 2, "x": 1056.3, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "左偏红黑树", "name": "左偏红黑树", "domain": "数据结构", "depth": 3, "x": 1708.2, "y": 1422, "w": 105.0, "h": 44, "pre": ["红黑树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "替罪羊树", "name": "替罪羊树", "domain": "数据结构", "depth": 2, "x": 1172.3, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "笛卡尔树", "name": "笛卡尔树", "domain": "数据结构", "depth": 2, "x": 2040.8, "y": 894, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树", "单调栈"], "suc": [], "tier": "rare", "entry": 2000, "pool": 44}, {"id": "红黑树", "name": "红黑树", "domain": "数据结构", "depth": 2, "x": 1288.3, "y": 1034, "w": 104, "h": 44, "pre": ["二叉搜索树 & 平衡树"], "suc": ["左偏红黑树"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Euler Tour Tree", "name": "Euler Tour Tree", "domain": "数据结构", "depth": 4, "x": 1859.8, "y": 1600, "w": 147.89999999999998, "h": 44, "pre": ["动态树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Link Cut Tree", "name": "Link Cut Tree", "domain": "数据结构", "depth": 4, "x": 2019.7, "y": 1600, "w": 133.38, "h": 44, "pre": ["动态树"], "suc": ["Top Tree"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "Top Tree", "name": "Top Tree", "domain": "数据结构", "depth": 5, "x": 1431.8, "y": 1778, "w": 104, "h": 44, "pre": ["Link Cut Tree"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "可持久化可并堆", "name": "可持久化可并堆", "domain": "数据结构", "depth": 3, "x": 1939.1, "y": 1352, "w": 131.39999999999998, "h": 44, "pre": ["可持久化数据结构", "左偏树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "可持久化块状数组", "name": "可持久化块状数组", "domain": "数据结构", "depth": 4, "x": 1182.5, "y": 1670, "w": 144.6, "h": 44, "pre": ["可持久化数据结构", "块状数组"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "可持久化字典树", "name": "可持久化字典树", "domain": "数据结构", "depth": 3, "x": 2147.1, "y": 1282, "w": 131.39999999999998, "h": 44, "pre": ["可持久化数据结构", "字典树 (Trie)"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "可持久化平衡树", "name": "可持久化平衡树", "domain": "数据结构", "depth": 3, "x": 2082.5, "y": 1352, "w": 131.39999999999998, "h": 44, "pre": ["可持久化数据结构", "二叉搜索树 & 平衡树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "可持久化线段树", "name": "可持久化线段树", "domain": "数据结构", "depth": 2, "x": 2024.1, "y": 1034, "w": 131.39999999999998, "h": 44, "pre": ["线段树"], "suc": [], "tier": "normal", "entry": 2000, "pool": 140}, {"id": "Sqrt Tree", "name": "Sqrt Tree", "domain": "数据结构", "depth": 3, "x": 1590.8, "y": 1352, "w": 104.34, "h": 44, "pre": ["分块思想"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "分块思想", "name": "分块思想", "domain": "数据结构", "depth": 2, "x": 677.9, "y": 964, "w": 104, "h": 44, "pre": ["前缀和 & 差分"], "suc": ["Sqrt Tree", "分块套树状数组", "块状数组", "块状链表", "树分块", "珂朵莉树/颜色段均摊", "莫队算法"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "块状数组", "name": "块状数组", "domain": "数据结构", "depth": 3, "x": 1707.1, "y": 1352, "w": 104, "h": 44, "pre": ["分块思想"], "suc": ["可持久化块状数组"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "块状链表", "name": "块状链表", "domain": "数据结构", "depth": 3, "x": 1799.1, "y": 1282, "w": 104, "h": 44, "pre": ["分块思想", "链表"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "树分块", "name": "树分块", "domain": "数据结构", "depth": 3, "x": 1823.1, "y": 1352, "w": 104, "h": 44, "pre": ["分块思想"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "二叉堆", "name": "二叉堆", "domain": "数据结构", "depth": 1, "x": 1086.6, "y": 366, "w": 104, "h": 44, "pre": ["堆"], "suc": ["A*", "Garsia–Wachs 算法", "Treap", "堆排序", "左偏树", "最短路", "配对堆", "霍夫曼树"], "tier": "core", "entry": 0, "pool": 0}, {"id": "左偏树", "name": "左偏树", "domain": "数据结构", "depth": 2, "x": 2057.2, "y": 824, "w": 104, "h": 44, "pre": ["二叉堆"], "suc": ["可持久化可并堆"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "配对堆", "name": "配对堆", "domain": "数据结构", "depth": 2, "x": 2173.2, "y": 824, "w": 104, "h": 44, "pre": ["二叉堆"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "并查集复杂度", "name": "并查集复杂度", "domain": "数据结构", "depth": 1, "x": 1319.6, "y": 366, "w": 118.19999999999999, "h": 44, "pre": ["并查集"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "分块套树状数组", "name": "分块套树状数组", "domain": "数据结构", "depth": 3, "x": 1332.8, "y": 1422, "w": 131.39999999999998, "h": 44, "pre": ["树套树", "分块思想"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "平衡树套线段树", "name": "平衡树套线段树", "domain": "数据结构", "depth": 3, "x": 369.9, "y": 1422, "w": 131.39999999999998, "h": 44, "pre": ["树套树", "二叉搜索树 & 平衡树"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "树状数组套权值线段树", "name": "树状数组套权值线段树", "domain": "数据结构", "depth": 4, "x": 999.5, "y": 1670, "w": 171.0, "h": 44, "pre": ["树套树", "树状数组"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "线段树套平衡树", "name": "线段树套平衡树", "domain": "数据结构", "depth": 3, "x": 513.3, "y": 1422, "w": 131.39999999999998, "h": 44, "pre": ["树套树", "二叉搜索树 & 平衡树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "线段树套线段树", "name": "线段树套线段树", "domain": "数据结构", "depth": 3, "x": 1825.2, "y": 1422, "w": 131.39999999999998, "h": 44, "pre": ["树套树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "15-puzzle", "name": "15-puzzle", "domain": "杂项", "depth": 2, "x": 934.4, "y": 894, "w": 104.34, "h": 44, "pre": ["BFS(搜索)"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Garsia–Wachs 算法", "name": "Garsia–Wachs 算法", "domain": "杂项", "depth": 2, "x": 181.7, "y": 894, "w": 159.78, "h": 44, "pre": ["二叉堆"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Kahan 求和", "name": "Kahan 求和", "domain": "杂项", "depth": 1, "x": 1512.3, "y": 436, "w": 108.96000000000001, "h": 44, "pre": ["模拟", "复杂度"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "主元素问题", "name": "主元素问题", "domain": "杂项", "depth": 1, "x": 1633.3, "y": 436, "w": 105.0, "h": 44, "pre": ["枚举"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "分数规划", "name": "分数规划", "domain": "杂项", "depth": 2, "x": 1167.8, "y": 894, "w": 104, "h": 44, "pre": ["二分"], "suc": [], "tier": "normal", "entry": 2000, "pool": 28}, {"id": "双指针", "name": "双指针", "domain": "杂项", "depth": 1, "x": 1750.3, "y": 436, "w": 104, "h": 44, "pre": ["枚举"], "suc": [], "tier": "core", "entry": 1200, "pool": 11}, {"id": "在一台机器上规划任务", "name": "在一台机器上规划任务", "domain": "杂项", "depth": 2, "x": 208.1, "y": 964, "w": 171.0, "h": 44, "pre": ["贪心"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "字节顺序", "name": "字节顺序", "domain": "杂项", "depth": 1, "x": 1523.9, "y": 226, "w": 104, "h": 44, "pre": ["位运算"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "悬线法", "name": "悬线法", "domain": "杂项", "depth": 2, "x": 924.1, "y": 964, "w": 104, "h": 44, "pre": ["前缀和 & 差分"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "格雷码", "name": "格雷码", "domain": "杂项", "depth": 1, "x": 1639.9, "y": 226, "w": 104, "h": 44, "pre": ["位运算"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "珂朵莉树/颜色段均摊", "name": "珂朵莉树/颜色段均摊", "domain": "杂项", "depth": 3, "x": 1273.6, "y": 1352, "w": 165.06, "h": 44, "pre": ["分块思想", "线段树"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "离散化", "name": "离散化", "domain": "杂项", "depth": 1, "x": 334.5, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": [], "tier": "core", "entry": 1600, "pool": 277}, {"id": "离线算法", "name": "离线算法", "domain": "杂项", "depth": 1, "x": 450.5, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": ["线段树与离线询问"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "约瑟夫问题", "name": "约瑟夫问题", "domain": "杂项", "depth": 1, "x": 661.2, "y": 506, "w": 105.0, "h": 44, "pre": ["模拟"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "表达式求值", "name": "表达式求值", "domain": "杂项", "depth": 1, "x": 1565.8, "y": 366, "w": 105.0, "h": 44, "pre": ["栈"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "计算理论基础", "name": "计算理论基础", "domain": "杂项", "depth": 0, "x": 1099.4, "y": 118, "w": 118.19999999999999, "h": 44, "pre": [], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "随机化", "name": "随机化", "domain": "杂项", "depth": 1, "x": 2029.9, "y": 296, "w": 104, "h": 44, "pre": ["概率论"], "suc": ["模拟退火", "爬山算法", "随机函数", "随机化技巧", "随机增量法"], "tier": "rare", "entry": 2000, "pool": 98}, {"id": "CDQ 分治", "name": "CDQ 分治", "domain": "杂项", "depth": 1, "x": 2121.0, "y": 436, "w": 104, "h": 44, "pre": ["递归 & 分治", "排序"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "整体二分", "name": "整体二分", "domain": "杂项", "depth": 2, "x": 1283.8, "y": 894, "w": 104, "h": 44, "pre": ["二分"], "suc": [], "tier": "normal", "entry": 2300, "pool": 44}, {"id": "莫队算法", "name": "莫队算法", "domain": "杂项", "depth": 3, "x": 1915.1, "y": 1282, "w": 104, "h": 44, "pre": ["分块思想", "排序"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "模拟退火", "name": "模拟退火", "domain": "杂项", "depth": 2, "x": 1698.1, "y": 754, "w": 104, "h": 44, "pre": ["随机化"], "suc": [], "tier": "rare", "entry": 2000, "pool": 40}, {"id": "爬山算法", "name": "爬山算法", "domain": "杂项", "depth": 2, "x": 1814.1, "y": 754, "w": 104, "h": 44, "pre": ["随机化"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "随机函数", "name": "随机函数", "domain": "杂项", "depth": 2, "x": 1930.1, "y": 754, "w": 104, "h": 44, "pre": ["随机化"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "随机化技巧", "name": "随机化技巧", "domain": "杂项", "depth": 2, "x": 2046.1, "y": 754, "w": 105.0, "h": 44, "pre": ["随机化"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "二分", "name": "二分", "domain": "算法基础", "depth": 1, "x": 218.5, "y": 436, "w": 104, "h": 44, "pre": ["排序", "复杂度"], "suc": ["倍增", "分数规划", "整体二分"], "tier": "core", "entry": 1600, "pool": 1470}, {"id": "倍增", "name": "倍增", "domain": "算法基础", "depth": 2, "x": 1399.8, "y": 894, "w": 104, "h": 44, "pre": ["二分"], "suc": ["RMQ", "ST 表", "后缀数组 (SA)", "快速幂", "最近公共祖先", "树状数组"], "tier": "core", "entry": 2000, "pool": 285}, {"id": "前缀和 & 差分", "name": "前缀和 & 差分", "domain": "算法基础", "depth": 1, "x": 1866.3, "y": 436, "w": 126.78, "h": 44, "pre": ["枚举"], "suc": ["分块思想", "块状数据结构", "悬线法", "树状数组"], "tier": "core", "entry": 0, "pool": 0}, {"id": "复杂度", "name": "复杂度", "domain": "算法基础", "depth": 0, "x": 1229.6, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["Kahan 求和", "二分"], "tier": "core", "entry": 0, "pool": 0}, {"id": "排序", "name": "排序", "domain": "算法基础", "depth": 0, "x": 1345.6, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["CDQ 分治", "二分", "冒泡排序", "排序应用", "排序相关 STL", "插入排序", "最小生成树", "离散化", "离线算法", "莫队算法", "计数排序", "贪心", "选择排序", "锦标赛排序"], "tier": "core", "entry": 1200, "pool": 1160}, {"id": "构造", "name": "构造", "domain": "算法基础", "depth": 1, "x": 2005.0, "y": 436, "w": 104, "h": 44, "pre": ["枚举"], "suc": [], "tier": "core", "entry": 1500, "pool": 972}, {"id": "枚举", "name": "枚举", "domain": "算法基础", "depth": 0, "x": 1461.6, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["主元素问题", "前缀和 & 差分", "动态规划基础", "双指针", "构造"], "tier": "core", "entry": 1200, "pool": 2952}, {"id": "模拟", "name": "模拟", "domain": "算法基础", "depth": 0, "x": 1577.6, "y": 118, "w": 104, "h": 44, "pre": [], "suc": ["Kahan 求和", "约瑟夫问题", "高精度计算"], "tier": "core", "entry": 800, "pool": 2791}, {"id": "贪心", "name": "贪心", "domain": "算法基础", "depth": 1, "x": 1163.3, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": ["在一台机器上规划任务"], "tier": "core", "entry": 1300, "pool": 2852}, {"id": "递归 & 分治", "name": "递归 & 分治", "domain": "算法基础", "depth": 0, "x": 1693.6, "y": 118, "w": 113.58, "h": 44, "pre": [], "suc": ["CDQ 分治", "DFS(图论)", "DFS(搜索)", "二叉搜索树 & 平衡树", "动态规划基础", "归并排序", "快速排序", "线段树", "记忆化搜索"], "tier": "core", "entry": 0, "pool": 0}, {"id": "tim排序", "name": "tim排序", "domain": "算法基础", "depth": 2, "x": 1040.1, "y": 964, "w": 104, "h": 44, "pre": ["归并排序", "插入排序"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "冒泡排序", "name": "冒泡排序", "domain": "算法基础", "depth": 1, "x": 566.5, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "基数排序", "name": "基数排序", "domain": "算法基础", "depth": 2, "x": 2156.8, "y": 894, "w": 104, "h": 44, "pre": ["计数排序"], "suc": ["后缀数组 (SA)"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "堆排序", "name": "堆排序", "domain": "算法基础", "depth": 2, "x": 353.4, "y": 894, "w": 104, "h": 44, "pre": ["二叉堆"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "希尔排序", "name": "希尔排序", "domain": "算法基础", "depth": 2, "x": 1924.8, "y": 894, "w": 104, "h": 44, "pre": ["插入排序"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "归并排序", "name": "归并排序", "domain": "算法基础", "depth": 1, "x": 1445.3, "y": 506, "w": 104, "h": 44, "pre": ["递归 & 分治"], "suc": ["tim排序"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "快速排序", "name": "快速排序", "domain": "算法基础", "depth": 1, "x": 1561.3, "y": 506, "w": 104, "h": 44, "pre": ["递归 & 分治"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "排序应用", "name": "排序应用", "domain": "算法基础", "depth": 1, "x": 682.5, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "排序相关 STL", "name": "排序相关 STL", "domain": "算法基础", "depth": 1, "x": 798.5, "y": 436, "w": 120.84, "h": 44, "pre": ["排序"], "suc": [], "tier": "core", "entry": 0, "pool": 0}, {"id": "插入排序", "name": "插入排序", "domain": "算法基础", "depth": 1, "x": 931.3, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": ["tim排序", "希尔排序"], "tier": "skip", "entry": 0, "pool": 0}, {"id": "桶排序", "name": "桶排序", "domain": "算法基础", "depth": 2, "x": 92.1, "y": 964, "w": 104, "h": 44, "pre": ["计数排序"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "计数排序", "name": "计数排序", "domain": "算法基础", "depth": 1, "x": 1047.3, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": ["基数排序", "桶排序"], "tier": "rare", "entry": 0, "pool": 0}, {"id": "选择排序", "name": "选择排序", "domain": "算法基础", "depth": 1, "x": 1279.3, "y": 436, "w": 104, "h": 44, "pre": ["排序"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "锦标赛排序", "name": "锦标赛排序", "domain": "算法基础", "depth": 1, "x": 1395.3, "y": 436, "w": 105.0, "h": 44, "pre": ["排序"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "Pick 定理", "name": "Pick 定理", "domain": "计算几何", "depth": 2, "x": 1317.2, "y": 684, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "三维计算几何基础", "name": "三维计算几何基础", "domain": "计算几何", "depth": 1, "x": 2119.1, "y": 226, "w": 144.6, "h": 44, "pre": ["向量"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "三角剖分", "name": "三角剖分", "domain": "计算几何", "depth": 2, "x": 1433.2, "y": 684, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "二维计算几何基础", "name": "二维计算几何基础", "domain": "计算几何", "depth": 1, "x": 827.3, "y": 296, "w": 144.6, "h": 44, "pre": ["向量", "弧度制与坐标系"], "suc": ["Pick 定理", "三角剖分", "凸包", "半平面交", "反演变换", "平面最近点对", "扫描线", "计算几何杂项", "距离"], "tier": "normal", "entry": 0, "pool": 0}, {"id": "凸包", "name": "凸包", "domain": "计算几何", "depth": 2, "x": 1549.2, "y": 684, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": ["旋转卡壳"], "tier": "normal", "entry": 2000, "pool": 152}, {"id": "半平面交", "name": "半平面交", "domain": "计算几何", "depth": 2, "x": 1665.2, "y": 684, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "rare", "entry": 2400, "pool": 27}, {"id": "反演变换", "name": "反演变换", "domain": "计算几何", "depth": 2, "x": 1781.2, "y": 684, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "skip", "entry": 0, "pool": 0}, {"id": "平面最近点对", "name": "平面最近点对", "domain": "计算几何", "depth": 2, "x": 1897.2, "y": 684, "w": 118.19999999999999, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}, {"id": "扫描线", "name": "扫描线", "domain": "计算几何", "depth": 2, "x": 2027.4, "y": 684, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "normal", "entry": 2000, "pool": 264}, {"id": "旋转卡壳", "name": "旋转卡壳", "domain": "计算几何", "depth": 3, "x": 656.4, "y": 1282, "w": 104, "h": 44, "pre": ["凸包"], "suc": [], "tier": "rare", "entry": 2600, "pool": 11}, {"id": "计算几何杂项", "name": "计算几何杂项", "domain": "计算几何", "depth": 2, "x": 2143.4, "y": 684, "w": 118.19999999999999, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "距离", "name": "距离", "domain": "计算几何", "depth": 2, "x": 58.3, "y": 754, "w": 104, "h": 44, "pre": ["二维计算几何基础"], "suc": [], "tier": "normal", "entry": 0, "pool": 0}, {"id": "随机增量法", "name": "随机增量法", "domain": "计算几何", "depth": 2, "x": 2163.1, "y": 754, "w": 105.0, "h": 44, "pre": ["随机化"], "suc": [], "tier": "rare", "entry": 0, "pool": 0}], "edges": [["倍增", "RMQ"], ["线段树", "RMQ"], ["并查集", "并查集应用"], ["栈", "括号序列"], ["动态规划基础", "括号序列"], ["线段树", "线段树与离线询问"], ["离线算法", "线段树与离线询问"], ["动态规划基础", "DAG 上的 DP"], ["拓扑排序", "DAG 上的 DP"], ["动态规划基础", "DP 优化"], ["动态规划基础", "其它 DP 方法"], ["动态规划基础", "动态 DP"], ["树链剖分", "动态 DP"], ["递归 & 分治", "动态规划基础"], ["枚举", "动态规划基础"], ["动态规划基础", "区间 DP"], ["状压 DP", "插头 DP"], ["动态规划基础", "数位 DP"], ["位运算", "数位 DP"], ["动态规划基础", "树形 DP"], ["DFS(搜索)", "树形 DP"], ["动态规划基础", "概率 DP"], ["概率论", "概率 DP"], ["动态规划基础", "状压 DP"], ["动态规划基础", "背包 DP"], ["动态规划基础", "计数 DP"], ["排列组合", "计数 DP"], ["递归 & 分治", "记忆化搜索"], ["动态规划基础", "单调队列/单调栈优化"], ["单调队列", "单调队列/单调栈优化"], ["单调栈", "单调队列/单调栈优化"], ["区间 DP", "四边形不等式优化"], ["单调队列/单调栈优化", "斜率优化"], ["区间 DP", "状态设计优化"], ["强连通分量", "2-SAT"], ["图的存储", "BFS(图论)"], ["队列", "BFS(图论)"], ["图的存储", "DFS(图论)"], ["递归 & 分治", "DFS(图论)"], ["组合数学", "LGV 引理"], ["行列式", "LGV 引理"], ["树基础", "Prüfer 序列"], ["图的存储", "Stoer–Wagner 算法"], ["最短路", "k 短路"], ["图的存储", "二分图"], ["最短路", "同余最短路"], ["线性同余方程", "同余最短路"], ["图的存储", "哈密顿图"], ["图的存储", "图上随机游走"], ["概率论", "图上随机游走"], ["图论相关概念", "图的匹配"], ["图论相关概念", "图的存储"], ["图的存储", "图的着色"], ["最短路", "差分约束"], ["欧拉图", "平面图"], ["图的匹配", "弦图"], ["网络流", "拆点"], ["有向无环图", "拓扑排序"], ["DFS(图论)", "支配树"], ["状压 DP", "斯坦纳树"], ["图的存储", "最大团搜索算法"], ["DFS(搜索)", "最大团搜索算法"], ["最小生成树", "最小树形图"], ["最短路", "最小环"], ["并查集", "最小生成树"], ["排序", "最小生成树"], ["最小生成树", "最小直径生成树"], ["最短路", "最小直径生成树"], ["二叉堆", "最短路"], ["BFS(图论)", "最短路"], ["图的存储", "有向无环图"], ["树基础", "树上问题"], ["图的存储", "欧拉图"], ["图的存储", "环计数问题"], ["高斯消元", "矩阵树定理"], ["行列式", "矩阵树定理"], ["BFS(图论)", "网络流"], ["图的存储", "连通性相关"], ["增广路", "一般图最大匹配"], ["一般图最大匹配", "一般图最大权匹配"], ["二分图最大权匹配", "一般图最大权匹配"], ["增广路", "二分图最大匹配"], ["二分图", "二分图最大匹配"], ["二分图最大匹配", "二分图最大权匹配"], ["图的匹配", "图匹配"], ["图的匹配", "增广路"], ["DFS(图论)", "增广路"], ["树哈希", "AHU 算法"], ["树分治", "动态树分治"], ["倍增", "最近公共祖先"], ["DFS(图论)", "最近公共祖先"], ["DFS(图论)", "树上启发式合并"], ["树基础", "树上随机游走"], ["概率论", "树上随机游走"], ["DFS(图论)", "树分治"], ["树基础", "树哈希"], ["字符串哈希", "树哈希"], ["图的存储", "树基础"], ["树基础", "树的直径"], ["树基础", "树的重心"], ["DFS(图论)", "树链剖分"], ["线段树", "树链剖分"], ["最近公共祖先", "虚树"], ["网络流", "上下界网络流"], ["网络流", "最大流"], ["增广路", "最大流"], ["网络流", "最小割"], ["网络流", "费用流"], ["连通性相关", "割点和桥"], ["DFS(图论)", "割点和桥"], ["连通性相关", "双连通分量"], ["DFS(图论)", "双连通分量"], ["双连通分量", "圆方树"], ["连通性相关", "强连通分量"], ["DFS(图论)", "强连通分量"], ["连通性相关", "点/边连通度"], ["字典树 (Trie)", "AC 自动机"], ["前缀函数与 KMP 算法", "AC 自动机"], ["字符串匹配", "Boyer–Moore 算法"], ["字符串基础", "Lyndon 分解"], ["前缀函数与 KMP 算法", "Main–Lorentz 算法"], ["字符串匹配", "Manacher"], ["前缀函数与 KMP 算法", "Z 函数(扩展 KMP)"], ["字符串匹配", "前缀函数与 KMP 算法"], ["二叉搜索树 & 平衡树", "后缀平衡树"], ["倍增", "后缀数组 (SA)"], ["基数排序", "后缀数组 (SA)"], ["后缀自动机 (SAM)", "后缀树"], ["字典树 (Trie)", "后缀自动机 (SAM)"], ["自动机", "后缀自动机 (SAM)"], ["字典树 (Trie)", "回文树"], ["字符串基础", "字典树 (Trie)"], ["字符串基础", "字符串匹配"], ["字符串基础", "字符串哈希"], ["后缀自动机 (SAM)", "广义后缀自动机"], ["字符串基础", "序列自动机"], ["字符串基础", "最小表示法"], ["字符串基础", "标准库"], ["字符串基础", "自动机"], ["后缀数组 (SA)", "最优原地后缀排序算法"], ["启发式搜索", "A*"], ["二叉堆", "A*"], ["优化", "Alpha-Beta 剪枝"], ["队列", "BFS(搜索)"], ["递归 & 分治", "DFS(搜索)"], ["DFS(搜索)", "Dancing Links"], ["迭代加深搜索", "IDA*"], ["A*", "IDA*"], ["DFS(搜索)", "优化"], ["BFS(搜索)", "双向搜索"], ["BFS(搜索)", "启发式搜索"], ["DFS(搜索)", "回溯法"], ["DFS(搜索)", "迭代加深搜索"], ["BFS(搜索)", "迭代加深搜索"], ["常系数齐次线性递推", "Berlekamp–Massey 算法"], ["群论", "Schreier–Sims 算法"], ["位运算", "二进制集合操作"], ["线性规划", "傅里叶-莫茨金消元法"], ["进位制", "平衡三进制"], ["倍增", "快速幂"], ["数论基础", "数论"], ["排列组合", "杨氏矩阵"], ["组合数学", "置换和排列"], ["置换群", "群论"], ["模拟", "高精度计算"], ["字符串基础", "高精度计算"], ["博弈论", "公平组合游戏"], ["公平组合游戏", "反常游戏"], ["公平组合游戏", "非公平组合游戏"], ["快速傅里叶变换", "Chirp Z 变换"], ["复数", "代数基本定理"], ["复数", "多项式初等函数"], ["多项式与生成函数", "多项式初等函数"], ["多项式与生成函数", "多项式多点求值|快速插值"], ["快速傅里叶变换", "多项式多点求值|快速插值"], ["多项式与生成函数", "多项式平移|连续点值平移"], ["多项式初等函数", "多项式牛顿迭代"], ["牛顿迭代法", "多项式牛顿迭代"], ["矩阵", "常系数齐次线性递推"], ["快速幂", "常系数齐次线性递推"], ["复数", "快速傅里叶变换"], ["快速傅里叶变换", "快速数论变换"], ["原根", "快速数论变换"], ["快速傅里叶变换", "快速沃尔什变换"], ["位运算", "快速沃尔什变换"], ["普通生成函数", "指数生成函数"], ["组合数学", "普通生成函数"], ["普通生成函数", "狄利克雷生成函数"], ["数论基础", "狄利克雷生成函数"], ["指数生成函数", "符号化方法"], ["多项式与生成函数", "拉格朗日插值"], ["数值算法", "数值积分"], ["数值算法", "牛顿迭代法"], ["矩阵", "高斯消元"], ["筛法", "Meissel–Lehmer 算法"], ["数论分块", "Meissel–Lehmer 算法"], ["筛法", "Min_25 筛"], ["杜教筛", "Min_25 筛"], ["连分数", "Pell 方程"], ["二次域", "Pell 方程"], ["筛法", "Powerful Number 筛"], ["莫比乌斯反演", "Powerful Number 筛"], ["连分数", "Stern–Brocot 树与 Farey 序列"], ["线性同余方程", "中国剩余定理"], ["欧拉定理 & 费马小定理", "乘法逆元"], ["线性同余方程", "乘法逆元"], ["乘法逆元", "二次剩余"], ["二次剩余", "二次域"], ["素数", "分解质因数"], ["同余方程", "剩余"], ["欧拉定理 & 费马小定理", "升幂引理"], ["排列组合", "卢卡斯定理"], ["乘法逆元", "卢卡斯定理"], ["欧拉定理 & 费马小定理", "原根"], ["数论基础", "同余方程"], ["素数", "威尔逊定理"], ["同余方程", "威尔逊定理"], ["连分数", "循环连分数"], ["数论基础", "数论分块"], ["数论基础", "最大公约数"], ["莫比乌斯反演", "杜教筛"], ["数论分块", "杜教筛"], ["分解质因数", "欧拉函数"], ["同余方程", "欧拉函数"], ["欧拉函数", "欧拉定理 & 费马小定理"], ["筛法", "洲阁筛"], ["莫比乌斯反演", "洲阁筛"], ["原根", "离散对数"], ["素数", "筛法"], ["最大公约数", "类欧几里德算法"], ["数论分块", "类欧几里德算法"], ["数论基础", "素数"], ["最大公约数", "线性同余方程"], ["同余方程", "线性同余方程"], ["容斥原理", "莫比乌斯反演"], ["欧拉函数", "莫比乌斯反演"], ["最大公约数", "裴蜀定理"], ["数论基础", "连分数"], ["概率论", "基本概念"], ["基本概念", "条件概率与独立性"], ["随机变量", "概率不等式"], ["条件概率与独立性", "随机变量"], ["随机变量", "随机变量的数字特征"], ["对角化", "Jordan标准型"], ["向量", "内积和外积"], ["矩阵", "初等变换"], ["特征多项式", "对角化"], ["行列式", "特征多项式"], ["线性映射", "特征多项式"], ["线性代数", "矩阵"], ["位运算", "线性基"], ["线性空间", "线性映射"], ["向量", "线性空间"], ["初等变换", "行列式"], ["矩阵", "单纯形算法"], ["排列组合", "Entringer Number"], ["排列组合", "Eulerian Number"], ["指数生成函数", "伯努利数"], ["排列组合", "分拆数"], ["排列组合", "卡特兰数"], ["组合数学", "图论计数"], ["Prüfer 序列", "图论计数"], ["排列组合", "容斥原理"], ["排列组合", "康托展开"], ["组合数学", "抽屉原理"], ["组合数学", "排列组合"], ["组合数学", "斐波那契数列"], ["容斥原理", "斯特林数"], ["排列组合", "范德蒙德卷积"], ["斯特林数", "贝尔数"], ["容斥原理", "错位排列"], ["置换和排列", "置换群"], ["线段树", "K-D Tree"], ["倍增", "ST 表"], ["递归 & 分治", "二叉搜索树 & 平衡树"], ["线段树", "划分树"], ["Splay 树", "动态树"], ["线段树", "区间最值操作 & 区间历史最值"], ["栈", "单调栈"], ["队列", "单调队列"], ["线段树", "可持久化数据结构"], ["链表", "哈希表"], ["前缀和 & 差分", "块状数据结构"], ["链表", "手指树"], ["线段树", "李超线段树"], ["单调栈", "析合树"], ["线段树", "树套树"], ["前缀和 & 差分", "树状数组"], ["倍增", "树状数组"], ["递归 & 分治", "线段树"], ["链表", "跳表"], ["二叉堆", "霍夫曼树"], ["二叉搜索树 & 平衡树", "2-3 树"], ["二叉搜索树 & 平衡树", "AA 树"], ["二叉搜索树 & 平衡树", "AVL 树"], ["二叉搜索树 & 平衡树", "B 树"], ["B 树", "B+ 树"], ["二叉搜索树 & 平衡树", "Leafy Tree"], ["二叉搜索树 & 平衡树", "Size Balanced Tree"], ["二叉搜索树 & 平衡树", "Splay 树"], ["二叉搜索树 & 平衡树", "Treap"], ["二叉堆", "Treap"], ["二叉搜索树 & 平衡树", "WBLT"], ["红黑树", "左偏红黑树"], ["二叉搜索树 & 平衡树", "替罪羊树"], ["二叉搜索树 & 平衡树", "笛卡尔树"], ["单调栈", "笛卡尔树"], ["二叉搜索树 & 平衡树", "红黑树"], ["动态树", "Euler Tour Tree"], ["动态树", "Link Cut Tree"], ["Link Cut Tree", "Top Tree"], ["可持久化数据结构", "可持久化可并堆"], ["左偏树", "可持久化可并堆"], ["可持久化数据结构", "可持久化块状数组"], ["块状数组", "可持久化块状数组"], ["可持久化数据结构", "可持久化字典树"], ["字典树 (Trie)", "可持久化字典树"], ["可持久化数据结构", "可持久化平衡树"], ["二叉搜索树 & 平衡树", "可持久化平衡树"], ["线段树", "可持久化线段树"], ["分块思想", "Sqrt Tree"], ["前缀和 & 差分", "分块思想"], ["分块思想", "块状数组"], ["分块思想", "块状链表"], ["链表", "块状链表"], ["分块思想", "树分块"], ["堆", "二叉堆"], ["二叉堆", "左偏树"], ["二叉堆", "配对堆"], ["并查集", "并查集复杂度"], ["树套树", "分块套树状数组"], ["分块思想", "分块套树状数组"], ["树套树", "平衡树套线段树"], ["二叉搜索树 & 平衡树", "平衡树套线段树"], ["树套树", "树状数组套权值线段树"], ["树状数组", "树状数组套权值线段树"], ["树套树", "线段树套平衡树"], ["二叉搜索树 & 平衡树", "线段树套平衡树"], ["树套树", "线段树套线段树"], ["BFS(搜索)", "15-puzzle"], ["二叉堆", "Garsia–Wachs 算法"], ["模拟", "Kahan 求和"], ["复杂度", "Kahan 求和"], ["枚举", "主元素问题"], ["二分", "分数规划"], ["枚举", "双指针"], ["贪心", "在一台机器上规划任务"], ["位运算", "字节顺序"], ["前缀和 & 差分", "悬线法"], ["位运算", "格雷码"], ["分块思想", "珂朵莉树/颜色段均摊"], ["线段树", "珂朵莉树/颜色段均摊"], ["排序", "离散化"], ["排序", "离线算法"], ["模拟", "约瑟夫问题"], ["栈", "表达式求值"], ["概率论", "随机化"], ["递归 & 分治", "CDQ 分治"], ["排序", "CDQ 分治"], ["二分", "整体二分"], ["分块思想", "莫队算法"], ["排序", "莫队算法"], ["随机化", "模拟退火"], ["随机化", "爬山算法"], ["随机化", "随机函数"], ["随机化", "随机化技巧"], ["排序", "二分"], ["复杂度", "二分"], ["二分", "倍增"], ["枚举", "前缀和 & 差分"], ["枚举", "构造"], ["排序", "贪心"], ["归并排序", "tim排序"], ["插入排序", "tim排序"], ["排序", "冒泡排序"], ["计数排序", "基数排序"], ["二叉堆", "堆排序"], ["插入排序", "希尔排序"], ["递归 & 分治", "归并排序"], ["递归 & 分治", "快速排序"], ["排序", "排序应用"], ["排序", "排序相关 STL"], ["排序", "插入排序"], ["计数排序", "桶排序"], ["排序", "计数排序"], ["排序", "选择排序"], ["排序", "锦标赛排序"], ["二维计算几何基础", "Pick 定理"], ["向量", "三维计算几何基础"], ["二维计算几何基础", "三角剖分"], ["向量", "二维计算几何基础"], ["弧度制与坐标系", "二维计算几何基础"], ["二维计算几何基础", "凸包"], ["二维计算几何基础", "半平面交"], ["二维计算几何基础", "反演变换"], ["二维计算几何基础", "平面最近点对"], ["二维计算几何基础", "扫描线"], ["凸包", "旋转卡壳"], ["二维计算几何基础", "计算几何杂项"], ["二维计算几何基础", "距离"], ["随机化", "随机增量法"]], "bands": [{"depth": 0, "count": 29, "y": 16.0, "h": 178, "rows": 2}, {"depth": 1, "count": 79, "y": 194.0, "h": 388, "rows": 5}, {"depth": 2, "count": 122, "y": 582.0, "h": 598, "rows": 8}, {"depth": 3, "count": 64, "y": 1180.0, "h": 318, "rows": 4}, {"depth": 4, "count": 36, "y": 1498.0, "h": 248, "rows": 3}, {"depth": 5, "count": 10, "y": 1746.0, "h": 108, "rows": 1}, {"depth": 6, "count": 7, "y": 1854.0, "h": 108, "rows": 1}, {"depth": 7, "count": 1, "y": 1962.0, "h": 108, "rows": 1}, {"depth": 8, "count": 1, "y": 2070.0, "h": 108, "rows": 1}], "size": [2326.5, 2194], "domainColors": {"算法基础": ["#2b7fe0", "#4aa3f7"], "数据结构": ["#0e9aa7", "#2dd4bf"], "图论": ["#6d4aca", "#a78bfa"], "动态规划": ["#d97706", "#ffa726"], "字符串": ["#16a24a", "#3fd968"], "数学": ["#c026d3", "#e879f9"], "搜索": ["#0891b2", "#22d3ee"], "计算几何": ["#e11d48", "#fb7185"], "杂项": ["#7a8fa6", "#94a3b8"], "专题": ["#b45309", "#fbbf24"]}};
|
|
654
|
+
const SVGNS = "http://www.w3.org/2000/svg";
|
|
655
|
+
const $ = (s) => document.querySelector(s);
|
|
656
|
+
const el = (t, a = {}) => { const n = document.createElementNS(SVGNS, t);
|
|
657
|
+
for (const k in a) n.setAttribute(k, a[k]); return n; };
|
|
658
|
+
const hexA = (hex, a) => { const v = parseInt(hex.slice(1), 16);
|
|
659
|
+
return `rgba(${v >> 16 & 255},${v >> 8 & 255},${v & 255},${a})`; };
|
|
660
|
+
|
|
661
|
+
/* ── 图标 ──
|
|
662
|
+
路径表抄自 VP 平台 frontend/src/components/Icon.jsx 的 ICO_PATHS。
|
|
663
|
+
规格一致:24 视口 / 1.75 线宽 / 圆头圆角 / currentColor(颜色由父级 color 决定)。
|
|
664
|
+
只有 expand 是我按同一规格补的 —— VP 那 45 个里没有「回到全图」的语义,
|
|
665
|
+
与其硬套 layers 或 target,不如照规格画一个。 */
|
|
666
|
+
const ICO = {
|
|
667
|
+
whale: '<path d="M2 14c2.5 0 3.5-2 5-2s2.5 2 5 2 3.5-2 5-2 2.5 2 5 2"/><path d="M6 9c1.5-3 4-5 6-5s4.5 2 6 5"/>',
|
|
668
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/>',
|
|
669
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
|
670
|
+
moon: '<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z"/>',
|
|
671
|
+
link: '<path d="M9.5 14.5a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7L11 7.3"/><path d="M14.5 9.5a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7L13 16.7"/>',
|
|
672
|
+
check: '<path d="m5 13 4.5 4.5L19 7"/>',
|
|
673
|
+
expand: '<path d="M9 4H4v5"/><path d="M15 4h5v5"/><path d="M15 20h5v-5"/><path d="M9 20H4v-5"/>', // ← 按规格补的
|
|
674
|
+
back: '<path d="m14.5 6-6 6 6 6"/>',
|
|
675
|
+
fwd: '<path d="m9.5 6 6 6-6 6"/>',
|
|
676
|
+
};
|
|
677
|
+
const ico = (name, size = 16) =>
|
|
678
|
+
`<svg class="vp-ico" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"`
|
|
679
|
+
+ ` stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"`
|
|
680
|
+
+ ` aria-hidden="true" focusable="false">${ICO[name] || ""}</svg>`;
|
|
681
|
+
|
|
682
|
+
const NODES = DATA.nodes, N = new Map(NODES.map(n => [n.id, n]));
|
|
683
|
+
const byId = (id) => N.get(id);
|
|
684
|
+
|
|
685
|
+
// ── 进度:从 PROGRESS.yaml 来(经 ./api/progress,相对路径)──────────
|
|
686
|
+
// 用相对路径是**故意的**:同一份 html 有两种挂法 ——
|
|
687
|
+
// acmer-coach 插件的 /coach 路由(/coach/skilltree.html → /coach/api/progress)
|
|
688
|
+
// serve_map.py 的离线预览(/skilltree.html → /api/progress)
|
|
689
|
+
// 写成绝对路径 /api/progress 就只会第二种通,在 dsh 里会打到 API 网关上。
|
|
690
|
+
// **这里绝不存第二份。** 之前标记存在 localStorage,那是两份真相:
|
|
691
|
+
// 页面显示「已学」,教练那边根本不知道。后来接上了真相源。
|
|
692
|
+
//
|
|
693
|
+
// 状态和 PROGRESS.yaml 里**逐字对齐**(别自己造词,否则又是两套词汇):
|
|
694
|
+
// none(未学) → studying(在学) → learned(学过·自评) → verified(已验证·过检测)
|
|
695
|
+
//
|
|
696
|
+
// ⚠️ CYCLE 只有**三级** —— verified 不在循环里。
|
|
697
|
+
// 手标不了它:自评不能冒充检测结论,已验证只能由 coach_grade 判卷产生。
|
|
698
|
+
// 键盘也从 1/2/3/4 改成 1/2/3。
|
|
699
|
+
// 但 verified 仍留在下面的 LABEL / COLOR 里 —— 那是**显示**用的:
|
|
700
|
+
// 节点可能真的过检测了,图上要认得出来。
|
|
701
|
+
const CYCLE = ["none", "studying", "learned"];
|
|
702
|
+
const STATUS_LABEL = { none: "未学", studying: "在学", learned: "学过", verified: "已验证" };
|
|
703
|
+
// [浅色, 深色] 两套 —— 和 VP 那边的双轨 token 一个道理,暗色下要提亮才看得清
|
|
704
|
+
const STATUS_COLOR = {
|
|
705
|
+
none: ["#9fb2c8", "#3d5470"],
|
|
706
|
+
studying: ["#d97706", "#ffa726"],
|
|
707
|
+
learned: ["#2b7fe0", "#5aa9f0"],
|
|
708
|
+
verified: ["#16a24a", "#3fd968"],
|
|
709
|
+
};
|
|
710
|
+
let progress = { cursor: "", nodes: {} };
|
|
711
|
+
const statusOf = (id) => progress.nodes?.[id]?.status ?? "none";
|
|
712
|
+
|
|
713
|
+
// ── 重点(tier)──────────────────────────────────────────────
|
|
714
|
+
// 回答的是另一个问题:地图只说「谁是谁的前置」——**允许**学什么;
|
|
715
|
+
// tier 说「这知识点重不重要」——**该不该**学。两条轴缺一不可。
|
|
716
|
+
//
|
|
717
|
+
// 颜色走 CSS 变量(--tier-*),因为色阶要跟主题联动;
|
|
718
|
+
// 但**形状通道**(skip 的虚线)在 CSS 里 —— 不能只靠颜色表达「这个不用学」。
|
|
719
|
+
const TIER_LABEL = { core: "核心", normal: "常见", rare: "少见", skip: "不考" };
|
|
720
|
+
const tierOf = (id) => byId(id)?.tier ?? "";
|
|
721
|
+
const entryOf = (id) => byId(id)?.entry ?? 0;
|
|
722
|
+
const tierColor = (t) =>
|
|
723
|
+
getComputedStyle(document.documentElement).getPropertyValue(`--tier-${t || "rare"}`).trim();
|
|
724
|
+
|
|
725
|
+
function setNote(html, isError) {
|
|
726
|
+
const el = $("#note");
|
|
727
|
+
el.innerHTML = html;
|
|
728
|
+
el.classList.toggle("err", Boolean(isError));
|
|
729
|
+
}
|
|
730
|
+
let noteTimer = null;
|
|
731
|
+
function toast(msg, isError) {
|
|
732
|
+
setNote(msg, isError);
|
|
733
|
+
clearTimeout(noteTimer);
|
|
734
|
+
noteTimer = setTimeout(showStatus, isError ? 7000 : 2000);
|
|
735
|
+
}
|
|
736
|
+
function showStatus() {
|
|
737
|
+
const all = Object.values(progress.nodes || {});
|
|
738
|
+
const k = (s) => all.filter((x) => x?.status === s).length;
|
|
739
|
+
setNote(
|
|
740
|
+
`<b>进度读自 PROGRESS.yaml</b>(点节点循环:未学 → 在学 → 学过)。<br>` +
|
|
741
|
+
`「已验证」手标不了 —— 它只能由检测产生。<br>` +
|
|
742
|
+
`游标 <code>${progress.cursor || "(未设)"}</code> ` +
|
|
743
|
+
`已验证 ${k("verified")} · 学过 ${k("learned")} · 在学 ${k("studying")}`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function loadProgress() {
|
|
748
|
+
try {
|
|
749
|
+
const r = await fetch("./api/progress");
|
|
750
|
+
const j = await r.json();
|
|
751
|
+
if (!j.ok) throw new Error(j.reject || "读进度失败");
|
|
752
|
+
progress = { cursor: j.cursor || "", nodes: j.nodes || {} };
|
|
753
|
+
showStatus();
|
|
754
|
+
} catch (e) {
|
|
755
|
+
// 读不到就**明说**,别退回「全部未学」—— 那看着像正常的空进度,
|
|
756
|
+
// 实际是接口挂了,人会以为自己的标记丢了。
|
|
757
|
+
setNote(`<b>进度读不到</b>:${e.message}<br>` +
|
|
758
|
+
`标记会失败。查:页面是从哪来的(dsh 的 /coach 路由,还是 serve_map.py),那里就是接口的提供方。`, true);
|
|
759
|
+
progress = { cursor: "", nodes: {} };
|
|
760
|
+
}
|
|
761
|
+
paint(); apply(); if (selected) panel(selected);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// 写进度:乐观更新 + 失败回滚。
|
|
765
|
+
// 点了没反应比点错了更让人困惑,所以先画再发;发失败了再退回去并说明。
|
|
766
|
+
async function postProgress(payload) {
|
|
767
|
+
const r = await fetch("./api/progress", {
|
|
768
|
+
method: "POST",
|
|
769
|
+
headers: { "Content-Type": "application/json" },
|
|
770
|
+
body: JSON.stringify(payload),
|
|
771
|
+
});
|
|
772
|
+
const j = await r.json();
|
|
773
|
+
if (!j.ok) throw new Error(j.reject || "写失败");
|
|
774
|
+
return j;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function setStatus(id, status) {
|
|
778
|
+
const before = JSON.parse(JSON.stringify(progress));
|
|
779
|
+
if (status === "none") delete progress.nodes[id];
|
|
780
|
+
else progress.nodes[id] = { ...(progress.nodes[id] || {}), status };
|
|
781
|
+
paint(); apply(); if (selected === id) panel(id);
|
|
782
|
+
try {
|
|
783
|
+
await postProgress({ op: "mark", node: id, status });
|
|
784
|
+
showStatus();
|
|
785
|
+
} catch (e) {
|
|
786
|
+
progress = before;
|
|
787
|
+
paint(); apply(); if (selected === id) panel(id);
|
|
788
|
+
toast(`<b>标记没存上</b>:${e.message}`, true);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function setCursor(id) {
|
|
793
|
+
const before = progress.cursor;
|
|
794
|
+
progress.cursor = id; paint();
|
|
795
|
+
try {
|
|
796
|
+
await postProgress({ op: "cursor", node: id });
|
|
797
|
+
showStatus();
|
|
798
|
+
} catch (e) {
|
|
799
|
+
progress.cursor = before; paint();
|
|
800
|
+
toast(`<b>游标没存上</b>:${e.message}`, true);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let mode = "domain"; // domain | state
|
|
805
|
+
let hidden = new Set(); // 被筛掉的学科
|
|
806
|
+
let query = "";
|
|
807
|
+
let selected = null;
|
|
808
|
+
// 401 条边一起画必然是面条,而且盖住节点。默认一条不画,
|
|
809
|
+
// 悬停/选中某个节点时才把「它的祖先链 + 后代链」画出来 —— 这本来就是这张图的用法。
|
|
810
|
+
let showAll = false;
|
|
811
|
+
|
|
812
|
+
/* ── 祖先/后代闭包:悬停时高亮整条链 ── */
|
|
813
|
+
const ancCache = new Map(), desCache = new Map();
|
|
814
|
+
function ancestors(id) {
|
|
815
|
+
if (ancCache.has(id)) return ancCache.get(id);
|
|
816
|
+
const out = new Set(), st = [id];
|
|
817
|
+
while (st.length) for (const p of byId(st.pop()).pre)
|
|
818
|
+
if (!out.has(p)) { out.add(p); st.push(p); }
|
|
819
|
+
ancCache.set(id, out); return out;
|
|
820
|
+
}
|
|
821
|
+
function descendants(id) {
|
|
822
|
+
if (desCache.has(id)) return desCache.get(id);
|
|
823
|
+
const out = new Set(), st = [id];
|
|
824
|
+
while (st.length) for (const s of byId(st.pop()).suc)
|
|
825
|
+
if (!out.has(s)) { out.add(s); st.push(s); }
|
|
826
|
+
desCache.set(id, out); return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/* ── 画静态结构 ── */
|
|
830
|
+
const gBands = $("#bands"), gEdges = $("#edges"), gNodes = $("#nodes");
|
|
831
|
+
const svg = $("#cv");
|
|
832
|
+
svg.setAttribute("viewBox", `0 0 ${DATA.size[0]} ${DATA.size[1]}`);
|
|
833
|
+
svg.setAttribute("width", DATA.size[0]);
|
|
834
|
+
svg.setAttribute("height", DATA.size[1]);
|
|
835
|
+
|
|
836
|
+
DATA.bands.forEach((b, i) => {
|
|
837
|
+
if (i % 2) gBands.appendChild(el("rect", { class: "band", x: 0, y: b.y,
|
|
838
|
+
width: DATA.size[0], height: b.h, rx: 0 }));
|
|
839
|
+
gBands.appendChild(el("line", { class: "bandline", x1: 24, x2: DATA.size[0] - 24, y1: b.y + 2, y2: b.y + 2 }));
|
|
840
|
+
const t = el("text", { class: "bandlabel", x: 24, y: b.y + 22 });
|
|
841
|
+
t.textContent = `L${b.depth}`;
|
|
842
|
+
gBands.appendChild(t);
|
|
843
|
+
const c = el("text", { class: "bandcount", x: 24 + 26, y: b.y + 22 });
|
|
844
|
+
c.textContent = `${b.count} 个`;
|
|
845
|
+
gBands.appendChild(c);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const nodeEls = new Map(), edgeEls = [];
|
|
849
|
+
for (const n of NODES) {
|
|
850
|
+
for (const p of n.pre) {
|
|
851
|
+
const a = byId(p), b = n;
|
|
852
|
+
const y1 = a.y + a.h, y2 = b.y, x1 = a.x + a.w / 2, x2 = b.x + b.w / 2;
|
|
853
|
+
const my = (y1 + y2) / 2;
|
|
854
|
+
const path = el("path", { class: "edge", d: `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`,
|
|
855
|
+
"marker-end": "url(#arrow)", "data-a": p, "data-b": b.id });
|
|
856
|
+
gEdges.appendChild(path);
|
|
857
|
+
edgeEls.push(path);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
for (const n of NODES) {
|
|
861
|
+
const g = el("g", { class: "node", "data-id": n.id });
|
|
862
|
+
g.appendChild(el("rect", { class: "chip", x: n.x, y: n.y, width: n.w, height: n.h, rx: 9 }));
|
|
863
|
+
g.appendChild(el("circle", { class: "ndot", cx: n.x + 15, cy: n.y + n.h / 2, r: 4.6 }));
|
|
864
|
+
const t = el("text", { x: n.x + 27, y: n.y + n.h / 2 + .5 });
|
|
865
|
+
t.textContent = n.name;
|
|
866
|
+
g.appendChild(t);
|
|
867
|
+
// ⚠️ 必须**先入文档再量**。getComputedTextLength() 在游离元素上恒返回 0,
|
|
868
|
+
// 而 `x+27+0+34 <= x+w-8` 对绝大多数节点都成立 —— 于是"量过再放"会变成
|
|
869
|
+
// "无条件全放",标签全压在名字上。这就是"验了个寂寞"。
|
|
870
|
+
gNodes.appendChild(g);
|
|
871
|
+
if (n.entry) {
|
|
872
|
+
const need = 34; // "≈2300" + 右边距
|
|
873
|
+
if (t.getComputedTextLength() + need <= n.w - 27 - 8) {
|
|
874
|
+
const e = el("text", { class: "entry", x: n.x + n.w - 10, y: n.y + n.h / 2 + .5 });
|
|
875
|
+
e.textContent = "≈" + n.entry;
|
|
876
|
+
g.appendChild(e);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
nodeEls.set(n.id, g);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/* ── 着色 ── */
|
|
883
|
+
function paint() {
|
|
884
|
+
const dark = document.documentElement.dataset.theme === "dark";
|
|
885
|
+
for (const n of NODES) {
|
|
886
|
+
const g = nodeEls.get(n.id);
|
|
887
|
+
let c;
|
|
888
|
+
const st = statusOf(n.id);
|
|
889
|
+
if (mode === "state") {
|
|
890
|
+
c = STATUS_COLOR[st][dark ? 1 : 0];
|
|
891
|
+
} else if (mode === "tier") {
|
|
892
|
+
c = tierColor(n.tier) || STATUS_COLOR.none[dark ? 1 : 0];
|
|
893
|
+
} else {
|
|
894
|
+
c = DATA.domainColors[n.domain][dark ? 1 : 0];
|
|
895
|
+
}
|
|
896
|
+
g.style.setProperty("--nc", c);
|
|
897
|
+
g.style.setProperty("--nc-fill", hexA(c, dark ? .10 : .05));
|
|
898
|
+
g.style.setProperty("--nc-s", hexA(c, dark ? .72 : .62));
|
|
899
|
+
// 状态标记在两种着色模式下都要看得见。
|
|
900
|
+
// 早先写成 mode==="state" 才加 done —— 结果在「按学科」模式点标记,屏幕上毫无反应。
|
|
901
|
+
g.classList.toggle("done", st === "learned" || st === "verified");
|
|
902
|
+
// 在学单独一个类:它和「学过」不是一回事,不能共用样式
|
|
903
|
+
g.classList.toggle("studying", st === "studying");
|
|
904
|
+
// 游标也要画出来 —— 它是「今天聚焦哪个」,和「学到什么程度」是两个维度
|
|
905
|
+
g.classList.toggle("cursor", progress.cursor === n.id);
|
|
906
|
+
// 「不考」用虚线描边,**形状通道**。它是条强判断(别浪费时间),
|
|
907
|
+
// 不该只体现在一个更浅的蓝上。
|
|
908
|
+
g.classList.toggle("tier-skip", n.tier === "skip");
|
|
909
|
+
}
|
|
910
|
+
$("#legTitle").textContent =
|
|
911
|
+
mode === "state" ? "状态(点节点切换)" : mode === "tier" ? "重点" : "学科";
|
|
912
|
+
buildLegend();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/* ── 图例 / 筛选 ── */
|
|
916
|
+
function buildLegend() {
|
|
917
|
+
const box = $("#legend"); box.innerHTML = "";
|
|
918
|
+
let entries;
|
|
919
|
+
if (mode === "state") {
|
|
920
|
+
// 图例走**状态字符串**,不再用 0/1/2 数字 —— 和 PROGRESS.yaml 一套词
|
|
921
|
+
entries = CYCLE.slice().reverse().map((k) => {
|
|
922
|
+
const label = STATUS_LABEL[k];
|
|
923
|
+
const c = STATUS_COLOR[k][document.documentElement.dataset.theme === "dark" ? 1 : 0];
|
|
924
|
+
const ct = NODES.filter((n) => statusOf(n.id) === k).length;
|
|
925
|
+
return { key: "s:" + k, label, c, ct };
|
|
926
|
+
});
|
|
927
|
+
} else if (mode === "tier") {
|
|
928
|
+
// 顺序定死 core → skip,图例本身就是优先级排序 —— 别按数量排,
|
|
929
|
+
// 那会让「不考」跑到最上面。
|
|
930
|
+
entries = ["core", "normal", "rare", "skip"].map((t) => {
|
|
931
|
+
const ct = NODES.filter((n) => n.tier === t).length;
|
|
932
|
+
return { key: "t:" + t, label: TIER_LABEL[t], c: tierColor(t), ct, dashed: t === "skip" };
|
|
933
|
+
}).filter((e) => e.ct);
|
|
934
|
+
} else {
|
|
935
|
+
const cnt = {};
|
|
936
|
+
for (const n of NODES) cnt[n.domain] = (cnt[n.domain] || 0) + 1;
|
|
937
|
+
entries = Object.keys(DATA.domainColors)
|
|
938
|
+
.filter(d => cnt[d])
|
|
939
|
+
.map(d => ({ key: "d:" + d, label: d, c: DATA.domainColors[d][0], ct: cnt[d] }));
|
|
940
|
+
}
|
|
941
|
+
for (const e of entries) {
|
|
942
|
+
const b = document.createElement("button");
|
|
943
|
+
b.setAttribute("aria-pressed", hidden.has(e.key) ? "false" : "true");
|
|
944
|
+
// skip 的色块画成虚线空心 —— 和节点上的描边同一套语言,
|
|
945
|
+
// 图例和图上长得一样才认得出。
|
|
946
|
+
const swatch = e.dashed
|
|
947
|
+
? `background:transparent;border:1.5px dashed ${e.c}`
|
|
948
|
+
: `background:${e.c}`;
|
|
949
|
+
b.innerHTML = `<span class="dot" style="${swatch}"></span>
|
|
950
|
+
<span class="nm">${e.label}</span><span class="ct">${e.ct}</span>`;
|
|
951
|
+
b.onclick = () => { hidden.has(e.key) ? hidden.delete(e.key) : hidden.add(e.key); apply(); };
|
|
952
|
+
box.appendChild(b);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/* ── 可见性 + 高亮 ── */
|
|
957
|
+
function visible(n) {
|
|
958
|
+
// 筛选键按模式分流。**三种模式各自一套键** —— 在「按重点」里点到「核心」
|
|
959
|
+
// 不该把「按学科」里的东西也筛掉,反过来也一样。
|
|
960
|
+
if (mode === "state") {
|
|
961
|
+
if (hidden.has("s:" + statusOf(n.id))) return false;
|
|
962
|
+
} else if (mode === "tier") {
|
|
963
|
+
if (hidden.has("t:" + (n.tier || "rare"))) return false;
|
|
964
|
+
} else if (hidden.has("d:" + n.domain)) { return false; }
|
|
965
|
+
if (query && !n.name.toLowerCase().includes(query)) return false;
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function apply() {
|
|
970
|
+
const shown = new Set(NODES.filter(visible).map(n => n.id));
|
|
971
|
+
for (const n of NODES) nodeEls.get(n.id).classList.toggle("hide", !shown.has(n.id));
|
|
972
|
+
for (const n of NODES) nodeEls.get(n.id).style.display = shown.has(n.id) ? "" : "none";
|
|
973
|
+
for (const p of edgeEls) {
|
|
974
|
+
p.dataset.ok = (shown.has(p.dataset.a) && shown.has(p.dataset.b)) ? "1" : "0";
|
|
975
|
+
}
|
|
976
|
+
buildLegend();
|
|
977
|
+
paintChain(selected);
|
|
978
|
+
$("#hdrCount").textContent = `${shown.size} / ${NODES.length} 个知识点`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 只在**点选之后**才画路径。
|
|
982
|
+
// 早先是悬停触发 —— 鼠标扫过整张图就闪一下,不是"看"是"晃"。
|
|
983
|
+
// 悬停的反馈交给纯 CSS(.node:hover 加粗描边),不碰别的节点、不画线。
|
|
984
|
+
function paintChain(id) {
|
|
985
|
+
const paintEdge = (e, inChain, allowed) => {
|
|
986
|
+
if (!allowed || (!inChain && !showAll)) {
|
|
987
|
+
// 隐藏时必须把类也清掉 —— 只设 display:none 会留下脏状态,
|
|
988
|
+
// 之后一开「显示全部连线」就会冒出一批旧的 hot 边。
|
|
989
|
+
e.style.display = "none";
|
|
990
|
+
e.classList.remove("hot", "dim");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
e.style.display = "";
|
|
994
|
+
e.classList.toggle("hot", inChain);
|
|
995
|
+
e.classList.toggle("dim", !inChain);
|
|
996
|
+
e.setAttribute("marker-end", inChain ? "url(#arrowHot)" : "url(#arrow)");
|
|
997
|
+
};
|
|
998
|
+
if (!id) {
|
|
999
|
+
nodeEls.forEach(g => g.classList.remove("hot", "dim"));
|
|
1000
|
+
edgeEls.forEach(e => paintEdge(e, false, e.dataset.ok === "1"));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const keep = new Set([id, ...ancestors(id), ...descendants(id)]);
|
|
1004
|
+
nodeEls.forEach((g, k) => { g.classList.toggle("hot", keep.has(k)); g.classList.toggle("dim", !keep.has(k)); });
|
|
1005
|
+
edgeEls.forEach(e => {
|
|
1006
|
+
// 这条边在链上 ⇔ 两端都在闭包里,且 a 确实是 b 的前置(方向不能反)
|
|
1007
|
+
const inChain = keep.has(e.dataset.a) && keep.has(e.dataset.b) &&
|
|
1008
|
+
ancestors(e.dataset.b).has(e.dataset.a);
|
|
1009
|
+
paintEdge(e, inChain, e.dataset.ok === "1");
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/* ── 详情面板 ── */
|
|
1014
|
+
/* 检测记录 + 判因记录 —— L5 刚落盘的两样东西。
|
|
1015
|
+
判因**按分类计数**:「不会」和「不认真」是两种病,混在一起看不出该治哪个。 */
|
|
1016
|
+
function histSection(n) {
|
|
1017
|
+
const rec = progress.nodes?.[n.id] ?? {};
|
|
1018
|
+
const checks = Array.isArray(rec.checks) ? rec.checks : [];
|
|
1019
|
+
const diags = Array.isArray(rec.diagnoses) ? rec.diagnoses : [];
|
|
1020
|
+
if (!checks.length && !diags.length) return "";
|
|
1021
|
+
const OUTCOME = { passed: "过", overTime: "超时", failed: "没过" };
|
|
1022
|
+
const out = [];
|
|
1023
|
+
if (checks.length) {
|
|
1024
|
+
out.push(`<div class="sec"><h3>检测记录(${checks.length} 次)</h3><div class="hist">`
|
|
1025
|
+
+ checks.slice(-4).reverse().map((c) =>
|
|
1026
|
+
`<div class="hrow"><b class="ho-${c.outcome}">${OUTCOME[c.outcome] ?? c.outcome}</b>`
|
|
1027
|
+
+ `<span class="hd">${c.date ?? ""}</span>`
|
|
1028
|
+
+ `<span class="hs">${c.score ?? "?"}/${c.maxScore ?? "?"} 分 · ${c.minutes ?? "?"}/${c.limit ?? "?"} 分钟</span>`
|
|
1029
|
+
+ (c.verifiedAt ? `<span class="he">验证于 ≈${c.verifiedAt}</span>` : "")
|
|
1030
|
+
+ `</div>`).join("")
|
|
1031
|
+
+ `</div></div>`);
|
|
1032
|
+
}
|
|
1033
|
+
if (diags.length) {
|
|
1034
|
+
const byCat = {};
|
|
1035
|
+
for (const d of diags) byCat[d.category] = (byCat[d.category] || 0) + 1;
|
|
1036
|
+
const tally = Object.entries(byCat).map(([k, v]) => `${k}×${v}`).join(" · ");
|
|
1037
|
+
out.push(`<div class="sec"><h3>判因记录(${diags.length} 次 ${tally})</h3><div class="hist">`
|
|
1038
|
+
+ diags.slice(-3).reverse().map((d) =>
|
|
1039
|
+
`<div class="hrow"><b class="hc">${d.category ?? ""}</b>`
|
|
1040
|
+
+ `<span class="hd">${d.date ?? ""}</span>`
|
|
1041
|
+
+ `<span class="hs" title="${(d.summary ?? "").replace(/"/g, """)}">${d.summary ?? ""}</span>`
|
|
1042
|
+
+ `</div>`).join("")
|
|
1043
|
+
+ `</div></div>`);
|
|
1044
|
+
}
|
|
1045
|
+
return out.join("");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* 距今多少天。按**本地零点**算,不是拿时间戳直接减 ——
|
|
1049
|
+
直接减的话「昨天 23:00」和「今天 09:00」差 10 小时,取整成 0 天,
|
|
1050
|
+
显示「今天」,可它明明是昨天标的。零点对齐就没这问题。 */
|
|
1051
|
+
function daysSince(dateStr) {
|
|
1052
|
+
if (!dateStr) return null;
|
|
1053
|
+
const d = new Date(dateStr + "T00:00:00");
|
|
1054
|
+
if (isNaN(d.getTime())) return null;
|
|
1055
|
+
const n = new Date();
|
|
1056
|
+
return Math.round((new Date(n.getFullYear(), n.getMonth(), n.getDate()) - d) / 86400000);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/* 「多久没碰了」的标签。
|
|
1060
|
+
rec.at 是**状态变成的时间**(不是"最后点它")—— 见 index.js 的 markStatus。
|
|
1061
|
+
没有 at 就什么都不显示:存量数据是早先标的,那时还没记这字段,
|
|
1062
|
+
下次标记会补上;在那之前显示「无日期」比不显示更让人困惑。 */
|
|
1063
|
+
function ageTag(rec) {
|
|
1064
|
+
const days = daysSince(rec && rec.at);
|
|
1065
|
+
if (days === null) return "";
|
|
1066
|
+
const label = days <= 0 ? "今天" : days === 1 ? "昨天" : `${days} 天前`;
|
|
1067
|
+
const cls = days <= 7 ? "" : days <= 30 ? " age-warm" : " age-stale";
|
|
1068
|
+
return `<span class="tag age${cls}" title="${rec.at} 变成这个状态">`
|
|
1069
|
+
+ label + (days > 30 ? " · 该复习了" : "") + `</span>`;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function panel(id) {
|
|
1073
|
+
const n = byId(id), p = $("#panel");
|
|
1074
|
+
if (!n) { p.classList.remove("on"); return; }
|
|
1075
|
+
const s = statusOf(id);
|
|
1076
|
+
const isCursor = progress.cursor === id;
|
|
1077
|
+
const chip = (x, cls = "") => `<button class="chip ${cls}" data-go="${x}">${x}</button>`;
|
|
1078
|
+
p.innerHTML = `
|
|
1079
|
+
<h2 class="ttl">${n.name}</h2>
|
|
1080
|
+
<div class="tags">
|
|
1081
|
+
<span class="tag dom">${n.domain}</span>
|
|
1082
|
+
<span class="tag st" data-st="${s}">${STATUS_LABEL[s]}</span>
|
|
1083
|
+
${ageTag(progress.nodes?.[id])}
|
|
1084
|
+
${isCursor ? '<span class="tag cur">▶ 当前游标</span>' : ''}
|
|
1085
|
+
${n.tier ? `<span class="tag" title="赛场重不重要">${TIER_LABEL[n.tier] ?? n.tier}</span>` : ''}
|
|
1086
|
+
${n.entry ? `<span class="tag" title="题池难度的 p25 —— 什么水平开始学得起">≈${n.entry}</span>` : ''}
|
|
1087
|
+
<span class="tag">L${n.depth}</span>
|
|
1088
|
+
<span class="tag">${n.pre.length} 前置 · ${n.suc.length} 后继</span>
|
|
1089
|
+
</div>
|
|
1090
|
+
${histSection(n, s)}
|
|
1091
|
+
<div class="sec"><h3>前置(学会这些才轮到它)</h3>
|
|
1092
|
+
<div class="chips">${n.pre.length ? n.pre.map(x => chip(x)).join("") : '<span class="none">无 —— 这是根节点</span>'}</div></div>
|
|
1093
|
+
<div class="sec"><h3>后继(学完它能开这些)</h3>
|
|
1094
|
+
<div class="chips">${n.suc.length ? n.suc.map(x => chip(x)).join("") : '<span class="none">无 —— 它是叶子</span>'}</div></div>
|
|
1095
|
+
<div class="acts">
|
|
1096
|
+
<button class="btn primary" id="mark" data-on="${s === "verified" ? 1 : 0}">
|
|
1097
|
+
${ico("check", 15)}${STATUS_LABEL[s]} → ${STATUS_LABEL[CYCLE[(CYCLE.indexOf(s) + 1) % CYCLE.length]]}</button>
|
|
1098
|
+
<button class="btn" id="cur" data-on="${isCursor ? 1 : 0}">
|
|
1099
|
+
${isCursor ? "▶ 就是当前游标" : "设为游标"}</button>
|
|
1100
|
+
<button class="btn" id="close">关闭</button>
|
|
1101
|
+
</div>`;
|
|
1102
|
+
p.classList.add("on");
|
|
1103
|
+
p.querySelectorAll("[data-go]").forEach(b => {
|
|
1104
|
+
b.onclick = () => select(b.dataset.go, true);
|
|
1105
|
+
});
|
|
1106
|
+
p.querySelector("#close").onclick = () => select(null);
|
|
1107
|
+
// 点一下**前进一级**,绕完一圈回未学。
|
|
1108
|
+
// 按钮上直接写出「当前 → 下一个」,省得靠记忆猜点几下到哪。
|
|
1109
|
+
p.querySelector("#mark").onclick = () => {
|
|
1110
|
+
const next = CYCLE[(CYCLE.indexOf(statusOf(id)) + 1) % CYCLE.length];
|
|
1111
|
+
setStatus(id, next);
|
|
1112
|
+
};
|
|
1113
|
+
p.querySelector("#cur").onclick = () => setCursor(id);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function select(id, fly) {
|
|
1117
|
+
selected = id;
|
|
1118
|
+
paintChain(id); // 别忘了这一行:悬停路径去掉后,画路径只剩这里
|
|
1119
|
+
panel(id);
|
|
1120
|
+
if (id && fly) focusNode(id);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/* ── 视口:缩放 / 平移 / 聚焦 ── */
|
|
1124
|
+
let vx = 0, vy = 0, vs = 1;
|
|
1125
|
+
function transform() { svg.style.transform = `translate(${vx}px,${vy}px) scale(${vs})`; }
|
|
1126
|
+
|
|
1127
|
+
// 节点造好、视口变量也就位了 —— **立刻**上色 + 定缩放,别等进度回来。
|
|
1128
|
+
//
|
|
1129
|
+
// paint():默认模式是「按学科」,不依赖进度。等 loadProgress 的话,
|
|
1130
|
+
// fetch 那几十毫秒里全部节点是 SVG 默认的黑(被截图抓到过)。
|
|
1131
|
+
// fit():地图 2336×2194,不先缩放的话就按原始尺寸铺出来 ——
|
|
1132
|
+
// 比视口大得多,看着像"页面被放大了一下",然后才缩回去。
|
|
1133
|
+
// 两个是同一个病:**把「等数据」当成了「等渲染」**,
|
|
1134
|
+
// 可数据没回来时页面并不是空白,而是用默认样式渲染出来的一份错的。
|
|
1135
|
+
//
|
|
1136
|
+
// ⚠️ 位置有讲究:必须在 `let vx/vy/vs` **之后** —— 放前面会撞 TDZ
|
|
1137
|
+
// (Cannot access 'vs' before initialization),整个主脚本当场中断,
|
|
1138
|
+
// 所有按钮全绑不上。踩过。这中间几百行都是同步的,不会有渲染帧。
|
|
1139
|
+
paint();
|
|
1140
|
+
fit();
|
|
1141
|
+
function fit(pad = 28) {
|
|
1142
|
+
const st = $("#stage").getBoundingClientRect();
|
|
1143
|
+
// 地图是竖长的(2336×2194)。早先按最短边缩 —— 结果是被高度卡住,
|
|
1144
|
+
// 字压到 5px。改成铺满宽度、纵向拖拽看;顶对齐还有个好处:
|
|
1145
|
+
// 一进来就是 L0「起点」,跟技能树的读法一致。
|
|
1146
|
+
vs = Math.max((st.width - pad * 2) / DATA.size[0], .06);
|
|
1147
|
+
const h = DATA.size[1] * vs;
|
|
1148
|
+
vx = (st.width - DATA.size[0] * vs) / 2;
|
|
1149
|
+
vy = h < st.height - pad * 2 ? (st.height - h) / 2 : pad;
|
|
1150
|
+
transform();
|
|
1151
|
+
}
|
|
1152
|
+
function focusNode(id) {
|
|
1153
|
+
const n = byId(id), st = $("#stage").getBoundingClientRect();
|
|
1154
|
+
vs = Math.max(vs, 1);
|
|
1155
|
+
vx = st.width / 2 - (n.x + n.w / 2) * vs;
|
|
1156
|
+
vy = st.height / 2 - (n.y + n.h / 2) * vs;
|
|
1157
|
+
transform();
|
|
1158
|
+
}
|
|
1159
|
+
const stage = $("#stage");
|
|
1160
|
+
stage.addEventListener("wheel", (e) => {
|
|
1161
|
+
e.preventDefault();
|
|
1162
|
+
const r = stage.getBoundingClientRect(), mx = e.clientX - r.left, my = e.clientY - r.top;
|
|
1163
|
+
const k = Math.exp(-e.deltaY * .0015), ns = Math.min(2.6, Math.max(.06, vs * k));
|
|
1164
|
+
vx = mx - (mx - vx) * (ns / vs); vy = my - (my - vy) * (ns / vs); vs = ns;
|
|
1165
|
+
transform();
|
|
1166
|
+
}, { passive: false });
|
|
1167
|
+
/* ⚠️ 这里**不能**用 stage.setPointerCapture()。
|
|
1168
|
+
指针被捕获到 stage 之后,click 的目标也跟着变成 stage,
|
|
1169
|
+
挂在节点上的 click 监听就再也不会触发 —— 真实鼠标点击节点会彻底失灵。
|
|
1170
|
+
(合成事件 dispatchEvent 绕过了原生命中测试,所以早先没测出来。)
|
|
1171
|
+
改成 document 级监听 move/up,既不丢捕获的好处,也不劫持 click 目标。 */
|
|
1172
|
+
let drag = null, dragMoved = false;
|
|
1173
|
+
|
|
1174
|
+
const onMove = (e) => {
|
|
1175
|
+
if (!drag) return;
|
|
1176
|
+
if (Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 4) dragMoved = true;
|
|
1177
|
+
vx = drag.vx + (e.clientX - drag.x); vy = drag.vy + (e.clientY - drag.y); transform();
|
|
1178
|
+
};
|
|
1179
|
+
const onUp = () => {
|
|
1180
|
+
drag = null;
|
|
1181
|
+
stage.classList.remove("dragging");
|
|
1182
|
+
document.removeEventListener("pointermove", onMove);
|
|
1183
|
+
document.removeEventListener("pointerup", onUp);
|
|
1184
|
+
document.removeEventListener("pointercancel", onUp);
|
|
1185
|
+
};
|
|
1186
|
+
stage.addEventListener("pointerdown", (e) => {
|
|
1187
|
+
if (e.target.closest(".panel") || e.button !== 0) return;
|
|
1188
|
+
e.preventDefault(); // 掐掉浏览器的「开始选择文本 / 拖拽图片」
|
|
1189
|
+
drag = { x: e.clientX, y: e.clientY, vx, vy };
|
|
1190
|
+
dragMoved = false; // 只在按下时重置 —— 要活到随后的 click 判断
|
|
1191
|
+
stage.classList.add("dragging");
|
|
1192
|
+
document.addEventListener("pointermove", onMove);
|
|
1193
|
+
document.addEventListener("pointerup", onUp);
|
|
1194
|
+
document.addEventListener("pointercancel", onUp);
|
|
1195
|
+
});
|
|
1196
|
+
// 双保险:万一还有漏网的(比如从空白处拖到画布外),直接把选区清掉
|
|
1197
|
+
document.addEventListener("selectstart", (e) => {
|
|
1198
|
+
if (e.target.closest?.(".stage")) e.preventDefault();
|
|
1199
|
+
});
|
|
1200
|
+
// 点空白 = 收起路径。dragMoved 为真说明刚拖过,那次 click 不算数。
|
|
1201
|
+
stage.addEventListener("click", (e) => {
|
|
1202
|
+
if (e.target.closest?.(".node") || e.target.closest?.(".panel") || dragMoved) return;
|
|
1203
|
+
select(null);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// 没有 pointerover/pointerout 监听 —— 悬停不触发任何重绘,鼠标划过是零成本的
|
|
1207
|
+
gNodes.addEventListener("click", (e) => {
|
|
1208
|
+
// ⚠️ dragMoved 必须在这里也查一遍。stage 那层查了,但**事件先到这一层** ——
|
|
1209
|
+
// 拖拽结束时 mousedown/mouseup 落在同一个节点上,click 就派发到那个节点,
|
|
1210
|
+
// 于是"想扒拉图"变成"又选中了一下"。stage 那层的判断救不了,因为它后到。
|
|
1211
|
+
if (dragMoved) return;
|
|
1212
|
+
const g = e.target.closest(".node"); if (g) select(g.dataset.id);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
/* 静态按钮上的图标:HTML 里只写 data-ico,路径表只此一份 */
|
|
1216
|
+
document.querySelectorAll("[data-ico]").forEach((el) => {
|
|
1217
|
+
el.innerHTML = ico(el.dataset.ico, +el.dataset.size || 16);
|
|
1218
|
+
});
|
|
1219
|
+
const syncThemeIcon = () => {
|
|
1220
|
+
const dark = document.documentElement.dataset.theme === "dark";
|
|
1221
|
+
const b = $("#theme");
|
|
1222
|
+
b.innerHTML = ico(dark ? "sun" : "moon", 16);
|
|
1223
|
+
b.title = dark ? "切到亮色" : "切到暗色";
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
/* ── 顶栏 ── */
|
|
1227
|
+
$("#modeSeg").onclick = (e) => {
|
|
1228
|
+
const b = e.target.closest("button"); if (!b) return;
|
|
1229
|
+
mode = b.dataset.mode; hidden.clear();
|
|
1230
|
+
[...e.currentTarget.children].forEach(x => x.setAttribute("aria-pressed", x === b));
|
|
1231
|
+
paint(); apply();
|
|
1232
|
+
};
|
|
1233
|
+
$("#q").oninput = (e) => { query = e.target.value.trim().toLowerCase(); apply(); };
|
|
1234
|
+
$("#wires").onclick = (e) => {
|
|
1235
|
+
showAll = !showAll;
|
|
1236
|
+
e.currentTarget.style.color = showAll ? "var(--brand)" : "";
|
|
1237
|
+
e.currentTarget.style.borderColor = showAll ? "var(--brand)" : "";
|
|
1238
|
+
paintChain(selected);
|
|
1239
|
+
};
|
|
1240
|
+
$("#fit").onclick = () => fit();
|
|
1241
|
+
$("#theme").onclick = () => {
|
|
1242
|
+
const h = document.documentElement;
|
|
1243
|
+
h.dataset.theme = h.dataset.theme === "dark" ? "light" : "dark";
|
|
1244
|
+
// 存起来,刷新后要还在(不然调成暗色一刷新又变亮)。
|
|
1245
|
+
// 内联背景也跟着切 —— 它是防透底用的,不跟就会在暗色下闪白。
|
|
1246
|
+
try { localStorage.setItem("coach.theme", h.dataset.theme); } catch (e) { /* 同上 */ }
|
|
1247
|
+
h.style.background = h.dataset.theme === "dark" ? "#071528" : "#eef5fc";
|
|
1248
|
+
paint(); syncThemeIcon();
|
|
1249
|
+
};
|
|
1250
|
+
/* ── 键盘盘点 ────────────────────────────────────────────────
|
|
1251
|
+
349 个节点靠"点节点 → 点按钮"要 700 下,标不完的。
|
|
1252
|
+
这里:选中一个节点后按 1/2/3/4 直接定级,并**自动跳到下一个** ——
|
|
1253
|
+
一路按下去就是一次盘点。
|
|
1254
|
+
|
|
1255
|
+
下一个按**图上顺序**(上到下、左到右),不是按数据顺序 ——
|
|
1256
|
+
人的眼睛在图上是这么走的,跳着走会跟丢。 */
|
|
1257
|
+
const ORDER = [...NODES].sort((a, b) => (a.y - b.y) || (a.x - b.x)).map((n) => n.id);
|
|
1258
|
+
const nextAfter = (id) => {
|
|
1259
|
+
const i = ORDER.indexOf(id);
|
|
1260
|
+
if (i < 0) return null;
|
|
1261
|
+
// 跳过一个已选中但还没标过的节点没意义,就老实取下一个;
|
|
1262
|
+
// 到末尾回绕,方便补漏。
|
|
1263
|
+
return ORDER[(i + 1) % ORDER.length];
|
|
1264
|
+
};
|
|
1265
|
+
addEventListener("keydown", (e) => {
|
|
1266
|
+
if (e.key === "Escape") { select(null); return; }
|
|
1267
|
+
// 在输入框里打字时不抢键
|
|
1268
|
+
if (e.target?.tagName === "INPUT" || e.target?.tagName === "TEXTAREA") return;
|
|
1269
|
+
if (!selected) return;
|
|
1270
|
+
|
|
1271
|
+
const idx = "123".indexOf(e.key); // 三档:verified 手标不了(见 CYCLE 上面的说明)
|
|
1272
|
+
if (idx >= 0) {
|
|
1273
|
+
e.preventDefault();
|
|
1274
|
+
// **标完不跳。** 原本按完 1-4 自动跳下一个
|
|
1275
|
+
// (本意是"一路按下去就是一次盘点"),实测反馈是「直接蹦到别的节点了」。
|
|
1276
|
+
// 标记是个**定点动作** —— 标完人还想看这个节点、还想接着扒拉图,
|
|
1277
|
+
// 工具不该自作主张把人挪走。想往前走,自己按方向键。
|
|
1278
|
+
setStatus(selected, CYCLE[idx]); // ["none","studying","learned"]
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
// 想往前走的时候自己按。方向键 / 空格 —— 图上顺序(上到下、左到右)。
|
|
1282
|
+
if (e.key === "ArrowDown" || e.key === "ArrowRight" || e.key === " ") {
|
|
1283
|
+
e.preventDefault();
|
|
1284
|
+
const to = nextAfter(selected);
|
|
1285
|
+
if (to) select(to, true);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
|
|
1289
|
+
e.preventDefault();
|
|
1290
|
+
const i = ORDER.indexOf(selected);
|
|
1291
|
+
if (i >= 0) select(ORDER[(i - 1 + ORDER.length) % ORDER.length], true);
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
addEventListener("resize", () => fit());
|
|
1295
|
+
|
|
1296
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1297
|
+
// 日程视图(L6 时间容量)
|
|
1298
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1299
|
+
// 三级数据写它们的人不同:busy = 他手标的 / planned = 教练排的(固化不重排)/
|
|
1300
|
+
// actual = 实际用时(校准真实速度的唯一来源)。
|
|
1301
|
+
//
|
|
1302
|
+
// 这个视图**只读 planned、只写 busy** —— 排块是教练的活,页面上不给入口。
|
|
1303
|
+
// 给了就会手滑改计划,那"计划"就不算数了,也没法拿它去对照实际。
|
|
1304
|
+
let sched = { days: {} };
|
|
1305
|
+
let schedAnchor = null; // 窗口起点;null = 「今天 - 3 天」
|
|
1306
|
+
let schedLoaded = false;
|
|
1307
|
+
|
|
1308
|
+
const HOUR_FROM = 8, HOUR_TO = 24; // 8:00 - 24:00,16 格
|
|
1309
|
+
const WINDOW_BACK = 3, WINDOW_DAYS = 10; // 前 3 回顾 + 后 7 可排(方针 §4.1)
|
|
1310
|
+
|
|
1311
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
1312
|
+
const ymdOf = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
1313
|
+
const ymdToday = () => ymdOf(new Date());
|
|
1314
|
+
|
|
1315
|
+
function schedStart() {
|
|
1316
|
+
if (schedAnchor) return new Date(`${schedAnchor}T00:00:00`);
|
|
1317
|
+
const t = new Date();
|
|
1318
|
+
const d = new Date(t.getFullYear(), t.getMonth(), t.getDate());
|
|
1319
|
+
d.setDate(d.getDate() - WINDOW_BACK);
|
|
1320
|
+
return d;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/* ── 时间 ↔ 坐标 ──
|
|
1324
|
+
只有两个刻度:日期用 "YYYY-MM-DD" 字符串比(定长同格式,字典序=时间序),
|
|
1325
|
+
列内位置用「离 8:00 多少分钟」。别再引入第三种刻度(比如像素直接参与运算),
|
|
1326
|
+
多一层转换就多一处能写错的地方。 */
|
|
1327
|
+
const TOTAL_MIN = (HOUR_TO - HOUR_FROM) * 60;
|
|
1328
|
+
// 落点吸附粒度:**5 分钟**。
|
|
1329
|
+
// 第一版是 15 分钟,太粗了(「粒度不够,能不能 1/5 分钟一档」)——
|
|
1330
|
+
// 他的时间不是按刻钟走的(13:20 下课、19:05 开始都常见),
|
|
1331
|
+
// 15 分钟一档会逼他要么多标要么少标。5 分钟已经比手表的分钟刻度还细,
|
|
1332
|
+
// 而列高 400+px 下每档约 2px,鼠标能停住 —— 再细就没有可操作性了。
|
|
1333
|
+
const SNAP = 5;
|
|
1334
|
+
|
|
1335
|
+
// 块型短名。**必须和 host 的 SCHED_KINDS[k].name 逐字一致** ——
|
|
1336
|
+
// 不一致时页面会拿 id 兜底显示(不会崩),但那块看着就像坏了。
|
|
1337
|
+
// host 是真相源,这里只管显示。verify.mjs 有一条盯着这个一致性。
|
|
1338
|
+
const KIND_NAME = { cycle: "题目循环", review: "复习一题", exam: "章节检测" };
|
|
1339
|
+
|
|
1340
|
+
const minToHM = (m) => {
|
|
1341
|
+
const t = HOUR_FROM * 60 + m;
|
|
1342
|
+
return `${pad2(Math.floor(t / 60))}:${pad2(t % 60)}`;
|
|
1343
|
+
};
|
|
1344
|
+
const hmToMin = (s) => {
|
|
1345
|
+
const p = String(s ?? "").split(":");
|
|
1346
|
+
const h = Number(p[0]), m = Number(p[1]);
|
|
1347
|
+
return Number.isFinite(h) && Number.isFinite(m) ? (h - HOUR_FROM) * 60 + m : null;
|
|
1348
|
+
};
|
|
1349
|
+
/** 列内 y 像素 → 分钟(吸附 + 夹在 [0, TOTAL_MIN]) */
|
|
1350
|
+
const yToMin = (y, colH) =>
|
|
1351
|
+
Math.max(0, Math.min(TOTAL_MIN, Math.round(((y / colH) * TOTAL_MIN) / SNAP) * SNAP));
|
|
1352
|
+
const pct = (min) => `${(min / TOTAL_MIN) * 100}%`;
|
|
1353
|
+
|
|
1354
|
+
/* 事由 → 颜色档。要求「不要全一个黄」。
|
|
1355
|
+
走**自动配色**而不是让他手选:同一种事永远同一个色(一致性靠函数保证,
|
|
1356
|
+
不靠他每次都记得选对),而且他少一步操作。
|
|
1357
|
+
顺序有讲究 —— 先匹配的赢:"打比赛"要判成比赛,不能被别的词抢走。 */
|
|
1358
|
+
/* ⚠️ 顺序 = 优先级,**越具体的越靠前**。
|
|
1359
|
+
踩过的坑:标成「排练和宣讲」这类含"赛"字的,会被 red 抢走判成比赛 ——
|
|
1360
|
+
可那是宣讲不是比赛,颜色误导。所以"宣讲/排练"必须排在"赛"前面。
|
|
1361
|
+
关键词是照**真实会写的词**收的(课表 / 午睡 / 陪…):
|
|
1362
|
+
第一版按想当然写"课""睡觉",真实用词一个都没命中,全掉进兜底 ——
|
|
1363
|
+
那看着就跟随机配色一样。 */
|
|
1364
|
+
const EVENT_TAGS = [
|
|
1365
|
+
["violet", ["宣讲", "排练", "社团", "活动", "讲座", "开会", "例会", "会议", "值班"]],
|
|
1366
|
+
["red", ["比赛", "模拟赛", "训练赛", "vp", "cf", "codeforces", "icpc", "测评"]],
|
|
1367
|
+
// "上"兜住「上高数」这种不写"课"字的课表 —— 口语里就是这么写的
|
|
1368
|
+
["blue", ["上课", "课", "实验", "作业", "考试", "自习", "复习", "上"]],
|
|
1369
|
+
["cyan", ["兼职", "工作", "实习", "项目", "面试"]],
|
|
1370
|
+
["teal", ["球", "运动", "健身", "跑步", "锻炼", "体测"]],
|
|
1371
|
+
["slate", ["饭", "睡", "休息", "玩", "游戏", "洗澡", "陪"]],
|
|
1372
|
+
];
|
|
1373
|
+
|
|
1374
|
+
function colorOf(label) {
|
|
1375
|
+
const s = String(label ?? "").trim();
|
|
1376
|
+
if (!s) return "";
|
|
1377
|
+
const low = s.toLowerCase();
|
|
1378
|
+
for (const [c, keys] of EVENT_TAGS) {
|
|
1379
|
+
if (keys.some((k) => low.includes(k))) return c;
|
|
1380
|
+
}
|
|
1381
|
+
// 兜底:按文字算一个**稳定**的档位(同输入同输出),而且**只从蓝青系里挑** ——
|
|
1382
|
+
// 兜底色要是乱跳,整体观感立刻退回"随机配色"(第一版就是这么翻车的)。
|
|
1383
|
+
let h = 0;
|
|
1384
|
+
for (const ch of s) h = (h * 31 + ch.codePointAt(0)) % 997;
|
|
1385
|
+
return ["blue", "teal", "slate"][h % 3];
|
|
1386
|
+
}
|
|
1387
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) =>
|
|
1388
|
+
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
1389
|
+
|
|
1390
|
+
function renderSched() {
|
|
1391
|
+
const start = schedStart(), today = ymdToday();
|
|
1392
|
+
const days = [];
|
|
1393
|
+
for (let i = 0; i < WINDOW_DAYS; i++) {
|
|
1394
|
+
const d = new Date(start);
|
|
1395
|
+
d.setDate(d.getDate() + i);
|
|
1396
|
+
days.push(d);
|
|
1397
|
+
}
|
|
1398
|
+
$("#schedSpan").textContent = `${ymdOf(days[0])} ~ ${ymdOf(days[WINDOW_DAYS - 1])}`;
|
|
1399
|
+
|
|
1400
|
+
const DOW = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
|
1401
|
+
const parts = ['<div class="corner"></div>'];
|
|
1402
|
+
for (const d of days) {
|
|
1403
|
+
const key = ymdOf(d);
|
|
1404
|
+
const cls = key === today ? " today" : key < today ? " past" : "";
|
|
1405
|
+
parts.push(`<div class="dh${cls}"><div class="dow">${DOW[d.getDay()]}</div>` +
|
|
1406
|
+
`<div class="dnum">${d.getMonth() + 1}/${d.getDate()}</div></div>`);
|
|
1407
|
+
}
|
|
1408
|
+
// 左侧整点标签:现在它是**唯一的纵向定位依据**了(列里不放横线)。
|
|
1409
|
+
// 不画最后一个(24:00)—— 它正好压在底边上,标签会被切掉一半。
|
|
1410
|
+
parts.push('<div class="stimes">');
|
|
1411
|
+
for (let h = HOUR_FROM; h < HOUR_TO; h++) {
|
|
1412
|
+
parts.push(`<span style="top:${((h - HOUR_FROM) / (HOUR_TO - HOUR_FROM)) * 100}%">` +
|
|
1413
|
+
`${pad2(h)}:00</span>`);
|
|
1414
|
+
}
|
|
1415
|
+
parts.push("</div>");
|
|
1416
|
+
// 每天一列。busy 画成**连续块**不是拼格子 —— 拼的话接缝处会有 1px 缝。
|
|
1417
|
+
for (const d of days) {
|
|
1418
|
+
const key = ymdOf(d);
|
|
1419
|
+
const day = sched.days[key] ?? {};
|
|
1420
|
+
const bars = (day.busy ?? []).map((b, i) => {
|
|
1421
|
+
const a = hmToMin(b.from), z = hmToMin(b.to);
|
|
1422
|
+
if (a === null || z === null || z <= a) return "";
|
|
1423
|
+
const has = String(b.label ?? "").trim().length > 0;
|
|
1424
|
+
const col = colorOf(b.label);
|
|
1425
|
+
const mins = z - a;
|
|
1426
|
+
// 三档:够高放两行 / 中等放一行 / 太矮不放字(信息挂 title)
|
|
1427
|
+
const size = mins >= 75 ? "s-full" : mins >= 40 ? "s-mid" : "s-mini";
|
|
1428
|
+
const body = size === "s-full"
|
|
1429
|
+
? `<span class="t">${esc(b.from)}–${esc(b.to)}</span>${has ? esc(b.label) : "—"}`
|
|
1430
|
+
: size === "s-mid"
|
|
1431
|
+
? (has ? esc(b.label) : esc(b.from))
|
|
1432
|
+
: "";
|
|
1433
|
+
// title 里永远放**完整**信息 —— 小块的文字虽然不显示,
|
|
1434
|
+
// 但鼠标停上去能看全,这是它不丢信息的兜底。
|
|
1435
|
+
const tip = `${b.from}–${b.to}${has ? ` ${b.label}` : "(没写事由)"}`;
|
|
1436
|
+
return `<div class="bbar ${size}${has ? (col ? ` c-${col}` : "") : " nolabel"}" ` +
|
|
1437
|
+
`data-i="${i}" title="${esc(tip)}" style="top:${pct(a)};height:${pct(mins)}">` +
|
|
1438
|
+
`${body}</div>`;
|
|
1439
|
+
}).join("");
|
|
1440
|
+
// planned:教练排的块,**只读**(页面上不给改动入口,理由见上面 CSS 那段)。
|
|
1441
|
+
// 画在 busy 之后,同位置时压在忙事上面 —— 正常情况两者不重叠(工具排块时
|
|
1442
|
+
// 会拦),真压上了说明数据是手改进去的,那时候看得见比看不见强。
|
|
1443
|
+
const plans = (day.planned ?? []).map((b) => {
|
|
1444
|
+
const a = hmToMin(b.from), z = hmToMin(b.to);
|
|
1445
|
+
if (a === null || z === null || z <= a) return "";
|
|
1446
|
+
const mins = z - a;
|
|
1447
|
+
const kind = String(b.kind ?? "");
|
|
1448
|
+
// 地图改过名、甚至删过节点,而计划是历史数据 ——
|
|
1449
|
+
// 查不到名字就退回 id,**不能因为查不到就不画**(那等于计划凭空消失)。
|
|
1450
|
+
const nm = byId(b.node)?.name ?? String(b.node ?? "");
|
|
1451
|
+
const size = mins >= 75 ? "s-full" : mins >= 40 ? "s-mid" : "s-mini";
|
|
1452
|
+
// 中等高度(40~74 分钟,循环块正好 60)挤不下两行,但**时间要留住** ——
|
|
1453
|
+
// busy 那边中档只放事由就够(他自己知道标了什么),
|
|
1454
|
+
// planned 不一样:这是要他**照着执行**的,"几点开始"就是那条执行信息。
|
|
1455
|
+
// 长了会被 nowrap 裁掉,全名挂在 title 上。
|
|
1456
|
+
const body = size === "s-full"
|
|
1457
|
+
? `<span class="t">${esc(b.from)}–${esc(b.to)}</span>${esc(nm)}`
|
|
1458
|
+
: size === "s-mid" ? `${esc(b.from)} ${esc(nm)}` : "";
|
|
1459
|
+
const tip = `${b.from}–${b.to} ${KIND_NAME[kind] ?? kind} ${nm}` +
|
|
1460
|
+
(kind === "cycle" ? " (40 自己做 → 10 解决遗留 → 10 重写)" : "");
|
|
1461
|
+
return `<div class="pbar k-${esc(kind)} ${size}" title="${esc(tip)}" ` +
|
|
1462
|
+
`style="top:${pct(a)};height:${pct(mins)}">${body}</div>`;
|
|
1463
|
+
}).join("");
|
|
1464
|
+
// 列里**只有内容,没有分隔线**(那些横线是明确要求去掉的)。
|
|
1465
|
+
// 定位靠左边的时间标签 + 悬停跟随线。
|
|
1466
|
+
const cls = key === today ? " today" : key < today ? " past" : "";
|
|
1467
|
+
parts.push(`<div class="dcol${cls}" data-date="${key}">${bars}${plans}</div>`);
|
|
1468
|
+
}
|
|
1469
|
+
$("#schedGrid").innerHTML = parts.join("");
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/* ── 拖出一段「有事」──────────────────────────────────────────────
|
|
1473
|
+
交互:按住 → 拖 → 松手弹框写事由,落点吸附 15 分钟。
|
|
1474
|
+
|
|
1475
|
+
为什么不做格子点选(第一版就是那样,被否了):1 小时的分块太粗了 ——
|
|
1476
|
+
人的时间不是按整点切的,用格子会逼你要么多标要么少标
|
|
1477
|
+
(13:20 下课,格子只能点 13:00 或 14:00)。拖就没有这个问题。 */
|
|
1478
|
+
let schedDrag = null;
|
|
1479
|
+
|
|
1480
|
+
$("#schedGrid").addEventListener("pointerdown", (e) => {
|
|
1481
|
+
const col = e.target.closest?.(".dcol");
|
|
1482
|
+
if (!col) return;
|
|
1483
|
+
if (e.target.closest(".bbar")) return; // 点已有的块走编辑,不拖
|
|
1484
|
+
if (col.dataset.date < ymdToday()) return; // 回顾期只读
|
|
1485
|
+
const r = col.getBoundingClientRect();
|
|
1486
|
+
const m = yToMin(e.clientY - r.top, r.height);
|
|
1487
|
+
const el = document.createElement("div");
|
|
1488
|
+
el.className = "selbar";
|
|
1489
|
+
el.style.top = pct(m);
|
|
1490
|
+
el.style.height = pct(SNAP);
|
|
1491
|
+
col.appendChild(el);
|
|
1492
|
+
schedDrag = { col, date: col.dataset.date, a: m, b: m, el };
|
|
1493
|
+
col.setPointerCapture?.(e.pointerId);
|
|
1494
|
+
e.preventDefault();
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
/* 悬停指示:一条横贯全宽的细线 + 跟着鼠标的时间标签。
|
|
1498
|
+
踩过「鼠标放上去看不到位置的时间,很容易选错」——
|
|
1499
|
+
刻度线只说明"整点在哪",不回答"我现在会选到哪一分钟"。 */
|
|
1500
|
+
const hoverLine = document.createElement("div");
|
|
1501
|
+
hoverLine.className = "hoverline";
|
|
1502
|
+
hoverLine.hidden = true;
|
|
1503
|
+
const hoverLabel = document.createElement("div");
|
|
1504
|
+
hoverLabel.className = "hoverlabel";
|
|
1505
|
+
hoverLabel.hidden = true;
|
|
1506
|
+
document.body.append(hoverLine, hoverLabel);
|
|
1507
|
+
const hideHover = () => { hoverLine.hidden = true; hoverLabel.hidden = true; };
|
|
1508
|
+
|
|
1509
|
+
/** 把指示线放到某列的第 m 分钟,标签跟着鼠标。
|
|
1510
|
+
* 文字由调用方给 —— 悬停时给单个时刻("会选到哪"),
|
|
1511
|
+
* 拖拽时给区间("起止到哪",因为那时候他已经知道起点了,
|
|
1512
|
+
* 关心的是松手会落在哪一分钟、总共多长)。 */
|
|
1513
|
+
function showHoverAt(rect, m, e, text) {
|
|
1514
|
+
const g = $("#schedGrid").getBoundingClientRect();
|
|
1515
|
+
hoverLine.hidden = false;
|
|
1516
|
+
hoverLine.style.left = `${g.left}px`;
|
|
1517
|
+
hoverLine.style.width = `${g.width}px`;
|
|
1518
|
+
hoverLine.style.top = `${rect.top + (m / TOTAL_MIN) * rect.height}px`;
|
|
1519
|
+
hoverLabel.hidden = false;
|
|
1520
|
+
hoverLabel.textContent = text;
|
|
1521
|
+
hoverLabel.style.left = `${e.clientX + 14}px`;
|
|
1522
|
+
hoverLabel.style.top = `${e.clientY - 10}px`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
$("#schedGrid").addEventListener("pointermove", (e) => {
|
|
1526
|
+
if (schedDrag) {
|
|
1527
|
+
// 拖动中:预览条画出"多长",标签画出"起止到哪"。
|
|
1528
|
+
// ⚠️ 一开始这里是把指示**收起来**的(怕两套线看花眼)——
|
|
1529
|
+
// 踩过:「按下鼠标后时间标又不存在了,不知道准备放开的时间是多少」。
|
|
1530
|
+
// 拖动时才是最需要它的时候,收起来正好反了。
|
|
1531
|
+
const r = schedDrag.col.getBoundingClientRect();
|
|
1532
|
+
schedDrag.b = yToMin(e.clientY - r.top, r.height);
|
|
1533
|
+
const a = Math.min(schedDrag.a, schedDrag.b), z = Math.max(schedDrag.a, schedDrag.b);
|
|
1534
|
+
schedDrag.el.style.top = pct(a);
|
|
1535
|
+
schedDrag.el.style.height = pct(Math.max(z - a, SNAP));
|
|
1536
|
+
showHoverAt(r, z, e, `${minToHM(a)} – ${minToHM(z)}`);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const col = e.target.closest?.(".dcol");
|
|
1540
|
+
if (!col || col.dataset.date < ymdToday()) { hideHover(); return; } // 回顾期不给指示
|
|
1541
|
+
const r = col.getBoundingClientRect();
|
|
1542
|
+
const m = yToMin(e.clientY - r.top, r.height);
|
|
1543
|
+
showHoverAt(r, m, e, minToHM(m));
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
$("#schedGrid").addEventListener("pointerleave", hideHover);
|
|
1547
|
+
|
|
1548
|
+
$("#schedGrid").addEventListener("pointerup", (e) => {
|
|
1549
|
+
if (!schedDrag) return;
|
|
1550
|
+
const { col, date, el } = schedDrag;
|
|
1551
|
+
const a = Math.min(schedDrag.a, schedDrag.b), z = Math.max(schedDrag.a, schedDrag.b);
|
|
1552
|
+
schedDrag = null;
|
|
1553
|
+
el.remove();
|
|
1554
|
+
if (z - a < SNAP) return; // 只是点了一下,没拖出区间 —— 什么都不做
|
|
1555
|
+
openPop(date, a, z, null, e.clientX, e.clientY);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
/* 点已有的块 → 改事由 / 删掉 */
|
|
1559
|
+
$("#schedGrid").addEventListener("click", (e) => {
|
|
1560
|
+
const bar = e.target.closest(".bbar");
|
|
1561
|
+
if (!bar) return;
|
|
1562
|
+
const col = bar.closest(".dcol");
|
|
1563
|
+
if (!col || col.dataset.date < ymdToday()) return;
|
|
1564
|
+
const b = (sched.days[col.dataset.date]?.busy ?? [])[Number(bar.dataset.i)];
|
|
1565
|
+
if (!b) return;
|
|
1566
|
+
const a = hmToMin(b.from), z = hmToMin(b.to);
|
|
1567
|
+
if (a === null || z === null) return;
|
|
1568
|
+
openPop(col.dataset.date, a, z, Number(bar.dataset.i), e.clientX, e.clientY);
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
/* ── 写「什么事」的小浮层 ─────────────────────────────────────────
|
|
1572
|
+
事由不是装饰:**它是给小鲸看的**。小鲸靠它知道"这段为什么不能用",
|
|
1573
|
+
而不是只看到一个光秃秃的忙碌标记 —— 那样它排程时只能当黑箱。 */
|
|
1574
|
+
let popEl = null;
|
|
1575
|
+
const closePop = () => { popEl?.remove(); popEl = null; };
|
|
1576
|
+
|
|
1577
|
+
function openPop(date, aMin, zMin, idx, px, py) {
|
|
1578
|
+
closePop();
|
|
1579
|
+
const cur = idx === null ? null : ((sched.days[date]?.busy ?? [])[idx] ?? null);
|
|
1580
|
+
const mins = zMin - aMin;
|
|
1581
|
+
const dur = mins >= 60
|
|
1582
|
+
? `${(mins / 60).toFixed(mins % 60 ? 1 : 0)} 小时`
|
|
1583
|
+
: `${mins} 分钟`;
|
|
1584
|
+
const el = document.createElement("div");
|
|
1585
|
+
el.className = "pop";
|
|
1586
|
+
el.innerHTML =
|
|
1587
|
+
`<div class="pt">${date} ${minToHM(aMin)} – ${minToHM(zMin)}` +
|
|
1588
|
+
`<span style="color:var(--text-4);font-weight:500"> ${dur}</span></div>` +
|
|
1589
|
+
`<input type="text" maxlength="40" placeholder="什么事?(上课 / 开会 / 打比赛…)" ` +
|
|
1590
|
+
`value="${esc(cur?.label ?? "")}">` +
|
|
1591
|
+
`<div class="prow">` +
|
|
1592
|
+
(cur ? `<button class="del">删除</button>` : "") +
|
|
1593
|
+
`<button class="cancel">取消</button><button class="pri ok">保存</button>` +
|
|
1594
|
+
`</div>`;
|
|
1595
|
+
document.body.appendChild(el);
|
|
1596
|
+
popEl = el;
|
|
1597
|
+
const w = 236, h = el.offsetHeight || 132;
|
|
1598
|
+
el.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, (px ?? innerWidth / 2) - w / 2))}px`;
|
|
1599
|
+
el.style.top = `${Math.max(8, Math.min(innerHeight - h - 8, (py ?? 200) + 10))}px`;
|
|
1600
|
+
|
|
1601
|
+
const inp = el.querySelector("input");
|
|
1602
|
+
inp.focus();
|
|
1603
|
+
inp.select();
|
|
1604
|
+
|
|
1605
|
+
const commit = () => {
|
|
1606
|
+
const list = [...(sched.days[date]?.busy ?? [])];
|
|
1607
|
+
const entry = { from: minToHM(aMin), to: minToHM(zMin), label: inp.value.trim() };
|
|
1608
|
+
if (cur) list[idx] = entry; else list.push(entry);
|
|
1609
|
+
closePop();
|
|
1610
|
+
saveBusy(date, list);
|
|
1611
|
+
};
|
|
1612
|
+
el.querySelector(".ok").onclick = commit;
|
|
1613
|
+
el.querySelector(".cancel").onclick = closePop;
|
|
1614
|
+
el.querySelector(".del")?.addEventListener("click", () => {
|
|
1615
|
+
const list = [...(sched.days[date]?.busy ?? [])];
|
|
1616
|
+
list.splice(idx, 1);
|
|
1617
|
+
closePop();
|
|
1618
|
+
saveBusy(date, list);
|
|
1619
|
+
});
|
|
1620
|
+
inp.addEventListener("keydown", (ev) => {
|
|
1621
|
+
if (ev.key === "Enter") { ev.preventDefault(); commit(); }
|
|
1622
|
+
else if (ev.key === "Escape") { ev.preventDefault(); closePop(); }
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// 点浮层外面关掉它(和面板/弹层的习惯一致)
|
|
1627
|
+
document.addEventListener("pointerdown", (e) => {
|
|
1628
|
+
if (popEl && !popEl.contains(e.target)) closePop();
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
/* 乐观更新 + 失败回滚 —— 点了没反应比点错了更让人困惑。 */
|
|
1632
|
+
async function saveBusy(date, list) {
|
|
1633
|
+
const before = JSON.parse(JSON.stringify(sched));
|
|
1634
|
+
const sorted = [...list].sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
1635
|
+
const day = { ...(sched.days[date] ?? {}) };
|
|
1636
|
+
if (sorted.length) day.busy = sorted; else delete day.busy;
|
|
1637
|
+
if (Object.keys(day).length) sched.days[date] = day; else delete sched.days[date];
|
|
1638
|
+
renderSched();
|
|
1639
|
+
try {
|
|
1640
|
+
const r = await fetch("./api/schedule", {
|
|
1641
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1642
|
+
body: JSON.stringify({ date, field: "busy", value: sorted }),
|
|
1643
|
+
});
|
|
1644
|
+
const j = await r.json();
|
|
1645
|
+
if (!j.ok) throw new Error(j.reject || "写失败");
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
sched = before;
|
|
1648
|
+
renderSched();
|
|
1649
|
+
setNote(`<b>没存上</b>:${err.message}`, true);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async function loadSchedule() {
|
|
1654
|
+
try {
|
|
1655
|
+
const r = await fetch("./api/schedule");
|
|
1656
|
+
const j = await r.json();
|
|
1657
|
+
if (!j.ok) throw new Error(j.reject || "读日程失败");
|
|
1658
|
+
sched = { days: j.days || {} };
|
|
1659
|
+
} catch (e) {
|
|
1660
|
+
// 读不到就明说,别退回"全空"装作没事 —— 那看着像"你没标过"
|
|
1661
|
+
setNote(`<b>日程读不到</b>:${e.message}`, true);
|
|
1662
|
+
sched = { days: {} };
|
|
1663
|
+
}
|
|
1664
|
+
renderSched();
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/* ⚠️ 视图状态要写进**地址栏**,不能只改内存。
|
|
1668
|
+
踩过:「点日程以后刷新,又回到技能树了」——
|
|
1669
|
+
原来 setView 只改 DOM,刷新就丢。地址栏是唯一真相,刷新/收藏/分享都靠它。
|
|
1670
|
+
(用 replaceState 不用 pushState:切视图不该污染后退历史,
|
|
1671
|
+
按一下后退跳回"上一个视图"而不是"上一页"会让人莫名其妙。) */
|
|
1672
|
+
function setView(v) {
|
|
1673
|
+
document.body.dataset.view = v;
|
|
1674
|
+
document.querySelectorAll("#viewSeg button").forEach((b) =>
|
|
1675
|
+
b.setAttribute("aria-pressed", String(b.dataset.view === v)));
|
|
1676
|
+
const want = v === "sched" ? "#sched" : "";
|
|
1677
|
+
if ((location.hash || "") !== want) {
|
|
1678
|
+
history.replaceState(null, "", want || location.pathname + location.search);
|
|
1679
|
+
}
|
|
1680
|
+
if (v === "sched") {
|
|
1681
|
+
if (!schedLoaded) { schedLoaded = true; loadSchedule(); } else renderSched();
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
$("#viewSeg").addEventListener("click", (e) => {
|
|
1686
|
+
const b = e.target.closest("button[data-view]");
|
|
1687
|
+
if (b) setView(b.dataset.view);
|
|
1688
|
+
});
|
|
1689
|
+
$("#schedPrev").onclick = () => { const s = schedStart(); s.setDate(s.getDate() - 3); schedAnchor = ymdOf(s); renderSched(); };
|
|
1690
|
+
$("#schedNext").onclick = () => { const s = schedStart(); s.setDate(s.getDate() + 3); schedAnchor = ymdOf(s); renderSched(); };
|
|
1691
|
+
$("#schedToday").onclick = () => { schedAnchor = null; renderSched(); };
|
|
1692
|
+
|
|
1693
|
+
// 先把进度拉回来再画 —— 不然会先闪一屏「全部未学」,然后才跳成真实状态。
|
|
1694
|
+
// 那一下闪烁会让人以为自己之前标的没存上。
|
|
1695
|
+
loadProgress().then(() => {
|
|
1696
|
+
paint(); apply(); fit(); syncThemeIcon();
|
|
1697
|
+
// 刷新后停在原来的视图 —— 地址栏是唯一真相(读它,别读内存)
|
|
1698
|
+
if (location.hash === "#sched") setView("sched");
|
|
1699
|
+
});
|
|
1700
|
+
</script>
|
|
1701
|
+
</body>
|
|
1702
|
+
</html>
|