weflow-cli 1.16.0 → 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.
- package/CHANGELOG.md +42 -0
- package/README.md +16 -4
- package/bin/weflow-cli.ts +348 -6
- package/dist/bin/weflow-cli.js +381 -8
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +47 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +24 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +8 -1
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +47 -8
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +8 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +67 -11
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/cursorProbe.d.ts +44 -0
- package/dist/src/services/cursorProbe.d.ts.map +1 -0
- package/dist/src/services/cursorProbe.js +183 -0
- package/dist/src/services/cursorProbe.js.map +1 -0
- package/dist/src/services/qchatImport.d.ts +89 -0
- package/dist/src/services/qchatImport.d.ts.map +1 -0
- package/dist/src/services/qchatImport.js +273 -0
- package/dist/src/services/qchatImport.js.map +1 -0
- package/dist/src/services/setupVenv.d.ts +43 -0
- package/dist/src/services/setupVenv.d.ts.map +1 -0
- package/dist/src/services/setupVenv.js +198 -0
- package/dist/src/services/setupVenv.js.map +1 -0
- package/dist/src/utils/python.d.ts +10 -0
- package/dist/src/utils/python.d.ts.map +1 -1
- package/dist/src/utils/python.js +27 -4
- package/dist/src/utils/python.js.map +1 -1
- package/docs/DATA_CONTRACT.md +25 -0
- package/docs/DECISIONS.md +92 -30
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
- package/docs/HEALTH-CHECK.md +104 -104
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
- package/docs/NEXT_ACTIONS.md +236 -236
- package/docs/NPM-PUBLISH.md +130 -130
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +6 -3
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- package/requirements-3x.txt +4 -4
- package/requirements-voice.txt +15 -15
- package/resources/js/graph3d/NOTICE.txt +35 -35
- package/resources/js/graph3d/d3-binarytree.min.js +2 -2
- package/resources/js/graph3d/d3-dispatch.min.js +2 -2
- package/resources/js/graph3d/d3-force-3d.min.js +2 -2
- package/resources/js/graph3d/d3-octree.min.js +2 -2
- package/resources/js/graph3d/d3-quadtree.min.js +2 -2
- package/resources/js/graph3d/d3-timer.min.js +2 -2
- package/resources/js/graph3d/three.min.js +6 -6
- package/resources/js/graph3d/viewer2d.js +550 -448
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/article_notes.py +423 -423
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_task.ps1 +213 -213
- package/scripts/fav_notes.py +321 -321
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -0
- package/scripts/install_tasks.ps1 +105 -105
- package/scripts/leads.py +179 -179
- package/scripts/network.py +206 -206
- package/scripts/nt_decrypt.py +135 -1
- package/scripts/on_this_day.py +178 -178
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +73 -5
- package/scripts/user_notes.py +315 -315
- package/scripts/voice_transcribe.py +139 -139
- package/scripts/watch_issues.py +212 -212
- package/scripts/watch_mail.py +131 -131
- package/src/core/ntCore.ts +59 -0
- package/src/services/assistantService.ts +48 -8
- package/src/services/assistantTools.ts +73 -10
- package/src/services/configService.ts +3 -2
- package/src/services/cursorProbe.ts +222 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatMedia.ts +166 -166
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
{
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
+
})()
|