weflow-cli 1.15.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/OPERATIONS.md +16 -1
  3. package/README.en.md +21 -2
  4. package/README.md +34 -6
  5. package/bin/weflow-cli.ts +389 -6
  6. package/dist/bin/weflow-cli.js +422 -8
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +47 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +24 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/assistantService.d.ts +8 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +47 -8
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +8 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +67 -11
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/configService.d.ts.map +1 -1
  21. package/dist/src/services/configService.js +3 -2
  22. package/dist/src/services/configService.js.map +1 -1
  23. package/dist/src/services/cursorProbe.d.ts +44 -0
  24. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  25. package/dist/src/services/cursorProbe.js +183 -0
  26. package/dist/src/services/cursorProbe.js.map +1 -0
  27. package/dist/src/services/qchatImport.d.ts +89 -0
  28. package/dist/src/services/qchatImport.d.ts.map +1 -0
  29. package/dist/src/services/qchatImport.js +273 -0
  30. package/dist/src/services/qchatImport.js.map +1 -0
  31. package/dist/src/services/setupVenv.d.ts +43 -0
  32. package/dist/src/services/setupVenv.d.ts.map +1 -0
  33. package/dist/src/services/setupVenv.js +198 -0
  34. package/dist/src/services/setupVenv.js.map +1 -0
  35. package/dist/src/utils/python.d.ts +10 -0
  36. package/dist/src/utils/python.d.ts.map +1 -1
  37. package/dist/src/utils/python.js +27 -4
  38. package/dist/src/utils/python.js.map +1 -1
  39. package/docs/DATA_CONTRACT.md +25 -0
  40. package/docs/DECISIONS.md +92 -30
  41. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  42. package/docs/HEALTH-CHECK.md +104 -104
  43. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  44. package/docs/NEXT_ACTIONS.md +236 -236
  45. package/docs/NPM-PUBLISH.md +130 -130
  46. package/docs/POSITIONING.md +68 -68
  47. package/docs/PROJECT_STATE.md +6 -3
  48. package/docs/REPLAY.md +169 -0
  49. package/docs/SYNC_CONTRACT.md +197 -180
  50. package/package.json +89 -88
  51. package/requirements-3x.txt +4 -4
  52. package/requirements-voice.txt +15 -15
  53. package/resources/js/graph3d/NOTICE.txt +35 -35
  54. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  55. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  56. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  57. package/resources/js/graph3d/d3-octree.min.js +2 -2
  58. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  59. package/resources/js/graph3d/d3-timer.min.js +2 -2
  60. package/resources/js/graph3d/three.min.js +6 -6
  61. package/resources/js/graph3d/viewer2d.js +550 -448
  62. package/resources/panel/quick-menu.cjs +197 -197
  63. package/scripts/_utils.py +1338 -1338
  64. package/scripts/article_notes.py +423 -423
  65. package/scripts/bonds.py +11 -0
  66. package/scripts/chat_notes.py +86 -3
  67. package/scripts/check_readme_commands.py +427 -426
  68. package/scripts/contact_schema.py +339 -339
  69. package/scripts/create_reading_notes.py +11 -11
  70. package/scripts/daily_task.ps1 +213 -177
  71. package/scripts/fav_notes.py +321 -321
  72. package/scripts/graph_2d.py +156 -151
  73. package/scripts/graph_3d.py +672 -646
  74. package/scripts/health_check.py +473 -473
  75. package/scripts/imported_chats.py +68 -0
  76. package/scripts/install_tasks.ps1 +105 -91
  77. package/scripts/leads.py +179 -0
  78. package/scripts/network.py +206 -206
  79. package/scripts/nt_decrypt.py +135 -1
  80. package/scripts/on_this_day.py +178 -178
  81. package/scripts/opinion_timeline.py +138 -138
  82. package/scripts/promises.py +185 -185
  83. package/scripts/replay.py +803 -0
  84. package/scripts/run_hidden.vbs +25 -0
  85. package/scripts/semantic_search.py +73 -5
  86. package/scripts/user_notes.py +315 -315
  87. package/scripts/voice_transcribe.py +139 -139
  88. package/scripts/watch_issues.py +212 -212
  89. package/scripts/watch_mail.py +131 -131
  90. package/src/core/ntCore.ts +59 -0
  91. package/src/services/assistantService.ts +48 -8
  92. package/src/services/assistantTools.ts +73 -10
  93. package/src/services/configService.ts +3 -2
  94. package/src/services/cursorProbe.ts +222 -0
  95. package/src/services/qchatImport.ts +327 -0
  96. package/src/services/setupVenv.ts +238 -0
  97. package/src/services/syncService.ts +363 -363
  98. package/src/services/syncState.ts +275 -275
  99. package/src/services/wechatMedia.ts +166 -166
  100. package/src/utils/python.ts +33 -4
@@ -1,448 +1,550 @@
1
- // 2D 图谱视图(canvas)。数据与坐标都是构建期内联进来的,这里只负责画与交互。
2
- //
3
- // 为什么是 canvas 而不是 SVG/DOM:5 万个点用元素画,光建元素就要几秒 —— 这和 Obsidian 全局图谱
4
- // 在十万级上吃力的原因同类。这里每帧只做三件事:一次批量描线、按线分三批批量填点、一小批文字。
5
- // 视口外的点与线直接跳过(所以缩放到任何一级,画的都只是眼前这些)。
6
- //
7
- // 坐标分两套,别混:**世界坐标**(构建期算出来的那套,成千上万)用来画点线,套在 context 变换里;
8
- // **屏幕坐标**用来写字与命中判断。第一版把两套混着用,图形会被画到屏幕外。
9
- //
10
- // 标签只给度数最高的一小批:5 万个名字全画上去就是一团墨,什么也读不出来。
11
- (() => {
12
- 'use strict'
13
-
14
- const data = window.__DATA__ || { nodes: [], links: [] }
15
- const nodes = data.nodes
16
- const N = nodes.length
17
- const flat = String(window.__POS__ || '').split(',')
18
- const X = new Float64Array(N)
19
- const Y = new Float64Array(N)
20
- for (let i = 0; i < N; i += 1) {
21
- X[i] = Number(flat[2 * i]) || 0
22
- Y[i] = Number(flat[2 * i + 1]) || 0
23
- }
24
-
25
- const index = new Map()
26
- for (let i = 0; i < N; i += 1) index.set(nodes[i].id, i)
27
-
28
- // **画给人看的一律用 `name`**:主键是 `线:名字`(两条线各有一张同名页时要分开),
29
- // 直接把主键印在画布上,两个同名概念会像叫「wiki:DeepSeek」「chat:DeepSeek」。
30
- const LINE_LABELS = data.lineLabels || {}
31
- const label = (n) => n.name || n.id
32
- const lineLabel = (n) => LINE_LABELS[n.line] || n.line || ''
33
-
34
- const edges = []
35
- for (const pair of data.links) {
36
- const a = index.get(pair[0])
37
- const b = index.get(pair[1])
38
- if (a === undefined || b === undefined) continue // 悬空边(上游过滤过,这里再兜一次)
39
- edges.push(a, b)
40
- }
41
- const E = Int32Array.from(edges)
42
- const adj = new Array(N)
43
- for (let i = 0; i < N; i += 1) adj[i] = []
44
- for (let k = 0; k < E.length; k += 2) {
45
- adj[E[k]].push(E[k + 1])
46
- adj[E[k + 1]].push(E[k])
47
- }
48
-
49
- const bucketOf = (line) => (line === 'wiki' || line === 'chat' ? line : 'other')
50
- const LINE_COLOR = { wiki: '#6ea8fe', chat: '#f0a35e', other: '#8b98b3' }
51
-
52
- // **按线预排一次**:每帧就能一趟扫完。不为分色把 5 万个点扫三遍 —— 省下的是纯遍历时间。
53
- const ORDER = new Int32Array(N)
54
- for (let i = 0; i < N; i += 1) ORDER[i] = i
55
- ORDER.sort((a, b) => bucketOf(nodes[a].line).localeCompare(bucketOf(nodes[b].line)))
56
- // 排序后同一条线的点连成一段,扫一遍就能得到三个桶的边界(顺序与上面的字典序一致)
57
- const BUCKET_LINE = ['chat', 'other', 'wiki']
58
- const BUCKET = [0, 0, 0, N]
59
- {
60
- let cursor = 0
61
- for (let b = 0; b < 3; b += 1) {
62
- BUCKET[b] = cursor
63
- while (cursor < N && bucketOf(nodes[ORDER[cursor]].line) === BUCKET_LINE[b]) cursor += 1
64
- }
65
- BUCKET[3] = N
66
- }
67
-
68
- // 边的**世界坐标**中位长度:乘上当前缩放就是屏幕上的长度。缩到看不见时直接不画边 ——
69
- // 一屏 14 万条线糊成一片,没有信息量,却要花掉每帧四成的时间。
70
- const MEDIAN_EDGE = (() => {
71
- if (E.length === 0) return 0
72
- const total = E.length / 2
73
- const step = Math.max(1, Math.floor(total / 2000))
74
- const lens = []
75
- for (let k = 0; k < E.length; k += 2 * step) {
76
- lens.push(Math.hypot(X[E[k]] - X[E[k + 1]], Y[E[k]] - Y[E[k + 1]]))
77
- }
78
- lens.sort((a, b) => a - b)
79
- return lens[lens.length >> 1] || 0
80
- })()
81
- const EDGE_MIN_PX = 2 // 屏幕上短于这么多像素的边就别画了
82
-
83
- // 度数最高的一批画名字(其余只在鼠标指着或点开时显示)
84
- const labelled = nodes
85
- .map((_n, i) => i)
86
- .sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))
87
- .slice(0, 80)
88
- const labelledSet = new Set(labelled)
89
- const LABEL_MAX = 40 // 一屏最多这么多名字,超了就前面的优先(还得互相避让,见 draw 里那段)
90
-
91
- const canvas = document.getElementById('cv')
92
- const hudStats = document.getElementById('stats')
93
- const hudInfo = document.getElementById('info')
94
- const boot = document.getElementById('boot')
95
- const q = document.getElementById('q')
96
- const HINT = '拖动平移 · 滚轮缩放 · 点一个概念看它连谁 · 双击空白复位'
97
-
98
- let ctx = null
99
- try {
100
- ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null
101
- } catch (_error) {
102
- ctx = null
103
- }
104
- if (!ctx) {
105
- if (boot) boot.textContent = '这个浏览器给不出 2D 画布,图谱无法渲染'
106
- return
107
- }
108
-
109
- const dpr = Math.min(2, window.devicePixelRatio || 1)
110
- const view = { scale: 1, x: 0, y: 0 } // 世界 → 屏幕:sx = view.x + X * scale
111
- let hovered = -1
112
- let focus = -1
113
- let neighbours = null
114
-
115
- const cssW = () => canvas.clientWidth || window.innerWidth || 800
116
- const cssH = () => canvas.clientHeight || window.innerHeight || 600
117
-
118
- function escapeHtml(text) {
119
- return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
120
- }
121
-
122
- function resize() {
123
- const w = cssW()
124
- const h = cssH()
125
- // **画布尺寸与 CSS 尺寸必须一起设**:canvas 是替换元素,`position:fixed; inset:0` 不会
126
- // 让它铺满(它保持固有的 300x150)—— 只设 width/height(后备缓冲)而 CSS 盒还是 300x150 的话,
127
- // `clientWidth` 就一直是 300,整张图会被算进左上角那一小块。第一版就是这么错的。
128
- canvas.style.width = `${w}px`
129
- canvas.style.height = `${h}px`
130
- canvas.width = Math.floor(w * dpr)
131
- canvas.height = Math.floor(h * dpr)
132
- draw()
133
- }
134
-
135
- function fit() {
136
- let minX = Infinity
137
- let minY = Infinity
138
- let maxX = -Infinity
139
- let maxY = -Infinity
140
- for (let i = 0; i < N; i += 1) {
141
- if (X[i] < minX) minX = X[i]
142
- if (X[i] > maxX) maxX = X[i]
143
- if (Y[i] < minY) minY = Y[i]
144
- if (Y[i] > maxY) maxY = Y[i]
145
- }
146
- if (!Number.isFinite(minX)) {
147
- view.scale = 1
148
- view.x = cssW() / 2
149
- view.y = cssH() / 2
150
- draw()
151
- return
152
- }
153
- const pad = 28
154
- view.scale = Math.max(1e-6, Math.min((cssW() - pad * 2) / Math.max(1, maxX - minX),
155
- (cssH() - pad * 2) / Math.max(1, maxY - minY)))
156
- view.x = cssW() / 2 - ((minX + maxX) / 2) * view.scale
157
- view.y = cssH() / 2 - ((minY + maxY) / 2) * view.scale
158
- draw()
159
- }
160
-
161
- /** 屏幕坐标 → 命中的节点下标(找不到是 -1)。 */
162
- function pick(px, py) {
163
- const s = view.scale
164
- const wx = (px - view.x) / s
165
- const wy = (py - view.y) / s
166
- const tol = 7 / s
167
- let best = -1
168
- let bestD = tol * tol
169
- for (let i = 0; i < N; i += 1) {
170
- const dx = X[i] - wx
171
- const dy = Y[i] - wy
172
- const d = dx * dx + dy * dy
173
- if (d <= bestD) {
174
- bestD = d
175
- best = i
176
- }
177
- }
178
- return best
179
- }
180
-
181
- function draw() {
182
- const w = cssW()
183
- const h = cssH()
184
- const s = view.scale
185
- const ox = view.x
186
- const oy = view.y
187
-
188
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
189
- ctx.clearRect(0, 0, w, h)
190
-
191
- // 视口在世界坐标里的范围(多留一点余量,免得边缘的点被切掉半个)
192
- const pad = 4 / s
193
- const x0 = -ox / s - pad
194
- const y0 = -oy / s - pad
195
- const x1 = (w - ox) / s + pad
196
- const y1 = (h - oy) / s + pad
197
-
198
- // ---- 世界坐标:点与线 ----
199
- ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * ox, dpr * oy)
200
-
201
- // 聚焦时把无关的点线压暗(不是藏起来):结构还在,但一眼看得出它连的是谁
202
- const dimOthers = focus >= 0
203
- const near = (i) => !dimOthers || i === focus || (neighbours !== null && neighbours.has(i))
204
-
205
- // 屏幕上的边短到看不清时,整批不画(见 MEDIAN_EDGE 那段)
206
- const drawEdges = MEDIAN_EDGE * s >= EDGE_MIN_PX
207
- ctx.lineWidth = 0.7 / s
208
- ctx.strokeStyle = dimOthers ? 'rgba(96,116,152,0.10)' : 'rgba(96,116,152,0.26)'
209
- ctx.beginPath()
210
- for (let k = 0; drawEdges && k < E.length; k += 2) {
211
- const i = E[k]
212
- const j = E[k + 1]
213
- if (dimOthers && !(near(i) && near(j))) continue
214
- const xi = X[i]
215
- const yi = Y[i]
216
- const xj = X[j]
217
- const yj = Y[j]
218
- if ((xi < x0 && xj < x0) || (xi > x1 && xj > x1)) continue
219
- if ((yi < y0 && yj < y0) || (yi > y1 && yj > y1)) continue
220
- ctx.moveTo(xi, yi)
221
- ctx.lineTo(xj, yj)
222
- }
223
- ctx.stroke()
224
-
225
- // 一趟扫完(ORDER 已按线排好,每段一个桶一次 fill),而不是为了分色把全部点扫三遍
226
- for (let b = 0; b < 3; b += 1) {
227
- const from = BUCKET[b]
228
- const to = BUCKET[b + 1]
229
- if (from === to) continue
230
- ctx.beginPath()
231
- ctx.fillStyle = LINE_COLOR[BUCKET_LINE[b]]
232
- let any = false
233
- for (let k = from; k < to; k += 1) {
234
- const i = ORDER[k]
235
- if (X[i] < x0 || X[i] > x1 || Y[i] < y0 || Y[i] > y1) continue
236
- if (dimOthers && !near(i)) continue
237
- const deg = nodes[i].deg || 0
238
- const r = (i === focus ? 4 : deg >= 50 ? 2.6 : deg >= 10 ? 1.9 : 1.4) / s
239
- // **屏幕上小到看不出圆角时用方块**:实测 5 万个方块 3.4ms、5 万个圆 9.0ms。
240
- // 缩到最远时每个点不到 2px,方块与圆在这里没有任何视觉差别。
241
- if (r * s < 2) ctx.rect(X[i] - r, Y[i] - r, r * 2, r * 2)
242
- else {
243
- ctx.moveTo(X[i] + r, Y[i])
244
- ctx.arc(X[i], Y[i], r, 0, Math.PI * 2)
245
- }
246
- any = true
247
- }
248
- if (any) ctx.fill()
249
- }
250
-
251
- // ---- 屏幕坐标:名字 ----
252
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
253
- ctx.font = '11px "Segoe UI","Microsoft YaHei",system-ui,sans-serif'
254
- ctx.textAlign = 'left'
255
- ctx.textBaseline = 'middle'
256
- // 只遍历"要画名字的那一小批",而不是全部 5 万个(以前每帧都要扫 N 次 Set 查询)
257
- const extra = []
258
- if (hovered >= 0 && !labelledSet.has(hovered)) extra.push(hovered)
259
- if (focus >= 0 && focus !== hovered && !labelledSet.has(focus)) extra.push(focus)
260
- // **聚焦时的候选换成"它自己 + 它连着的那些"**:其余点都压暗了,给它们画名字只会糊住
261
- // 中间那个。邻居按度数从高到低(`LABEL_MAX` 截断时先给连接多的),所以同一张图每次
262
- // 画出来的名字是一样的。
263
- //
264
- // 这里原来写的是"聚焦就只画 `extra`",而 `extra` 只装**不在度数最高那批里的**点 ——
265
- // 于是点开一个枢纽(小图里就是点开任何一个点)画布上**一个名字都没有**,正好与上面
266
- // "一眼看得出它连的是谁"相反(2026-10-11 修)。
267
- const focusLabels = []
268
- if (dimOthers) {
269
- focusLabels.push(focus)
270
- if (neighbours) {
271
- for (const j of [...neighbours].sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))) {
272
- focusLabels.push(j)
273
- }
274
- }
275
- }
276
- // **互相压住的名字谁也别画**(贪心避让):度数最高的那批全是枢纽,它们本来就挤在中心,
277
- // 早先那版把它们全画出来,正中间是一团白字,等于没有标签。鼠标指着/点开的那两个永远画。
278
- const placed = []
279
- let shownLabels = 0
280
- for (const i of dimOthers ? focusLabels.concat(extra) : labelled.concat(extra)) {
281
- const sx = ox + X[i] * s
282
- const sy = oy + Y[i] * s
283
- if (sx < -40 || sy < -20 || sx > w + 40 || sy > h + 20) continue
284
- const forced = i === hovered || i === focus
285
- if (!forced) {
286
- if (shownLabels >= LABEL_MAX) continue
287
- let clash = false
288
- for (let p = 0; p < placed.length; p += 1) {
289
- if (Math.abs(placed[p][0] - sx) < 52 && Math.abs(placed[p][1] - sy) < 13) {
290
- clash = true
291
- break
292
- }
293
- }
294
- if (clash) continue
295
- }
296
- placed.push([sx, sy])
297
- shownLabels += 1
298
- ctx.fillStyle = forced ? '#ffffff' : 'rgba(201,211,230,0.78)'
299
- ctx.fillText(label(nodes[i]), sx + 6, sy)
300
- }
301
- }
302
-
303
- // **每帧最多画一次。** 拖动、滚轮、悬停原本是每个事件都直接 draw();事件比一帧还密时
304
- // (拖动时一秒几百个 pointermove)画布就一直在重画,一次 12.8ms —— 操作会"粘住",这才是
305
- // 它最初卡的主因。合并成每帧一次之后,帧时间就是那一次 draw 的时间,交互立刻顺。
306
- const RAF = (typeof window.requestAnimationFrame === 'function')
307
- ? (fn) => window.requestAnimationFrame(fn)
308
- : (fn) => setTimeout(fn, 16)
309
- let scheduled = false
310
- function scheduleDraw() {
311
- if (scheduled) return
312
- scheduled = true
313
- RAF(() => {
314
- scheduled = false
315
- draw()
316
- })
317
- }
318
-
319
- function setFocus(i) {
320
- focus = i
321
- if (i >= 0) {
322
- neighbours = new Set(adj[i])
323
- if (hudInfo) {
324
- const names = [...neighbours].slice(0, 6).map((j) => label(nodes[j])).join('、')
325
- const more = neighbours.size > 6 ? ' 等' : ''
326
- hudInfo.innerHTML = `<b>${escapeHtml(label(nodes[i]))}</b> · ${escapeHtml(lineLabel(nodes[i]))} · 连 ${neighbours.size} 个(${escapeHtml(names)}${more})`
327
- }
328
- } else {
329
- neighbours = null
330
- if (hudInfo) hudInfo.textContent = HINT
331
- }
332
- scheduleDraw()
333
- }
334
-
335
- // ---- 交互 ----
336
- let dragging = false
337
- let lastX = 0
338
- let lastY = 0
339
- let moved = 0
340
-
341
- canvas.addEventListener('pointerdown', (event) => {
342
- if (event.button !== 0) return
343
- dragging = true
344
- moved = 0
345
- lastX = event.clientX
346
- lastY = event.clientY
347
- if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId)
348
- })
349
- canvas.addEventListener('pointermove', (event) => {
350
- if (dragging) {
351
- const dx = event.clientX - lastX
352
- const dy = event.clientY - lastY
353
- moved += Math.abs(dx) + Math.abs(dy)
354
- lastX = event.clientX
355
- lastY = event.clientY
356
- view.x += dx
357
- view.y += dy
358
- scheduleDraw()
359
- return
360
- }
361
- const rect = canvas.getBoundingClientRect()
362
- const hit = pick(event.clientX - rect.left, event.clientY - rect.top)
363
- if (hit !== hovered) {
364
- hovered = hit
365
- canvas.style.cursor = hit >= 0 ? 'pointer' : 'grab'
366
- scheduleDraw()
367
- }
368
- })
369
- canvas.addEventListener('pointerup', (event) => {
370
- if (!dragging) return
371
- dragging = false
372
- if (moved > 3) return // 拖过就是平移,不算点选
373
- const rect = canvas.getBoundingClientRect()
374
- setFocus(pick(event.clientX - rect.left, event.clientY - rect.top))
375
- })
376
- canvas.addEventListener('dblclick', () => setFocus(-1))
377
- canvas.addEventListener('pointerleave', () => {
378
- if (hovered >= 0) {
379
- hovered = -1
380
- scheduleDraw()
381
- }
382
- })
383
- canvas.addEventListener('wheel', (event) => {
384
- event.preventDefault()
385
- const rect = canvas.getBoundingClientRect()
386
- const px = event.clientX - rect.left
387
- const py = event.clientY - rect.top
388
- const next = Math.max(0.02, Math.min(60, view.scale * Math.exp(-event.deltaY * 0.0015)))
389
- const k = next / view.scale
390
- view.x = px - (px - view.x) * k
391
- view.y = py - (py - view.y) * k
392
- view.scale = next
393
- scheduleDraw()
394
- }, { passive: false })
395
-
396
- if (q) {
397
- q.addEventListener('keydown', (event) => {
398
- if (event.key !== 'Enter') return
399
- const needle = q.value.trim().toLowerCase()
400
- if (!needle) return
401
- // 搜 `name`(主键也认,方便精确指定某一条线)。同名两页都在时**两个都算命中**,
402
- // 跳连接更多的那张并把另一条线也说出来 —— 合并视图里这正是要看见的东西。
403
- const exact = []
404
- for (let i = 0; i < N; i += 1) {
405
- if (label(nodes[i]).toLowerCase() === needle || nodes[i].id.toLowerCase() === needle) exact.push(i)
406
- }
407
- let pool = exact
408
- if (!pool.length) {
409
- pool = []
410
- for (let i = 0; i < N; i += 1) {
411
- if (label(nodes[i]).toLowerCase().includes(needle)) pool.push(i)
412
- }
413
- }
414
- if (!pool.length) {
415
- if (hudInfo) hudInfo.textContent = `没找到「${q.value.trim()}」`
416
- return
417
- }
418
- pool.sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))
419
- const hit = pool[0]
420
- view.scale = Math.max(view.scale, 2.5)
421
- view.x = cssW() / 2 - X[hit] * view.scale
422
- view.y = cssH() / 2 - Y[hit] * view.scale
423
- setFocus(hit)
424
- if (pool.length > 1 && hudInfo) {
425
- hudInfo.innerHTML += `<div>两条线都有「${escapeHtml(label(nodes[hit]))}」:${escapeHtml(pool.map((i) => lineLabel(nodes[i])).join('、'))}</div>`
426
- }
427
- })
428
- }
429
-
430
- if (hudStats) {
431
- let wiki = 0
432
- let chat = 0
433
- for (let i = 0; i < N; i += 1) {
434
- if (nodes[i].line === 'wiki') wiki += 1
435
- else if (nodes[i].line === 'chat') chat += 1
436
- }
437
- hudStats.textContent = `${N.toLocaleString('en-US')} 个概念 · ${(E.length / 2).toLocaleString('en-US')} 条链接`
438
- + ` · ${LINE_LABELS.wiki || 'wiki'} ${wiki.toLocaleString('en-US')} / ${LINE_LABELS.chat || 'chat'} ${chat.toLocaleString('en-US')}`
439
- }
440
- if (hudInfo) hudInfo.textContent = HINT
441
- if (boot) boot.style.display = 'none'
442
-
443
- window.addEventListener('resize', resize)
444
- resize()
445
- fit()
446
- // 给冒烟测试一个把手:能问它"画了多少个点、多少条线"
447
- window.__GRAPH2D__ = { nodes: N, edges: E.length / 2, draw, fit, view }
448
- })()
1
+ // 2D 图谱视图(canvas)。数据与坐标都是构建期内联进来的,这里只负责画与交互。
2
+ //
3
+ // 为什么是 canvas 而不是 SVG/DOM:5 万个点用元素画,光建元素就要几秒 —— 这和 Obsidian 全局图谱
4
+ // 在十万级上吃力的原因同类。这里每帧只做三件事:一次批量描线、按线分三批批量填点、一小批文字。
5
+ // 视口外的点与线直接跳过(所以缩放到任何一级,画的都只是眼前这些)。
6
+ //
7
+ // 坐标分两套,别混:**世界坐标**(构建期算出来的那套,成千上万)用来画点线,套在 context 变换里;
8
+ // **屏幕坐标**用来写字与命中判断。第一版把两套混着用,图形会被画到屏幕外。
9
+ //
10
+ // 标签只给度数最高的一小批:5 万个名字全画上去就是一团墨,什么也读不出来。
11
+ (() => {
12
+ 'use strict'
13
+
14
+ const data = window.__DATA__ || { nodes: [], links: [] }
15
+ const nodes = data.nodes
16
+ const N = nodes.length
17
+ const flat = String(window.__POS__ || '').split(',')
18
+ const X = new Float64Array(N)
19
+ const Y = new Float64Array(N)
20
+ for (let i = 0; i < N; i += 1) {
21
+ X[i] = Number(flat[2 * i]) || 0
22
+ Y[i] = Number(flat[2 * i + 1]) || 0
23
+ }
24
+
25
+ const index = new Map()
26
+ for (let i = 0; i < N; i += 1) index.set(nodes[i].id, i)
27
+
28
+ // **画给人看的一律用 `name`**:主键是 `线:名字`(两条线各有一张同名页时要分开),
29
+ // 直接把主键印在画布上,两个同名概念会像叫「wiki:DeepSeek」「chat:DeepSeek」。
30
+ const LINE_LABELS = data.lineLabels || {}
31
+ const label = (n) => n.name || n.id
32
+ const lineLabel = (n) => LINE_LABELS[n.line] || n.line || ''
33
+
34
+ const edges = []
35
+ for (const pair of data.links) {
36
+ const a = index.get(pair[0])
37
+ const b = index.get(pair[1])
38
+ if (a === undefined || b === undefined) continue // 悬空边(上游过滤过,这里再兜一次)
39
+ edges.push(a, b)
40
+ }
41
+ const E = Int32Array.from(edges)
42
+ const adj = new Array(N)
43
+ for (let i = 0; i < N; i += 1) adj[i] = []
44
+ for (let k = 0; k < E.length; k += 2) {
45
+ adj[E[k]].push(E[k + 1])
46
+ adj[E[k + 1]].push(E[k])
47
+ }
48
+
49
+ // ---- 边证据(D-033:图上一条边就是"这两个概念有关系"的判断,判断要能对回出处) ----
50
+ // `id|id`(两侧排序后拼;构建侧保证了 `|` 不会出现在名字里)→ [共同来源数, 名字…最多 2 个]。
51
+ // 没有共同来源的边**不在这张表里**:那种边来自页面正文里的 [[链接]](相关概念/跨线回退),
52
+ // 前端照样能说、说得诚实——比揣一个空列表冒充"查过了"强。
53
+ const EV = new Map(Object.entries(data.edgeEvidence || {}))
54
+ const evKey = (i, j) => (nodes[i].id < nodes[j].id
55
+ ? `${nodes[i].id}|${nodes[j].id}`
56
+ : `${nodes[j].id}|${nodes[i].id}`)
57
+
58
+ const bucketOf = (line) => (line === 'wiki' || line === 'chat' ? line : 'other')
59
+ const LINE_COLOR = { wiki: '#6ea8fe', chat: '#f0a35e', other: '#8b98b3' }
60
+
61
+ // **按线预排一次**:每帧就能一趟扫完。不为分色把 5 万个点扫三遍 —— 省下的是纯遍历时间。
62
+ const ORDER = new Int32Array(N)
63
+ for (let i = 0; i < N; i += 1) ORDER[i] = i
64
+ ORDER.sort((a, b) => bucketOf(nodes[a].line).localeCompare(bucketOf(nodes[b].line)))
65
+ // 排序后同一条线的点连成一段,扫一遍就能得到三个桶的边界(顺序与上面的字典序一致)
66
+ const BUCKET_LINE = ['chat', 'other', 'wiki']
67
+ const BUCKET = [0, 0, 0, N]
68
+ {
69
+ let cursor = 0
70
+ for (let b = 0; b < 3; b += 1) {
71
+ BUCKET[b] = cursor
72
+ while (cursor < N && bucketOf(nodes[ORDER[cursor]].line) === BUCKET_LINE[b]) cursor += 1
73
+ }
74
+ BUCKET[3] = N
75
+ }
76
+
77
+ // 边的**世界坐标**中位长度:乘上当前缩放就是屏幕上的长度。缩到看不见时直接不画边 ——
78
+ // 一屏 14 万条线糊成一片,没有信息量,却要花掉每帧四成的时间。
79
+ const MEDIAN_EDGE = (() => {
80
+ if (E.length === 0) return 0
81
+ const total = E.length / 2
82
+ const step = Math.max(1, Math.floor(total / 2000))
83
+ const lens = []
84
+ for (let k = 0; k < E.length; k += 2 * step) {
85
+ lens.push(Math.hypot(X[E[k]] - X[E[k + 1]], Y[E[k]] - Y[E[k + 1]]))
86
+ }
87
+ lens.sort((a, b) => a - b)
88
+ return lens[lens.length >> 1] || 0
89
+ })()
90
+ const EDGE_MIN_PX = 2 // 屏幕上短于这么多像素的边就别画了
91
+
92
+ // 度数最高的一批画名字(其余只在鼠标指着或点开时显示)
93
+ const labelled = nodes
94
+ .map((_n, i) => i)
95
+ .sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))
96
+ .slice(0, 80)
97
+ const labelledSet = new Set(labelled)
98
+ const LABEL_MAX = 40 // 一屏最多这么多名字,超了就前面的优先(还得互相避让,见 draw 里那段)
99
+
100
+ const canvas = document.getElementById('cv')
101
+ const hudStats = document.getElementById('stats')
102
+ const hudInfo = document.getElementById('info')
103
+ const boot = document.getElementById('boot')
104
+ const q = document.getElementById('q')
105
+ const HINT = '拖动平移 · 滚轮缩放 · 点一个概念看它连谁 · 点一条边看它凭什么连着 · 双击空白复位'
106
+
107
+ let ctx = null
108
+ try {
109
+ ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null
110
+ } catch (_error) {
111
+ ctx = null
112
+ }
113
+ if (!ctx) {
114
+ if (boot) boot.textContent = '这个浏览器给不出 2D 画布,图谱无法渲染'
115
+ return
116
+ }
117
+
118
+ const dpr = Math.min(2, window.devicePixelRatio || 1)
119
+ const view = { scale: 1, x: 0, y: 0 } // 世界 → 屏幕:sx = view.x + X * scale
120
+ let hovered = -1
121
+ let focus = -1
122
+ let neighbours = null
123
+ // 聚焦的边(E 里的偏移 k,端点 E[k]/E[k+1])。点概念与点边互斥:后点的清掉先点的。
124
+ let edgeFocus = -1
125
+
126
+ const cssW = () => canvas.clientWidth || window.innerWidth || 800
127
+ const cssH = () => canvas.clientHeight || window.innerHeight || 600
128
+
129
+ function escapeHtml(text) {
130
+ return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
131
+ }
132
+
133
+ function resize() {
134
+ const w = cssW()
135
+ const h = cssH()
136
+ // **画布尺寸与 CSS 尺寸必须一起设**:canvas 是替换元素,`position:fixed; inset:0` 不会
137
+ // 让它铺满(它保持固有的 300x150)—— 只设 width/height(后备缓冲)而 CSS 盒还是 300x150 的话,
138
+ // `clientWidth` 就一直是 300,整张图会被算进左上角那一小块。第一版就是这么错的。
139
+ canvas.style.width = `${w}px`
140
+ canvas.style.height = `${h}px`
141
+ canvas.width = Math.floor(w * dpr)
142
+ canvas.height = Math.floor(h * dpr)
143
+ draw()
144
+ }
145
+
146
+ function fit() {
147
+ let minX = Infinity
148
+ let minY = Infinity
149
+ let maxX = -Infinity
150
+ let maxY = -Infinity
151
+ for (let i = 0; i < N; i += 1) {
152
+ if (X[i] < minX) minX = X[i]
153
+ if (X[i] > maxX) maxX = X[i]
154
+ if (Y[i] < minY) minY = Y[i]
155
+ if (Y[i] > maxY) maxY = Y[i]
156
+ }
157
+ if (!Number.isFinite(minX)) {
158
+ view.scale = 1
159
+ view.x = cssW() / 2
160
+ view.y = cssH() / 2
161
+ draw()
162
+ return
163
+ }
164
+ const pad = 28
165
+ view.scale = Math.max(1e-6, Math.min((cssW() - pad * 2) / Math.max(1, maxX - minX),
166
+ (cssH() - pad * 2) / Math.max(1, maxY - minY)))
167
+ view.x = cssW() / 2 - ((minX + maxX) / 2) * view.scale
168
+ view.y = cssH() / 2 - ((minY + maxY) / 2) * view.scale
169
+ draw()
170
+ }
171
+
172
+ /** 屏幕坐标 → 命中的节点下标(找不到是 -1)。 */
173
+ function pick(px, py) {
174
+ const s = view.scale
175
+ const wx = (px - view.x) / s
176
+ const wy = (py - view.y) / s
177
+ const tol = 7 / s
178
+ let best = -1
179
+ let bestD = tol * tol
180
+ for (let i = 0; i < N; i += 1) {
181
+ const dx = X[i] - wx
182
+ const dy = Y[i] - wy
183
+ const d = dx * dx + dy * dy
184
+ if (d <= bestD) {
185
+ bestD = d
186
+ best = i
187
+ }
188
+ }
189
+ return best
190
+ }
191
+
192
+ /** 点到线段的距离(屏幕坐标)。 */
193
+ function segDist(px, py, x1, y1, x2, y2) {
194
+ const dx = x2 - x1
195
+ const dy = y2 - y1
196
+ const len2 = dx * dx + dy * dy
197
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2))
198
+ return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
199
+ }
200
+
201
+ /**
202
+ * 屏幕坐标 → 命中的边(E 里的偏移 k;找不到 -1)。只在边**画得出来**时命中
203
+ * (与 draw 里同一道缩放门槛):看不见的边被"点中",用户会以为图坏了。
204
+ * 整趟 O(边数),但只在点击时跑一次。
205
+ */
206
+ function nearestEdge(px, py, tol = 5) {
207
+ if (MEDIAN_EDGE * view.scale < EDGE_MIN_PX) return -1
208
+ const s = view.scale
209
+ const ox = view.x
210
+ const oy = view.y
211
+ let best = -1
212
+ let bestD = tol
213
+ for (let k = 0; k < E.length; k += 2) {
214
+ const d = segDist(px, py,
215
+ ox + X[E[k]] * s, oy + Y[E[k]] * s,
216
+ ox + X[E[k + 1]] * s, oy + Y[E[k + 1]] * s)
217
+ if (d <= bestD) {
218
+ bestD = d
219
+ best = k
220
+ }
221
+ }
222
+ return best
223
+ }
224
+
225
+ function draw() {
226
+ const w = cssW()
227
+ const h = cssH()
228
+ const s = view.scale
229
+ const ox = view.x
230
+ const oy = view.y
231
+
232
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
233
+ ctx.clearRect(0, 0, w, h)
234
+
235
+ // 视口在世界坐标里的范围(多留一点余量,免得边缘的点被切掉半个)
236
+ const pad = 4 / s
237
+ const x0 = -ox / s - pad
238
+ const y0 = -oy / s - pad
239
+ const x1 = (w - ox) / s + pad
240
+ const y1 = (h - oy) / s + pad
241
+
242
+ // ---- 世界坐标:点与线 ----
243
+ ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * ox, dpr * oy)
244
+
245
+ // 聚焦(点或边)时把无关的点线压暗(不是藏起来):结构还在,但一眼看得出连的是谁
246
+ const dimOthers = focus >= 0 || edgeFocus >= 0
247
+ const edgeEnds = edgeFocus >= 0 ? [E[edgeFocus], E[edgeFocus + 1]] : null
248
+ const near = (i) => {
249
+ if (!dimOthers) return true
250
+ if (focus >= 0) return i === focus || (neighbours !== null && neighbours.has(i))
251
+ return i === edgeEnds[0] || i === edgeEnds[1]
252
+ }
253
+
254
+ // 屏幕上的边短到看不清时,整批不画(见 MEDIAN_EDGE 那段)
255
+ const drawEdges = MEDIAN_EDGE * s >= EDGE_MIN_PX
256
+ ctx.lineWidth = 0.7 / s
257
+ ctx.strokeStyle = dimOthers ? 'rgba(96,116,152,0.10)' : 'rgba(96,116,152,0.26)'
258
+ ctx.beginPath()
259
+ for (let k = 0; drawEdges && k < E.length; k += 2) {
260
+ const i = E[k]
261
+ const j = E[k + 1]
262
+ if (dimOthers && !(near(i) && near(j))) continue
263
+ const xi = X[i]
264
+ const yi = Y[i]
265
+ const xj = X[j]
266
+ const yj = Y[j]
267
+ if ((xi < x0 && xj < x0) || (xi > x1 && xj > x1)) continue
268
+ if ((yi < y0 && yj < y0) || (yi > y1 && yj > y1)) continue
269
+ ctx.moveTo(xi, yi)
270
+ ctx.lineTo(xj, yj)
271
+ }
272
+ ctx.stroke()
273
+ // 聚焦的那条边再描一遍亮的:主批它已经画过(两端点都 near),这里覆盖成高亮。
274
+ if (edgeFocus >= 0 && drawEdges) {
275
+ const i = edgeEnds[0]
276
+ const j = edgeEnds[1]
277
+ ctx.lineWidth = 2.2 / s
278
+ ctx.strokeStyle = '#8fd6a8'
279
+ ctx.beginPath()
280
+ ctx.moveTo(X[i], Y[i])
281
+ ctx.lineTo(X[j], Y[j])
282
+ ctx.stroke()
283
+ }
284
+
285
+ // 一趟扫完(ORDER 已按线排好,每段一个桶一次 fill),而不是为了分色把全部点扫三遍
286
+ for (let b = 0; b < 3; b += 1) {
287
+ const from = BUCKET[b]
288
+ const to = BUCKET[b + 1]
289
+ if (from === to) continue
290
+ ctx.beginPath()
291
+ ctx.fillStyle = LINE_COLOR[BUCKET_LINE[b]]
292
+ let any = false
293
+ for (let k = from; k < to; k += 1) {
294
+ const i = ORDER[k]
295
+ if (X[i] < x0 || X[i] > x1 || Y[i] < y0 || Y[i] > y1) continue
296
+ if (dimOthers && !near(i)) continue
297
+ const deg = nodes[i].deg || 0
298
+ const r = (i === focus ? 4 : deg >= 50 ? 2.6 : deg >= 10 ? 1.9 : 1.4) / s
299
+ // **屏幕上小到看不出圆角时用方块**:实测 5 万个方块 3.4ms、5 万个圆 9.0ms。
300
+ // 缩到最远时每个点不到 2px,方块与圆在这里没有任何视觉差别。
301
+ if (r * s < 2) ctx.rect(X[i] - r, Y[i] - r, r * 2, r * 2)
302
+ else {
303
+ ctx.moveTo(X[i] + r, Y[i])
304
+ ctx.arc(X[i], Y[i], r, 0, Math.PI * 2)
305
+ }
306
+ any = true
307
+ }
308
+ if (any) ctx.fill()
309
+ }
310
+
311
+ // ---- 屏幕坐标:名字 ----
312
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
313
+ ctx.font = '11px "Segoe UI","Microsoft YaHei",system-ui,sans-serif'
314
+ ctx.textAlign = 'left'
315
+ ctx.textBaseline = 'middle'
316
+ // 只遍历"要画名字的那一小批",而不是全部 5 万个(以前每帧都要扫 N 次 Set 查询)
317
+ const extra = []
318
+ if (hovered >= 0 && !labelledSet.has(hovered)) extra.push(hovered)
319
+ if (focus >= 0 && focus !== hovered && !labelledSet.has(focus)) extra.push(focus)
320
+ // 边聚焦时两端点的名字必须出现——它们就是这条判断的两个主角
321
+ if (edgeEnds) {
322
+ for (const i of edgeEnds) if (!extra.includes(i)) extra.push(i)
323
+ }
324
+ // **聚焦时的候选换成"它自己 + 它连着的那些"**:其余点都压暗了,给它们画名字只会糊住
325
+ // 中间那个。邻居按度数从高到低(`LABEL_MAX` 截断时先给连接多的),所以同一张图每次
326
+ // 画出来的名字是一样的。
327
+ //
328
+ // 这里原来写的是"聚焦就只画 `extra`",而 `extra` 只装**不在度数最高那批里的**点 ——
329
+ // 于是点开一个枢纽(小图里就是点开任何一个点)画布上**一个名字都没有**,正好与上面
330
+ // "一眼看得出它连的是谁"相反(2026-10-11 修)。
331
+ const focusLabels = []
332
+ if (dimOthers && focus >= 0) {
333
+ focusLabels.push(focus)
334
+ if (neighbours) {
335
+ for (const j of [...neighbours].sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))) {
336
+ focusLabels.push(j)
337
+ }
338
+ }
339
+ }
340
+ // **互相压住的名字谁也别画**(贪心避让):度数最高的那批全是枢纽,它们本来就挤在中心,
341
+ // 早先那版把它们全画出来,正中间是一团白字,等于没有标签。鼠标指着/点开的那两个永远画。
342
+ const placed = []
343
+ let shownLabels = 0
344
+ for (const i of dimOthers ? focusLabels.concat(extra) : labelled.concat(extra)) {
345
+ const sx = ox + X[i] * s
346
+ const sy = oy + Y[i] * s
347
+ if (sx < -40 || sy < -20 || sx > w + 40 || sy > h + 20) continue
348
+ const forced = i === hovered || i === focus || (edgeEnds !== null && (i === edgeEnds[0] || i === edgeEnds[1]))
349
+ if (!forced) {
350
+ if (shownLabels >= LABEL_MAX) continue
351
+ let clash = false
352
+ for (let p = 0; p < placed.length; p += 1) {
353
+ if (Math.abs(placed[p][0] - sx) < 52 && Math.abs(placed[p][1] - sy) < 13) {
354
+ clash = true
355
+ break
356
+ }
357
+ }
358
+ if (clash) continue
359
+ }
360
+ placed.push([sx, sy])
361
+ shownLabels += 1
362
+ ctx.fillStyle = forced ? '#ffffff' : 'rgba(201,211,230,0.78)'
363
+ ctx.fillText(label(nodes[i]), sx + 6, sy)
364
+ }
365
+ }
366
+
367
+ // **每帧最多画一次。** 拖动、滚轮、悬停原本是每个事件都直接 draw();事件比一帧还密时
368
+ // (拖动时一秒几百个 pointermove)画布就一直在重画,一次 12.8ms —— 操作会"粘住",这才是
369
+ // 它最初卡的主因。合并成每帧一次之后,帧时间就是那一次 draw 的时间,交互立刻顺。
370
+ const RAF = (typeof window.requestAnimationFrame === 'function')
371
+ ? (fn) => window.requestAnimationFrame(fn)
372
+ : (fn) => setTimeout(fn, 16)
373
+ let scheduled = false
374
+ function scheduleDraw() {
375
+ if (scheduled) return
376
+ scheduled = true
377
+ RAF(() => {
378
+ scheduled = false
379
+ draw()
380
+ })
381
+ }
382
+
383
+ /** 一条边的人类可读证据:共同来源卡片;查不到时说清这条边是凭什么连的。 */
384
+ function edgeInfoText(k) {
385
+ const a = nodes[E[k]]
386
+ const b = nodes[E[k + 1]]
387
+ const head = `${label(a)} ⟷ ${label(b)}`
388
+ const ev = EV.get(evKey(E[k], E[k + 1]))
389
+ if (!ev) {
390
+ return `${head} · 两页没有共同来源卡片——这条边来自页面正文里的 [[链接]]`
391
+ }
392
+ const count = ev[0] || 0
393
+ const names = ev.slice(1).join('、')
394
+ const more = count > ev.length - 1
395
+ ? ` 等 ${count} 个(完整清单见两页的「## 来源」段)`
396
+ : ''
397
+ return `${head} · 共同来源 ${count} 个:${names}${more}`
398
+ }
399
+
400
+ function setFocus(i) {
401
+ focus = i
402
+ edgeFocus = -1
403
+ if (i >= 0) {
404
+ neighbours = new Set(adj[i])
405
+ if (hudInfo) {
406
+ const names = [...neighbours].slice(0, 6).map((j) => label(nodes[j])).join('、')
407
+ const more = neighbours.size > 6 ? ' 等' : ''
408
+ hudInfo.innerHTML = `<b>${escapeHtml(label(nodes[i]))}</b> · ${escapeHtml(lineLabel(nodes[i]))} · 连 ${neighbours.size} 个(${escapeHtml(names)}${more})`
409
+ }
410
+ } else {
411
+ neighbours = null
412
+ if (hudInfo) hudInfo.textContent = HINT
413
+ }
414
+ scheduleDraw()
415
+ }
416
+
417
+ function setEdgeFocus(k) {
418
+ edgeFocus = k
419
+ focus = -1
420
+ neighbours = null
421
+ if (hudInfo) hudInfo.textContent = k >= 0 ? edgeInfoText(k) : HINT
422
+ scheduleDraw()
423
+ }
424
+
425
+ // ---- 交互 ----
426
+ let dragging = false
427
+ let lastX = 0
428
+ let lastY = 0
429
+ let moved = 0
430
+
431
+ canvas.addEventListener('pointerdown', (event) => {
432
+ if (event.button !== 0) return
433
+ dragging = true
434
+ moved = 0
435
+ lastX = event.clientX
436
+ lastY = event.clientY
437
+ if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId)
438
+ })
439
+ canvas.addEventListener('pointermove', (event) => {
440
+ if (dragging) {
441
+ const dx = event.clientX - lastX
442
+ const dy = event.clientY - lastY
443
+ moved += Math.abs(dx) + Math.abs(dy)
444
+ lastX = event.clientX
445
+ lastY = event.clientY
446
+ view.x += dx
447
+ view.y += dy
448
+ scheduleDraw()
449
+ return
450
+ }
451
+ const rect = canvas.getBoundingClientRect()
452
+ const hit = pick(event.clientX - rect.left, event.clientY - rect.top)
453
+ if (hit !== hovered) {
454
+ hovered = hit
455
+ canvas.style.cursor = hit >= 0 ? 'pointer' : 'grab'
456
+ scheduleDraw()
457
+ }
458
+ })
459
+ canvas.addEventListener('pointerup', (event) => {
460
+ if (!dragging) return
461
+ dragging = false
462
+ if (moved > 3) return // 拖过就是平移,不算点选
463
+ const rect = canvas.getBoundingClientRect()
464
+ const px = event.clientX - rect.left
465
+ const py = event.clientY - rect.top
466
+ const hit = pick(px, py)
467
+ if (hit >= 0) setFocus(hit)
468
+ else setEdgeFocus(nearestEdge(px, py)) // 没点中概念就试边;试不上自然复位
469
+ })
470
+ canvas.addEventListener('dblclick', () => {
471
+ setFocus(-1)
472
+ setEdgeFocus(-1)
473
+ })
474
+ canvas.addEventListener('pointerleave', () => {
475
+ if (hovered >= 0) {
476
+ hovered = -1
477
+ scheduleDraw()
478
+ }
479
+ })
480
+ canvas.addEventListener('wheel', (event) => {
481
+ event.preventDefault()
482
+ const rect = canvas.getBoundingClientRect()
483
+ const px = event.clientX - rect.left
484
+ const py = event.clientY - rect.top
485
+ const next = Math.max(0.02, Math.min(60, view.scale * Math.exp(-event.deltaY * 0.0015)))
486
+ const k = next / view.scale
487
+ view.x = px - (px - view.x) * k
488
+ view.y = py - (py - view.y) * k
489
+ view.scale = next
490
+ scheduleDraw()
491
+ }, { passive: false })
492
+
493
+ if (q) {
494
+ q.addEventListener('keydown', (event) => {
495
+ if (event.key !== 'Enter') return
496
+ const needle = q.value.trim().toLowerCase()
497
+ if (!needle) return
498
+ // 搜 `name`(主键也认,方便精确指定某一条线)。同名两页都在时**两个都算命中**,
499
+ // 跳连接更多的那张并把另一条线也说出来 —— 合并视图里这正是要看见的东西。
500
+ const exact = []
501
+ for (let i = 0; i < N; i += 1) {
502
+ if (label(nodes[i]).toLowerCase() === needle || nodes[i].id.toLowerCase() === needle) exact.push(i)
503
+ }
504
+ let pool = exact
505
+ if (!pool.length) {
506
+ pool = []
507
+ for (let i = 0; i < N; i += 1) {
508
+ if (label(nodes[i]).toLowerCase().includes(needle)) pool.push(i)
509
+ }
510
+ }
511
+ if (!pool.length) {
512
+ if (hudInfo) hudInfo.textContent = `没找到「${q.value.trim()}」`
513
+ return
514
+ }
515
+ pool.sort((a, b) => (nodes[b].deg || 0) - (nodes[a].deg || 0))
516
+ const hit = pool[0]
517
+ view.scale = Math.max(view.scale, 2.5)
518
+ view.x = cssW() / 2 - X[hit] * view.scale
519
+ view.y = cssH() / 2 - Y[hit] * view.scale
520
+ setFocus(hit)
521
+ if (pool.length > 1 && hudInfo) {
522
+ hudInfo.innerHTML += `<div>两条线都有「${escapeHtml(label(nodes[hit]))}」:${escapeHtml(pool.map((i) => lineLabel(nodes[i])).join('、'))}</div>`
523
+ }
524
+ })
525
+ }
526
+
527
+ if (hudStats) {
528
+ let wiki = 0
529
+ let chat = 0
530
+ for (let i = 0; i < N; i += 1) {
531
+ if (nodes[i].line === 'wiki') wiki += 1
532
+ else if (nodes[i].line === 'chat') chat += 1
533
+ }
534
+ hudStats.textContent = `${N.toLocaleString('en-US')} 个概念 · ${(E.length / 2).toLocaleString('en-US')} 条链接`
535
+ + ` · ${LINE_LABELS.wiki || 'wiki'} ${wiki.toLocaleString('en-US')} / ${LINE_LABELS.chat || 'chat'} ${chat.toLocaleString('en-US')}`
536
+ }
537
+ if (hudInfo) hudInfo.textContent = HINT
538
+ if (boot) boot.style.display = 'none'
539
+
540
+ window.addEventListener('resize', resize)
541
+ resize()
542
+ fit()
543
+ // 给冒烟测试一个把手:能问它"画了多少个点、多少条线",也能驱动边命中与边证据
544
+ window.__GRAPH2D__ = {
545
+ nodes: N, edges: E.length / 2, draw, fit, view,
546
+ nearestEdge, edgeInfoText, setEdgeFocus, setFocus,
547
+ edgeAt: (k) => [E[k], E[k + 1]],
548
+ xy: (i) => [X[i], Y[i]],
549
+ }
550
+ })()