weflow-cli 1.9.0 → 1.11.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/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1051 -0
- package/OPERATIONS.md +99 -13
- package/README.en.md +14 -5
- package/README.md +23 -8
- package/bin/weflow-cli.ts +364 -40
- package/dist/bin/weflow-cli.js +381 -69
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +19 -1
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +35 -0
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +19 -0
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +20 -0
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +27 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +169 -35
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +2 -0
- 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/exportService.d.ts +43 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +116 -11
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1274 -0
- package/docs/EXTENDING.md +69 -7
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +2 -2
- package/docs/PROJECT_STATE.md +64 -5
- package/docs/RELEASING.md +128 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/index.ts +18 -3
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/index.html +12 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +111 -0
- package/resources/panel/renderer.js +480 -8
- package/scripts/_utils.py +1203 -977
- package/scripts/biz_daily.py +25 -10
- package/scripts/chat_notes.py +15 -5
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +163 -57
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +346 -6
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +212 -58
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wiki_lint.py +94 -17
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +33 -0
- package/src/services/assistantService.ts +26 -0
- package/src/services/assistantTools.ts +2452 -2319
- package/src/services/configService.ts +5 -2
- package/src/services/exportService.ts +129 -12
- package/src/services/messageQuery.ts +29 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
|
@@ -0,0 +1,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
|
+
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
+
})()
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
<span class="brand">第二大脑</span>
|
|
30
30
|
<span id="status" class="status">连接中…</span>
|
|
31
31
|
<button id="collapse" type="button" class="ghost" hidden>收起</button>
|
|
32
|
+
<!-- "它记住了什么"(只读)。**不经过模型**:那条路由只返回数据,页面只渲染;
|
|
33
|
+
模型看到的是另一份(带预算与脱敏)。点开时才拉一次,不轮询。 -->
|
|
34
|
+
<button id="memory" type="button" class="ghost" title="它记住了什么(只读)">记忆</button>
|
|
32
35
|
</header>
|
|
33
36
|
|
|
34
37
|
<main id="log" aria-live="polite"></main>
|
|
@@ -40,6 +43,15 @@
|
|
|
40
43
|
|
|
41
44
|
<footer id="foot"></footer>
|
|
42
45
|
|
|
46
|
+
<section id="memory-panel" hidden>
|
|
47
|
+
<div class="memhead">
|
|
48
|
+
<span class="brand">它记住了什么</span>
|
|
49
|
+
<span id="memory-note" class="status"></span>
|
|
50
|
+
<button id="memory-close" type="button" class="ghost">关闭</button>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="memory-list" class="memlist"></div>
|
|
53
|
+
</section>
|
|
54
|
+
|
|
43
55
|
<div id="tail" aria-hidden="true"></div>
|
|
44
56
|
</div>
|
|
45
57
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -245,6 +245,62 @@ body.ball-happy #ball .face {
|
|
|
245
245
|
background-image: url('/panel/mascot-happy.png');
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/* ---- 被拎起来那一下(6 帧硬切) ----
|
|
249
|
+
|
|
250
|
+
按下球时它像被拎住后颈提起、松手落回。**硬切,不做淡入** —— 同上面 ball-happy 那条的理由:
|
|
251
|
+
换帧本来就是一帧的事,淡入反而像"图片正在加载"。
|
|
252
|
+
|
|
253
|
+
按住期间它同时**在被拎着左右摆**(摆动幅度与朝哪一侧偏由 JS 算,见 `renderer.js` 里那一节;
|
|
254
|
+
晃起来从**按下那一刻**就开始,不必先拖动),松手才停。下面第 1 条同时也是"摆多大都不会被裁"的依据。
|
|
255
|
+
|
|
256
|
+
五条规矩,每条都对应一处会静默出错的地方:
|
|
257
|
+
|
|
258
|
+
1. **必须写在 `body.ball-happy` 之后**:同特异性时后写的赢,而"正在被拎"比"被捏了一下"
|
|
259
|
+
更该被看见(按下那一下这两个类同时挂着)。
|
|
260
|
+
2. **动画期间把虹膜层藏掉**:这五帧的眼睛是画死的,叠上会动的虹膜就是两个瞳仁 ——
|
|
261
|
+
与忙 / 连不上 / 额度用完那几张同理。
|
|
262
|
+
3. **动画期间连悬停放大与按下压扁一起停**:帧本身已在逐帧收小(最小那帧比静止帧小 8%),
|
|
263
|
+
再叠 `scale(1.02)` 与 `:active` 的 `scale(.92)` 就成了缩两回,看起来是"躲开"而不是
|
|
264
|
+
"被提起来"。这条的选择器特异性高于那两条,所以能压住它们。
|
|
265
|
+
4. **切帧只换 `background-image`**:不动尺寸、不动位置,所以不会跳。
|
|
266
|
+
5. **过渡要短(45ms)**:这条 transform 现在不只是"按下那一下的姿势" —— 按住期间它一直在**左右摆**
|
|
267
|
+
(`dangleTick` 每 16ms 写一个新角度)。90ms 那版是照着"一次性倾斜"写的:一阶滞后在 900ms 的
|
|
268
|
+
周期上会吃掉约 15% 的幅度、再把画面拖后约 90ms,也就是**看到的比算出来的小一圈**,
|
|
269
|
+
而这条测试看不见(测试读的是 `--dangle-deg` 这个属性,不是渲染结果)。 */
|
|
270
|
+
body.ball-lift #ball { transform: rotate(var(--dangle-deg, 0deg)); transition: transform 45ms linear; }
|
|
271
|
+
body.ball-lift #ball .iris { display: none; }
|
|
272
|
+
body.ball-lift-up-1 #ball .face { background-image: url('/panel/mascot-lift-up-1.png'); }
|
|
273
|
+
body.ball-lift-up-2 #ball .face { background-image: url('/panel/mascot-lift-up-2.png'); }
|
|
274
|
+
body.ball-lift-up-3 #ball .face { background-image: url('/panel/mascot-lift-up-3.png'); }
|
|
275
|
+
body.ball-lift-up-4 #ball .face { background-image: url('/panel/mascot-lift-up-4.png'); }
|
|
276
|
+
body.ball-lift-up-5 #ball .face { background-image: url('/panel/mascot-lift-up-5.png'); }
|
|
277
|
+
body.ball-lift-up-6 #ball .face { background-image: url('/panel/mascot-lift-up-6.png'); }
|
|
278
|
+
body.ball-lift-down-1 #ball .face { background-image: url('/panel/mascot-lift-down-1.png'); }
|
|
279
|
+
body.ball-lift-down-2 #ball .face { background-image: url('/panel/mascot-lift-down-2.png'); }
|
|
280
|
+
body.ball-lift-down-3 #ball .face { background-image: url('/panel/mascot-lift-down-3.png'); }
|
|
281
|
+
body.ball-lift-down-4 #ball .face { background-image: url('/panel/mascot-lift-down-4.png'); }
|
|
282
|
+
body.ball-lift-down-5 #ball .face { background-image: url('/panel/mascot-lift-down-5.png'); }
|
|
283
|
+
body.ball-lift-down-6 #ball .face { background-image: url('/panel/mascot-lift-down-6.png'); }
|
|
284
|
+
|
|
285
|
+
/* ---- 挠痒痒(2026-10-02,倾斜已去掉) ----
|
|
286
|
+
|
|
287
|
+
鼠标在球身上划过就扭:4 张生图姿势帧循环(只负责表情与爪子的小动作),停下 260ms 收。
|
|
288
|
+
|
|
289
|
+
**这里曾经还有一条 `transform: rotate(var(--tickle-deg))`,2026-10-02 去掉了。** 倾斜与"被拎起来"
|
|
290
|
+
走的是同一个视觉通道(都是 `#ball` 上的 rotate),于是随手扫过看起来像一次小号起飞。用户的报告:
|
|
291
|
+
「为什么鼠标划动也会晃,而不是在起飞状态才会晃」—— 现在**旋转只归被拎那条**,划过只有帧、不转动。
|
|
292
|
+
两条规矩:
|
|
293
|
+
|
|
294
|
+
1. **必须写在这四条脸规则之后**:同特异性时后写的赢,挠痒痒的姿势要压过空闲脸。
|
|
295
|
+
2. **藏掉虹膜层**:这 4 帧的眼睛是画死的,叠上会动的虹膜就是两个瞳仁。
|
|
296
|
+
|
|
297
|
+
(反向断言在 `test/panel-lift-frames.test.ts`:挠痒痒那块 CSS 里不许再出现 rotate。) */
|
|
298
|
+
body.ball-tickle #ball .iris { display: none; }
|
|
299
|
+
body.ball-tickle-1 #ball .face { background-image: url('/panel/mascot-tickle-1.png'); }
|
|
300
|
+
body.ball-tickle-2 #ball .face { background-image: url('/panel/mascot-tickle-2.png'); }
|
|
301
|
+
body.ball-tickle-3 #ball .face { background-image: url('/panel/mascot-tickle-3.png'); }
|
|
302
|
+
body.ball-tickle-4 #ball .face { background-image: url('/panel/mascot-tickle-4.png'); }
|
|
303
|
+
|
|
248
304
|
/* ---------------------------------------------------------------- 状态 */
|
|
249
305
|
|
|
250
306
|
/* 状态的表达**全在那四张脸上**(见上面 `.face` 那几条):忙=思考、连不上=委屈、
|
|
@@ -457,3 +513,58 @@ footer {
|
|
|
457
513
|
font-size: 12px;
|
|
458
514
|
}
|
|
459
515
|
footer .warn { color: var(--warn); }
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
/* ---- "它记住了什么"(只读) ----
|
|
519
|
+
|
|
520
|
+
盖在对话之上的一层:记忆是"关于我"的东西,与某一句问答不同,混进对话流里会让人以为
|
|
521
|
+
那是这一轮说的话。只读、不经过模型 —— `/api/memory` 只返回数据,页面只渲染。
|
|
522
|
+
写在整个文件末尾是有意的:那条 reduced-motion 断言看的是它自己那段开头 240 个字符,
|
|
523
|
+
往前面插规则会把它挤出窗口。 */
|
|
524
|
+
#memory-panel {
|
|
525
|
+
position: absolute;
|
|
526
|
+
inset: 0;
|
|
527
|
+
display: flex;
|
|
528
|
+
flex-direction: column;
|
|
529
|
+
background: var(--panel);
|
|
530
|
+
border-radius: var(--bubble-radius);
|
|
531
|
+
z-index: 3;
|
|
532
|
+
}
|
|
533
|
+
#memory-panel[hidden] { display: none; }
|
|
534
|
+
.memhead {
|
|
535
|
+
display: flex;
|
|
536
|
+
align-items: center;
|
|
537
|
+
gap: 8px;
|
|
538
|
+
padding: 10px 12px;
|
|
539
|
+
border-bottom: 1px solid var(--line);
|
|
540
|
+
}
|
|
541
|
+
.memhead .status { flex: 1; }
|
|
542
|
+
.memlist {
|
|
543
|
+
flex: 1;
|
|
544
|
+
overflow-y: auto;
|
|
545
|
+
padding: 10px 12px;
|
|
546
|
+
display: flex;
|
|
547
|
+
flex-direction: column;
|
|
548
|
+
gap: 10px;
|
|
549
|
+
}
|
|
550
|
+
.memlist .fact { background: var(--bg); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; }
|
|
551
|
+
.memlist .fact p { margin: 0; }
|
|
552
|
+
.memlist .muted { color: var(--dim); font-size: 12px; }
|
|
553
|
+
.memlist .fact .muted { margin-top: 4px; }
|
|
554
|
+
|
|
555
|
+
/* ---- 答完了高兴一下 + 偶尔眨一下眼(2026-10-04) ----
|
|
556
|
+
|
|
557
|
+
两条都由 `renderer.js` 判"什么时候不播",这里只管长什么样。
|
|
558
|
+
|
|
559
|
+
跳:**没有生图帧**,是位移 + 轻微缩放 + 现有的眯眼笑脸 —— 模型画"蹲下去"一定会把身体压扁,
|
|
560
|
+
而跳本来就只是位移。空中那步的 0.96 不是随手取的:内容到球心 125.25 / 圆半径 128,
|
|
561
|
+
缩到 0.96 之后才抬得起那 7 像素(120.2 + 7 = 127.2 < 128)。改数之前先重算这条。
|
|
562
|
+
|
|
563
|
+
眨眼:2 帧,**眼睛以外与静止帧逐像素相同**(出图后只把眼睛带贴回来),所以藏虹膜层就够,
|
|
564
|
+
不会整颗球闪一下。 */
|
|
565
|
+
body.ball-hop #ball { transform: translateY(var(--hop-y, 0px)) scale(var(--hop-scale, 1)); transition: transform 95ms ease-out; }
|
|
566
|
+
body.ball-hop-happy #ball .face { background-image: url('/panel/mascot-happy.png'); }
|
|
567
|
+
body.ball-hop #ball .iris { display: none; }
|
|
568
|
+
body.ball-blink #ball .iris { display: none; }
|
|
569
|
+
body.ball-blink-1 #ball .face { background-image: url('/panel/mascot-blink-1.png'); }
|
|
570
|
+
body.ball-blink-2 #ball .face { background-image: url('/panel/mascot-blink-2.png'); }
|